fix(layout): project ResizeObserver sizes from frozen box axes

This commit is contained in:
ldm0
2026-10-14 23:28:19 +08:00
parent 88afc72b13
commit 28cbee209e
8 changed files with 394 additions and 40 deletions
+30 -1
View File
@@ -33,7 +33,7 @@ pub struct LayoutDocumentMetrics {
pub content_size: LayoutSize,
}
/// CSSOM View and observer metrics for one source element.
/// CSSOM View metrics for one source element.
///
/// Transformed quads use viewport CSS pixels and therefore include CSS
/// `zoom`. Offset, size, and scroll fields are untransformed and have the
@@ -65,6 +65,25 @@ pub struct LayoutElementMetrics<N> {
pub pointer_events: bool,
}
/// One ResizeObserver box size in logical CSS pixels.
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub struct LayoutResizeObserverSize {
pub inline_size: f32,
pub block_size: f32,
}
/// CSS-box ResizeObserver sizes projected wholly from one frozen principal box.
/// The physical content size is used for contentRect width/height. The logical
/// content and border pairs use the writing axes and effective zoom captured
/// with the geometry, independently of authored box-sizing. This owned answer
/// does not add state to the frozen tree or provide SVG bounding-box semantics.
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub struct LayoutResizeObserverMetrics {
pub content_size: LayoutSize,
pub content_box_size: LayoutResizeObserverSize,
pub border_box_size: LayoutResizeObserverSize,
}
/// One scroll container on a target's layout ancestor chain.
#[derive(Clone, Debug, PartialEq)]
pub struct LayoutScrollContainerMetrics<N> {
@@ -113,6 +132,10 @@ pub enum LayoutQuery<N> {
ElementMetrics {
source: N,
},
/// Physical content dimensions and logical CSS content/border sizes for RO.
ResizeObserverMetrics {
source: N,
},
/// Used Grid row and column tracks for a principal Grid container.
UsedGridTracks {
source: N,
@@ -166,6 +189,7 @@ pub enum LayoutQueryAnswer<N> {
ContentQuads(Vec<LayoutQuad>),
TextRangeRects(Vec<LayoutQuad>),
ElementMetrics(Option<LayoutElementMetrics<N>>),
ResizeObserverMetrics(Option<LayoutResizeObserverMetrics>),
UsedGridTracks(Option<LayoutResolvedGridTracks>),
ScrollIntoViewGeometry(Option<LayoutScrollIntoViewGeometry<N>>),
IntersectionGeometry(Option<LayoutIntersectionGeometry>),
@@ -309,6 +333,11 @@ where
LayoutQuery::ElementMetrics { source } => {
LayoutQueryAnswer::ElementMetrics(self.element_metrics_for_source(*source))
}
LayoutQuery::ResizeObserverMetrics { source } => {
LayoutQueryAnswer::ResizeObserverMetrics(
self.resize_observer_metrics_for_source(*source),
)
}
LayoutQuery::UsedGridTracks { source } => {
LayoutQueryAnswer::UsedGridTracks(self.used_grid_tracks_for_source(*source))
}
+46 -1
View File
@@ -10,7 +10,10 @@ use super::{
LayoutOutputBoxId, LayoutPoint, LayoutQuad, LayoutRect, LayoutResolvedGridTracks,
LayoutSize, LayoutTransform2D, LayoutUsedSize,
},
query::{LayoutElementMetrics, LayoutNodeOutput},
query::{
LayoutElementMetrics, LayoutNodeOutput, LayoutResizeObserverMetrics,
LayoutResizeObserverSize,
},
tree::{CssSizingBox, FrozenLayoutBox, FrozenLayoutTree},
};
@@ -104,6 +107,19 @@ where
self.element_metrics_for_source_with_offset_parent_filter(source, |_| true)
}
/// ResizeObserver's CSS-box sizes, interpreted using only metadata and
/// geometry from this snapshot. Non-replaced inline and suppressed sources
/// have no applicable CSS sizing box and produce no measurement here.
pub fn resize_observer_metrics_for_source(
&self,
source: N,
) -> Option<LayoutResizeObserverMetrics> {
self.boxes
.iter()
.find(|layout_box| layout_box.principal_source == Some(source))?
.resize_observer_metrics()
}
/// Physical and logical CSSOM sizes from this snapshot's sizing box,
/// without transforms, integer rounding, or absolute CSS zoom. A source
/// without an applicable principal box keeps its computed value.
@@ -549,6 +565,35 @@ where
}
impl<N> FrozenLayoutBox<N> {
fn resize_observer_metrics(&self) -> Option<LayoutResizeObserverMetrics> {
let sizing = self.css_sizing?;
let unzoom = CssomAbsoluteZoom::new(self.effective_zoom);
let content_size = unzoom.size(LayoutSize::new(
self.content_box.width,
self.content_box.height,
));
let border_size = unzoom.size(LayoutSize::new(
self.border_box.width,
self.border_box.height,
));
let logical = |size: LayoutSize| {
let (inline_size, block_size) = if sizing.horizontal {
(size.width, size.height)
} else {
(size.height, size.width)
};
LayoutResizeObserverSize {
inline_size,
block_size,
}
};
Some(LayoutResizeObserverMetrics {
content_size,
content_box_size: logical(content_size),
border_box_size: logical(border_size),
})
}
fn used_size(&self) -> Option<LayoutUsedSize> {
let sizing = self.css_sizing?;
let rect = match sizing.box_kind {
+5 -4
View File
@@ -59,10 +59,11 @@ pub use layout_tree::{
LayoutFragmentBoxModel, LayoutFragmentId, LayoutFragmentKind, LayoutHit,
LayoutIntersectionGeometry, LayoutNodeOutput, LayoutOutputBoxId, LayoutPaintedSurfaceHit,
LayoutPassMetrics, LayoutPassResult, LayoutPoint, LayoutQuad, LayoutQuery, LayoutQueryAnswer,
LayoutQueryBatch, LayoutRect, LayoutResolvedGridTrackList, LayoutResolvedGridTracks,
LayoutScrollContainerMetrics, LayoutScrollExtent, LayoutScrollIntoViewGeometry, LayoutSize,
LayoutTransform2D, LayoutTreeRetentionMetrics, LayoutUsedSize, LayoutViewport,
MAX_RETAINED_LAYOUT_BOXES, MAX_RETAINED_LAYOUT_FRAGMENTS, MAX_RETAINED_LAYOUT_TREE_BYTES,
LayoutQueryBatch, LayoutRect, LayoutResizeObserverMetrics, LayoutResizeObserverSize,
LayoutResolvedGridTrackList, LayoutResolvedGridTracks, LayoutScrollContainerMetrics,
LayoutScrollExtent, LayoutScrollIntoViewGeometry, LayoutSize, LayoutTransform2D,
LayoutTreeRetentionMetrics, LayoutUsedSize, LayoutViewport, MAX_RETAINED_LAYOUT_BOXES,
MAX_RETAINED_LAYOUT_FRAGMENTS, MAX_RETAINED_LAYOUT_TREE_BYTES,
};
pub use normalize::{NormalizedBoxNode, NormalizedBoxTree, NormalizedFormattingContext};
pub use normalize_source::{
@@ -454,7 +454,7 @@ fn for_each_resize_observation<'s, 'i>(
handles
.iter()
.copied()
.map(|source| moli_layout::LayoutQuery::ElementMetrics { source }),
.map(|source| moli_layout::LayoutQuery::ResizeObserverMetrics { source }),
);
let answers = match crate::native_bridge::element::published_geometry_batch(
runtime,
@@ -480,10 +480,10 @@ fn for_each_resize_observation<'s, 'i>(
}
};
for (handle, answer) in handles.into_iter().zip(answers) {
let moli_layout::LayoutQueryAnswer::ElementMetrics(metrics) = answer else {
let moli_layout::LayoutQueryAnswer::ResizeObserverMetrics(metrics) = answer else {
return Err(moli_layout::LayoutError::source_contract(
"ResizeObserver geometry",
"provider returned a mismatched element-metrics answer",
"provider returned a mismatched ResizeObserver-metrics answer",
));
};
geometry.insert(handle, (metrics, dpr));
@@ -493,43 +493,36 @@ fn for_each_resize_observation<'s, 'i>(
for entry in pending {
let (metrics, dpr) = entry
.handle
.and_then(|handle| geometry.get(&handle).cloned())
.and_then(|handle| geometry.get(&handle).copied())
.and_then(|(metrics, dpr)| metrics.map(|metrics| (metrics, dpr)))
.map(|(metrics, dpr)| (Some(metrics), dpr))
.unwrap_or((None, 1.0));
let content_width = metrics
.as_ref()
.map(|metrics| f64::from(metrics.content_size.width))
.unwrap_or(0.0);
let content_height = metrics
.as_ref()
.map(|metrics| f64::from(metrics.content_size.height))
.unwrap_or(0.0);
let border_width = metrics
.as_ref()
.map(|metrics| f64::from(metrics.offset_size.width))
.unwrap_or(0.0);
let border_height = metrics
.as_ref()
.map(|metrics| f64::from(metrics.offset_size.height))
.unwrap_or(0.0);
let rect = (0.0, 0.0, content_width, content_height);
let content_box_size = (content_width, content_height);
let border_box_size = (border_width, border_height);
.unwrap_or((moli_layout::LayoutResizeObserverMetrics::default(), 1.0));
let rect = (
0.0,
0.0,
f64::from(metrics.content_size.width),
f64::from(metrics.content_size.height),
);
let content_box_size = (
f64::from(metrics.content_box_size.inline_size),
f64::from(metrics.content_box_size.block_size),
);
let border_box_size = (
f64::from(metrics.border_box_size.inline_size),
f64::from(metrics.border_box_size.block_size),
);
// Preserve the existing device-pixel approximation, now on the same
// logical axes. Effective zoom and positional snapping are separate.
let device_pixel_content_box_size = (
(content_width * f64::from(dpr)).round(),
(content_height * f64::from(dpr)).round(),
(content_box_size.0 * f64::from(dpr)).round(),
(content_box_size.1 * f64::from(dpr)).round(),
);
let observed_size = match entry
.record
.and_then(|record| observed_record_box(scope, record))
{
Some(ResizeObserverBoxOptions::BorderBox) => (border_width, border_height),
Some(ResizeObserverBoxOptions::DevicePixelContentBox) => (
(content_width * f64::from(dpr)).round(),
(content_height * f64::from(dpr)).round(),
),
Some(ResizeObserverBoxOptions::ContentBox) | None => (content_width, content_height),
Some(ResizeObserverBoxOptions::BorderBox) => border_box_size,
Some(ResizeObserverBoxOptions::DevicePixelContentBox) => device_pixel_content_box_size,
Some(ResizeObserverBoxOptions::ContentBox) | None => content_box_size,
};
if compare_reported_sizes && let Some(record) = entry.record {
let previous = resize_observer_last_reported_size(scope, record);
@@ -4,7 +4,8 @@ use moli_layout::{
LayoutAnswers, LayoutBoxModel, LayoutCaretPosition, LayoutDocumentMetrics,
LayoutElementMetrics, LayoutFlushReason, LayoutHit, LayoutIntersectionGeometry,
LayoutPassMetrics, LayoutPoint, LayoutQuery, LayoutQueryAnswer, LayoutQueryBatch,
LayoutScrollContainerMetrics, LayoutScrollIntoViewGeometry, LayoutSize,
LayoutResizeObserverMetrics, LayoutResizeObserverSize, LayoutScrollContainerMetrics,
LayoutScrollIntoViewGeometry, LayoutSize,
};
use super::super::client_rect::{quad_from_client_rect, zero_client_rect};
@@ -107,6 +108,22 @@ pub(crate) fn answer_query(
LayoutQuery::ElementMetrics { source } => {
LayoutQueryAnswer::ElementMetrics(mock_element_metrics(runtime, *source))
}
LayoutQuery::ResizeObserverMetrics { source } => {
// Keep Mock's existing approximate physical-as-logical contract.
LayoutQueryAnswer::ResizeObserverMetrics(mock_element_metrics(runtime, *source).map(
|metrics| LayoutResizeObserverMetrics {
content_size: metrics.content_size,
content_box_size: LayoutResizeObserverSize {
inline_size: metrics.content_size.width,
block_size: metrics.content_size.height,
},
border_box_size: LayoutResizeObserverSize {
inline_size: metrics.offset_size.width,
block_size: metrics.offset_size.height,
},
},
))
}
LayoutQuery::UsedGridTracks { .. } => LayoutQueryAnswer::UsedGridTracks(None),
LayoutQuery::ScrollIntoViewGeometry { source } => {
LayoutQueryAnswer::ScrollIntoViewGeometry(mock_scroll_into_view_geometry(
@@ -2,6 +2,7 @@ use super::realtime_opportunity::run_opportunity;
use super::*;
use moli_page_types::LayoutPolicy;
mod geometry;
mod lifecycle;
mod ownership;
mod rewrites;
@@ -0,0 +1,184 @@
use super::*;
fn observe_axis_boxes(page: &mut PageVm, size: &str) {
page.vm_mut().eval(&format!(r#"
document.body.innerHTML='<div id=target style="display:block;{size};padding:0;border:2px solid;box-sizing:content-box;writing-mode:vertical-lr"></div>';
window.samples={{content:[],border:[]}};
window.record=e=>[
e.contentRect.width,e.contentRect.height,
e.contentBoxSize[0].inlineSize,e.contentBoxSize[0].blockSize,
e.borderBoxSize[0].inlineSize,e.borderBoxSize[0].blockSize
];
window.contentObserver=new ResizeObserver(entries=>samples.content.push(...entries.map(record)));
window.borderObserver=new ResizeObserver(entries=>samples.border.push(...entries.map(record)));
contentObserver.observe(target);
borderObserver.observe(target,{{box:'border-box'}});
"#)).unwrap();
}
#[test]
fn realtime_ro_physical_dimensions_notify_when_only_logical_axes_change() {
run_page_vm_large_stack_async_test("realtime-ro-physical-axis-change", || async move {
let loader = crate::network::ResourceRequestClient::new(&FetchConfig::default()).unwrap();
let mut page = realtime_page(&loader);
observe_axis_boxes(&mut page, "width:50px;height:100px");
run_opportunity(&mut page, &loader).await.unwrap();
assert_eq!(
page.vm_mut().eval("JSON.stringify([samples.content.map(x=>x.slice(0,2)),samples.border.map(x=>x.slice(0,2))])").unwrap(),
"[[[50,100]],[[50,100]]]"
);
page.vm_mut()
.eval("target.style.writingMode='horizontal-tb'")
.unwrap();
run_opportunity(&mut page, &loader).await.unwrap();
assert_eq!(
page.vm_mut()
.eval("JSON.stringify([samples.content.length,samples.border.length])")
.unwrap(),
"[2,2]",
"changing only logical axes is a size change for both observed boxes"
);
let expected = r#"{"content":[[50,100,100,50,104,54],[50,100,50,100,54,104]],"border":[[50,100,100,50,104,54],[50,100,50,100,54,104]]}"#;
assert_eq!(
page.vm_mut().eval("JSON.stringify(samples)").unwrap(),
expected
);
let builds = page.vm().layout_pass_observability_for_test().1;
page.vm_mut().eval("requestAnimationFrame(()=>{})").unwrap();
run_opportunity(&mut page, &loader).await.unwrap();
assert_eq!(
page.vm_mut().eval("JSON.stringify(samples)").unwrap(),
expected
);
assert_eq!(page.vm().layout_pass_observability_for_test().1, builds);
assert_eq!(
page.vm()
.layout_pass_observability_for_test()
.3
.unwrap()
.paint_operation_count,
0
);
assert!(
page.next_rendering_opportunity_deadline_for_test()
.is_none()
);
assert!(page.vm().next_timeout_deadline().is_none());
});
}
#[test]
fn realtime_ro_logical_dimensions_do_not_notify_when_only_physical_axes_change() {
run_page_vm_large_stack_async_test("realtime-ro-logical-axis-change", || async move {
let loader = crate::network::ResourceRequestClient::new(&FetchConfig::default()).unwrap();
let mut page = realtime_page(&loader);
observe_axis_boxes(&mut page, "inline-size:100px;block-size:50px");
run_opportunity(&mut page, &loader).await.unwrap();
let expected = r#"{"content":[[50,100,100,50,104,54]],"border":[[50,100,100,50,104,54]]}"#;
assert_eq!(
page.vm_mut().eval("JSON.stringify([samples.content.map(x=>x.slice(0,2)),samples.border.map(x=>x.slice(0,2))])").unwrap(),
"[[[50,100]],[[50,100]]]",
"the ordinary backend must first resolve these logical CSS dimensions"
);
page.vm_mut()
.eval("target.style.writingMode='horizontal-tb'")
.unwrap();
run_opportunity(&mut page, &loader).await.unwrap();
assert_eq!(
page.vm_mut()
.eval("JSON.stringify([samples.content.length,samples.border.length])")
.unwrap(),
"[1,1]",
"unchanged logical sizes must not produce a second notification"
);
assert_eq!(
page.vm_mut().eval("JSON.stringify(samples)").unwrap(),
expected
);
let builds = page.vm().layout_pass_observability_for_test().1;
// The observer decision above must use the frame's already-current
// layout. This getter proves the physical box swapped without fixing it.
assert_eq!(
page.vm_mut()
.eval("JSON.stringify([target.offsetWidth,target.offsetHeight])")
.unwrap(),
"[104,54]"
);
assert_eq!(page.vm().layout_pass_observability_for_test().1, builds);
page.vm_mut().eval("requestAnimationFrame(()=>{})").unwrap();
run_opportunity(&mut page, &loader).await.unwrap();
assert_eq!(
page.vm_mut().eval("JSON.stringify(samples)").unwrap(),
expected
);
assert_eq!(page.vm().layout_pass_observability_for_test().1, builds);
assert_eq!(
page.vm()
.layout_pass_observability_for_test()
.3
.unwrap()
.paint_operation_count,
0
);
assert!(
page.next_rendering_opportunity_deadline_for_test()
.is_none()
);
assert!(page.vm().next_timeout_deadline().is_none());
});
}
#[test]
fn realtime_ro_uses_the_axes_frozen_with_each_published_sample() {
run_page_vm_large_stack_async_test("realtime-ro-published-axes", || async move {
for force in [false, true] {
let loader =
crate::network::ResourceRequestClient::new(&FetchConfig::default()).unwrap();
let mut page = realtime_page(&loader);
page.vm_mut().eval(&format!(r#"
document.body.innerHTML='<div id=a style="width:100px;height:20px;padding:0;border:0"></div><div id=b style="width:50px;height:100px;padding:0;border:0;writing-mode:vertical-lr"></div>';
window.order=[];window.errors=[];
window.onerror=message=>{{errors.push(message);return true}};
window.first=new ResizeObserver(()=>{{order.push('first');b.style.writingMode='horizontal-tb';if({force})void b.offsetWidth}});
window.second=new ResizeObserver(entries=>order.push(entries.map(e=>[
e.contentRect.width,e.contentRect.height,
e.contentBoxSize[0].inlineSize,e.contentBoxSize[0].blockSize,
e.borderBoxSize[0].inlineSize,e.borderBoxSize[0].blockSize
])));
first.observe(a);second.observe(b);
"#)).unwrap();
run_opportunity(&mut page, &loader).await.unwrap();
assert_eq!(
page.vm_mut()
.eval("JSON.stringify([order,errors])")
.unwrap(),
if force {
r#"[["first",[[50,100,50,100,50,100]]],[]]"#
} else {
r#"[["first",[[50,100,100,50,100,50]]],["ResizeObserver loop completed with undelivered notifications."]]"#
}
);
if !force {
run_opportunity(&mut page, &loader).await.unwrap();
assert_eq!(
page.vm_mut().eval("JSON.stringify(order)").unwrap(),
r#"["first",[[50,100,100,50,100,50]],[[50,100,50,100,50,100]]]"#
);
}
assert!(
page.next_rendering_opportunity_deadline_for_test()
.is_none()
);
assert_eq!(
page.vm()
.layout_pass_observability_for_test()
.3
.unwrap()
.paint_operation_count,
0
);
}
});
}
@@ -176,3 +176,87 @@ async fn resize_observer_entries_preserve_webidl_snapshots_and_callback_realms()
assert_eq!(result["failures"], serde_json::json!([]), "{name}");
}
}
#[test]
fn ondemand_ro_uses_published_css_box_axes_and_unzoomed_dimensions() {
let mut vm = new_storage_html_test_vm("https://resize-published-axis.test/");
vm.set_layout_policy(moli_page_types::LayoutPolicy::OnDemand);
vm.eval(r#"
document.body.innerHTML='<div id=target style="display:block;width:50px;height:100px;padding:0;border:2px solid;box-sizing:content-box;writing-mode:vertical-lr;zoom:2"></div>';
window.samples=[];
"#).unwrap();
vm.publish_layout().unwrap();
let builds = vm.layout_pass_observability_for_test().1;
vm.eval(
r#"
window.observer=new ResizeObserver(entries=>samples.push(...entries.map(e=>[
e.contentRect.width,e.contentRect.height,
e.contentBoxSize[0].inlineSize,e.contentBoxSize[0].blockSize,
e.borderBoxSize[0].inlineSize,e.borderBoxSize[0].blockSize
])));
observer.observe(target);
"#,
)
.unwrap();
assert_eq!(
vm.eval("JSON.stringify(samples)").unwrap(),
"[[50,100,100,50,104,54]]"
);
assert_eq!(vm.layout_pass_observability_for_test().1, builds);
vm.eval("target.style.writingMode='horizontal-tb'").unwrap();
assert_eq!(
vm.eval("JSON.stringify(samples)").unwrap(),
"[[50,100,100,50,104,54]]",
"OnDemand must continue sampling the axes frozen with its previous layout"
);
assert_eq!(vm.layout_pass_observability_for_test().1, builds);
vm.publish_layout().unwrap();
let builds = vm.layout_pass_observability_for_test().1;
// Publishing a snapshot is not itself a legacy RO scheduling operation.
// This mutation schedules its existing check without requesting layout.
vm.eval("target.setAttribute('data-resample','yes')")
.unwrap();
assert_eq!(
vm.eval("JSON.stringify(samples)").unwrap(),
"[[50,100,100,50,104,54],[50,100,50,100,54,104]]"
);
assert_eq!(vm.layout_pass_observability_for_test().1, builds);
assert_eq!(
vm.layout_pass_observability_for_test()
.3
.unwrap()
.paint_operation_count,
0
);
}
#[test]
fn real_ro_css_box_projection_excludes_non_replaced_inline_fragments() {
let mut vm = new_storage_html_test_vm("https://resize-css-box-kind.test/");
vm.set_layout_policy(moli_page_types::LayoutPolicy::OnDemand);
vm.eval(r#"
document.body.innerHTML='<span id=inline style="padding:0;border:2px solid">text</span><span id=atomic style="display:inline-block;width:50px;height:100px;padding:0;border:2px solid;box-sizing:content-box;writing-mode:vertical-lr"></span>';
window.samples=[];
"#).unwrap();
vm.publish_layout().unwrap();
let builds = vm.layout_pass_observability_for_test().1;
vm.eval(
r#"
window.observer=new ResizeObserver(entries=>samples.push(...entries.map(e=>[
e.target.id,e.contentRect.width,e.contentRect.height,
e.contentBoxSize[0].inlineSize,e.contentBoxSize[0].blockSize,
e.borderBoxSize[0].inlineSize,e.borderBoxSize[0].blockSize
])));
observer.observe(document.getElementById('inline'));
observer.observe(document.getElementById('atomic'));
"#,
)
.unwrap();
assert_eq!(
vm.eval("JSON.stringify(samples)").unwrap(),
r#"[["inline",0,0,0,0,0,0],["atomic",50,100,100,50,104,54]]"#,
"a non-replaced inline's CSSOM fragment union is not an RO CSS box"
);
assert_eq!(vm.layout_pass_observability_for_test().1, builds);
}