fix(layout): refresh geometry after DOM and style changes

Check the DOM, document style generations, viewport state, media environment,
and visual resources before reusing layout for synchronous geometry or input.
Keep snapshot reads available and reuse the tree while its inputs are unchanged.

Cover repeated trusted clicks on newly inserted buttons and CSSOM-only changes.
Update geometry, innerText, grid, and screencast tests for synchronous refresh,
and record nine newly passing CloseWatcher WPT cases.
This commit is contained in:
ldm0
2026-09-23 00:14:06 +08:00
parent 5d88d0cdd9
commit f4d34a69ae
9 changed files with 232 additions and 60 deletions
@@ -81,6 +81,15 @@ clipboard-apis/clipboard-events-synthetic.html
clipboard-apis/data-transfer-file-list-change-reference-updates.html
clipboard-apis/dataTransfer-clearData.html
clipboard-apis/drag-multiple-urls.html
close-watcher/abortsignal.html
close-watcher/inside-event-listeners.html
close-watcher/user-activation/ny-activate-preventDefault.html?CloseWatcher
close-watcher/user-activation/nyyn-CloseWatcher.html
close-watcher/user-activation/nyyyn-CloseWatcher.html
close-watcher/user-activation/yn-activate.html?CloseWatcher
close-watcher/user-activation/yy.html?CloseWatcher
close-watcher/user-activation/yyn.html?CloseWatcher
close-watcher/user-activation/yyy.html?CloseWatcher
compat/css-style-declaration-alias-enumeration.html
compat/historical.html
compat/webkit-box-removing-triggering-anonymous-merge.html
@@ -7,7 +7,7 @@ use moli_layout::{
use super::JsContextHost;
use super::layout_snapshot::LayoutEnvironment;
use super::layout_state::InferredFrameStyleViewportCacheKey;
use super::layout_state::{InferredFrameStyleViewportCacheKey, LayoutSnapshotInputs};
use crate::{
css_resource_urls::{CompletedStylesheetWebFont, StylesheetLoadBlockingResource},
document_runtime::DomHandle,
@@ -327,16 +327,18 @@ impl JsContextHost {
(frame, viewport)
})
.collect::<Vec<_>>();
let tree = pass.into_tree();
let frame_viewports_changed = {
let mut state = self.document_layout_state.borrow_mut();
state.publish_latest_layout(document, tree, environment);
state.update_frame_viewports(frame_viewports)
};
if frame_viewports_changed {
self.style_viewport_generation
.set(self.style_viewport_generation.get().saturating_add(1));
}
let inputs = self.layout_snapshot_inputs(document);
self.document_layout_state
.borrow_mut()
.publish_latest_layout(document, pass.into_tree(), environment, inputs);
self.last_layout_pass_metrics.set(Some(metrics));
self.layout_snapshot_cache_publishes
.set(self.layout_snapshot_cache_publishes.get().saturating_add(1));
@@ -396,7 +398,7 @@ impl JsContextHost {
if self.force_fresh_layout_reads_for_test {
return None;
}
if !self
if !self.layout_snapshot_inputs_are_current(document) || !self
.document_layout_state
.borrow()
.latest_layout_matches_environment(self.layout_environment())
@@ -409,6 +411,27 @@ impl JsContextHost {
.flatten()
}
fn layout_snapshot_inputs(&self, document: DomHandle) -> LayoutSnapshotInputs {
let mut documents = self.active_layout_document_handles();
documents.push(document);
LayoutSnapshotInputs {
dom_version: self.dom_host().dom_version(),
style_generations: self
.style_engine
.computed_style_observation_generations(documents),
style_viewport_generation: self.style_viewport_generation.get(),
environment: StyloStyleEnvironment::from_emulated_media(self.emulated_media()),
visual_resource_generation: self.visual_resource_generation(),
}
}
fn layout_snapshot_inputs_are_current(&self, document: DomHandle) -> bool {
let inputs = self.layout_snapshot_inputs(document);
self.document_layout_state
.borrow()
.latest_layout_inputs_match(&inputs)
}
/// Inspects the member tree for one exact Document in the single latest
/// recursively frozen snapshot.
///
@@ -24,6 +24,18 @@ struct CachedInferredFrameStyleViewport {
viewport: StyleViewport,
}
/// Inputs sampled by the owner of the latest layout pass. The frozen tree
/// remains available to snapshot consumers, but synchronous geometry queries
/// must rebuild it after any of these inputs change.
#[derive(PartialEq)]
pub(super) struct LayoutSnapshotInputs {
pub(super) dom_version: u64,
pub(super) style_generations: Vec<(DomHandle, u64, u64, u64)>,
pub(super) style_viewport_generation: u64,
pub(super) environment: StyloStyleEnvironment,
pub(super) visual_resource_generation: u64,
}
/// Layout-facing state whose lifetime is bounded by exactly one main Document.
///
/// `ScriptVm` outlives `document.open()`, so the main-document owner
@@ -43,6 +55,7 @@ pub(super) struct DocumentLayoutState {
web_font_resource_generation: Option<StylesheetResourceGeneration>,
visual_state_generation: u64,
latest_layout: LatestLayoutTreeCache,
latest_layout_inputs: Option<LayoutSnapshotInputs>,
/// Last used content viewport published by each live iframe owner's
/// parent layout. Blink keeps the equivalent size on LocalFrameView; it is
/// separate from the single latest-tree slot because a later fresh layout
@@ -140,16 +153,23 @@ impl DocumentLayoutState {
document: DomHandle,
tree: FrozenLayoutTree<DomHandle>,
environment: LayoutEnvironment,
inputs: LayoutSnapshotInputs,
) {
self.latest_layout.publish(document, tree, environment);
self.latest_layout_inputs = Some(inputs);
}
pub(super) fn latest_layout_matches_environment(&self, environment: LayoutEnvironment) -> bool {
self.latest_layout.matches_environment(environment)
}
pub(super) fn latest_layout_inputs_match(&self, inputs: &LayoutSnapshotInputs) -> bool {
self.latest_layout_inputs.as_ref() == Some(inputs)
}
pub(super) fn clear_latest_layout(&mut self) {
self.latest_layout.clear();
self.latest_layout_inputs = None;
self.mark_visual_state_dirty();
}
@@ -72,7 +72,7 @@ async fn computed_size_held_getters_observe_grid_sampling_without_dom_mutation()
}
#[tokio::test(flavor = "current_thread")]
async fn computed_size_grid_sampling_reuses_old_geometry_until_explicit_refresh() {
async fn computed_size_grid_sampling_refreshes_after_style_mutation() {
run_page_vm_async_test(async move {
let mut page = page_with_size_fixture(GRID)?;
page.set_viewport_surface(Some(crate::protocol_types::ViewportSurface {
@@ -116,14 +116,13 @@ async fn computed_size_grid_sampling_reuses_old_geometry_until_explicit_refresh(
);
assert_eq!(
page.vm_mut().eval("held.gridTemplateColumns")?,
"40px 120px"
"100px 100px"
);
assert_eq!(page.vm().layout_pass_observability_for_test().1, passes + 1);
assert_eq!(page.vm().layout_pass_observability_for_test().1, passes + 2);
assert_eq!(
page.vm().layout_snapshot_cache_observability_for_test().2,
sampled.2
sampled.2 + 1
);
publish_size_layout(&mut page)?;
assert_eq!(
held_sizes(&mut page)?,
json!(["200px", "40px", "200px", "40px"])
@@ -133,6 +132,12 @@ async fn computed_size_grid_sampling_reuses_old_geometry_until_explicit_refresh(
"100px 100px"
);
assert_eq!(page.vm().layout_pass_observability_for_test().1, passes + 2);
publish_size_layout(&mut page)?;
assert_eq!(
held_sizes(&mut page)?,
json!(["200px", "40px", "200px", "40px"])
);
assert_eq!(page.vm().layout_pass_observability_for_test().1, passes + 3);
Ok::<_, anyhow::Error>(())
})
.await
@@ -1,7 +1,7 @@
use super::*;
#[tokio::test(flavor = "current_thread")]
async fn computed_style_serializes_used_grid_tracks_from_the_frozen_layout_tree() {
async fn computed_style_refreshes_used_grid_tracks_after_style_mutation() {
run_page_vm_async_test(async move {
let loader =
crate::network::ResourceRequestClient::new(&FetchConfig::default()).expect("loader");
@@ -89,6 +89,7 @@ vertical:getComputedStyle(document.getElementById('vertical')).gridTemplateColum
"resolved horizontal Grid longhands must expose used tracks while preserving expanded line names, without publishing physical-axis values for vertical Grid",
);
let passes = page_vm.vm().layout_pass_observability_for_test().1;
page_vm
.vm_mut()
.eval("document.getElementById('named').style.cssText='width:400px;grid-template-columns:[new] 1fr 1fr';'mutated'")?;
@@ -96,9 +97,17 @@ vertical:getComputedStyle(document.getElementById('vertical')).gridTemplateColum
page_vm.vm_mut().eval(
"getComputedStyle(document.getElementById('named')).gridTemplateColumns",
)?,
"[a] 21px [b c] 22px [d] 23px [e c] 22px [d] 23px [e f] 189px [g]",
"a synchronous style read must stay on the last published layout epoch",
"[new] 200px 200px",
"a synchronous used-track read must reflect the current grid styles",
);
assert_eq!(page_vm.vm().layout_pass_observability_for_test().1, passes + 1);
assert_eq!(
page_vm.vm_mut().eval(
"getComputedStyle(document.getElementById('named')).gridTemplateColumns",
)?,
"[new] 200px 200px",
);
assert_eq!(page_vm.vm().layout_pass_observability_for_test().1, passes + 1);
page_vm
.vm_mut()
.screenshot_layout_snapshot(moli_layout::PaintViewport::new(400, 300, 1.0))?
@@ -110,6 +119,7 @@ vertical:getComputedStyle(document.getElementById('vertical')).gridTemplateColum
"[new] 200px 200px",
"a screenshot must publish the new Grid track sizes",
);
assert_eq!(page_vm.vm().layout_pass_observability_for_test().1, passes + 2);
Ok::<_, anyhow::Error>(())
})
.await
@@ -116,7 +116,7 @@ getComputedStyle(document.getElementById('fallback')).display
}
#[tokio::test(flavor = "current_thread")]
async fn geometry_batch_reuses_latest_tree_at_same_viewport_until_fresh_paint_replaces_it() {
async fn geometry_batch_reuses_current_tree_and_refreshes_after_mutation() {
run_page_vm_async_test(async move {
let loader =
crate::network::ResourceRequestClient::new(&FetchConfig::default()).expect("loader");
@@ -226,6 +226,15 @@ document.body.innerHTML = '<div id="target"></div><div id="pass-through"></div><
"{transformed_rect:?}"
);
let reused = moli_layout::GeometryProvider::answer(
page_vm.vm_mut(),
moli_layout::LayoutFlushReason::SynchronousGeometry,
moli_layout::LayoutViewport::new(320, 200, 1.0),
&batch,
)?;
assert_eq!(reused.metrics, first.metrics);
assert_eq!(page_vm.vm().layout_pass_observability_for_test(), after_first);
page_vm
.vm_mut()
.eval("document.getElementById('target').style.width='180px'; 'mutated'")?;
@@ -240,20 +249,21 @@ document.body.innerHTML = '<div id="target"></div><div id="pass-through"></div><
.vm()
.layout_snapshot_cache_observability_for_test();
assert!(!after_second.0);
assert_eq!(after_second.1, before.1 + 1);
assert_eq!(after_second.1, before.1 + 2);
assert_eq!(cache_after_second.0, cache_before.0 + 1);
assert_eq!(cache_after_second.1, cache_before.1 + 1);
assert_eq!(cache_after_second.2, cache_before.2 + 1);
assert_eq!(cache_after_second.1, cache_before.1 + 2);
assert_eq!(cache_after_second.2, cache_before.2 + 2);
let second_width = match &second.answers[1] {
moli_layout::LayoutQueryAnswer::BoxModel(Some(model)) => {
model.border.bounding_rect().width
}
answer => panic!("unexpected box-model answer: {answer:?}"),
};
assert!((second_width - 120.0).abs() <= 0.05, "{second_width}");
assert_eq!(after_second.2, after_first.2);
assert_eq!(after_second.3, after_first.3);
assert_eq!(second.metrics, first.metrics);
assert!((second_width - 180.0).abs() <= 0.05, "{second_width}");
assert_eq!(after_second.2, after_first.2 + second.metrics.elapsed);
assert_eq!(after_second.3, Some(second.metrics));
assert_eq!(second.metrics.reason, moli_layout::LayoutFlushReason::SynchronousGeometry);
assert_eq!(second.metrics.paint_operation_count, 0);
assert!(matches!(
second.answers[0],
moli_layout::LayoutQueryAnswer::DocumentMetrics(metrics)
@@ -269,10 +279,10 @@ document.body.innerHTML = '<div id="target"></div><div id="pass-through"></div><
.vm()
.layout_snapshot_cache_observability_for_test();
assert!(!after_screenshot.0);
assert_eq!(after_screenshot.1, before.1 + 2);
assert_eq!(after_screenshot.1, before.1 + 3);
assert_eq!(cache_after_screenshot.0, cache_before.0 + 1);
assert_eq!(cache_after_screenshot.1, cache_before.1 + 1);
assert_eq!(cache_after_screenshot.2, cache_before.2 + 2);
assert_eq!(cache_after_screenshot.1, cache_before.1 + 2);
assert_eq!(cache_after_screenshot.2, cache_before.2 + 3);
let (_, retention) = cache_after_screenshot
.3
.expect("fresh paint layout should publish its frozen tree");
@@ -303,10 +313,10 @@ document.body.innerHTML = '<div id="target"></div><div id="pass-through"></div><
let cache_after_third = page_vm
.vm()
.layout_snapshot_cache_observability_for_test();
assert_eq!(after_third.1, before.1 + 2);
assert_eq!(after_third.1, before.1 + 3);
assert_eq!(cache_after_third.0, cache_before.0 + 2);
assert_eq!(cache_after_third.1, cache_before.1 + 1);
assert_eq!(cache_after_third.2, cache_before.2 + 2);
assert_eq!(cache_after_third.1, cache_before.1 + 2);
assert_eq!(cache_after_third.2, cache_before.2 + 3);
let third_width = match &third.answers[1] {
moli_layout::LayoutQueryAnswer::BoxModel(Some(model)) => {
model.border.bounding_rect().width
@@ -371,22 +381,22 @@ document.body.innerHTML = '<div id=target></div>';
page_vm
.vm_mut()
.eval("document.getElementById('target').style.width='80px'; 'mutated'")?;
let stale = moli_layout::GeometryProvider::answer(
let before_next_frame = moli_layout::GeometryProvider::answer(
page_vm.vm_mut(),
moli_layout::LayoutFlushReason::SynchronousGeometry,
moli_layout::LayoutViewport::new(320, 200, 1.0),
&batch,
)?;
let stale_width = match &stale.answers[0] {
let before_next_frame_width = match &before_next_frame.answers[0] {
moli_layout::LayoutQueryAnswer::BoxModel(Some(model)) => {
model.border.bounding_rect().width
}
answer => panic!("unexpected stale box-model answer: {answer:?}"),
answer => panic!("unexpected box-model answer before the next frame: {answer:?}"),
};
assert!((stale_width - 40.0).abs() <= 0.05, "{stale_width}");
assert!((before_next_frame_width - 80.0).abs() <= 0.05, "{before_next_frame_width}");
assert_eq!(
page_vm.vm().layout_pass_observability_for_test().1,
passes_before + 1
passes_before + 2
);
page_vm
@@ -398,14 +408,14 @@ document.body.innerHTML = '<div id=target></div>';
.expect("second screencast frame layout");
assert_eq!(
page_vm.vm().layout_pass_observability_for_test().1,
passes_before + 2
passes_before + 3
);
let cache_after_second = page_vm
.vm()
.layout_snapshot_cache_observability_for_test();
assert_eq!(cache_after_second.0, cache_before.0 + 1);
assert_eq!(cache_after_second.1, cache_before.1);
assert_eq!(cache_after_second.2, cache_before.2 + 2);
assert_eq!(cache_after_second.0, cache_before.0);
assert_eq!(cache_after_second.1, cache_before.1 + 1);
assert_eq!(cache_after_second.2, cache_before.2 + 3);
assert!(cache_after_second.3.is_some());
let refreshed = moli_layout::GeometryProvider::answer(
@@ -423,14 +433,14 @@ document.body.innerHTML = '<div id=target></div>';
assert!((refreshed_width - 80.0).abs() <= 0.05, "{refreshed_width}");
assert_eq!(
page_vm.vm().layout_pass_observability_for_test().1,
passes_before + 2
passes_before + 3
);
let cache_after_query = page_vm
.vm()
.layout_snapshot_cache_observability_for_test();
assert_eq!(cache_after_query.0, cache_before.0 + 2);
assert_eq!(cache_after_query.1, cache_before.1);
assert_eq!(cache_after_query.2, cache_before.2 + 2);
assert_eq!(cache_after_query.0, cache_before.0 + 1);
assert_eq!(cache_after_query.1, cache_before.1 + 1);
assert_eq!(cache_after_query.2, cache_before.2 + 3);
Ok::<_, anyhow::Error>(())
})
.await
@@ -769,6 +769,60 @@ fn iframe_input_reuses_one_top_level_snapshot_without_parent_child_ping_pong() {
assert_eq!(cached.0, vm.document_runtime.document_handle());
}
#[test]
fn repeated_native_clicks_target_newly_inserted_buttons() {
let mut vm = new_parsed_test_vm(
"https://repeated-native-click.test/",
"<!doctype html><html><body></body></html>",
);
vm.eval("globalThis.clicks = []").unwrap();
for index in 0..3 {
vm.eval(&format!("globalThis.buttonIndex = {index}"))
.unwrap();
let geometry = vm
.eval(
r#"
(() => {
globalThis.button = document.createElement('button');
button.id = 'button-' + buttonIndex;
button.textContent = 'Activate';
button.style.cssText = 'position:absolute;left:10px;top:10px;width:100px;height:40px';
button.onclick = event => clicks.push([event.target.id, event.isTrusted]);
document.body.appendChild(button);
const rects = button.getClientRects();
if (rects.length !== 1) throw Error('new button must have a client rect');
const rect = rects[0];
const x = rect.left + rect.width / 2;
const y = rect.top + rect.height / 2;
if (document.elementFromPoint(x, y) !== button) throw Error('new button must be the hit target');
return JSON.stringify([x, y]);
})()
"#,
)
.expect("newly connected button geometry must replace stale input geometry");
let point: [f64; 2] = serde_json::from_str(&geometry).unwrap();
for (event, button, buttons) in
[("mousemove", -1, 0), ("mousedown", 0, 1), ("mouseup", 0, 0)]
{
vm.dispatch_mouse_event_at_point(
point[0],
point[1],
event,
button,
Some(buttons),
0.0,
0.0,
)
.expect("native click should reach the newly inserted button");
}
vm.eval("button.remove()").unwrap();
}
assert_eq!(
vm.eval("JSON.stringify(clicks)").unwrap(),
r#"[["button-0",true],["button-1",true],["button-2",true]]"#,
);
}
#[test]
fn iframe_wheel_batch_reuses_one_composite_snapshot_for_every_scroll_step() {
let mut vm = new_storage_test_vm("https://iframe-wheel-snapshot.test/");
@@ -8934,8 +8934,11 @@ for (let index = 0; index < 128; index++) {
);
assert_eq!(layout_after_first.1, layout_before.1 + 1);
assert_eq!(layout_after_repeated.1, layout_after_first.1);
assert_eq!(layout_after_second.1, layout_after_first.1);
assert_eq!(layout_after_stylesheet_mutation.1, layout_after_first.1);
assert_eq!(layout_after_second.1, layout_after_first.1 + 1);
assert_eq!(
layout_after_stylesheet_mutation.1,
layout_after_second.1 + 1
);
assert_eq!(
update_materializations_after_first.saturating_sub(update_materializations_before),
1,
@@ -8980,7 +8983,7 @@ for (let index = 0; index < 128; index++) {
}
#[test]
fn inner_text_new_sources_wait_for_a_fresh_paint_layout() {
fn inner_text_new_sources_refresh_layout_before_paint() {
let mut vm = new_parsed_test_vm(
"https://inner-text-latest-layout.test/",
"<!doctype html><html><body><div id=target><span>a</span></div></body></html>",
@@ -9007,30 +9010,30 @@ fn inner_text_new_sources_wait_for_a_fresh_paint_layout() {
"const added = document.createElement('span'); added.textContent = 'b'; target.append(added); target.innerText",
)
.expect("the warm innerText read should evaluate"),
"a",
"a text source absent from the latest frozen layout tree remains unrendered until refresh"
"ab",
"a connected text source must be rendered by the next synchronous innerText read"
);
assert_eq!(vm.layout_pass_observability_for_test().1, passes_before + 1);
assert_eq!(vm.layout_pass_observability_for_test().1, passes_before + 2);
vm.screenshot_layout_snapshot(moli_layout::PaintViewport::new(320, 200, 1.0))
.expect("fresh paint layout should succeed")
.expect("the fixture should have a layout root");
assert_eq!(vm.layout_pass_observability_for_test().1, passes_before + 2);
assert_eq!(vm.layout_pass_observability_for_test().1, passes_before + 3);
assert_eq!(
vm.eval("target.innerText")
.expect("innerText should read the refreshed geometry snapshot"),
"ab"
);
assert_eq!(vm.layout_pass_observability_for_test().1, passes_before + 2);
assert_eq!(vm.layout_pass_observability_for_test().1, passes_before + 3);
let cache_after = vm.layout_snapshot_cache_observability_for_test();
assert_eq!(cache_after.0, cache_before.0 + 2);
assert_eq!(cache_after.1, cache_before.1 + 1);
assert_eq!(cache_after.2, cache_before.2 + 2);
assert_eq!(cache_after.0, cache_before.0 + 1);
assert_eq!(cache_after.1, cache_before.1 + 2);
assert_eq!(cache_after.2, cache_before.2 + 3);
}
#[test]
fn parser_connected_inner_text_bypasses_a_stale_layout_snapshot() {
fn inner_text_refreshes_during_and_after_parsing() {
let mut vm = new_parsed_test_vm(
"https://parser-inner-text-layout.test/",
"<!doctype html><html><body><div id=target><span>a</span></div></body></html>",
@@ -9073,12 +9076,21 @@ fn parser_connected_inner_text_bypasses_a_stale_layout_snapshot() {
"const addedAfterParser = document.createElement('span'); addedAfterParser.textContent = 'c'; target.append(addedAfterParser); target.innerText",
)
.expect("post-parse innerText should evaluate"),
"ab",
"ordinary post-parse reads should keep the demand-driven snapshot contract"
"abc",
"ordinary post-parse reads must also render newly inserted text"
);
assert_eq!(
vm.layout_pass_observability_for_test().1,
passes_after_initial_read + 1
passes_after_initial_read + 2
);
assert_eq!(
vm.eval("target.innerText")
.expect("unchanged innerText read"),
"abc"
);
assert_eq!(
vm.layout_pass_observability_for_test().1,
passes_after_initial_read + 2
);
}
@@ -1615,7 +1615,7 @@ body.setHTMLUnsafe(`
}
#[test]
fn geometry_getters_reuse_latest_layout_across_nodes_and_mutation() {
fn geometry_getters_reuse_layout_until_dom_mutation() {
let mut vm = new_storage_test_vm("https://oneshot-layout-demand.test/");
let passes_before = vm
._context_host
@@ -1664,18 +1664,21 @@ fn geometry_getters_reuse_latest_layout_across_nodes_and_mutation() {
)
.expect("latest layout snapshot reads should evaluate");
assert_eq!(result, "38|8|38|8|38|38|0");
assert_eq!(result, "38|8|38|8|38|43|8");
let passes = vm
._context_host
.borrow()
.layout_pass_observability_for_test()
.1
.saturating_sub(passes_before);
assert_eq!(passes, 1, "only the cold getter may build layout");
assert_eq!(
passes, 2,
"the first getter after mutation must refresh layout"
);
let cache_after = vm.layout_snapshot_cache_observability_for_test();
assert_eq!(cache_after.0, cache_before.0 + 10);
assert_eq!(cache_after.1, cache_before.1 + 1);
assert_eq!(cache_after.2, cache_before.2 + 1);
assert_eq!(cache_after.0, cache_before.0 + 9);
assert_eq!(cache_after.1, cache_before.1 + 2);
assert_eq!(cache_after.2, cache_before.2 + 2);
assert!(cache_after.3.is_some());
}
@@ -1715,6 +1718,32 @@ fn switching_to_mock_geometry_drops_the_latest_real_layout_snapshot() {
);
}
#[test]
fn geometry_getters_refresh_after_cssom_changes_without_dom_mutation() {
let mut vm = new_parsed_test_vm(
"https://layout-cssom-inputs.test/",
"<!doctype html><style>#target { width: 10px; height: 20px }</style><div id=target></div>",
);
assert_eq!(vm.eval("target.offsetWidth").unwrap(), "10");
let passes_before = vm.layout_pass_observability_for_test().1;
let dom_version = vm._context_host.borrow().dom_host().dom_version();
assert_eq!(
vm.eval("document.styleSheets[0].cssRules[0].style.width = '40px'; target.offsetWidth")
.unwrap(),
"40",
);
assert_eq!(
vm._context_host.borrow().dom_host().dom_version(),
dom_version
);
assert_eq!(vm.layout_pass_observability_for_test().1, passes_before + 1);
assert_eq!(
vm.eval("target.getBoundingClientRect().width").unwrap(),
"40"
);
assert_eq!(vm.layout_pass_observability_for_test().1, passes_before + 1);
}
#[test]
fn block_flow_geometry_includes_body_margin_for_following_nodes() {
let mut vm = new_storage_test_vm("https://mock-flow-height-hint.test/");