From 69e53ec34c62ed831703ef4cd154bae4f5edcc56 Mon Sep 17 00:00:00 2001 From: ldm0 Date: Wed, 9 Sep 2026 10:31:33 +0800 Subject: [PATCH] fix(layout): derive directional scroll geometry from fragment bounds Preserve pre-relative in-flow positions and used margins in the Taffy layout result, keeping alignment bounds separate from transformed visual overflow. Resolve reachable regions and signed scroll ranges once for scrollbar feedback and frozen CSSOM/paint projection. Retain fixed descendants of transformed containing blocks in their overflow graph. Cover writing directions, reverse flex packing, relative and transformed fragments, collapsing and negative margins, zero-area bounds, fixed containing blocks, and removal after capture. Chromium-checked release probes match 1668 geometry and 180 pixel checks; overflow focus gains 10 cases while the previous 306-case focus is unchanged. Full workspace fmt, Clippy and 17193 Nextest tests pass. --- Cargo.lock | 2 +- Cargo.toml | 2 +- moli-layout/src/overflow.rs | 354 +++++++++++++++--- moli-layout/src/projection.rs | 37 +- moli-layout/src/table.rs | 5 + moli-layout/src/taffy_tree.rs | 17 + .../page_vm/tests/directional_overflow.rs | 73 ++++ .../src/runtime/page_vm/tests/mod.rs | 1 + .../fixtures/directional-scroll-overflow.html | 137 +++++++ 9 files changed, 541 insertions(+), 87 deletions(-) create mode 100644 moli-renderer-v8/src/runtime/page_vm/tests/directional_overflow.rs create mode 100644 moli-renderer-v8/tests/fixtures/directional-scroll-overflow.html diff --git a/Cargo.lock b/Cargo.lock index 239c6074f1..c291992be0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4842,7 +4842,7 @@ dependencies = [ [[package]] name = "taffy" version = "0.13.0" -source = "git+https://github.com/ldm0/taffy?rev=151ce0426f1b0d8a0fa312192f4f1356b982bd51#151ce0426f1b0d8a0fa312192f4f1356b982bd51" +source = "git+https://github.com/ldm0/taffy?rev=1301f8118a0fd0e7e151eb61d16ec137df6f2039#1301f8118a0fd0e7e151eb61d16ec137df6f2039" dependencies = [ "arrayvec", "serde", diff --git a/Cargo.toml b/Cargo.toml index 09e55372e4..f9f4d42155 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -102,7 +102,7 @@ stylo_atoms = { git = "https://github.com/ldm0/stylo", rev = "cfa170da90c467e937 stylo_dom = { git = "https://github.com/ldm0/stylo", rev = "cfa170da90c467e93786ec0fa5526bd83f201933" } stylo_static_prefs = { git = "https://github.com/ldm0/stylo", rev = "cfa170da90c467e93786ec0fa5526bd83f201933" } stylo_traits = { git = "https://github.com/ldm0/stylo", rev = "cfa170da90c467e93786ec0fa5526bd83f201933" } -taffy = { git = "https://github.com/ldm0/taffy", rev = "151ce0426f1b0d8a0fa312192f4f1356b982bd51" } +taffy = { git = "https://github.com/ldm0/taffy", rev = "1301f8118a0fd0e7e151eb61d16ec137df6f2039" } [workspace.lints.clippy] disallowed_methods = "deny" diff --git a/moli-layout/src/overflow.rs b/moli-layout/src/overflow.rs index 1db2cb59f9..59df3db35a 100644 --- a/moli-layout/src/overflow.rs +++ b/moli-layout/src/overflow.rs @@ -1,10 +1,149 @@ use std::{collections::VecDeque, fmt::Debug, hash::Hash}; use crate::{ - LayoutBoxId, LayoutPoint, LayoutRect, LayoutScrollbarAxis, LayoutTransform2D, LayoutViewport, - LayoutWorld, style::ResolvedLayoutTransform, + LayoutBoxId, LayoutPoint, LayoutRect, LayoutScrollbarAxis, LayoutSize, LayoutTransform2D, + LayoutViewport, LayoutWorld, + style::{LayoutOverflowMode, ResolvedLayoutStyle, ResolvedLayoutTransform}, }; +/// The physical edge from which an axis scrolls. Flex packing can reverse it +/// independently of the writing direction (including with wrap-reverse). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum ScrollStartEdge { + Min, + Max, +} + +impl ScrollStartEdge { + fn from_reversed(reversed: bool) -> Self { + if reversed { Self::Max } else { Self::Min } + } + + fn reachable_edges(self, min: f32, max: f32, port_min: f32, port_max: f32) -> (f32, f32) { + match self { + Self::Min => (min.max(port_min), max.max(port_min)), + Self::Max => (min.min(port_max), max.min(port_max)), + } + } + + fn flow_margins(self, min: f32, max: f32, size: f32) -> (f32, f32) { + // Negative margins can retract the scroll-end edge, but cannot remove + // more than the fragment's size or retract the opposite edge. + match self { + Self::Min => (min.max(0.0), max.max(-size)), + Self::Max => (min.max(-size), max.max(0.0)), + } + } +} + +#[derive(Clone, Copy, Debug)] +struct ScrollOrigin { + horizontal: ScrollStartEdge, + vertical: ScrollStartEdge, +} + +impl ScrollOrigin { + fn for_style(style: &ResolvedLayoutStyle, is_viewport: bool) -> Self { + let mode = style.writing_mode(); + let mut inline_reversed = mode.is_inline_flow_reversed(style.taffy.direction); + let mut block_reversed = mode.is_block_flow_reversed(); + if !is_viewport && style.taffy.display == taffy::Display::Flex { + let reverse = matches!( + style.taffy.flex_direction, + taffy::FlexDirection::RowReverse | taffy::FlexDirection::ColumnReverse + ); + let wrap_reverse = style.taffy.flex_wrap == taffy::FlexWrap::WrapReverse; + if matches!( + style.taffy.flex_direction, + taffy::FlexDirection::Column | taffy::FlexDirection::ColumnReverse + ) { + block_reversed ^= reverse; + inline_reversed ^= wrap_reverse; + } else { + inline_reversed ^= reverse; + block_reversed ^= wrap_reverse; + } + } + let reversed = mode.to_physical(taffy::LogicalSize { + inline_size: inline_reversed, + block_size: block_reversed, + }); + Self { + horizontal: ScrollStartEdge::from_reversed(reversed.width), + vertical: ScrollStartEdge::from_reversed(reversed.height), + } + } + + fn reachable_rect(self, rect: LayoutRect, port: LayoutRect) -> LayoutRect { + let (left, right) = + self.horizontal + .reachable_edges(rect.x, rect.right(), port.x, port.right()); + let (top, bottom) = + self.vertical + .reachable_edges(rect.y, rect.bottom(), port.y, port.bottom()); + LayoutRect::new(left, top, right - left, bottom - top) + } + + fn add_visual_overflow( + self, + overflow: &mut LayoutRect, + contribution: LayoutRect, + scrollport: Option, + ) { + let reachable = + scrollport.map_or(contribution, |port| self.reachable_rect(contribution, port)); + // Clip before union: a rectangle unreachable in one axis must not + // extend the other axis through the empty space back to the scrollport. + if reachable.width > 0.0 && reachable.height > 0.0 { + *overflow = overflow.union(reachable); + } + } + + fn flow_bounds(self, flow: taffy::InFlowLayout, size: taffy::Size) -> LayoutRect { + let (left, right) = + self.horizontal + .flow_margins(flow.margin.left, flow.margin.right, size.width); + let (top, bottom) = + self.vertical + .flow_margins(flow.margin.top, flow.margin.bottom, size.height); + LayoutRect::new( + flow.location.x - left, + flow.location.y - top, + size.width + left + right, + size.height + top + bottom, + ) + } +} + +/// One derivation of content extent and signed scroll range, shared by +/// automatic scrollbar feedback and frozen CSSOM/paint projection. +pub(crate) struct ScrollDimensions { + pub(crate) size: LayoutSize, + pub(crate) minimum: LayoutPoint, + pub(crate) maximum: LayoutPoint, +} + +impl ScrollDimensions { + fn from_rects(port: LayoutRect, overflow: LayoutRect) -> Self { + let overflow = port.union(overflow); + Self { + size: LayoutSize::new(overflow.width, overflow.height), + minimum: LayoutPoint::new(overflow.x - port.x, overflow.y - port.y), + maximum: LayoutPoint::new( + overflow.right() - port.right(), + overflow.bottom() - port.bottom(), + ), + } + } + + pub(crate) fn overflowing_axes(&self) -> (bool, bool) { + ( + self.maximum.x - self.minimum.x > f32::EPSILON, + self.maximum.y - self.minimum.y > f32::EPSILON, + ) + } +} + /// Geometry needed to resolve scrollable overflow and no other projection /// concern. Keeping this sidecar smaller than `OutputProjection` lets automatic /// scrollbar feedback converge without allocating sources, fragments, clips, @@ -22,6 +161,8 @@ pub(crate) struct OverflowBoxGeometry { pub(crate) horizontal_gutter: f32, pub(crate) horizontal_leading_gutter: f32, local_overflow: LayoutRect, + inflow_bounds: Option, + scroll_origin: ScrollOrigin, } /// One pass-local, incrementally refreshed scrollable-overflow projection. @@ -84,6 +225,14 @@ impl OverflowProjection { self.scrollable_overflow[id.index()] } + pub(crate) fn scroll_dimensions(&self, id: LayoutBoxId) -> ScrollDimensions { + let geometry = self.geometry(id); + let reachable = geometry + .scroll_origin + .reachable_rect(self.scrollable_overflow(id), geometry.local_scrollport); + ScrollDimensions::from_rects(geometry.local_scrollport, reachable) + } + pub(crate) fn overflowing_axes( &self, world: &LayoutWorld, @@ -95,22 +244,7 @@ impl OverflowProjection { if !establishes_scroll_container(world, id) { return (false, false); } - let geometry = self.geometry(id); - let overflow = self.scrollable_overflow(id); - let horizontal_range = geometry - .local_scrollport - .width - .max((overflow.right() - geometry.local_scrollport.x).max(0.0)) - - geometry.local_scrollport.width; - let vertical_range = geometry - .local_scrollport - .height - .max((overflow.bottom() - geometry.local_scrollport.y).max(0.0)) - - geometry.local_scrollport.height; - ( - horizontal_range > f32::EPSILON, - vertical_range > f32::EPSILON, - ) + self.scroll_dimensions(id).overflowing_axes() } /// Reprojects the boxes changed by numeric layout and their overflow @@ -222,9 +356,40 @@ impl OverflowProjection { where N: Copy + Debug + Eq + Hash, { - let mut overflow = self.geometries[id.index()].local_overflow; + let geometry = self.geometries[id.index()]; + let mut overflow = geometry.local_overflow; + let is_scroller = establishes_scroll_container(world, id); + let mut inflow_bounds = is_scroller.then_some(geometry.inflow_bounds).flatten(); for child in self.children[id.index()].iter().copied() { - overflow = overflow.union(self.child_contribution(world, child)); + geometry.scroll_origin.add_visual_overflow( + &mut overflow, + self.child_contribution(world, child), + is_scroller.then_some(geometry.local_scrollport), + ); + if is_scroller { + let layout = world.boxes[child.index()].final_layout; + if let Some(flow) = layout.in_flow { + let bounds = geometry.scroll_origin.flow_bounds(flow, layout.size); + inflow_bounds = + Some(inflow_bounds.map_or(bounds, |current| current.union(bounds))); + } + } + } + if let Some(bounds) = inflow_bounds { + let padding = world.boxes[id.index()].final_layout.padding; + let padded = outset_rect( + bounds, + padding.top, + padding.right, + padding.bottom, + padding.left, + ); + // Even zero-area in-flow fragments establish trailing padding. + overflow = overflow.union( + geometry + .scroll_origin + .reachable_rect(padded, geometry.local_scrollport), + ); } self.scrollable_overflow[id.index()] = overflow; } @@ -234,22 +399,23 @@ impl OverflowProjection { N: Copy + Debug + Eq + Hash, { let geometry = self.geometries[child.index()]; - let visual_overflow = if clips_overflow(world, child) { - geometry.border_box - } else { - self.scrollable_overflow[child.index()] - }; + let mut visual_overflow = self.scrollable_overflow[child.index()]; + let modes = overflow_modes(world, child); + if modes[0] != LayoutOverflowMode::Visible { + let right = visual_overflow.right().min(geometry.border_box.right()); + visual_overflow.x = visual_overflow.x.max(geometry.border_box.x); + visual_overflow.width = (right - visual_overflow.x).max(0.0); + } + if modes[1] != LayoutOverflowMode::Visible { + let bottom = visual_overflow.bottom().min(geometry.border_box.bottom()); + visual_overflow.y = visual_overflow.y.max(geometry.border_box.y); + visual_overflow.height = (bottom - visual_overflow.y).max(0.0); + } + let visual_overflow = geometry.border_box.union(visual_overflow); let location = world.boxes[child.index()].final_layout.location; let layout_translation = LayoutTransform2D::translation(location.x, location.y); let local_to_parent = layout_translation.concatenate(geometry.resolved_transform.transform); - local_to_parent - .map_rect(visual_overflow) - .bounding_rect() - .union( - layout_translation - .map_rect(geometry.margin_box) - .bounding_rect(), - ) + local_to_parent.map_rect(visual_overflow).bounding_rect() } } @@ -257,10 +423,17 @@ fn overflow_parent(world: &LayoutWorld, id: LayoutBoxId) -> Option( @@ -325,18 +498,10 @@ where horizontal_gutter, horizontal_leading_gutter, ); - let mut local_overflow = LayoutRect::new( - local_scrollport.x, - local_scrollport.y, - local_scrollport - .width - .max(layout.content_size.width) - .max(0.0), - local_scrollport - .height - .max(layout.content_size.height) - .max(0.0), - ); + let mut local_overflow = local_scrollport; + let scroll_origin = ScrollOrigin::for_style(&layout_box.style, is_root); + let clip_to_scrollport = establishes_scroll_container(world, id).then_some(local_scrollport); + let mut inflow_bounds: Option = None; if is_root { local_overflow = local_overflow.union(padding_box); } @@ -358,15 +523,27 @@ where }, ); for line in &context.fragments.lines { - local_overflow = local_overflow.union(offset_rect(line.rect, origin)); + let rect = offset_rect(line.rect, origin); + if rect.width > 0.0 && rect.height > 0.0 { + local_overflow = local_overflow.union(rect); + inflow_bounds = Some(inflow_bounds.map_or(rect, |current| current.union(rect))); + } } for fragment in &context.fragments.text { if fragment.kind == crate::inline::InlineTextFragmentKind::Content { - local_overflow = local_overflow.union(offset_rect(fragment.rect, origin)); + scroll_origin.add_visual_overflow( + &mut local_overflow, + offset_rect(fragment.rect, origin), + clip_to_scrollport, + ); } } for fragment in &context.fragments.boxes { - local_overflow = local_overflow.union(offset_rect(fragment.box_model.border, origin)); + scroll_origin.add_visual_overflow( + &mut local_overflow, + offset_rect(fragment.box_model.border, origin), + clip_to_scrollport, + ); } } OverflowBoxGeometry { @@ -381,6 +558,8 @@ where horizontal_gutter, horizontal_leading_gutter, local_overflow, + inflow_bounds, + scroll_origin, } } @@ -439,16 +618,16 @@ where } } -fn clips_overflow(world: &LayoutWorld, id: LayoutBoxId) -> bool +fn overflow_modes(world: &LayoutWorld, id: LayoutBoxId) -> [LayoutOverflowMode; 2] where N: Copy + Debug + Eq + Hash, { if id == world.root { - world.viewport_scroll_policy.clips_overflow() + world.viewport_scroll_policy.effective_overflow } else if world.is_viewport_defining_body(id) { - false + [LayoutOverflowMode::Visible; 2] } else { - world.boxes[id.index()].style.clips_overflow() + world.boxes[id.index()].style.overflow_modes() } } @@ -519,3 +698,70 @@ fn scrollport_for_box( scrollport.y += horizontal_leading_gutter; scrollport } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn signed_scroll_ranges_derive_from_the_port_and_overflow_rectangles() { + let dimensions = ScrollDimensions::from_rects( + LayoutRect::new(12.0, 8.0, 100.0, 200.0), + LayoutRect::new(-8.0, 8.0, 120.0, 250.0), + ); + assert_eq!(dimensions.size, LayoutSize::new(120.0, 250.0)); + assert_eq!(dimensions.minimum, LayoutPoint::new(-20.0, 0.0)); + assert_eq!(dimensions.maximum, LayoutPoint::new(0.0, 50.0)); + assert_eq!(dimensions.overflowing_axes(), (true, true)); + } + + #[test] + fn unreachable_area_in_one_axis_does_not_create_scroll_in_the_other() { + let port = LayoutRect::new(0.0, 0.0, 100.0, 100.0); + let origin = ScrollOrigin { + horizontal: ScrollStartEdge::Min, + vertical: ScrollStartEdge::Max, + }; + let unreachable = origin.reachable_rect(LayoutRect::new(200.0, 200.0, 100.0, 100.0), port); + assert_eq!(unreachable, LayoutRect::new(200.0, 100.0, 100.0, 0.0)); + let mut overflow = port; + origin.add_visual_overflow( + &mut overflow, + LayoutRect::new(200.0, 200.0, 100.0, 100.0), + Some(port), + ); + assert_eq!(overflow, port); + // Non-scrolling ancestors must retain it for a later scroll container + // to evaluate in that container's own coordinate system. + origin.add_visual_overflow( + &mut overflow, + LayoutRect::new(200.0, 200.0, 100.0, 100.0), + None, + ); + assert_eq!(overflow, LayoutRect::new(0.0, 0.0, 300.0, 300.0)); + } + + #[test] + fn negative_flow_margins_retract_only_the_scroll_end_and_preserve_empty_bounds() { + let origin = ScrollOrigin { + horizontal: ScrollStartEdge::Max, + vertical: ScrollStartEdge::Min, + }; + let bounds = origin.flow_bounds( + taffy::InFlowLayout { + location: taffy::Point { x: 100.0, y: 100.0 }, + margin: taffy::Rect { + left: -30.0, + right: -6.0, + top: -4.0, + bottom: -100.0, + }, + }, + taffy::Size { + width: 20.0, + height: 10.0, + }, + ); + assert_eq!(bounds, LayoutRect::new(120.0, 100.0, 0.0, 0.0)); + } +} diff --git a/moli-layout/src/projection.rs b/moli-layout/src/projection.rs index dee0c98a63..d84ee45b4a 100644 --- a/moli-layout/src/projection.rs +++ b/moli-layout/src/projection.rs @@ -479,30 +479,13 @@ where } else { local_scrollport }; - let scroll_size = LayoutSize::new( - local_scrollport - .width - .max((overflow.right() - local_scrollport.x).max(0.0)), - local_scrollport - .height - .max((overflow.bottom() - local_scrollport.y).max(0.0)), - ); - let horizontal_range = (scroll_size.width - scrollport.width).max(0.0); - let vertical_range = (scroll_size.height - scrollport.height).max(0.0); + let dimensions = overflow_projection.scroll_dimensions(id); + let scroll_size = dimensions.size; + let (horizontal_overflow, vertical_overflow) = dimensions.overflowing_axes(); let is_scroll_container = self.establishes_scroll_container(id); let requested = finite_point(self.world.boxes[index].scroll_offset); let (minimum_offset, maximum_offset) = if is_scroll_container { - if self.world.boxes[index].style.direction() == crate::style::InlineDirection::Rtl { - ( - LayoutPoint::new(-horizontal_range, 0.0), - LayoutPoint::new(0.0, vertical_range), - ) - } else { - ( - LayoutPoint::ZERO, - LayoutPoint::new(horizontal_range, vertical_range), - ) - } + (dimensions.minimum, dimensions.maximum) } else { (LayoutPoint::ZERO, LayoutPoint::ZERO) }; @@ -515,11 +498,7 @@ where LayoutPoint::ZERO }; let horizontal_scrollbar = self - .has_scrollbar( - id, - LayoutScrollbarAxis::Horizontal, - horizontal_range > f32::EPSILON, - ) + .has_scrollbar(id, LayoutScrollbarAxis::Horizontal, horizontal_overflow) .then(|| { let frame = LayoutRect::new( scrollport.x, @@ -539,11 +518,7 @@ where }) .filter(|bar| bar.frame.width > 0.0 && bar.frame.height > 0.0); let vertical_scrollbar = self - .has_scrollbar( - id, - LayoutScrollbarAxis::Vertical, - vertical_range > f32::EPSILON, - ) + .has_scrollbar(id, LayoutScrollbarAxis::Vertical, vertical_overflow) .then(|| { let x = if vertical_scrollbar_on_left { scrollport.x - scrollbar_thickness diff --git a/moli-layout/src/table.rs b/moli-layout/src/table.rs index 52e65aced5..3b72acbbbb 100644 --- a/moli-layout/src/table.rs +++ b/moli-layout/src/table.rs @@ -1377,6 +1377,10 @@ fn set_table_part_layout( world.boxes[id.index()].unrounded_layout = Layout { order: 0, location: Point { x, y }, + in_flow: Some(taffy::InFlowLayout { + location: Point { x, y }, + margin: Rect::ZERO, + }), size: Size { width, height }, content_size: Size { width, height }, scrollbar_size: Size::ZERO, @@ -1409,6 +1413,7 @@ fn set_box_layout( world.boxes[id.index()].unrounded_layout = Layout { order: u32::try_from(order).unwrap_or(u32::MAX), location, + in_flow: Some(taffy::InFlowLayout { location, margin }), size: output.size, content_size: output.content_size, scrollbar_size: Size::ZERO, diff --git a/moli-layout/src/taffy_tree.rs b/moli-layout/src/taffy_tree.rs index 017d84c32c..3dd173c381 100644 --- a/moli-layout/src/taffy_tree.rs +++ b/moli-layout/src/taffy_tree.rs @@ -329,6 +329,10 @@ where fn scale_layout(layout: Layout, factor: f32) -> Layout { Layout { location: layout.location.map(|value| value * factor), + in_flow: layout.in_flow.map(|flow| taffy::InFlowLayout { + location: flow.location.map(|value| value * factor), + margin: flow.margin.map(|value| value * factor), + }), size: layout.size.map(|value| value * factor), content_size: layout.content_size.map(|value| value * factor), scrollbar_size: layout.scrollbar_size.map(|value| value * factor), @@ -621,6 +625,7 @@ where world.set_inline_child_layout( marker, Point { x, y }, + Point::ZERO, output, marker.index(), Some(parent_width), @@ -679,6 +684,7 @@ where world.set_inline_child_layout( content, Point { x, y }, + Point::ZERO, output, content.index(), Some(content_width), @@ -1405,6 +1411,7 @@ fn layout_inline_absolute_child( let scrollbar_size = world.get_scrollbar_insets(child.to_taffy()).sum_axes(); world.boxes[child.index()].unrounded_layout = Layout { order: 0, + in_flow: None, size: final_size, content_size: output.content_size, scrollbar_size, @@ -2566,6 +2573,7 @@ where self.set_inline_child_layout( floated.child, floated.location, + Point::ZERO, floated.output, floated.order, floated.parent_width, @@ -2642,6 +2650,7 @@ where + vertical_offset + inset_offset.y, }, + inset_offset, atomic.output, object_index, measurement.percentage_basis, @@ -2654,6 +2663,7 @@ where &mut self, child: LayoutBoxId, location: Point, + relative_offset: Point, output: LayoutOutput, order: usize, parent_width: Option, @@ -2672,6 +2682,13 @@ where self.boxes[child.index()].unrounded_layout = Layout { order: u32::try_from(order).unwrap_or(u32::MAX), location, + in_flow: Some(taffy::InFlowLayout { + location: Point { + x: location.x - relative_offset.x, + y: location.y - relative_offset.y, + }, + margin, + }), size: output.size, content_size: output.content_size, scrollbar_size, diff --git a/moli-renderer-v8/src/runtime/page_vm/tests/directional_overflow.rs b/moli-renderer-v8/src/runtime/page_vm/tests/directional_overflow.rs new file mode 100644 index 0000000000..2d24a82811 --- /dev/null +++ b/moli-renderer-v8/src/runtime/page_vm/tests/directional_overflow.rs @@ -0,0 +1,73 @@ +use super::*; + +#[tokio::test(flavor = "current_thread")] +async fn screenshot_resolves_directional_scroll_ranges_and_normal_flow_bounds() { + run_page_vm_async_test(async move { + let loader = crate::network::ResourceRequestClient::new(&FetchConfig::default())?; + let mut page = test_page_vm_with_loader_and_document_url( + &loader, + Vec::new(), + Url::parse("https://example.com/directional-overflow.html")?, + ); + page.set_viewport_surface(Some(crate::protocol_types::ViewportSurface { + inner_width: 1280, + inner_height: 3600, + outer_width: 1280, + outer_height: 3600, + device_pixel_ratio: 1.0, + screen_width: 1280, + screen_height: 3600, + screen_avail_width: 1280, + screen_avail_height: 3600, + }))?; + page.vm_mut() + .set_layout_policy(moli_page_types::LayoutPolicy::OnDemand); + page.vm_mut().eval(&format!( + "document.open();document.write({});document.close()", + serde_json::to_string(include_str!( + "../../../../tests/fixtures/directional-scroll-overflow.html" + ))?, + ))?; + page.vm_mut() + .prime_document_lifecycle_processing_and_record_stylesheet_network_results(); + // Every observation follows an explicit capture. Synchronous geometry + // remains intentionally stale between layout demands, including after + // the final display:none mutation removes the overflow contribution. + for phase in ["initial", "end", "start", "hidden"] { + page.vm_mut() + .eval(&format!("setOverflowPhase('{phase}')"))?; + let crate::runtime::RendererCaptureScreenshotReply::Captured(screenshot) = page + .capture_screenshot( + crate::runtime::RendererCaptureScreenshotRequest::viewport_png(), + )? + else { + panic!("explicit screenshot demand must produce pixels"); + }; + let image = moli_image::decode_png(&screenshot.bytes)?; + let checks: serde_json::Value = serde_json::from_str( + &page + .vm_mut() + .eval("JSON.stringify(collectOverflowChecks())")?, + )?; + let checks = checks.as_array().expect("overflow checks"); + assert_eq!(checks.len(), if phase == "hidden" { 282 } else { 462 }); + for check in checks { + assert_eq!(check["actual"], check["expected"], "{phase}: {check}"); + if let Some(pixel) = check.get("pixel") { + let x = pixel[0].as_f64().expect("pixel x") as usize; + let y = pixel[1].as_f64().expect("pixel y") as usize; + let offset = (y * image.width as usize + x) * 4; + assert_eq!( + &image.rgba[offset..offset + 4], + &[31, 127, 63, 255], + "{phase}: {} paint", + check["id"] + ); + } + } + } + Ok::<_, anyhow::Error>(()) + }) + .await + .expect("directional overflow fixture should run"); +} diff --git a/moli-renderer-v8/src/runtime/page_vm/tests/mod.rs b/moli-renderer-v8/src/runtime/page_vm/tests/mod.rs index a3a2eb7c2a..635ae902c4 100644 --- a/moli-renderer-v8/src/runtime/page_vm/tests/mod.rs +++ b/moli-renderer-v8/src/runtime/page_vm/tests/mod.rs @@ -119,6 +119,7 @@ mod command_checkpoint; mod computed_size; mod cssom_zoom; mod dedicated_worker_client_event; +mod directional_overflow; mod document_script_completion; mod element_toggle_event; mod fetch_xhr; diff --git a/moli-renderer-v8/tests/fixtures/directional-scroll-overflow.html b/moli-renderer-v8/tests/fixtures/directional-scroll-overflow.html new file mode 100644 index 0000000000..ef3d28199e --- /dev/null +++ b/moli-renderer-v8/tests/fixtures/directional-scroll-overflow.html @@ -0,0 +1,137 @@ + + +Directional scroll overflow preserves normal-flow and visual geometry + + + +