From d1c5f5cd341bcf7dc76173c03af7020efe98c339 Mon Sep 17 00:00:00 2001 From: lanyue-llk <270302213+lanyue-llk@users.noreply.github.com> Date: Tue, 22 Sep 2026 20:09:25 +0800 Subject: [PATCH] fix(layout): preserve inline fragments and published geometry --- .../src/page/renderer_command_support.rs | 9 +- moli-layout/src/builder.rs | 19 + moli-layout/src/inline.rs | 101 +- moli-layout/src/pass.rs | 8 +- moli-layout/src/projection.rs | 245 ++++- moli-layout/src/taffy_tree.rs | 67 +- moli-layout/src/world.rs | 38 + moli-layout/tests/phase5_output_contract.rs | 914 +++++++++++++++++- .../protocol_server/tests/cdp_dynamic_page.rs | 33 +- .../src/protocol_server/webdriver_bidi.rs | 1 + .../src/commands/window.rs | 1 + moli-protocol/src/automation.rs | 1 + moli-protocol/src/conn/dispatch_tests/page.rs | 1 + .../src/domains/dom/tests/geometry.rs | 33 +- moli-protocol/src/domains/page/capture.rs | 20 +- moli-protocol/src/domains/page/native.rs | 4 +- .../src/domains/page/tests/capture.rs | 64 ++ .../src/devtools/command/nested.rs | 2 +- .../context_host/host_environment.rs | 125 ++- .../context_host/image_resources/mod.rs | 4 + moli-renderer-v8/src/runtime/page_commands.rs | 5 +- moli-renderer-v8/src/runtime/page_surface.rs | 6 +- .../runtime/page_vm/tests/rendering_update.rs | 85 ++ .../tests/dom_elements/dom_surface.rs | 369 +++++++ .../dom_surface/extracted/inline_layout.rs | 58 +- .../script_vm/tests/dom_xhr/computed_style.rs | 1 + .../dom_xhr/computed_style/root_font_units.rs | 186 ++++ .../tests/dom_xhr/dom/rendered_text.rs | 71 ++ .../src/script_vm/tests/dom_xhr/misc.rs | 100 +- .../src/script_vm/tests/element_click.rs | 49 + moli-renderer-v8/src/style_engine/computed.rs | 39 +- moli-renderer-v8/src/style_engine/retained.rs | 32 + moli-renderer-v8/src/style_engine/state.rs | 6 + 33 files changed, 2579 insertions(+), 118 deletions(-) create mode 100644 moli-renderer-v8/src/script_vm/tests/dom_xhr/computed_style/root_font_units.rs diff --git a/moli-core/src/page/renderer_command_support.rs b/moli-core/src/page/renderer_command_support.rs index 7d34e2c121b..4c0dd8ca9fd 100644 --- a/moli-core/src/page/renderer_command_support.rs +++ b/moli-core/src/page/renderer_command_support.rs @@ -1673,7 +1673,14 @@ impl Page { } pub fn start_layout_metrics(&self) -> Result { - self.start_page_command(RendererPageCommand::LayoutMetrics) + self.start_layout_metrics_with_publication(false) + } + + pub fn start_layout_metrics_with_publication( + &self, + publish_layout: bool, + ) -> Result { + self.start_page_command(RendererPageCommand::LayoutMetrics { publish_layout }) } pub fn finish_layout_metrics( diff --git a/moli-layout/src/builder.rs b/moli-layout/src/builder.rs index 5e5c3c0fc31..f2f822df228 100644 --- a/moli-layout/src/builder.rs +++ b/moli-layout/src/builder.rs @@ -624,6 +624,9 @@ where let mut output = Vec::new(); let mut current = Some(principal); let mut run = Vec::new(); + let mut blocks: Option = None; + let mut preceding_inline = principal; + world.inline_split_sources.insert(owner); for child in children { if self.is_block_in_flow(world, child) { @@ -634,11 +637,24 @@ where std::mem::take(&mut run), )?; output.push(fragment); + preceding_inline = fragment; + } + if let Some(run) = blocks.as_mut() { + run.last = child; + } else { + blocks = Some(crate::world::InlineSplitBlockRun { + preceding_inline, + first: child, + last: child, + }); } output.push(child); continue; } + if let Some(run) = blocks.take() { + world.inline_split_block_runs.push(run); + } if current.is_none() { current = Some(self.allocate_inline_continuation(world, owner, style, semantics.clone())); @@ -646,6 +662,9 @@ where run.push(child); } + if let Some(run) = blocks { + world.inline_split_block_runs.push(run); + } if let Some(fragment) = current { self.replace_children_and_mark_context(world, fragment, run)?; output.push(fragment); diff --git a/moli-layout/src/inline.rs b/moli-layout/src/inline.rs index 9f5c614a9bf..e1fe96644c0 100644 --- a/moli-layout/src/inline.rs +++ b/moli-layout/src/inline.rs @@ -37,6 +37,16 @@ pub(crate) fn relative_atomic_inset_offset( style: &taffy::Style, containing_block_size: Size, container_direction: InlineDirection, +) -> Point { + relative_inset_offset(style, containing_block_size.map(Some), container_direction) +} + +/// The block-axis percentage basis may be indefinite even when the final +/// content height is nonzero (auto height or min-height-only sizing). +pub(crate) fn relative_inset_offset( + style: &taffy::Style, + containing_block_size: Size>, + container_direction: InlineDirection, ) -> Point { let inset = taffy::Rect { left: style.inset.left.maybe_resolve( @@ -92,6 +102,7 @@ pub(crate) struct InlineTextUnit { pub(crate) sources: Vec, pub(crate) control: bool, /// A normalized CSS space that can still collapse at a line boundary. + /// Preserved spaces and non-breaking spaces retain their geometry. pub(crate) collapsible_whitespace: bool, pub(crate) break_spaces_opportunity: bool, } @@ -563,13 +574,56 @@ pub(crate) fn build_inline_fragments( .iter() .map(text_style_paint_outsets) .collect::>(); + // Inline objects occupy positions between text units, not text bytes. + // Retain their logical offsets so spaces before an atomic box cannot be + // mistaken for line-end whitespace (including in bidi-reordered lines). + let atomic_offsets: HashMap<_, _> = layout + .inline_boxes() + .filter(|inline_box| { + context + .object(inline_box.id) + .is_some_and(|object| object.role == InlineObjectRole::Atomic) + }) + .map(|inline_box| (inline_box.id, inline_box.index)) + .collect(); for (line_index, line) in layout.lines().enumerate() { let metrics = line.metrics(); + let line_range = line.text_range(); + let last_atomic_offset = line + .items() + .filter_map(|item| match item { + PositionedLayoutItem::InlineBox(positioned) => { + atomic_offsets.get(&positioned.id).copied() + } + PositionedLayoutItem::GlyphRun(_) => None, + }) + .max() + .unwrap_or(line_range.start); + let trailing_start = overlapping_output_ranges(&context.text_units, &line_range) + .iter() + .rev() + .take_while(|unit| { + unit.output_range.start >= last_atomic_offset + && (unit.control || unit.collapsible_whitespace) + }) + .filter(|unit| unit.collapsible_whitespace) + .map(|unit| unit.output_range.start) + .last(); + let mut collapsed_trailing_advance = 0.0; + if let Some(start) = trailing_start { + for run in line.runs() { + for cluster in run.visual_clusters() { + if cluster.text_range().start >= start { + collapsed_trailing_advance += cluster.advance().max(0.0); + } + } + } + } let placement = line_placements .get(line_index) .filter(|placement| placement.line_index == line_index); - let line_rect = placement.map_or_else( + let mut line_rect = placement.map_or_else( || { PaintRect::new( metrics.inline_min_coord + metrics.offset, @@ -580,6 +634,14 @@ pub(crate) fn build_inline_fragments( }, |placement| placement.rect, ); + // Parley retains hanging spaces for shaping/line breaking. CSS + // collapsed line-end spaces contribute neither range/inline-box + // geometry nor scrollable overflow. Do not trim preserved or NBSP + // advances merely because Parley classifies them as whitespace. + line_rect.width = (line_rect.width - collapsed_trailing_advance).max(0.0); + if layout.is_rtl() { + line_rect.x += collapsed_trailing_advance; + } fragments.lines.push(InlineLineFragment { line_index, rect: line_rect, @@ -604,6 +666,9 @@ pub(crate) fn build_inline_fragments( let run_metrics = run.font_metrics(); for cluster in run.visual_clusters() { let range = cluster.text_range(); + if trailing_start.is_some_and(|start| range.start >= start) { + continue; + } let style_index = usize::from(cluster.style_index()); let vertical_offset = placement.map_or(0.0, |placement| { placement.glyph_offset(run.index(), style_index) @@ -3405,6 +3470,40 @@ mod tests { normalizer.finish() } + #[test] + fn only_collapsed_spaces_are_marked_for_line_end_removal() { + let text = LayoutBoxId::from_index(1); + for mode in [ + InlineWhiteSpaceCollapse::Collapse, + InlineWhiteSpaceCollapse::PreserveBreaks, + InlineWhiteSpaceCollapse::Preserve, + InlineWhiteSpaceCollapse::BreakSpaces, + ] { + let input = normalize(&[(text, "A \u{a0} B")], mode, InlineTextTransform::None); + let collapsed = input + .units + .iter() + .filter(|unit| unit.collapsible_whitespace) + .collect::>(); + assert_eq!( + collapsed.len(), + if matches!( + mode, + InlineWhiteSpaceCollapse::Collapse | InlineWhiteSpaceCollapse::PreserveBreaks + ) { + 2 + } else { + 0 + } + ); + assert!( + collapsed + .iter() + .all(|unit| &input.text[unit.output_range.clone()] == " ") + ); + } + } + #[test] fn preserve_merges_crlf_across_adjacent_text_nodes_with_both_origins() { let first = LayoutBoxId::from_index(1); diff --git a/moli-layout/src/pass.rs b/moli-layout/src/pass.rs index fbf2d088fe1..a0a570ee0d8 100644 --- a/moli-layout/src/pass.rs +++ b/moli-layout/src/pass.rs @@ -314,11 +314,13 @@ where /// /// Every iteration enters Taffy through the synthetic viewport root, but that /// is a scheduling entry point rather than an unconditional full-tree layout. -/// `invalidate_scrollbar_feedback` clears only changed boxes and their numeric -/// ancestors. All other subtrees keep valid Taffy cache entries and return +/// `invalidate_scrollbar_feedback` clears a changed local scroll container's +/// subtree and numeric ancestor path. A viewport gutter invalidates the whole +/// tree because it changes the initial containing block. Unaffected branches +/// of a local scrollbar change keep valid Taffy cache entries and return /// immediately. This is the same important boundary as Blink's corrective /// scrollbar relayout: pay a follow-up pass only after state changes, and only -/// recompute the paths whose available space can have changed. +/// recompute boxes whose available space can have changed. #[derive(Default)] struct NumericLayoutMetrics { pass_count: usize, diff --git a/moli-layout/src/projection.rs b/moli-layout/src/projection.rs index b2022c72e80..63c84071671 100644 --- a/moli-layout/src/projection.rs +++ b/moli-layout/src/projection.rs @@ -1,6 +1,6 @@ use std::{collections::HashMap, fmt::Debug, hash::Hash, time::Instant}; -use taffy::ResolveOrZero; +use taffy::{MaybeMath, MaybeResolve, ResolveOrZero}; use crate::layout_tree::{CssSizing, CssSizingBox, LayoutCoordinateSpace}; use crate::overflow::{OverflowProjection, inset_rect, offset_rect, outset_rect}; @@ -52,6 +52,7 @@ where projection.build_scroll_extents(&overflow); projection.build_coordinate_spaces()?; projection.build_fragments(); + projection.build_split_block_fragments(); projection.assign_clip_and_paint_order(); let content_size = projection.document_content_size(); @@ -750,6 +751,150 @@ where } } + fn build_split_block_fragments(&mut self) { + // Construction records only contiguous, promoted in-flow block runs. + // Their middle continuation uses the existing flow bounds and parent + // content width, not transformed descendant ink or a new numeric box. + // In particular this must not introduce an auto-height containing block. + for run in &self.world.inline_split_block_runs { + let first = &self.world.boxes[run.first.index()]; + let last = &self.world.boxes[run.last.index()]; + let Some(parent) = first.parent else { + continue; + }; + let content = self.boxes[parent.index()].content_box; + let basis = self.split_block_relative_inset_basis(parent); + let direction = self.world.boxes[parent.index()].style.direction(); + let first_offset = + crate::inline::relative_inset_offset(&first.style.taffy, basis, direction); + let last_offset = + crate::inline::relative_inset_offset(&last.style.taffy, basis, direction); + let top = first.final_layout.location.y - first_offset.y; + let bottom = + last.final_layout.location.y - last_offset.y + last.final_layout.size.height; + let rect = LayoutRect::new(content.x, top, content.width, (bottom - top).max(0.0)); + self.reposition_empty_inline_continuation(run.preceding_inline, parent, top); + let fragment = self.push_fragment(LayoutFragment { + id: LayoutFragmentId::from_index(0), + kind: LayoutFragmentKind::Box { + box_id: LayoutOutputBoxId::from_index(run.preceding_inline.index()), + }, + rect, + box_model: Some(LayoutFragmentBoxModel { + content: rect, + padding: rect, + border: rect, + margin: rect, + }), + coordinate_space: LayoutCoordinateSpaceId::from_index(parent.index() + 1), + clip_chain: None, + paint_order: None, + }); + self.register_box_fragment(run.preceding_inline.index(), fragment); + } + } + + /// An empty leading continuation follows escaped margins into the first + /// block's flow position; it carries no independent line strut. + fn reposition_empty_inline_continuation( + &mut self, + inline: LayoutBoxId, + parent: LayoutBoxId, + top: f32, + ) { + let coordinate_space = LayoutCoordinateSpaceId::from_index(parent.index() + 1); + let document_to_parent = self.coordinate_spaces[coordinate_space.index()] + .layout + .local_to_document_ignoring_css_transforms + .inverse(); + for id in &self.boxes[inline.index()].fragments { + let fragment = &mut self.fragments[id.index()]; + if !matches!(fragment.kind, LayoutFragmentKind::InlineBox { .. }) + || fragment.rect.width != 0.0 + || fragment.rect.height != 0.0 + { + continue; + } + let original = self.coordinate_spaces[fragment.coordinate_space.index()] + .layout + .local_to_document_ignoring_css_transforms + .map_point(LayoutPoint::new(fragment.rect.x, fragment.rect.y)); + let x = document_to_parent + .map(|inverse| inverse.map_point(original).x) + .unwrap_or(fragment.rect.x); + let boundary = LayoutRect::new(x, top, 0.0, 0.0); + fragment.rect = boundary; + fragment.coordinate_space = coordinate_space; + fragment.box_model = Some(LayoutFragmentBoxModel { + content: boundary, + padding: boundary, + border: boundary, + margin: boundary, + }); + } + } + + fn split_block_relative_inset_basis(&self, parent: LayoutBoxId) -> taffy::Size> { + let content = self.boxes[parent.index()].content_box; + let Some(inputs) = self + .world + .inline_split_parent_inputs + .get(&parent) + .copied() + .flatten() + else { + return taffy::Size { + width: Some(content.width), + height: None, + }; + }; + let layout_box = &self.world.boxes[parent.index()]; + let style = &layout_box.style.taffy; + let percentage_basis = inputs + .constraint_space(layout_box.style.writing_mode()) + .margin_padding_percentage_basis(); + let padding = style + .padding + .resolve_or_zero(percentage_basis, crate::style::resolve_stylo_calc_value); + let border = style + .border + .resolve_or_zero(percentage_basis, crate::style::resolve_stylo_calc_value); + let padding_border = (padding + border).sum_axes(); + let adjustment = if style.box_sizing == taffy::BoxSizing::ContentBox { + padding_border + } else { + taffy::Size::ZERO + }; + // Match the block backend's relative-inset definiteness, not its + // broader percentage-size/min-height basis. The actual incoming + // definite dimensions win; auto/min-height alone remain indefinite. + let preferred = if inputs.sizing_mode == taffy::SizingMode::InherentSize { + let size = style + .size + .maybe_resolve(inputs.parent_size, crate::style::resolve_stylo_calc_value) + .maybe_add(adjustment); + block_relative_inset_preferred_size( + size, + style.size.map(|dimension| dimension.is_auto()), + layout_box.style.writing_mode(), + inputs.block_auto_behavior, + layout_box.resolved_aspect_ratio(), + padding_border, + ) + } else { + taffy::Size::NONE + }; + let vertical_inset = layout_box.final_layout.size.height - content.height; + taffy::Size { + width: Some(content.width), + height: inputs + .definite_dimensions + .height + .or(preferred.height) + .map(|height| (height - vertical_inset).max(0.0)), + } + } + fn register_box_fragment(&mut self, box_index: usize, fragment: LayoutFragmentId) { self.boxes[box_index].fragments.push(fragment); } @@ -1318,12 +1463,32 @@ where let right_padding = if has_right_edge { padding.right } else { 0.0 }; let left_border = if has_left_edge { border.left } else { 0.0 }; let right_border = if has_right_edge { border.right } else { 0.0 }; - let border_box = LayoutRect::new( + let mut border_box = LayoutRect::new( content_origin.x + fragment.rect.x + left_margin, content_origin.y + fragment.rect.y - padding.top - border.top, (fragment.rect.width - left_margin - right_margin).max(0.0), fragment.rect.height + padding.top + padding.bottom + border.top + border.bottom, ); + if fragment.rect.width == 0.0 + && inline_box + .source + .or(inline_box.owner) + .is_some_and(|source| world.inline_split_sources.contains(&source)) + && padding.top + padding.bottom + border.top + border.bottom == 0.0 + { + // Empty split continuations are zero-area boundaries of the block + // continuation, not font-height glyph boxes. Ordinary empty inlines + // retain their normal font metrics. + if let Some(line) = owner + .inline_layout + .as_ref() + .and_then(|context| context.fragments.lines.get(fragment.line_index)) + .filter(|line| line.rect.height == 0.0) + { + border_box.y = content_origin.y + line.rect.y; + border_box.height = 0.0; + } + } let padding_box = inset_rect( border_box, border.top, @@ -1353,10 +1518,86 @@ where } } +// Taffy 71bc149 compute/block.rs uses the *unclamped preferred size* for +// relative-inset definiteness, not used/min/max height. This narrow adapter +// mirrors common/aspect_ratio.rs::apply_preferred_aspect_ratio; it does not +// re-run layout or transfer min/max constraints. Taffy keeps that helper private. +fn block_relative_inset_preferred_size( + size: taffy::Size>, + size_is_auto: taffy::Size, + writing_mode: taffy::WritingMode, + block_auto_behavior: taffy::AutoSizeBehavior, + aspect_ratio: Option, + padding_border: taffy::Size, +) -> taffy::Size> { + let ratio_size = size.maybe_apply_aspect_ratio_with_box_sizing( + aspect_ratio, + taffy::BoxSizing::BorderBox, + padding_border, + ); + if block_auto_behavior != taffy::AutoSizeBehavior::StretchExplicit { + return ratio_size; + } + let source = writing_mode.to_logical(size); + let auto = writing_mode.to_logical(size_is_auto); + let mut resolved = writing_mode.to_logical(ratio_size); + if auto.block_size && source.block_size.is_none() && resolved.block_size.is_some() { + resolved.block_size = None; + } + writing_mode.to_physical(resolved) +} + #[cfg(test)] mod paint_space_tests { use super::*; + #[test] + fn split_block_relative_basis_keeps_preferred_ratio_and_stretch_provenance() { + use taffy::{AutoSizeBehavior, BoxSizing, ResolvedAspectRatio, Size, WritingMode}; + let ratio = ResolvedAspectRatio::new(2.0, BoxSizing::BorderBox); + let source = Size { + width: Some(200.0), + height: None, + }; + let auto = Size { + width: false, + height: true, + }; + let fit = block_relative_inset_preferred_size( + source, + auto, + WritingMode::HorizontalTb, + AutoSizeBehavior::FitContent, + ratio, + Size::ZERO, + ); + assert_eq!(fit.height, Some(100.0)); + let stretch = block_relative_inset_preferred_size( + source, + auto, + WritingMode::HorizontalTb, + AutoSizeBehavior::StretchExplicit, + ratio, + Size::ZERO, + ); + assert_eq!(stretch.height, None); + let explicit = block_relative_inset_preferred_size( + Size { + width: Some(200.0), + height: Some(200.0), + }, + Size { + width: false, + height: false, + }, + WritingMode::HorizontalTb, + AutoSizeBehavior::StretchExplicit, + ratio, + Size::ZERO, + ); + assert_eq!(explicit.height, Some(200.0)); + } + fn property_transform(transform: LayoutTransform2D) -> ResolvedLayoutTransform { ResolvedLayoutTransform { transform, diff --git a/moli-layout/src/taffy_tree.rs b/moli-layout/src/taffy_tree.rs index 1ece773c859..b2b26ca238a 100644 --- a/moli-layout/src/taffy_tree.rs +++ b/moli-layout/src/taffy_tree.rs @@ -79,9 +79,14 @@ impl PreparedWorldLayout { self.numeric_viewport_layout = world.viewport_layout.unrounded_layout; } - /// Clears only the cache entries on changed scrollbar boxes and their - /// numeric ancestors. The reusable mark table makes this proportional to - /// the affected paths rather than to every box in the document. + /// Clears the cache entries whose available space can change after a + /// scrollbar appears. + /// + /// A viewport gutter changes the initial containing block, so every box + /// is affected. A local gutter changes the scroll container's content box, + /// so its complete numeric subtree and ancestor path are affected. Keeping + /// descendant caches in either case can reuse a size-only result during + /// the corrective pass and leave nested boxes without final geometry. /// /// Taffy's next compute still starts at the viewport root, as its public /// API requires, but siblings outside these paths retain their cached @@ -98,15 +103,21 @@ impl PreparedWorldLayout { N: Copy + Debug + Eq + Hash, { assert!(self.feedback_invalidation_worklist.is_empty()); - self.feedback_invalidation_marks - .resize(world.boxes.len(), false); if viewport_changed { - self.feedback_invalidation_worklist.push(world.root); + for layout_box in &mut world.boxes { + layout_box.clear_layout_caches(); + } + world.viewport_layout.cache.clear(); + return world.boxes.len(); } + if changed_boxes.is_empty() { + return 0; + } + self.feedback_invalidation_marks + .resize(world.boxes.len(), false); + let mut invalidated = Vec::new(); self.feedback_invalidation_worklist .extend(changed_boxes.iter().copied()); - - let mut invalidated = Vec::new(); while let Some(id) = self.feedback_invalidation_worklist.pop() { if self.feedback_invalidation_marks[id.index()] { continue; @@ -114,13 +125,22 @@ impl PreparedWorldLayout { self.feedback_invalidation_marks[id.index()] = true; invalidated.push(id); world.boxes[id.index()].clear_layout_caches(); - if let Some(parent) = world.boxes[id.index()].layout_parent { - self.feedback_invalidation_worklist.push(parent); - } + self.feedback_invalidation_worklist + .extend(world.boxes[id.index()].layout_children.iter().copied()); } - if viewport_changed || !changed_boxes.is_empty() { - world.viewport_layout.cache.clear(); + + for changed in changed_boxes.iter().copied() { + let mut ancestor = world.boxes[changed.index()].layout_parent; + while let Some(id) = ancestor { + if !self.feedback_invalidation_marks[id.index()] { + self.feedback_invalidation_marks[id.index()] = true; + invalidated.push(id); + world.boxes[id.index()].clear_layout_caches(); + } + ancestor = world.boxes[id.index()].layout_parent; + } } + world.viewport_layout.cache.clear(); for id in invalidated.iter().copied() { self.feedback_invalidation_marks[id.index()] = false; } @@ -1622,6 +1642,14 @@ where } fn compute_child_layout(&mut self, node_id: NodeId, inputs: LayoutInput) -> LayoutOutput { + if !self.is_viewport_taffy_node(node_id) + && inputs.run_mode == RunMode::PerformLayout + && let Some(slot) = self + .inline_split_parent_inputs + .get_mut(&LayoutBoxId::from_taffy(node_id)) + { + *slot = Some(inputs); + } // Float parents own horizontal margin subtraction in both the Taffy // block path and Moli's Parley IFC path. The generic intrinsic // resolver follows Taffy's ordinary child-input contract and subtracts @@ -1781,6 +1809,13 @@ where inputs: LayoutInput, block_context: Option<&mut BlockContext<'_>>, ) -> LayoutOutput { + if inputs.run_mode == RunMode::PerformLayout + && let Some(slot) = self + .inline_split_parent_inputs + .get_mut(&LayoutBoxId::from_taffy(node_id)) + { + *slot = Some(inputs); + } if self.should_hide(node_id, inputs) { return compute_hidden_layout(self, node_id); } @@ -2849,7 +2884,11 @@ where parent_width: child_inputs.parent_size.width, }); let line_y = state.line_y() as f32; - slot = inline_float_slot(block_context, line_y, None, 0.0); + // Keep the immediate edge slot, even when it has no room + // for text. An empty inline boundary remains beside an + // oversized float; the next nonempty line break searches + // for a fitting slot below it. + slot = block_context.find_content_slot(line_y, Clear::None, None); if slot.y > line_y { clearance = clearance.max(slot.y); } diff --git a/moli-layout/src/world.rs b/moli-layout/src/world.rs index 2b86514028b..0ab2a79b0d3 100644 --- a/moli-layout/src/world.rs +++ b/moli-layout/src/world.rs @@ -640,6 +640,14 @@ impl LayoutBox { } } +/// One source-owned middle continuation, with no numeric containing block. +#[derive(Debug)] +pub(crate) struct InlineSplitBlockRun { + pub(crate) preceding_inline: LayoutBoxId, + pub(crate) first: LayoutBoxId, + pub(crate) last: LayoutBoxId, +} + /// Entire short-lived sidecar used for one construction/layout demand. #[derive(Debug)] pub struct LayoutWorld @@ -649,6 +657,9 @@ where pub(crate) boxes: Vec>, pub(crate) source_mapping: HashMap, pub(crate) display_contents_mapping: HashMap>, + pub(crate) inline_split_sources: std::collections::HashSet, + pub(crate) inline_split_block_runs: Vec, + pub(crate) inline_split_parent_inputs: HashMap>, pub(crate) root: LayoutBoxId, /// The root is the document element only for a complete document source. /// Subtree and synthetic sources still use the same internal root slot. @@ -676,6 +687,9 @@ where boxes: vec![root], source_mapping: HashMap::new(), display_contents_mapping: HashMap::new(), + inline_split_sources: std::collections::HashSet::new(), + inline_split_block_runs: Vec::new(), + inline_split_parent_inputs: HashMap::new(), root: LayoutBoxId::from_index(0), root_is_document_element, quirks_mode: style::context::QuirksMode::NoQuirks, @@ -956,6 +970,30 @@ where *ids = ids.iter().filter_map(|id| remap[id.index()]).collect(); !ids.is_empty() }); + self.inline_split_block_runs.retain_mut(|run| { + let (Some(preceding), Some(first), Some(last)) = ( + remap[run.preceding_inline.index()], + remap[run.first.index()], + remap[run.last.index()], + ) else { + return false; + }; + run.preceding_inline = preceding; + run.first = first; + run.last = last; + true + }); + self.inline_split_sources + .retain(|source| self.source_mapping.contains_key(source)); + self.inline_split_parent_inputs = self + .inline_split_block_runs + .iter() + .filter_map(|run| { + self.boxes[run.first.index()] + .parent + .map(|parent| (parent, None)) + }) + .collect(); } /// Validates graph ownership invariants without relying on allocator IDs. diff --git a/moli-layout/tests/phase5_output_contract.rs b/moli-layout/tests/phase5_output_contract.rs index fc1e7ae1f13..fa06bf92dd2 100644 --- a/moli-layout/tests/phase5_output_contract.rs +++ b/moli-layout/tests/phase5_output_contract.rs @@ -5,9 +5,9 @@ use moli_layout::{ LayoutElementSemantics, LayoutEnvironment, LayoutError, LayoutFlushReason, LayoutFragmentKind, LayoutNamespace, LayoutPaintedSurfaceHit, LayoutPassRequest, LayoutPassResult, LayoutPoint, LayoutPosition, LayoutQuery, LayoutQueryAnswer, LayoutQueryBatch, LayoutRect, - LayoutScrollbarColors, LayoutScrollbarGutter, LayoutScrollbarPart, LayoutScrollbarWidth, - LayoutSource, LayoutSourceKind, LayoutStyleResolver, LayoutTransform2D, LayoutViewport, - PaintBrush, PaintCaptureRequest, PaintColor, PaintFragment, PaintShape, + LayoutReplacedKind, LayoutScrollbarColors, LayoutScrollbarGutter, LayoutScrollbarPart, + LayoutScrollbarWidth, LayoutSource, LayoutSourceKind, LayoutStyleResolver, LayoutTransform2D, + LayoutViewport, PaintBrush, PaintCaptureRequest, PaintColor, PaintFragment, PaintShape, ResolvedLayoutElementStyles, ResolvedLayoutStyle, build_layout_pass, }; use style::Atom; @@ -91,7 +91,7 @@ impl LayoutSource for Source { LayoutNamespace::Html, self.0[node].local_name, LayoutElementCategory::Generic, - None, + (self.0[node].local_name == "img").then_some(LayoutReplacedKind::Image), ) }) } @@ -785,8 +785,8 @@ fn auto_scrollbar_feedback_reveals_the_perpendicular_axis() { let output = build(&source, &mut styles); assert_eq!(output.metrics.numeric_layout_pass_count, 3); assert_eq!( - output.metrics.numeric_feedback_invalidated_node_count, 4, - "each newly revealed axis invalidates only the scroller-to-root path" + output.metrics.numeric_feedback_invalidated_node_count, 6, + "each newly revealed axis invalidates the scroller subtree and root path" ); let metrics = output.element_metrics_for_source(1).unwrap(); assert_eq!( @@ -880,6 +880,180 @@ fn hidden_scrollbar_environment_skips_feedback_and_preserves_scroll_ranges() { assert!(extent.scrollbar_corner.is_none()); } +#[test] +fn viewport_scrollbar_feedback_relayouts_nested_absolute_descendants() { + for (tall_height, expects_feedback) in [(100.0, false), (480.0, true)] { + let source = Source(vec![ + Node::element("root", vec![1, 6]), + Node::element("positioned-container", vec![2]), + Node::element("absolute-menu", vec![3]), + Node::element("menu-item", vec![4]), + Node::element("menu-link", vec![5]), + Node::text("menu-link-text", "New"), + Node::element("page-content", Vec::new()), + ]); + let mut styles = Styles::default(); + styles + .0 + .insert(0, fixed_size(LayoutDisplay::Block, 320.0, 240.0)); + styles.0.insert( + 1, + fixed_size(LayoutDisplay::Block, 40.0, 25.0).with_position(LayoutPosition::Relative), + ); + styles.0.insert( + 2, + resolved( + LayoutDisplay::Block, + Style { + position: Position::Absolute, + inset: Rect { + left: length(0.0), + right: LengthPercentageAuto::auto(), + top: length(25.0), + bottom: LengthPercentageAuto::auto(), + }, + size: Size { + width: length(120.0), + height: Dimension::auto(), + }, + ..Style::default() + }, + ) + .with_position(LayoutPosition::Absolute), + ); + styles + .0 + .insert(3, resolved(LayoutDisplay::Block, Style::default())); + styles + .0 + .insert(4, fixed_size(LayoutDisplay::Block, 120.0, 35.0)); + styles.0.insert( + 5, + resolved(LayoutDisplay::Inline, Style::default()).with_text_metrics(14.0, 21.0), + ); + styles + .0 + .insert(6, fixed_size(LayoutDisplay::Block, 320.0, tall_height)); + + let output = build(&source, &mut styles); + assert_eq!( + output.metrics.numeric_layout_pass_count > 1, + expects_feedback, + "only the overflowing control should need scrollbar feedback" + ); + let link = output + .source_output(4) + .and_then(|source| source.principal_box) + .and_then(|id| output.box_geometry(id)) + .expect("the nested absolute descendant must retain a principal box"); + assert_rect(link.border_box, LayoutRect::new(0.0, 0.0, 120.0, 35.0)); + } +} + +#[test] +fn local_scrollbar_feedback_invalidates_only_the_changed_subtree_and_ancestor_path() { + for (content_height, expects_feedback) in [(50.0, false), (200.0, true)] { + let source = Source(vec![ + Node::element("root", vec![1, 7]), + Node::element("scroller", vec![2, 6]), + Node::element("absolute-menu", vec![3]), + Node::element("menu-item", vec![4]), + Node::element("menu-link", vec![5]), + Node::text("menu-link-text", "New"), + Node::element("scroll-content", Vec::new()), + Node::element("unrelated-fixed", Vec::new()), + ]); + let mut styles = Styles::default(); + styles + .0 + .insert(0, fixed_size(LayoutDisplay::Block, 320.0, 240.0)); + styles.0.insert( + 1, + resolved( + LayoutDisplay::Block, + Style { + position: Position::Relative, + size: Size { + width: length(200.0), + height: length(100.0), + }, + overflow: Point { + x: Overflow::Scroll, + y: Overflow::Scroll, + }, + ..Style::default() + }, + ) + .with_position(LayoutPosition::Relative), + ); + styles.0.insert( + 2, + resolved( + LayoutDisplay::Block, + Style { + position: Position::Absolute, + inset: Rect { + left: length(0.0), + right: LengthPercentageAuto::auto(), + top: length(0.0), + bottom: LengthPercentageAuto::auto(), + }, + size: Size { + width: length(120.0), + height: Dimension::auto(), + }, + ..Style::default() + }, + ) + .with_position(LayoutPosition::Absolute), + ); + styles + .0 + .insert(3, resolved(LayoutDisplay::Block, Style::default())); + styles + .0 + .insert(4, fixed_size(LayoutDisplay::Block, 120.0, 35.0)); + styles.0.insert( + 5, + resolved(LayoutDisplay::Inline, Style::default()).with_text_metrics(14.0, 21.0), + ); + styles + .0 + .insert(6, fixed_size(LayoutDisplay::Block, 200.0, content_height)); + styles.0.insert( + 7, + fixed_size(LayoutDisplay::Block, 10.0, 10.0).with_position(LayoutPosition::Fixed), + ); + + let output = build(&source, &mut styles); + assert_eq!( + output.metrics.numeric_layout_pass_count > 1, + expects_feedback, + "a non-overflowing local scrollport must keep its first-pass caches" + ); + assert_eq!( + output.metrics.numeric_feedback_invalidated_node_count, + if expects_feedback { 12 } else { 0 }, + "each of two scrollbar turns must invalidate the five-box numeric scroller subtree and its root ancestor; inline text has no separate numeric box, and the unrelated fixed branch remains cached" + ); + let link = output + .source_output(4) + .and_then(|source| source.principal_box) + .and_then(|id| output.box_geometry(id)) + .expect("the nested local descendant must retain a principal box"); + assert_rect(link.border_box, LayoutRect::new(0.0, 0.0, 120.0, 35.0)); + assert_eq!( + output + .source_output(7) + .and_then(|source| source.principal_box) + .and_then(|id| output.box_geometry(id)) + .expect("the unrelated fixed branch must retain geometry") + .border_box, + LayoutRect::new(0.0, 0.0, 10.0, 10.0), + ); + } +} + fn fixed_inline_font() -> (ResolvedLayoutStyle, DocumentLayoutServices) { use style::values::computed::font::{ FamilyName, FontFamily, FontFamilyList, FontFamilyNameSyntax, SingleFontFamily, @@ -918,11 +1092,23 @@ fn scrollbar_feedback_rebreaks_the_reused_inline_layout_at_its_final_width() { "alpha beta gamma delta epsilon zeta eta theta iota kappa ", "supercalifragilisticexpialidocious", ); + assert_scrollbar_feedback_inline_geometry(TEXT, true); +} + +#[test] +fn collapsed_line_end_spaces_do_not_create_horizontal_scrollbar_after_feedback() { + assert_scrollbar_feedback_inline_geometry( + "alpha beta gamma delta epsilon zeta eta theta iota kappa", + false, + ); +} + +fn assert_scrollbar_feedback_inline_geometry(text: &'static str, horizontal_overflow: bool) { let source = Source(vec![ Node::element("root", vec![1]), Node::element("scroller", vec![2]), Node::element("fixed-font", vec![3]), - Node::text("text", TEXT), + Node::text("text", text), ]); let mut styles = Styles::default(); styles @@ -938,10 +1124,9 @@ fn scrollbar_feedback_rebreaks_the_reused_inline_layout_at_its_final_width() { height: length(40.0), }, overflow: Point { - // Only the vertical scrollbar narrows the text. Keep the - // long word's horizontal overflow without showing a - // horizontal scrollbar. - x: Overflow::Hidden, + // Exercise both scrollbar directions when the final word + // is wider than the converged content box. + x: Overflow::Scroll, y: Overflow::Scroll, }, ..Style::default() @@ -963,14 +1148,13 @@ fn scrollbar_feedback_rebreaks_the_reused_inline_layout_at_its_final_width() { assert_eq!(feedback.metrics.numeric_layout_pass_count, 2); assert_eq!( feedback.element_metrics_for_source(1).unwrap().client_size, - moli_layout::LayoutSize::new(85.0, 40.0), + moli_layout::LayoutSize::new(85.0, if horizontal_overflow { 25.0 } else { 40.0 }), ); let scroller_box = feedback.source_output(1).unwrap().principal_box.unwrap(); let extent = feedback.scroll_extent(scroller_box).unwrap(); assert!(extent.vertical_scrollbar.is_some()); - assert!(extent.horizontal_scrollbar.is_none()); - assert!(!extent.allows_user_scroll_x); - let feedback_text = feedback.text_range_rects(3, 0..TEXT.encode_utf16().count()); + assert_eq!(extent.horizontal_scrollbar.is_some(), horizontal_overflow); + let feedback_text = feedback.text_range_rects(3, 0..text.encode_utf16().count()); assert!( !feedback_text.is_empty(), "compare real text fragments, not element-only client rects" @@ -1001,16 +1185,82 @@ fn scrollbar_feedback_rebreaks_the_reused_inline_layout_at_its_final_width() { .unwrap(); assert_eq!( feedback_text, - direct.text_range_rects(3, 0..TEXT.encode_utf16().count()) + direct.text_range_rects(3, 0..text.encode_utf16().count()) ); - assert!( + assert_eq!( feedback_text .iter() .any(|quad| quad.points.iter().any(|point| point.x > 85.0)), - "the unbreakable final word should retain horizontal overflow" + horizontal_overflow, + "only the unbreakable final word should retain horizontal overflow" ); } +#[test] +fn inline_scroll_overflow_keeps_unbreakable_text_and_nbsp() { + for text in ["abcdefghijklmno", "alpha\u{a0}beta\u{a0}gamma"] { + let source = Source(vec![ + Node::element("root", vec![1]), + Node::element("scroller", vec![2]), + Node::element("fixed-font", vec![3]), + Node::text("text", text), + ]); + let (font_style, mut services) = fixed_inline_font(); + let mut styles = Styles::default(); + styles + .0 + .insert(0, fixed_size(LayoutDisplay::Block, 320.0, 240.0)); + styles.0.insert( + 1, + resolved( + LayoutDisplay::Block, + Style { + size: Size { + width: length(85.0), + height: length(40.0), + }, + overflow: Point { + x: Overflow::Scroll, + y: Overflow::Scroll, + }, + ..Style::default() + }, + ), + ); + styles.0.insert(2, font_style); + let output = build_layout_pass( + &source, + &mut styles, + &mut services, + LayoutPassRequest::new(LayoutViewport::new(320, 240, 1.0), LayoutFlushReason::Test), + ) + .unwrap(); + let scroller = output.source_output(1).unwrap().principal_box.unwrap(); + assert!( + output + .scroll_extent(scroller) + .unwrap() + .horizontal_scrollbar + .is_some(), + "{text}" + ); + assert!( + output + .element_metrics_for_source(1) + .unwrap() + .scroll_size + .width + > 85.0, + "{text}" + ); + assert!( + !output + .text_range_rects(3, 0..text.encode_utf16().count()) + .is_empty() + ); + } +} + #[test] fn scrollbar_feedback_prepares_static_position_placeholders_once() { let source = Source(vec![ @@ -2622,6 +2872,49 @@ fn inline_output_preserves_line_text_and_utf16_source_fragments() { assert_eq!(scroll_geometry.scroll_containers.len(), 1); } +#[test] +fn collapsed_space_before_atomic_inline_keeps_its_range_geometry() { + for local_name in ["span", "img"] { + for trailing_text in ["", " "] { + let source = Source(vec![ + Node::element("root", vec![1, 2, 3]), + Node::text("before", "a "), + Node::html_element("atomic", local_name, vec![]), + Node::text("after", trailing_text), + ]); + let mut styles = Styles::default(); + styles + .0 + .insert(0, fixed_size(LayoutDisplay::Block, 200.0, 80.0)); + for node in [1, 3] { + styles + .0 + .insert(node, resolved(LayoutDisplay::Inline, Style::default())); + } + let display = if local_name == "img" { + LayoutDisplay::Inline + } else { + LayoutDisplay::InlineBlock + }; + styles.0.insert(2, fixed_size(display, 20.0, 20.0)); + let output = build(&source, &mut styles); + let space = output.text_range_rects(1, 1..2); + assert_eq!(space.len(), 1, "{local_name}, {trailing_text:?}"); + let space = space[0].bounding_rect(); + assert!( + space.width > 0.0, + "space before an atomic inline is not trailing" + ); + let atomic = output.client_rects_for_source(2)[0].bounding_rect(); + assert_close(space.x + space.width, atomic.x); + assert!( + output.text_range_rects(3, 0..1).is_empty(), + "line-end space still collapses" + ); + } + } +} + #[test] fn inline_offset_metrics_use_fragment_bounds_and_keep_empty_sibling_anchors() { let source = Source(vec![ @@ -2898,6 +3191,583 @@ fn caret_query_uses_parley_cluster_sides_and_inline_direction() { assert_cluster_sides("א", true); } +#[test] +fn split_inline_block_continuation_keeps_its_middle_fragment() { + let source = Source(vec![ + Node::element("root", vec![1]), + Node::element("split-inline", vec![2, 3]), + Node::element("flex", Vec::new()), + Node::element("block", Vec::new()), + ]); + let mut styles = Styles::default(); + styles + .0 + .insert(0, fixed_size(LayoutDisplay::Block, 200.0, 120.0)); + styles + .0 + .insert(1, resolved(LayoutDisplay::Inline, Style::default())); + styles + .0 + .insert(2, fixed_size(LayoutDisplay::Flex, 50.0, 20.0)); + styles + .0 + .insert(3, fixed_size(LayoutDisplay::Block, 80.0, 30.0)); + let output = build(&source, &mut styles); + let rects = output.client_rects_for_source(1); + assert_eq!(rects.len(), 3, "{rects:?}"); + let middle = rects[1].bounding_rect(); + assert_eq!(middle.width, 200.0, "{rects:?}"); + assert_eq!(middle.height, 50.0, "{rects:?}"); + assert_eq!(rects[0].bounding_rect().width, 0.0); + assert_eq!(rects[2].bounding_rect().width, 0.0); + assert_rect( + rects[0].bounding_rect(), + LayoutRect::new(0.0, 0.0, 0.0, 0.0), + ); + assert_rect( + rects[2].bounding_rect(), + LayoutRect::new(0.0, 50.0, 0.0, 0.0), + ); +} + +#[test] +fn split_inline_nested_and_transformed_continuations_keep_source_geometry() { + for nested in [false, true] { + for transformed_child in [false, true] { + let source = Source(vec![ + Node::element("root", vec![1]), + Node::element("outer", vec![2]), + Node::element("inner", vec![3]), + Node::element("block", Vec::new()), + ]); + let mut styles = Styles::default(); + styles.0.insert( + 0, + fixed_size(LayoutDisplay::Block, 200.0, 120.0) + .with_2d_transform(LayoutTransform2D::translation(10.0, 5.0)), + ); + styles + .0 + .insert(1, resolved(LayoutDisplay::Inline, Style::default())); + styles.0.insert( + 2, + resolved( + if nested { + LayoutDisplay::Inline + } else { + LayoutDisplay::Contents + }, + Style::default(), + ), + ); + let mut child = fixed_size(LayoutDisplay::Block, 50.0, 20.0); + if transformed_child { + child = child.with_2d_transform(LayoutTransform2D::translation(100.0, 10.0)); + } + styles.0.insert(3, child); + let output = build(&source, &mut styles); + for source_id in if nested { vec![1, 2] } else { vec![1] } { + let rects = output.client_rects_for_source(source_id); + assert_eq!( + rects.len(), + 3, + "nested={nested}, source={source_id}: {rects:?}" + ); + assert_rect( + rects[0].bounding_rect(), + LayoutRect::new(10.0, 5.0, 0.0, 0.0), + ); + assert_rect( + rects[1].bounding_rect(), + LayoutRect::new(10.0, 5.0, 200.0, 20.0), + ); + assert_rect( + rects[2].bounding_rect(), + LayoutRect::new(10.0, 25.0, 0.0, 0.0), + ); + } + } + } +} + +#[test] +fn split_inline_does_not_union_out_of_flow_descendants() { + let source = Source(vec![ + Node::element("root", vec![1]), + Node::element("inline", vec![2, 3]), + Node::element("block", Vec::new()), + Node::element("absolute", Vec::new()), + ]); + let mut styles = Styles::default(); + styles + .0 + .insert(0, fixed_size(LayoutDisplay::Block, 200.0, 120.0)); + styles + .0 + .insert(1, resolved(LayoutDisplay::Inline, Style::default())); + styles + .0 + .insert(2, fixed_size(LayoutDisplay::Block, 50.0, 20.0)); + styles.0.insert( + 3, + fixed_size(LayoutDisplay::Block, 500.0, 800.0).with_position(LayoutPosition::Absolute), + ); + let output = build(&source, &mut styles); + let rects = output.client_rects_for_source(1); + assert_eq!(rects.len(), 3, "{rects:?}"); + assert_rect( + rects[1].bounding_rect(), + LayoutRect::new(0.0, 0.0, 200.0, 20.0), + ); +} + +#[test] +fn split_inline_percentage_block_height_uses_nonanonymous_containing_block() { + for nested in [false, true] { + for rule in 0..3 { + let source = Source(vec![ + Node::element("root", vec![1]), + Node::element("outer", if nested { vec![2] } else { vec![3] }), + Node::element("inner", vec![3]), + Node::element("block", Vec::new()), + ]); + let mut styles = Styles::default(); + styles + .0 + .insert(0, fixed_size(LayoutDisplay::Block, 200.0, 200.0)); + for id in [1, 2] { + styles + .0 + .insert(id, resolved(LayoutDisplay::Inline, Style::default())); + } + let mut child = Style { + size: Size { + width: percent(0.5), + height: if rule == 2 { + length(150.0) + } else { + Dimension::auto() + }, + }, + ..Style::default() + }; + match rule { + 0 => child.size.height = percent(0.5), + 1 => child.min_size.height = percent(0.5), + _ => child.max_size.height = percent(0.5), + } + styles.0.insert(3, resolved(LayoutDisplay::Block, child)); + let output = build(&source, &mut styles); + assert_rect( + output.client_rects_for_source(3)[0].bounding_rect(), + LayoutRect::new(0.0, 0.0, 100.0, 100.0), + ); + let rects = output.client_rects_for_source(1); + assert_rect( + rects[1].bounding_rect(), + LayoutRect::new(0.0, 0.0, 200.0, 100.0), + ); + } + } +} + +#[test] +fn split_inline_middle_fragment_ignores_child_relative_insets() { + for parent_mode in 0..3 { + let fixed_height = parent_mode == 1; + for (top, bottom, expected_offset) in [ + (length(10.0), LengthPercentageAuto::auto(), 10.0), + (LengthPercentageAuto::auto(), length(10.0), -10.0), + ( + percent(0.1), + LengthPercentageAuto::auto(), + if fixed_height { 20.0 } else { 0.0 }, + ), + ( + LengthPercentageAuto::auto(), + percent(0.1), + if fixed_height { -20.0 } else { 0.0 }, + ), + ] { + let source = Source(vec![ + Node::element("root", vec![1]), + Node::element("inline", vec![2]), + Node::element("block", Vec::new()), + ]); + let mut styles = Styles::default(); + styles.0.insert( + 0, + resolved( + LayoutDisplay::Block, + Style { + size: Size { + width: length(200.0), + height: if fixed_height { + length(200.0) + } else { + Dimension::auto() + }, + }, + min_size: Size { + width: Dimension::auto(), + height: if parent_mode == 2 { + length(200.0) + } else { + Dimension::auto() + }, + }, + ..Style::default() + }, + ), + ); + styles + .0 + .insert(1, resolved(LayoutDisplay::Inline, Style::default())); + styles.0.insert( + 2, + resolved( + LayoutDisplay::Block, + Style { + size: Size { + width: length(100.0), + height: length(20.0), + }, + inset: Rect { + left: length(30.0), + top, + bottom, + ..Rect::auto() + }, + ..Style::default() + }, + ) + .with_position(LayoutPosition::Relative), + ); + let output = build(&source, &mut styles); + assert_rect( + output.client_rects_for_source(2)[0].bounding_rect(), + LayoutRect::new(30.0, expected_offset, 100.0, 20.0), + ); + let rects = output.client_rects_for_source(1); + assert_rect( + rects[1].bounding_rect(), + LayoutRect::new(0.0, 0.0, 200.0, 20.0), + ); + } + } +} + +#[test] +fn split_inline_relative_basis_tracks_clamped_and_ratio_sized_parents() { + for mode in 0..3 { + let source = Source(vec![ + Node::element("root", vec![1]), + Node::element("inline", vec![2]), + Node::element("child", Vec::new()), + ]); + let mut styles = Styles::default(); + let mut parent = Style { + size: Size { + width: length(200.0), + height: if mode == 2 { + Dimension::auto() + } else { + length(200.0) + }, + }, + ..Style::default() + }; + if mode == 0 { + parent.max_size.height = length(100.0); + } + if mode == 1 { + parent.min_size.height = length(300.0); + } + if mode == 2 { + parent.aspect_ratio = Some(2.0); + } + styles.0.insert(0, resolved(LayoutDisplay::Block, parent)); + styles + .0 + .insert(1, resolved(LayoutDisplay::Inline, Style::default())); + styles.0.insert( + 2, + resolved( + LayoutDisplay::Block, + Style { + size: Size { + width: length(100.0), + height: length(20.0), + }, + inset: Rect { + top: percent(0.5), + ..Rect::auto() + }, + ..Style::default() + }, + ) + .with_position(LayoutPosition::Relative), + ); + let output = build(&source, &mut styles); + let middle = output.client_rects_for_source(1)[1].bounding_rect(); + assert_rect(middle, LayoutRect::new(0.0, 0.0, 200.0, 20.0)); + } +} + +#[test] +fn split_inline_avatar_flex_and_title_inline_both_keep_nonempty_geometry() { + let source = Source(vec![ + Node::element("root", vec![1, 5]), + Node::element("avatar-cell", vec![2]), + Node::html_element("avatar-link", "a", vec![3]), + Node::element("avatar-flex", vec![4]), + Node::text("avatar-letter", "D"), + Node::html_element("title-link", "a", vec![6]), + Node::text("title", "Primer / design"), + ]); + let mut styles = Styles::default(); + styles + .0 + .insert(0, fixed_size(LayoutDisplay::Block, 320.0, 240.0)); + styles + .0 + .insert(1, fixed_size(LayoutDisplay::Block, 48.0, 48.0)); + styles + .0 + .insert(3, fixed_size(LayoutDisplay::Flex, 48.0, 48.0)); + for id in [2, 4, 5, 6] { + styles + .0 + .insert(id, resolved(LayoutDisplay::Inline, Style::default())); + } + let output = build(&source, &mut styles); + let avatar = output.client_rects_for_source(2); + assert_eq!(avatar.len(), 3); + assert_rect( + avatar[1].bounding_rect(), + LayoutRect::new(0.0, 0.0, 48.0, 48.0), + ); + let title = output.client_rects_for_source(5); + assert!(!title.is_empty() && title.iter().any(|r| r.bounding_rect().width > 0.0)); +} + +#[test] +fn split_inline_two_block_runs_keep_content_fragment_order() { + let source = Source(vec![ + Node::element("root", vec![1]), + Node::element("inline", vec![2, 3, 4]), + Node::element("first", Vec::new()), + Node::text("between", "between"), + Node::element("second", Vec::new()), + ]); + let mut styles = Styles::default(); + styles + .0 + .insert(0, fixed_size(LayoutDisplay::Block, 200.0, 200.0)); + styles + .0 + .insert(1, resolved(LayoutDisplay::Inline, Style::default())); + styles + .0 + .insert(2, fixed_size(LayoutDisplay::Block, 50.0, 20.0)); + styles + .0 + .insert(3, resolved(LayoutDisplay::Inline, Style::default())); + styles + .0 + .insert(4, fixed_size(LayoutDisplay::Block, 80.0, 30.0)); + let output = build(&source, &mut styles); + let rects = output.client_rects_for_source(1); + assert_eq!(rects.len(), 5, "{rects:?}"); + assert_eq!(rects[1].bounding_rect().height, 20.0); + assert!(rects[2].bounding_rect().width > 0.0); + assert_eq!(rects[3].bounding_rect().height, 30.0); + for pair in rects.windows(2) { + assert!( + pair[0].bounding_rect().y <= pair[1].bounding_rect().y, + "{rects:?}" + ); + } +} + +#[test] +fn split_inline_large_float_between_blocks_does_not_expand_middle_fragments() { + let source = Source(vec![ + Node::element("root", vec![1]), + Node::element("inline", vec![2, 3, 4]), + Node::element("first", Vec::new()), + Node::element("float", Vec::new()), + Node::element("second", Vec::new()), + ]); + let mut styles = Styles::default(); + styles + .0 + .insert(0, fixed_size(LayoutDisplay::Block, 200.0, 200.0)); + styles + .0 + .insert(1, resolved(LayoutDisplay::Inline, Style::default())); + styles + .0 + .insert(2, fixed_size(LayoutDisplay::Block, 200.0, 20.0)); + styles.0.insert( + 3, + fixed_size(LayoutDisplay::Block, 500.0, 800.0) + .with_float(taffy::Float::Left, taffy::Clear::None), + ); + styles + .0 + .insert(4, fixed_size(LayoutDisplay::Block, 200.0, 30.0)); + let output = build(&source, &mut styles); + let rects = output.client_rects_for_source(1); + assert_eq!(rects.len(), 5, "{rects:?}"); + assert_rect( + rects[1].bounding_rect(), + LayoutRect::new(0.0, 0.0, 200.0, 20.0), + ); + assert_rect( + rects[2].bounding_rect(), + LayoutRect::new(500.0, 20.0, 0.0, 0.0), + ); + assert_rect( + rects[3].bounding_rect(), + LayoutRect::new(0.0, 20.0, 200.0, 30.0), + ); +} + +#[test] +fn split_inline_metadata_survives_discarded_table_children() { + let source = Source(vec![ + Node::element("root", vec![1, 4]), + Node::element("column-group", vec![2]), + Node::element("discarded", vec![3]), + Node::text("discarded-text", "discarded"), + Node::element("inline", vec![5]), + Node::element("block", Vec::new()), + ]); + let mut styles = Styles::default(); + styles + .0 + .insert(0, fixed_size(LayoutDisplay::Block, 200.0, 200.0)); + styles.0.insert( + 1, + resolved(LayoutDisplay::TableColumnGroup, Style::default()), + ); + styles + .0 + .insert(2, fixed_size(LayoutDisplay::Block, 50.0, 20.0)); + styles + .0 + .insert(3, resolved(LayoutDisplay::Inline, Style::default())); + styles + .0 + .insert(4, resolved(LayoutDisplay::Inline, Style::default())); + styles + .0 + .insert(5, fixed_size(LayoutDisplay::Block, 50.0, 20.0)); + let output = build(&source, &mut styles); + let rects = output.client_rects_for_source(4); + assert_eq!(rects.len(), 3, "{rects:?}"); + assert_eq!(rects[1].bounding_rect().width, 200.0); + assert_eq!(rects[1].bounding_rect().height, 20.0); +} + +#[test] +fn split_inline_escaped_margins_keep_middle_flow_bounds() { + let source = Source(vec![ + Node::element("root", vec![1]), + Node::element("inline", vec![2]), + Node::element("block", Vec::new()), + ]); + let mut styles = Styles::default(); + styles + .0 + .insert(0, fixed_size(LayoutDisplay::Block, 200.0, 200.0)); + styles + .0 + .insert(1, resolved(LayoutDisplay::Inline, Style::default())); + styles.0.insert( + 2, + resolved( + LayoutDisplay::Block, + Style { + size: Size { + width: length(100.0), + height: length(20.0), + }, + margin: Rect { + top: length(10.0), + bottom: length(10.0), + ..Rect::zero() + }, + ..Style::default() + }, + ), + ); + let output = build(&source, &mut styles); + assert_rect( + output.client_rects_for_source(2)[0].bounding_rect(), + LayoutRect::new(0.0, 10.0, 100.0, 20.0), + ); + let rects = output.client_rects_for_source(1); + assert_rect( + rects[0].bounding_rect(), + LayoutRect::new(0.0, 10.0, 0.0, 0.0), + ); + assert_rect( + rects[1].bounding_rect(), + LayoutRect::new(0.0, 10.0, 200.0, 20.0), + ); + assert_rect( + rects[2].bounding_rect(), + LayoutRect::new(0.0, 40.0, 0.0, 0.0), + ); +} + +#[test] +fn split_inline_middle_fragment_preserves_child_flow_and_margin_collapse() { + for margin in [0.0, 10.0, -5.0] { + let mut observed = Vec::new(); + for split in [false, true] { + let source = Source(vec![ + Node::element("root", if split { vec![1] } else { vec![2, 3] }), + Node::element("inline", vec![2, 3]), + Node::element("first", Vec::new()), + Node::element("second", Vec::new()), + ]); + let mut styles = Styles::default(); + styles + .0 + .insert(0, fixed_size(LayoutDisplay::Block, 200.0, 120.0)); + styles + .0 + .insert(1, resolved(LayoutDisplay::Inline, Style::default())); + for id in [2, 3] { + styles.0.insert( + id, + resolved( + LayoutDisplay::Block, + Style { + size: Size { + width: percent(0.5), + height: length(20.0), + }, + margin: Rect { + top: length(margin), + bottom: length(margin), + ..Rect::zero() + }, + ..Style::default() + }, + ), + ); + } + let output = build(&source, &mut styles); + observed.push([2, 3].map(|id| output.client_rects_for_source(id)[0].bounding_rect())); + } + assert_eq!(observed[0], observed[1], "margin={margin}"); + } +} + #[test] fn split_inline_continuations_remain_mapped_to_the_originating_element() { let source = Source(vec![ @@ -2927,9 +3797,13 @@ fn split_inline_continuations_remain_mapped_to_the_originating_element() { let output = build(&source, &mut styles); assert_fragment_batches_match_direct_queries(&output, 0..7); let rects = output.client_rects_for_source(1); - assert_eq!(rects.len(), 2, "{rects:?}"); + assert_eq!(rects.len(), 3, "{rects:?}"); let first = rects[0].bounding_rect(); - let second = rects[1].bounding_rect(); + let middle = rects[1].bounding_rect(); + let second = rects[2].bounding_rect(); + assert_eq!(middle.width, 200.0); + assert_eq!(middle.height, 20.0); + assert!(first.y < middle.y && middle.y < second.y, "{rects:?}"); assert!(first.width > 0.0 && second.width > 0.0); assert!(second.y > first.y, "{rects:?}"); let union = output diff --git a/moli-protocol-server/src/protocol_server/tests/cdp_dynamic_page.rs b/moli-protocol-server/src/protocol_server/tests/cdp_dynamic_page.rs index 011dc5c3a28..47c957b2d4e 100644 --- a/moli-protocol-server/src/protocol_server/tests/cdp_dynamic_page.rs +++ b/moli-protocol-server/src/protocol_server/tests/cdp_dynamic_page.rs @@ -2877,12 +2877,35 @@ async fn named_popup_references_from_sibling_pages_share_the_close_lifetime() { async fn navigate_dynamic_page_and_wait_for_load(page: &mut TestCdpSocket, id: u64, url: &str) { send_cdp_command_without_wait(page, id, "Page.navigate", None, json!({"url": url})).await; - let mut responded = false; - let mut loaded = false; + let mut requested_loader = None; + let mut committed_loader = None; + let mut loaded_loaders = std::collections::HashSet::new(); recv_until_match(page, |message| { - responded |= message["id"] == id; - loaded |= message["method"] == "Page.loadEventFired"; - responded && loaded + if message["id"] == id { + requested_loader = Some( + message["result"]["loaderId"] + .as_str() + .expect("navigation must create a document loader") + .to_owned(), + ); + } + if message["method"] == "Page.frameNavigated" + && message["params"]["frame"]["parentId"].is_null() + { + committed_loader = message["params"]["frame"]["loaderId"] + .as_str() + .map(str::to_owned); + } + // Correlate the load with the command's loader, including redirects. + // Queued events from an earlier navigation cannot satisfy this wait. + if message["method"] == "Page.loadEventFired" + && let Some(loader) = committed_loader.as_ref() + { + loaded_loaders.insert(loader.clone()); + } + requested_loader + .as_ref() + .is_some_and(|loader| loaded_loaders.contains(loader)) }) .await; } diff --git a/moli-protocol-server/src/protocol_server/webdriver_bidi.rs b/moli-protocol-server/src/protocol_server/webdriver_bidi.rs index 49e8e146e17..f6f468fbb16 100644 --- a/moli-protocol-server/src/protocol_server/webdriver_bidi.rs +++ b/moli-protocol-server/src/protocol_server/webdriver_bidi.rs @@ -2952,6 +2952,7 @@ async fn bidi_input_viewport_bounds( target_id: Some(DevToolsTargetId::from(context_id)), browser_context_id: None, }, + publish_layout: false, }); match scheduler .execute_automation_command_with_protocol_messages(command) diff --git a/moli-protocol-webdriver-classic/src/commands/window.rs b/moli-protocol-webdriver-classic/src/commands/window.rs index be5beed5db0..9dfed87875b 100644 --- a/moli-protocol-webdriver-classic/src/commands/window.rs +++ b/moli-protocol-webdriver-classic/src/commands/window.rs @@ -31,6 +31,7 @@ pub fn create_initial_target_command(context: &ClassicDevToolsCommandContext) -> pub fn layout_metrics_command(context: &ClassicDevToolsCommandContext) -> AutomationCommand { AutomationCommand::GetLayoutMetrics(DevToolsGetLayoutMetricsCommand { context: context.command_context(), + publish_layout: false, }) } diff --git a/moli-protocol/src/automation.rs b/moli-protocol/src/automation.rs index 907e6ebc29e..627851ff6e8 100644 --- a/moli-protocol/src/automation.rs +++ b/moli-protocol/src/automation.rs @@ -605,6 +605,7 @@ pub struct DevToolsGetFrameTreesCommand { #[derive(Debug, Clone, PartialEq, Eq)] pub struct DevToolsGetLayoutMetricsCommand { pub context: AutomationContext, + pub publish_layout: bool, } #[derive(Debug, Clone, PartialEq, Eq)] diff --git a/moli-protocol/src/conn/dispatch_tests/page.rs b/moli-protocol/src/conn/dispatch_tests/page.rs index 9db97f171d8..12dc9b7c62c 100644 --- a/moli-protocol/src/conn/dispatch_tests/page.rs +++ b/moli-protocol/src/conn/dispatch_tests/page.rs @@ -930,6 +930,7 @@ async fn automation_command_executes_context_viewport_override() { target_id: Some(target_id), ..context }, + publish_layout: false, }, )) .await diff --git a/moli-protocol/src/domains/dom/tests/geometry.rs b/moli-protocol/src/domains/dom/tests/geometry.rs index 9e4ba0a80e9..17151c308e8 100644 --- a/moli-protocol/src/domains/dom/tests/geometry.rs +++ b/moli-protocol/src/domains/dom/tests/geometry.rs @@ -1408,9 +1408,9 @@ async fn scroll_into_view_if_needed_accepts_element_node() { load_bc(&mut ctx, "BID-A"); // Keep the text-row scenario while making its height independent of fonts. - let rows = "
row
".repeat(60); + let spacer = "
row
".repeat(60); let url = format!( - "data:text/html,{rows}
" + "data:text/html,{spacer}
" ); navigate_to_url_and_wait_for_load_async(&mut ctx, 1, url).await; @@ -1566,9 +1566,9 @@ async fn scroll_into_view_if_needed_supports_object_id() { load_bc(&mut ctx, "BID-A"); // Keep the text-row scenario while making its height independent of fonts. - let rows = "
row
".repeat(60); + let spacer = "
row
".repeat(60); let url = format!( - "data:text/html,{rows}
" + "data:text/html,{spacer}
" ); navigate_to_url_and_wait_for_load_async(&mut ctx, 1, url).await; @@ -1648,11 +1648,22 @@ async fn scroll_into_view_if_needed_uses_first_rendered_child_of_display_content "params": { "expression": "window.scrollY" } })) .await; + let scroll_y = take_response_by_id(&mut ctx, 33)["result"]["result"]["value"] + .as_f64() + .unwrap_or_default(); + ctx.process_async(json!({ + "id": 34, + "method": "Runtime.evaluate", + "params": { "expression": "JSON.stringify({scrollY:window.scrollY, viewport:innerHeight, scrollHeight:document.documentElement.scrollHeight, targetRect:document.querySelector('#contents').getBoundingClientRect().toJSON()})" } + })) + .await; + let diagnostics = take_response_by_id(&mut ctx, 34)["result"]["result"]["value"] + .as_str() + .unwrap_or_default() + .to_owned(); assert!( - take_response_by_id(&mut ctx, 33)["result"]["result"]["value"] - .as_f64() - .is_some_and(|scroll_y| scroll_y > 0.0), - "the display:contents element should use its rendered descendant geometry" + scroll_y > 0.0, + "the display:contents element should use its rendered descendant geometry: {diagnostics}" ); } @@ -1660,9 +1671,9 @@ async fn scroll_into_view_if_needed_uses_first_rendered_child_of_display_content async fn scroll_into_view_if_needed_honors_stylesheet_display_and_visibility() { let mut ctx = TestContext::new(); load_bc(&mut ctx, "BID-A"); - let rows = "
row
".repeat(60); + let spacer = "
row
".repeat(60); let url = format!( - "data:text/html,{rows}
suppressed child
" + "data:text/html,{spacer}
suppressed child
" ); navigate_to_url_and_wait_for_load_async(&mut ctx, 60, url).await; ctx.capture_fixture_layout(None).await; @@ -1821,7 +1832,7 @@ async fn scroll_into_view_if_needed_observes_style_change_after_explicit_capture async fn scroll_into_view_if_needed_clamps_relative_rect_to_document_scroll_range() { let mut ctx = TestContext::new(); load_bc(&mut ctx, "BID-A"); - let rows = "
row
".repeat(60); + let rows = "
row
".repeat(60); let url = format!("data:text/html,{rows}
"); navigate_to_url_and_wait_for_load_async(&mut ctx, 44, url).await; ctx.capture_fixture_layout(None).await; diff --git a/moli-protocol/src/domains/page/capture.rs b/moli-protocol/src/domains/page/capture.rs index ae05bbc3b01..0413e22f74f 100644 --- a/moli-protocol/src/domains/page/capture.rs +++ b/moli-protocol/src/domains/page/capture.rs @@ -357,13 +357,16 @@ pub(super) async fn execute_devtools_get_layout_metrics_command( command: DevToolsGetLayoutMetricsCommand, ) -> Result { let owner = page_command_owner(conn, &command.context)?; - let result = execute_devtools_get_layout_metrics_for_current_owner(conn, &owner).await; + let result = + execute_devtools_get_layout_metrics_for_current_owner(conn, &owner, command.publish_layout) + .await; result.map(AutomationResult::LayoutMetrics) } pub(super) async fn execute_devtools_get_layout_metrics_for_current_owner( conn: &mut CdpConnection, owner: &CommandOwnerScope, + publish_layout: bool, ) -> Result { let Some(page) = conn .runtime_session_owner_slot_mut_for_owner(owner) @@ -372,9 +375,11 @@ pub(super) async fn execute_devtools_get_layout_metrics_for_current_owner( else { return Err(devtools_layout_metrics_error("NoDocumentLoaded")); }; - let pending = page.start_layout_metrics().map_err(|error| { - devtools_layout_metrics_error(format!("Failed to start layout metrics: {error}")) - })?; + let pending = page + .start_layout_metrics_with_publication(publish_layout) + .map_err(|error| { + devtools_layout_metrics_error(format!("Failed to start layout metrics: {error}")) + })?; let completed = pending.wait().await.map_err(|error| { devtools_layout_metrics_error(format!("Failed to produce layout metrics: {error}")) })?; @@ -669,6 +674,11 @@ pub(super) fn build_cdp_get_layout_metrics_command( .unwrap_or((None, None)); crate::automation::DevToolsGetLayoutMetricsCommand { context: cmd.automation_context(target_id.as_deref(), browser_context_id.as_deref()), + publish_layout: cmd + .params + .and_then(|params| params.get("publishLayout")) + .and_then(serde_json::Value::as_bool) + .unwrap_or(false), } } @@ -696,7 +706,7 @@ pub(super) fn start_devtools_get_layout_metrics_command( devtools_layout_metrics_error("NoDocumentLoaded"), )); }; - match page.start_layout_metrics() { + match page.start_layout_metrics_with_publication(command.publish_layout) { Ok(pending) => PageCommandTaskStep::Pending(PendingPageCommandDispatch { command_id, owner_scope, diff --git a/moli-protocol/src/domains/page/native.rs b/moli-protocol/src/domains/page/native.rs index 3810fbcf747..b1923cf8a9f 100644 --- a/moli-protocol/src/domains/page/native.rs +++ b/moli-protocol/src/domains/page/native.rs @@ -28,8 +28,10 @@ fn prepare( resource_tree::prepare_native_tree(conn, cmd, action == PageAction::GetResourceTree) } PageAction::GetLayoutMetrics => { + let publish_layout = + capture::build_cdp_get_layout_metrics_command(conn, cmd).publish_layout; Ok(Operation::new( - Command::LayoutMetrics, + Command::LayoutMetrics { publish_layout }, |reply| match reply { Ok(Reply::LayoutMetrics(metrics)) => { Response::success(CommandOutputPlan::devtools_result_payload( diff --git a/moli-protocol/src/domains/page/tests/capture.rs b/moli-protocol/src/domains/page/tests/capture.rs index d2c7d3679af..dbe546f062d 100644 --- a/moli-protocol/src/domains/page/tests/capture.rs +++ b/moli-protocol/src/domains/page/tests/capture.rs @@ -1149,6 +1149,70 @@ fn screenshot_data_url(html: &str) -> String { percent_encoding::percent_encode(html.as_bytes(), percent_encoding::NON_ALPHANUMERIC) ) } + +#[tokio::test(flavor = "multi_thread")] +async fn explicit_layout_metrics_publication_exposes_new_click_target() { + let mut ctx = TestContext::new(); + let session = "SID-EXPLICIT-LAYOUT-PUBLICATION"; + install_active_screenshot_page( + &mut ctx, + "BID-EXPLICIT-LAYOUT-PUBLICATION", + "TID-EXPLICIT-LAYOUT-PUBLICATION", + session, + &screenshot_data_url( + "", + ), + ) + .await; + set_screenshot_viewport(&mut ctx, session, 320, 240, 1.0, 200).await; + + ctx.process_async(json!({"id":201,"method":"Page.getLayoutMetrics","sessionId":session})) + .await; + take_response_by_id(&mut ctx, 201); + ctx.process_async(json!({ + "id":202,"method":"Runtime.evaluate","sessionId":session, + "params":{"expression":"document.querySelector('#action').style.display='block'"} + })) + .await; + take_response_by_id(&mut ctx, 202); + + for (id, params) in [(203, json!({})), (205, json!({"publishLayout":false}))] { + ctx.process_async(json!({ + "id":id,"method":"Page.getLayoutMetrics","sessionId":session, + "params":params + })) + .await; + assert!(take_response_by_id(&mut ctx, id).get("result").is_some()); + ctx.process_async(json!({ + "id":id+1,"method":"Runtime.evaluate","sessionId":session, + "params":{"expression":"document.querySelector('#action').getBoundingClientRect().width"} + })) + .await; + assert_eq!( + take_response_by_id(&mut ctx, id + 1)["result"]["result"]["value"], + 0, + "ordinary metrics must retain the published frame" + ); + } + + ctx.process_async(json!({ + "id":207,"method":"Page.getLayoutMetrics","sessionId":session, + "params":{"publishLayout":true} + })) + .await; + assert!(take_response_by_id(&mut ctx, 207).get("result").is_some()); + ctx.process_async(json!({ + "id":208,"method":"Runtime.evaluate","sessionId":session, + "params":{"expression":"({width:document.querySelector('#action').getBoundingClientRect().width,hit:document.elementFromPoint(20,30)?.id})","returnByValue":true} + })) + .await; + assert_eq!( + take_response_by_id(&mut ctx, 208)["result"]["result"]["value"], + json!({"width":80,"hit":"action"}), + "an explicit automation refresh must publish geometry and hit testing" + ); +} + #[tokio::test(flavor = "multi_thread")] async fn get_layout_metrics_initializes_layout_and_reuses_content_until_capture() { let mut ctx = TestContext::new(); diff --git a/moli-renderer-v8/src/devtools/command/nested.rs b/moli-renderer-v8/src/devtools/command/nested.rs index edf5001f58a..b1d96fe4cc7 100644 --- a/moli-renderer-v8/src/devtools/command/nested.rs +++ b/moli-renderer-v8/src/devtools/command/nested.rs @@ -78,7 +78,7 @@ impl RendererPageCommand { | Self::OuterHtmlForDocument { .. } | Self::OuterHtmlForNode { .. } | Self::SerializeDocument - | Self::LayoutMetrics + | Self::LayoutMetrics { .. } | Self::PublishLayout | Self::CaptureScreencastFrame(..) | Self::BlobBytesForUuid { .. } diff --git a/moli-renderer-v8/src/native_bridge/context_host/host_environment.rs b/moli-renderer-v8/src/native_bridge/context_host/host_environment.rs index fe2aec5152c..8e87246ec67 100644 --- a/moli-renderer-v8/src/native_bridge/context_host/host_environment.rs +++ b/moli-renderer-v8/src/native_bridge/context_host/host_environment.rs @@ -15,9 +15,9 @@ use crate::{ style_engine::{ AdoptedStyleSheetInstallation, CssCustomPropertyRegistration, CssCustomPropertyRegistrationError, FullStyleWorldSnapshot, - OwnedStyleSourceDocumentContext, PreparedStyleWorldUpdate, StyleAttributeImpact, - StyleMutationEffect, StyleObservationSnapshot, StyleViewport, StylesheetResourceSnapshot, - StyloStyleEnvironment, StyloStylesheetSource, + OwnedStyleSourceDocumentContext, PreparedStyleWorldUpdate, StyleMutationEffect, + StyleObservationSnapshot, StyleViewport, StylesheetResourceSnapshot, StyloStyleEnvironment, + StyloStylesheetSource, }, { RendererWebStorageHandles, @@ -356,11 +356,13 @@ impl JsContextHost { .get_attribute(owner, "media") .unwrap_or_default(); stylesheet.set_media_text(&media); - self.style_engine.refresh_owner_live_stylesheet_with_host( + if self.style_engine.refresh_owner_live_stylesheet_with_host( unsafe { &*dom_host }, owner, stylesheet.id(), - ); + ) { + self.clear_layout_rect_cache(); + } } fn refresh_linked_live_stylesheet_attributes(&mut self, owner: DomHandle) { @@ -821,6 +823,7 @@ impl JsContextHost { ) { if self.emulated_media != *overrides { self.invalidate_layout_inputs(LayoutInputChange::StyleEnvironment); + self.invalidate_layout_inputs(LayoutInputChange::UsedGeometry); } self.emulated_media = overrides.clone(); } @@ -834,10 +837,26 @@ impl JsContextHost { viewport_surface: Option, ) -> bool { let changed = self.viewport_surface != viewport_surface; - if changed { + let previous_style_viewport = self.style_viewport(); + let previous_emulated_view = self + .viewport_surface + .and_then(|surface| surface.emulated_view); + self.viewport_surface = viewport_surface; + if changed && previous_style_viewport != self.style_viewport() { self.invalidate_layout_inputs(LayoutInputChange::StyleEnvironment); + self.invalidate_layout_inputs(LayoutInputChange::UsedGeometry); + } + if previous_emulated_view + != self + .viewport_surface + .and_then(|surface| surface.emulated_view) + { + // A compositor-only emulated view changes screencast pixels without + // changing the CSS layout viewport. + self.document_layout_state + .borrow_mut() + .mark_visual_state_dirty(); } - self.viewport_surface = viewport_surface; changed } @@ -897,6 +916,7 @@ impl JsContextHost { document: DomHandle, installations: Vec, ) { + self.clear_layout_rect_cache(); self.style_engine .set_document_adopted_style_sheet_installations(document, installations); } @@ -1128,12 +1148,17 @@ impl JsContextHost { suppressed: bool, ) -> bool { let dom_host = self.dom_host() as *const _; - self.style_engine + let changed = self + .style_engine .set_owner_style_sheet_csp_suppressed_with_host( unsafe { &*dom_host }, owner, suppressed, - ) + ); + if changed { + self.clear_layout_rect_cache(); + } + changed } pub(crate) fn install_linked_stylesheet( @@ -1306,13 +1331,16 @@ impl JsContextHost { document: DomHandle, registration: CssCustomPropertyRegistration, ) -> Result<(), CssCustomPropertyRegistrationError> { - self.clear_layout_rect_cache(); let base_url = self.style_base_url_for_document(document); - self.style_engine.register_css_custom_property_for_document( + let result = self.style_engine.register_css_custom_property_for_document( document, registration, base_url, - ) + ); + if result.is_ok() { + self.clear_layout_rect_cache(); + } + result } pub(crate) fn validate_css_custom_property_registration_for_document( @@ -1895,6 +1923,14 @@ impl JsContextHost { previous: Option, next: Option, ) { + if previous != next + && previous + .into_iter() + .chain(next) + .any(|element| self.dom_host().is_connected(element)) + { + self.clear_layout_rect_cache(); + } let dom_host = self.dom_host() as *const _; let emulated_media = self.emulated_media().clone(); let viewport = self.style_viewport(); @@ -1913,6 +1949,14 @@ impl JsContextHost { next: Option, previous_focus_within: Option>, ) { + if previous != next + && previous + .into_iter() + .chain(next) + .any(|element| self.dom_host().is_connected(element)) + { + self.clear_layout_rect_cache(); + } let dom_host = self.dom_host() as *const _; let emulated_media = self.emulated_media().clone(); let viewport = self.style_viewport(); @@ -1932,6 +1976,14 @@ impl JsContextHost { previous: Option, next: Option, ) { + if previous != next + && previous + .into_iter() + .chain(next) + .any(|element| self.dom_host().is_connected(element)) + { + self.clear_layout_rect_cache(); + } let dom_host = self.dom_host() as *const _; let emulated_media = self.emulated_media().clone(); let viewport = self.style_viewport(); @@ -1951,6 +2003,12 @@ impl JsContextHost { state: StyloElementState, old_state: Option, ) { + let state_changed = old_state + .zip(self.retained_current_element_state(element)) + .is_none_or(|(old, current)| old & state != current & state); + if state_changed && self.dom_host().is_connected(element) { + self.clear_layout_rect_cache(); + } let dom_host = self.dom_host() as *const _; let emulated_media = self.emulated_media().clone(); let viewport = self.style_viewport(); @@ -2017,6 +2075,9 @@ impl JsContextHost { state_names: Vec, old_custom_states: Vec, ) { + if !state_names.is_empty() && self.dom_host().is_connected(element) { + self.clear_layout_rect_cache(); + } let dom_host = self.dom_host() as *const _; let emulated_media = self.emulated_media().clone(); let viewport = self.style_viewport(); @@ -2293,7 +2354,7 @@ impl JsContextHost { } pub(crate) fn note_style_mutation_effects(&mut self, effects: &[StyleMutationEffect]) { - if style_mutation_effects_affect_layout_metric(effects) { + if style_mutation_effects_affect_layout_metric(self.dom_host(), effects) { self.clear_layout_rect_cache(); } let validity_containers = self.validity_containers_for_child_list_mutations(effects); @@ -2319,14 +2380,18 @@ impl JsContextHost { } pub(crate) fn note_element_inline_style_subtree_activity(&mut self, root: DomHandle) { - self.clear_layout_rect_cache(); + if self.dom_host().is_connected(root) { + self.clear_layout_rect_cache(); + } let dom_host = self.dom_host() as *const _; self.style_engine .invalidate_inline_style_subtree(unsafe { &*dom_host }, root); } pub(crate) fn note_style_subtree_context_change(&mut self, root: DomHandle) { - self.clear_layout_rect_cache(); + if self.dom_host().is_connected(root) { + self.clear_layout_rect_cache(); + } let dom_host = self.dom_host() as *const _; self.style_engine .invalidate_style_subtree(unsafe { &*dom_host }, root); @@ -2625,15 +2690,27 @@ impl JsContextHost { } } -fn style_mutation_effects_affect_layout_metric(effects: &[StyleMutationEffect]) -> bool { +fn style_mutation_effects_affect_layout_metric( + host: &crate::dom::native::DomHost, + effects: &[StyleMutationEffect], +) -> bool { effects.iter().any(|effect| match effect { - StyleMutationEffect::Attribute { name, .. } => { - StyleAttributeImpact::for_attribute_name(name).affects_layout_metric() - } - StyleMutationEffect::ConnectedSubtrees { .. } - | StyleMutationEffect::DisconnectedSubtrees { .. } - | StyleMutationEffect::SlotAssignment { .. } - | StyleMutationEffect::CharacterData { .. } - | StyleMutationEffect::ChildList { .. } => true, + // Any connected attribute can participate in an authored attribute + // selector whose winning declaration changes box geometry. The fixed + // native-attribute classification only covers non-CSS side effects. + StyleMutationEffect::Attribute { element, .. } => host.is_connected(*element), + // Tree-effect names describe the insertion/removal operation, not a + // guaranteed document-connected transition: inserting into a detached + // parent still emits `ConnectedSubtrees`. Check the post-insertion + // roots directly. A removed root is already disconnected by the time + // effects are consumed; removal from the live document remains covered + // by its accompanying `ChildList`, whose parent stays connected. + StyleMutationEffect::ConnectedSubtrees { roots } => { + roots.iter().any(|root| host.is_connected(*root)) + } + StyleMutationEffect::DisconnectedSubtrees { .. } => false, + StyleMutationEffect::SlotAssignment { slot, .. } => host.is_connected(*slot), + StyleMutationEffect::CharacterData { node } => host.is_connected(*node), + StyleMutationEffect::ChildList { parent, .. } => host.is_connected(*parent), }) } diff --git a/moli-renderer-v8/src/native_bridge/context_host/image_resources/mod.rs b/moli-renderer-v8/src/native_bridge/context_host/image_resources/mod.rs index bbf542d65e7..bdfb05026a6 100644 --- a/moli-renderer-v8/src/native_bridge/context_host/image_resources/mod.rs +++ b/moli-renderer-v8/src/native_bridge/context_host/image_resources/mod.rs @@ -423,6 +423,7 @@ impl super::JsContextHost { .optional_resource_fetch_enabled(crate::types::SubresourceResourceType::Image) }); if decode_enabled && self.image_resources.complete_shared_ready(&identity) { + self.clear_layout_rect_cache(); let Some(pending) = self.pending_image_load_events.get_mut(&element) else { return ImageResponseCompletion::Ignored; }; @@ -571,6 +572,9 @@ impl super::JsContextHost { false } }; + if successful { + self.clear_layout_rect_cache(); + } let Some(pending) = self.pending_image_load_events.get_mut(&task_id.element()) else { return false; }; diff --git a/moli-renderer-v8/src/runtime/page_commands.rs b/moli-renderer-v8/src/runtime/page_commands.rs index 550b2e021b2..d98f9a3b4a8 100644 --- a/moli-renderer-v8/src/runtime/page_commands.rs +++ b/moli-renderer-v8/src/runtime/page_commands.rs @@ -826,7 +826,10 @@ impl PageVm { html: self.serialize_html(), }) } - RendererPageCommand::LayoutMetrics => { + RendererPageCommand::LayoutMetrics { publish_layout } => { + if publish_layout { + self.vm_mut().publish_layout()?; + } Ok(RendererPageReply::LayoutMetrics(self.layout_metrics()?)) } RendererPageCommand::PublishLayout => { diff --git a/moli-renderer-v8/src/runtime/page_surface.rs b/moli-renderer-v8/src/runtime/page_surface.rs index 6f849fe6df2..af46677dfa0 100644 --- a/moli-renderer-v8/src/runtime/page_surface.rs +++ b/moli-renderer-v8/src/runtime/page_surface.rs @@ -5104,7 +5104,9 @@ pub enum RendererPageCommand { options: RendererPageDumpOptions, }, SerializeDocument, - LayoutMetrics, + LayoutMetrics { + publish_layout: bool, + }, PublishLayout, CaptureScreenshot(RendererCaptureScreenshotRequest), CaptureScreencastFrame(RendererCaptureScreencastFrameRequest), @@ -5705,7 +5707,7 @@ impl RendererPageCommand { Self::OuterHtmlForNode { .. } => Some("OuterHtmlForNode"), Self::RenderPageDump { .. } => Some("RenderPageDump"), Self::SerializeDocument => Some("SerializeDocument"), - Self::LayoutMetrics => Some("LayoutMetrics"), + Self::LayoutMetrics { .. } => Some("LayoutMetrics"), Self::PublishLayout => Some("PublishLayout"), Self::CaptureScreenshot(_) => Some("CaptureScreenshot"), Self::CaptureScreencastFrame(_) => Some("CaptureScreencastFrame"), diff --git a/moli-renderer-v8/src/runtime/page_vm/tests/rendering_update.rs b/moli-renderer-v8/src/runtime/page_vm/tests/rendering_update.rs index 229142871ca..fa7cc8a59ec 100644 --- a/moli-renderer-v8/src/runtime/page_vm/tests/rendering_update.rs +++ b/moli-renderer-v8/src/runtime/page_vm/tests/rendering_update.rs @@ -12,6 +12,91 @@ use crate::page_task_queue::{ }; use crate::script_vm::MainDocumentLifecycleBody; +#[tokio::test(flavor = "current_thread")] +async fn interactive_geometry_updates_only_after_visual_publication() { + run_page_vm_async_test(async move { + let loader = + crate::network::ResourceRequestClient::new(&FetchConfig::default()).expect("loader"); + let mut page_vm = test_page_vm_with_loader_and_document_url( + &loader, + Vec::new(), + Url::parse("https://example.com/dynamic-button.html")?, + ); + page_vm + .vm_mut() + .set_layout_policy(moli_page_types::LayoutPolicy::OnDemand); + page_vm.vm_mut().eval( + "document.body.innerHTML = ''; 'ready'", + )?; + let first_width: f64 = page_vm + .vm_mut() + .eval("document.getElementById('first').getBoundingClientRect().width")? + .parse()?; + assert!(first_width > 0.0); + let first_publish = page_vm.vm().layout_snapshot_cache_observability_for_test().2; + + page_vm.vm_mut().eval( + "document.body.insertAdjacentHTML('beforeend', ''); 'inserted'", + )?; + let added_width_before: f64 = page_vm + .vm_mut() + .eval("document.getElementById('added').getBoundingClientRect().width")? + .parse()?; + assert_eq!(added_width_before, 0.0); + assert_eq!(page_vm.vm().layout_snapshot_cache_observability_for_test().2, first_publish); + page_vm.vm_mut().publish_layout_for_test()?; + let dom_publish = page_vm.vm().layout_snapshot_cache_observability_for_test().2; + assert_eq!(dom_publish, first_publish + 1); + let added_width: f64 = page_vm + .vm_mut() + .eval("document.getElementById('added').getBoundingClientRect().width")? + .parse()?; + assert!(added_width > 0.0); + page_vm + .vm_mut() + .eval("document.getElementById('added').getBoundingClientRect().width")?; + assert_eq!(page_vm.vm().layout_snapshot_cache_observability_for_test().2, dom_publish); + + page_vm.vm_mut().eval( + "document.body.insertAdjacentHTML('beforeend', ''); 'inserted'", + )?; + let offset_width_before: i32 = page_vm + .vm_mut() + .eval("document.getElementById('offset').offsetWidth")? + .parse()?; + assert_eq!(offset_width_before, 0); + assert_eq!(page_vm.vm().layout_snapshot_cache_observability_for_test().2, dom_publish); + page_vm.vm_mut().publish_layout_for_test()?; + let offset_width: i32 = page_vm + .vm_mut() + .eval("document.getElementById('offset').offsetWidth")? + .parse()?; + assert!(offset_width > 0); + let metrics_publish = page_vm.vm().layout_snapshot_cache_observability_for_test().2; + assert_eq!(metrics_publish, dom_publish + 1); + + page_vm.vm_mut().eval( + "document.body.insertAdjacentHTML('beforeend', ''); 'inserted'", + )?; + let handle = page_vm + .vm() + .element_handle_by_id_for_test("cdp") + .expect("new CDP target"); + assert!(page_vm.vm_mut().client_rect_for_live_node_handle(handle)?.is_none()); + assert_eq!(page_vm.vm().layout_snapshot_cache_observability_for_test().2, metrics_publish); + page_vm.vm_mut().publish_layout_for_test()?; + let rect = page_vm + .vm_mut() + .client_rect_for_live_node_handle(handle)? + .expect("new CDP target should have geometry"); + assert!(rect.width > 0.0); + assert_eq!(page_vm.vm().layout_snapshot_cache_observability_for_test().2, metrics_publish + 1); + Ok::<_, anyhow::Error>(()) + }) + .await + .expect("interactive geometry should update only after visual publication"); +} + fn viewport_screencast_request( known_visual_state: Option, ) -> crate::runtime::RendererCaptureScreencastFrameRequest { diff --git a/moli-renderer-v8/src/script_vm/tests/dom_elements/dom_surface.rs b/moli-renderer-v8/src/script_vm/tests/dom_elements/dom_surface.rs index a62ffc3d955..1f74f303d80 100644 --- a/moli-renderer-v8/src/script_vm/tests/dom_elements/dom_surface.rs +++ b/moli-renderer-v8/src/script_vm/tests/dom_elements/dom_surface.rs @@ -73,3 +73,372 @@ fn drain_image_load_event_bodies_for_test(vm: &mut ScriptVm) -> usize { count } mod extracted; + +#[test] +fn inline_element_exposes_geometry_for_promoted_block_children() { + for children in [ + "X", + "XY", + ] { + let mut vm = new_parsed_test_vm( + "https://inline-block-fragments.test/", + &format!("{children}"), + ); + let result = vm + .eval( + "JSON.stringify({width:document.getElementById('link').getBoundingClientRect().width,rects:[...document.getElementById('link').getClientRects()].map(rect=>rect.width)})", + ) + .expect("inline geometry should evaluate"); + let geometry: serde_json::Value = serde_json::from_str(&result).expect("geometry JSON"); + assert!( + geometry["width"].as_f64().is_some_and(|width| width > 0.0), + "a visible block child must contribute to its inline parent's bounding rect: {geometry}" + ); + assert!( + geometry["rects"].as_array().is_some_and(|rects| rects + .iter() + .any(|rect| rect.as_f64().is_some_and(|width| width > 0.0))), + "a visible block child must contribute a nonempty client rect: {geometry}" + ); + } +} + +#[test] +fn offset_parent_observes_connected_insertion_after_paint_publication() { + let mut vm = new_storage_test_vm("https://offset-parent-layout.test/"); + vm.eval( + r#" + if (!document.documentElement) document.appendChild(document.createElement('html')); + if (!document.head) document.documentElement.appendChild(document.createElement('head')); + if (!document.body) document.documentElement.appendChild(document.createElement('body')); + document.head.innerHTML = ``; + document.body.innerHTML = '
'; + document.getElementById('existing').getBoundingClientRect().width; + globalThis.menu = document.createElement('div'); + menu.id = 'menu'; + globalThis.candidate = document.createElement('button'); + candidate.id = 'candidate'; + candidate.textContent = 'Abishek S abisubramanya27'; + menu.appendChild(candidate); + 'installed'; + "#, + ) + .expect("fixture and initial layout should evaluate"); + + let passes_after_initial_layout = vm.layout_pass_observability_for_test().1; + assert_eq!( + vm.eval("candidate.offsetParent === null && candidate.offsetWidth === 0") + .unwrap(), + "true", + "geometry for a detached candidate must remain empty" + ); + assert_eq!( + vm.layout_pass_observability_for_test().1, + passes_after_initial_layout, + "building and querying a detached subtree must not invalidate live layout" + ); + + vm.eval("document.body.appendChild(menu); 'connected'") + .expect("connected insertion should evaluate"); + assert_eq!( + vm.layout_pass_observability_for_test().1, + passes_after_initial_layout, + "the connected insertion must mark layout dirty without rebuilding it eagerly" + ); + + let result = vm + .eval( + r#" + (() => { + const parent = candidate.offsetParent; + const width = candidate.offsetWidth; + return JSON.stringify({ parentIsMenu: parent === menu, width }); + })() + "#, + ) + .expect("offsetParent-first geometry read should evaluate"); + assert_eq!( + result, r#"{"parentIsMenu":false,"width":0}"#, + "the inserted element is absent from the published layout" + ); + assert_eq!( + vm.layout_pass_observability_for_test().1, + passes_after_initial_layout, + "geometry reads must not publish a new layout" + ); + + vm.screenshot_layout_snapshot(moli_layout::PaintViewport::new(800, 600, 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_after_initial_layout + 1 + ); + + assert_eq!( + vm.eval("candidate.offsetParent === menu && candidate.offsetWidth === 240") + .unwrap(), + "true" + ); + assert_eq!( + vm.layout_pass_observability_for_test().1, + passes_after_initial_layout + 1, + "geometry reads must reuse the published tree" + ); +} + +#[test] +fn geometry_reuses_clean_layout_after_detached_subtree_mutations() { + let mut vm = new_storage_test_vm("https://layout-detached.test/"); + vm.eval( + r#" + if (!document.documentElement) document.appendChild(document.createElement('html')); + if (!document.body) document.documentElement.appendChild(document.createElement('body')); + document.body.innerHTML = '
'; + globalThis.target = document.getElementById('target'); + globalThis.detached = document.createElement('div'); + target.getBoundingClientRect().width; + "#, + ) + .expect("fixture should evaluate"); + let passes = vm.layout_pass_observability_for_test().1; + for mutation in [ + "detached.textContent = 'not on the page'", + "detached.appendChild(document.createElement('span'))", + "detached.style.width = '300px'", + "detached.setAttribute('data-wide', '')", + "detached.firstChild.data = 'changed text'", + ] { + vm.eval(mutation) + .expect("detached mutation should evaluate"); + assert_eq!( + vm.eval("target.getBoundingClientRect().width").unwrap(), + "40" + ); + assert_eq!( + vm.layout_pass_observability_for_test().1, + passes, + "detached mutation must not invalidate page layout: {mutation}" + ); + } +} + +#[test] +fn geometry_publishes_pseudo_class_inputs_at_paint_boundary() { + let mut vm = new_storage_test_vm("https://layout-state.test/"); + vm.eval(r#" + if (!document.documentElement) document.appendChild(document.createElement('html')); + if (!document.body) document.documentElement.appendChild(document.createElement('body')); + document.body.innerHTML = ''; + globalThis.target = document.getElementById('target'); + target.getBoundingClientRect().width; + "#).expect("fixture should evaluate"); + let mut passes = vm.layout_pass_observability_for_test().1; + for (mutation, previous, expected) in [ + ("target.focus({preventScroll: true})", "40", "80"), + ("target.checked = true", "80", "120"), + ("target.checked = false", "120", "80"), + ("target.blur()", "80", "40"), + ] { + vm.eval(mutation).expect("element state should change"); + assert_eq!(vm.layout_pass_observability_for_test().1, passes); + assert_eq!( + vm.eval("target.getBoundingClientRect().width").unwrap(), + previous, + "geometry must retain the previous published pseudo-class state: {mutation}" + ); + assert_eq!(vm.layout_pass_observability_for_test().1, passes); + vm.screenshot_layout_snapshot(moli_layout::PaintViewport::new(800, 600, 1.0)) + .expect("fresh paint layout should succeed") + .expect("the fixture should have a layout root"); + passes += 1; + assert_eq!(vm.layout_pass_observability_for_test().1, passes); + assert_eq!( + vm.eval("target.getBoundingClientRect().width").unwrap(), + expected + ); + assert_eq!(vm.layout_pass_observability_for_test().1, passes); + } +} + +#[test] +fn geometry_publishes_attribute_selectors_and_cssom_at_paint_boundary() { + let mut vm = new_storage_test_vm("https://layout-inputs.test/"); + vm.eval(r#" + if (!document.documentElement) document.appendChild(document.createElement('html')); + if (!document.body) document.documentElement.appendChild(document.createElement('body')); + document.body.innerHTML = '
'; + globalThis.target = document.getElementById('target'); + target.getBoundingClientRect().width; + "#).expect("fixture should evaluate"); + let mut passes = vm.layout_pass_observability_for_test().1; + for (mutation, previous, expected) in [ + ("target.setAttribute('data-wide', '')", "40", "80"), + ( + "document.getElementById('rules').sheet.insertRule('#target[data-wide] { width: 120px }', 2)", + "80", + "120", + ), + ("target.removeAttribute('data-wide')", "120", "40"), + ] { + vm.eval(mutation).expect("layout input should change"); + assert_eq!( + vm.layout_pass_observability_for_test().1, + passes, + "input changes must not eagerly lay out: {mutation}" + ); + assert_eq!( + vm.eval("target.getBoundingClientRect().width").unwrap(), + previous, + "geometry must retain the published style before capture: {mutation}" + ); + assert_eq!(vm.layout_pass_observability_for_test().1, passes); + vm.screenshot_layout_snapshot(moli_layout::PaintViewport::new(800, 600, 1.0)) + .expect("fresh paint layout should succeed") + .expect("the fixture should have a layout root"); + passes += 1; + assert_eq!(vm.layout_pass_observability_for_test().1, passes); + assert_eq!( + vm.eval("target.getBoundingClientRect().width").unwrap(), + expected + ); + assert_eq!( + vm.layout_pass_observability_for_test().1, + passes, + "clean geometry must be reusable across script turns" + ); + } +} + +#[test] +fn geometry_publishes_inserted_and_existing_nodes_at_paint_boundary() { + let mut vm = new_storage_test_vm("https://layout-mutation.test/"); + + vm.eval( + r#" + (() => { + if (!document.documentElement) { + document.appendChild(document.createElement("html")); + } + if (!document.body) { + document.documentElement.appendChild(document.createElement("body")); + } + document.body.innerHTML = '
Marker
'; + return document.getElementById("marker").getBoundingClientRect().top; + })() + "#, + ) + .expect("initial layout read should evaluate"); + + let before_top = vm + .eval("document.getElementById('marker').getBoundingClientRect().top") + .expect("initial marker geometry should evaluate") + .parse::() + .expect("initial marker top should be numeric"); + + let passes_before = vm.layout_pass_observability_for_test().1; + vm.eval( + r#"document.getElementById("content").insertAdjacentHTML( + "afterbegin", + '
' + )"#, + ) + .expect("connected insertion should evaluate"); + assert_eq!( + vm.layout_pass_observability_for_test().1, + passes_before, + "a DOM mutation alone must not run layout" + ); + assert!( + vm.layout_snapshot_cache_observability_for_test() + .3 + .is_some(), + "mutation should retain the sampled frozen tree" + ); + + let stale = vm + .eval( + r#" + (() => { + const rect = document.getElementById("target").getBoundingClientRect(); + const marker = document.getElementById("marker").getBoundingClientRect(); + return JSON.stringify({ target: [rect.width, rect.height], markerTop: marker.top }); + })() + "#, + ) + .expect("geometry before layout publication should evaluate"); + + let stale: serde_json::Value = + serde_json::from_str(&stale).expect("geometry result should be JSON"); + assert_eq!(stale["target"], serde_json::json!([0, 0])); + assert_eq!(stale["markerTop"].as_f64(), Some(before_top)); + assert_eq!(vm.layout_pass_observability_for_test().1, passes_before); + + vm.screenshot_layout_snapshot(moli_layout::PaintViewport::new(800, 600, 1.0)) + .expect("fresh paint layout should succeed") + .expect("the fixture should have a layout root"); + let result = vm + .eval( + r#"(() => { + const rect = document.getElementById("target").getBoundingClientRect(); + const marker = document.getElementById("marker").getBoundingClientRect(); + return JSON.stringify({ target: [rect.width, rect.height], markerTop: marker.top }); + })()"#, + ) + .expect("published geometry should evaluate"); + + assert_eq!( + vm.layout_pass_observability_for_test().1, + passes_before + 1, + "all geometry reads after capture should share one published pass" + ); + + let result: serde_json::Value = + serde_json::from_str(&result).expect("geometry result should be JSON"); + assert!( + result["target"][0] + .as_f64() + .is_some_and(|width| width > 0.0) + && result["target"][1] + .as_f64() + .is_some_and(|height| height > 0.0), + "inserted content should receive real geometry: {result}" + ); + assert!( + result["markerTop"] + .as_f64() + .is_some_and(|top| top > before_top), + "existing geometry must not be reused after a preceding sibling is inserted: {result}" + ); + vm.eval("const content = document.getElementById('content'); content.firstElementChild.remove(); content.firstElementChild.remove()") + .expect("connected removals should evaluate"); + assert_eq!( + vm.layout_pass_observability_for_test().1, + passes_before + 1, + "removal itself must not run layout" + ); + assert_eq!( + vm.eval("document.getElementById('marker').getBoundingClientRect().top") + .unwrap() + .parse::() + .unwrap(), + result["markerTop"].as_f64().unwrap(), + "connected removal must leave the published geometry frozen" + ); + assert_eq!(vm.layout_pass_observability_for_test().1, passes_before + 1); + vm.screenshot_layout_snapshot(moli_layout::PaintViewport::new(800, 600, 1.0)) + .expect("fresh paint layout after removal should succeed") + .expect("the fixture should have a layout root"); + assert_eq!( + vm.eval("document.getElementById('marker').getBoundingClientRect().top") + .unwrap() + .parse::() + .unwrap(), + before_top, + "a subsequent paint must publish geometry after connected removal" + ); + assert_eq!(vm.layout_pass_observability_for_test().1, passes_before + 2); +} diff --git a/moli-renderer-v8/src/script_vm/tests/dom_elements/dom_surface/extracted/inline_layout.rs b/moli-renderer-v8/src/script_vm/tests/dom_elements/dom_surface/extracted/inline_layout.rs index 27246e0286a..2df4fe6d9cd 100644 --- a/moli-renderer-v8/src/script_vm/tests/dom_elements/dom_surface/extracted/inline_layout.rs +++ b/moli-renderer-v8/src/script_vm/tests/dom_elements/dom_surface/extracted/inline_layout.rs @@ -1339,7 +1339,39 @@ fn inline_end_border_preserves_tall_atomic_content() { } } -// Geometry measured in Chromium 145.0.7632.116, before and after publication. +// Pin font metrics so reference geometry does not depend on the host's +// choice of generic monospace family. +fn new_fixed_font_inline_test_vm(url: &str, markup: &str) -> StandaloneScriptVmHarness { + let css = "@font-face{font-family:FixedInline;src:url(https://inline-font.test/fixed.ttf)}"; + let markup = markup.replacen("
{content}
"# + r#"{doctype}
{content}
"# ); - let mut vm = new_parsed_test_vm("https://inline-alignment.test/", &markup); + let mut vm = new_fixed_font_inline_test_vm("https://inline-alignment.test/", &markup); let query = "JSON.stringify([line.getBoundingClientRect().height,b.getBoundingClientRect().top-a.getBoundingClientRect().top])"; let expected = if quirks { quirks_geometry @@ -1471,15 +1503,17 @@ fn forced_breaks_restore_ancestor_bidi_before_closing_inner_inline_boxes() { let whitespace = if preserved { ";white-space:pre" } else { "" }; let newline = if preserved { "\n" } else { "
" }; let markup = format!( - r#"{doctype}
x{newline}x
"# + r#"{doctype}
x{newline}x
"# + ); + let mut vm = new_fixed_font_inline_test_vm( + "https://inline-bidi-forced-break.test/", + &markup, ); - let mut vm = - new_parsed_test_vm("https://inline-bidi-forced-break.test/", &markup); let query = "JSON.stringify([line.getBoundingClientRect().height,second.getBoundingClientRect().top-line.getBoundingClientRect().top])"; let expected = if quirks || bidi == "normal" { - [90.0, 63.0] + [90.0, 65.0] } else { - [120.0, 81.0] + [120.0, 83.0] }; let first = vm.eval(query).unwrap(); assert_eq!( diff --git a/moli-renderer-v8/src/script_vm/tests/dom_xhr/computed_style.rs b/moli-renderer-v8/src/script_vm/tests/dom_xhr/computed_style.rs index d23e1c5673d..8e82b03f9ee 100644 --- a/moli-renderer-v8/src/script_vm/tests/dom_xhr/computed_style.rs +++ b/moli-renderer-v8/src/script_vm/tests/dom_xhr/computed_style.rs @@ -61,4 +61,5 @@ mod cross_document_and_animations; mod css_computed_map_enumeration; mod nested_document_invalidation; mod properties_and_selectors; +mod root_font_units; mod stylesheet_and_document_lifecycle; diff --git a/moli-renderer-v8/src/script_vm/tests/dom_xhr/computed_style/root_font_units.rs b/moli-renderer-v8/src/script_vm/tests/dom_xhr/computed_style/root_font_units.rs new file mode 100644 index 00000000000..7eb7812d618 --- /dev/null +++ b/moli-renderer-v8/src/script_vm/tests/dom_xhr/computed_style/root_font_units.rs @@ -0,0 +1,186 @@ +use super::*; + +#[test] +fn root_font_relative_units_follow_each_documents_live_root_style() { + let mut vm = new_parsed_test_vm( + "https://root-font-relative-units.test/", + r#" + + +
+ + "#, + ); + + let result = vm + .eval( + r#" +(() => { + const root = document.documentElement; + const target = document.getElementById('target'); + const values = () => { + const targetStyle = getComputedStyle(target); + const targetValues = [targetStyle.width, targetStyle.height, targetStyle.fontSize]; + const rootFontSize = getComputedStyle(root).fontSize; + return [rootFontSize, ...targetValues].join(','); + }; + const initial = values(); + + root.style.fontSize = '20px'; + const updated = values(); + + root.style.fontSize = '0.625rem'; + const selfRelative = values(); + + root.style.lineHeight = '3'; + const updatedLineHeight = values(); + root.style.lineHeight = '2'; + + const frame = document.createElement('iframe'); + document.body.appendChild(frame); + const childDocument = frame.contentWindow.document; + childDocument.open(); + childDocument.write('
'); + childDocument.close(); + const childWidth = frame.contentWindow + .getComputedStyle(childDocument.getElementById('child')) + .width; + + return [initial, updated, selfRelative, updatedLineHeight, childWidth, values()].join('|'); +})() +"#, + ) + .expect("root font-relative units should track isolated live root styles"); + + assert_eq!( + result, + "10px,88px,20px,14px|20px,176px,40px,28px|10px,88px,20px,14px|10px,88px,30px,14px|70px|10px,88px,20px,14px" + ); +} + +#[test] +fn replacing_document_root_replaces_retained_root_font_state() { + let mut vm = new_parsed_test_vm( + "https://replaced-root-font-state.test/", + r#" +
+ "#, + ); + + let initial = vm + .eval("getComputedStyle(document.getElementById('target')).width") + .expect("initial descendant should resolve root-relative width"); + assert_eq!(initial, "20px"); + + let replaced = vm + .eval( + r#" +(() => { + const nextRoot = document.createElement('html'); + nextRoot.style.fontSize = '7px'; + const nextBody = document.createElement('body'); + const nextTarget = document.createElement('div'); + nextTarget.id = 'next-target'; + nextTarget.style.width = '2rem'; + nextBody.appendChild(nextTarget); + nextRoot.appendChild(nextBody); + document.replaceChild(nextRoot, document.documentElement); + return getComputedStyle(nextTarget).width; +})() +"#, + ) + .expect("new descendant should replace the retained root state"); + assert_eq!(replaced, "14px"); + + vm.set_viewport_surface(Some(crate::protocol_types::ViewportSurface { + inner_width: 800, + inner_height: 600, + outer_width: 800, + outer_height: 600, + device_pixel_ratio: 1.0, + screen_width: 1920, + screen_height: 1080, + screen_avail_width: 1920, + screen_avail_height: 1040, + ..Default::default() + })) + .expect("viewport device should rebuild after root replacement"); + let rebuilt = vm + .eval("getComputedStyle(document.getElementById('next-target')).width") + .expect("rebuilt device should retain the replacement root state"); + assert_eq!(rebuilt, "14px"); +} + +#[test] +fn root_font_relative_units_survive_viewport_device_rebuilds() { + let mut vm = new_parsed_test_vm( + "https://root-rem-device-rebuild.test/", + "", + ); + let surface = |inner_width| crate::protocol_types::ViewportSurface { + inner_width, + inner_height: 600, + outer_width: inner_width, + outer_height: 600, + device_pixel_ratio: 1.0, + screen_width: 1920, + screen_height: 1080, + screen_avail_width: 1920, + screen_avail_height: 1040, + ..Default::default() + }; + vm.set_viewport_surface(Some(surface(1000))) + .expect("initial viewport should update"); + + let wide = vm + .eval( + r#" +(() => { + const style = document.createElement('style'); + style.textContent = ` + html { font-size: 10px; } + @media (min-width: 800px) { html { font-size: 20px; } } + #root-rem-target { width: 2rem; } + `; + document.head.appendChild(style); + const target = document.createElement('div'); + target.id = 'root-rem-target'; + document.body.appendChild(target); + globalThis.__rootRemTarget = target; + const width = getComputedStyle(target).width; + return [width, getComputedStyle(document.documentElement).fontSize].join('|'); +})() +"#, + ) + .expect("wide root rem styles should resolve"); + assert_eq!(wide, "40px|20px"); + + vm.set_viewport_surface(Some(surface(500))) + .expect("narrow viewport should update"); + let narrow = vm + .eval( + "[getComputedStyle(__rootRemTarget).width, getComputedStyle(document.documentElement).fontSize].join('|')", + ) + .expect("narrow root rem styles should resolve"); + assert_eq!(narrow, "20px|10px"); + + vm.set_viewport_surface(Some(surface(1000))) + .expect("restored viewport should update"); + let restored = vm + .eval( + "[getComputedStyle(__rootRemTarget).width, getComputedStyle(document.documentElement).fontSize].join('|')", + ) + .expect("restored root rem styles should resolve"); + assert_eq!(restored, "40px|20px"); + + vm.eval("document.documentElement.style.fontSize = '30px'; true") + .expect("pending root font mutation should apply"); + vm.set_viewport_surface(Some(surface(900))) + .expect("device should rebuild while the root mutation is pending"); + let pending_mutation = vm + .eval( + "[getComputedStyle(__rootRemTarget).width, getComputedStyle(document.documentElement).fontSize].join('|')", + ) + .expect("descendant should observe the pending root mutation after device rebuild"); + assert_eq!(pending_mutation, "60px|30px"); +} diff --git a/moli-renderer-v8/src/script_vm/tests/dom_xhr/dom/rendered_text.rs b/moli-renderer-v8/src/script_vm/tests/dom_xhr/dom/rendered_text.rs index 9ab6de0b373..1ccc75290e9 100644 --- a/moli-renderer-v8/src/script_vm/tests/dom_xhr/dom/rendered_text.rs +++ b/moli-renderer-v8/src/script_vm/tests/dom_xhr/dom/rendered_text.rs @@ -1189,3 +1189,74 @@ fn document_import_node_clones_dom_parser_svg_snapshot() { "http://www.w3.org/2000/svg|symbol|icon|path|M0 0h1v1" ); } + +#[test] +fn inner_text_publishes_successive_new_sources_only_after_paint() { + let mut vm = new_parsed_test_vm( + "https://inner-text-latest-layout.test/", + "
a
", + ); + vm.set_viewport_surface(Some(crate::protocol_types::ViewportSurface { + inner_width: 320, + inner_height: 200, + device_pixel_ratio: 1.0, + ..Default::default() + })) + .expect("innerText viewport should match the paint layout"); + let passes_before = vm.layout_pass_observability_for_test().1; + let cache_before = vm.layout_snapshot_cache_observability_for_test(); + + assert_eq!( + vm.eval("document.getElementById('target').innerText") + .expect("the cold innerText read should evaluate"), + "a" + ); + assert_eq!(vm.layout_pass_observability_for_test().1, passes_before + 1); + + vm.eval("const added = document.createElement('span'); added.textContent = 'b'; target.append(added)") + .expect("text insertion should evaluate"); + assert_eq!( + vm.layout_pass_observability_for_test().1, + passes_before + 1, + "text insertion alone must not run layout" + ); + assert_eq!( + vm.eval("target.innerText") + .expect("the warm innerText read should evaluate"), + "a", + "new text remains outside the published layout until capture" + ); + assert_eq!(vm.layout_pass_observability_for_test().1, passes_before + 1); + assert_eq!(vm.eval("target.innerText").unwrap(), "a"); + assert_eq!( + vm.layout_pass_observability_for_test().1, + passes_before + 1, + "repeated innerText should reuse the frozen geometry across turns" + ); + + 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.eval("target.innerText") + .expect("innerText should read the refreshed geometry snapshot"), + "ab" + ); + assert_eq!(vm.layout_pass_observability_for_test().1, passes_before + 2); + + vm.eval("const later = document.createElement('span'); later.textContent = 'c'; target.append(later)") + .expect("a second text insertion should evaluate"); + assert_eq!(vm.eval("target.innerText").unwrap(), "ab"); + 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("second fresh paint layout should succeed") + .expect("the fixture should have a layout root"); + assert_eq!(vm.eval("target.innerText").unwrap(), "abc"); + assert_eq!(vm.layout_pass_observability_for_test().1, passes_before + 3); + + let cache_after = vm.layout_snapshot_cache_observability_for_test(); + assert!(cache_after.0 > cache_before.0); + assert_eq!(cache_after.1, cache_before.1); + assert_eq!(cache_after.2, cache_before.2 + 3); +} diff --git a/moli-renderer-v8/src/script_vm/tests/dom_xhr/misc.rs b/moli-renderer-v8/src/script_vm/tests/dom_xhr/misc.rs index 7c53f41d786..3860b6afcac 100644 --- a/moli-renderer-v8/src/script_vm/tests/dom_xhr/misc.rs +++ b/moli-renderer-v8/src/script_vm/tests/dom_xhr/misc.rs @@ -1507,7 +1507,88 @@ return [ } #[test] -fn geometry_getters_reuse_latest_layout_across_nodes_and_mutation() { +fn adopted_stylesheet_geometry_updates_at_visual_publication() { + for shadow in [false, true] { + let mut vm = new_parsed_test_vm( + "https://adopted-sheet-layout.test/", + "
", + ); + vm.eval(&format!(r#" + globalThis.styleRoot = {root}; + styleRoot.innerHTML = '
'; + globalThis.target = styleRoot.querySelector('#target'); + globalThis.sheet = new CSSStyleSheet(); + sheet.replaceSync('#target {{width:40px;height:10px}}'); + "#, root = if shadow {"document.getElementById('host').attachShadow({mode:'open'})"} else {"document.body"})) + .expect("create the same baseline rules in document and shadow scopes"); + // Document adoption is on Document, while its test markup lives in body. + vm.eval(if shadow { + "globalThis.adopter=styleRoot;" + } else { + "globalThis.adopter=document;" + }) + .unwrap(); + let read = "[target.getBoundingClientRect().width,parseFloat(getComputedStyle(target).width),target.offsetWidth].join('|')"; + assert_eq!(vm.eval(read).unwrap(), "20|20|20"); + let mut published_width = 20; + for (mutation, width) in [ + ("adopter.adoptedStyleSheets=[sheet];", 40), + ("sheet.replaceSync('#target {width:80px;height:10px}');", 80), + ("sheet.deleteRule(0);", 20), + ( + "sheet.insertRule('#target {width:120px;height:10px}',0);", + 120, + ), + ("adopter.adoptedStyleSheets=[];", 20), + ("adopter.adoptedStyleSheets.push(sheet);", 120), + ("adopter.adoptedStyleSheets.pop();", 20), + ] { + let before = vm + ._context_host + .borrow() + .layout_pass_observability_for_test() + .1; + vm.eval(mutation).unwrap(); + assert_eq!( + vm._context_host + .borrow() + .layout_pass_observability_for_test() + .1, + before, + "stylesheet changes must not eagerly build layout: shadow={shadow} {mutation}" + ); + let frozen = format!("{published_width}|{published_width}|{published_width}"); + assert_eq!( + vm.eval(read).unwrap(), + frozen, + "ordinary reads should retain the published frame: shadow={shadow} {mutation}" + ); + assert_eq!( + vm._context_host + .borrow() + .layout_pass_observability_for_test() + .1, + before, + "ordinary geometry reads must not publish a new frame: shadow={shadow} {mutation}" + ); + vm.publish_layout_for_test().unwrap(); + published_width = width; + let expected = format!("{width}|{width}|{width}"); + assert_eq!(vm.eval(read).unwrap(), expected); + assert_eq!( + vm._context_host + .borrow() + .layout_pass_observability_for_test() + .1, + before + 1, + "a later script turn must reuse the published layout" + ); + } + } +} + +#[test] +fn geometry_getters_share_published_layout_until_next_visual_capture() { let mut vm = new_storage_test_vm("https://oneshot-layout-demand.test/"); let passes_before = vm ._context_host @@ -1542,6 +1623,9 @@ yield; // Publish this scene before reading its geometry. const inserted = document.createElement('div'); inserted.style.height = '5px'; document.body.insertBefore(inserted, targets[0]); + const frozenLast = targets[3].offsetTop; + const frozenInserted = inserted.offsetTop; +yield; // Publish the inserted node before reading its new box. const afterInsertion = targets[3].offsetTop; return [ initialLast, @@ -1549,15 +1633,17 @@ yield; // Publish this scene before reading its geometry. first[3], second[0], second[3], + frozenLast, + frozenInserted, afterInsertion, inserted.offsetTop ].join('|'); })() "#, ) - .expect("latest layout snapshot reads should evaluate"); + .expect("geometry reads across visual publications should evaluate"); - assert_eq!(result, "38|8|38|8|38|38|0"); + assert_eq!(result, "38|8|38|8|38|38|0|43|8"); let passes = vm ._context_host .borrow() @@ -1565,13 +1651,13 @@ yield; // Publish this scene before reading its geometry. .1 .saturating_sub(passes_before); assert_eq!( - passes, 1, - "only the explicit fixture publication builds layout" + passes, 2, + "only the two explicit visual publications build layout" ); let cache_after = vm.layout_snapshot_cache_observability_for_test(); - assert_eq!(cache_after.0, cache_before.0 + 11); + assert!(cache_after.0 > cache_before.0); assert_eq!(cache_after.1, cache_before.1); - assert_eq!(cache_after.2, cache_before.2 + 1); + assert_eq!(cache_after.2, cache_before.2 + 2); assert!(cache_after.3.is_some()); } diff --git a/moli-renderer-v8/src/script_vm/tests/element_click.rs b/moli-renderer-v8/src/script_vm/tests/element_click.rs index 1ebfa41bcf4..377a005d643 100644 --- a/moli-renderer-v8/src/script_vm/tests/element_click.rs +++ b/moli-renderer-v8/src/script_vm/tests/element_click.rs @@ -102,6 +102,55 @@ fn native_element_click_reuses_frozen_layout_after_dom_and_style_changes() { } } +#[test] +fn native_element_click_observes_dom_and_style_changes_after_capture() { + let mut vm = new_parsed_test_vm( + "https://click-after-capture.test/", + r#" + + "#, + ); + let target = vm.document_runtime.get_element_by_id("target").unwrap(); + publish_layout_for_test(&mut vm); + let RendererElementClickTarget::Pointer(first) = vm.prepare_element_click(target).unwrap() + else { + panic!("initial target should be clickable"); + }; + + vm.eval("target.style.left='300px'").unwrap(); + let RendererElementClickTarget::Pointer(frozen) = vm.prepare_element_click(target).unwrap() + else { + panic!("the published target should remain clickable"); + }; + assert_eq!((frozen.root_x, frozen.root_y), (first.root_x, first.root_y)); + publish_layout_for_test(&mut vm); + let RendererElementClickTarget::Pointer(moved) = vm.prepare_element_click(target).unwrap() + else { + panic!("the moved target should remain clickable after capture"); + }; + assert!(moved.root_x > first.root_x); + + vm.eval("const veil=document.createElement('div');veil.id='veil';veil.style.cssText='position:fixed;inset:0;z-index:999';document.body.appendChild(veil)") + .unwrap(); + assert!(matches!( + vm.prepare_element_click(target), + Ok(RendererElementClickTarget::Pointer(_)) + )); + publish_layout_for_test(&mut vm); + assert!(matches!( + vm.prepare_element_click(target), + Err(crate::runtime::RendererElementClickError::Obscured) + )); + + vm.eval("veil.remove();const style=document.createElement('style');document.head.appendChild(style);style.sheet.insertRule('button {pointer-events:none}')") + .unwrap(); + publish_layout_for_test(&mut vm); + assert!(matches!( + vm.prepare_element_click(target), + Err(crate::runtime::RendererElementClickError::Obscured) + )); +} + #[test] fn native_element_click_mock_preparation_does_not_build_layout() { let mut vm = new_parsed_test_vm( diff --git a/moli-renderer-v8/src/style_engine/computed.rs b/moli-renderer-v8/src/style_engine/computed.rs index 7161ee44144..3443630189a 100644 --- a/moli-renderer-v8/src/style_engine/computed.rs +++ b/moli-renderer-v8/src/style_engine/computed.rs @@ -4,6 +4,7 @@ use dom::ElementState as StyloElementState; use style::{ Atom, animation::DocumentAnimationSet, + computed_value_flags::ComputedValueFlags, context::{ QuirksMode, RegisteredSpeculativePainter, RegisteredSpeculativePainters, SharedStyleContext, StyleContext, StyleSystemOptions, ThreadLocalStyleContext, @@ -49,6 +50,7 @@ use super::{ cache::ComputedElementStyleCacheKey, document_world::DocumentStyleWorld, lazy_invalidation::StyleValidationPathEntry, + retained::synchronize_root_style_device, source_lifecycle::StyleSourceDocumentContext, world_key::StyleWorldKey, world_lifecycle::{ @@ -928,12 +930,13 @@ fn resolve_element_styles( shared: &shared, thread_local: &mut thread_local, }; - let ancestor_resolution_count = materialize_ancestor_styles_for_resolution( - &mut context, - element, - world, - validation_path, - ); + let (ancestor_resolution_count, mut resolved_root_style) = + materialize_ancestor_styles_for_resolution( + &mut context, + element, + world, + validation_path, + ); let target = DomHandle::new(element.as_node().debug_id()); world.pseudo_style_cache.invalidate_handles([target]); // `resolve_style` is the single-node initial-style API. Keep dirty @@ -949,6 +952,14 @@ fn resolve_element_styles( pseudo_element, None, ); + if let Some(root_style) = styles.primary.as_ref().filter(|computed| { + computed + .flags + .contains(ComputedValueFlags::IS_ROOT_ELEMENT_STYLE) + }) { + synchronize_root_style_device(context.shared.stylist.device(), root_style); + resolved_root_style = Some(root_style.clone()); + } unsafe { let mut data = element.ensure_data(); data.styles = styles.clone(); @@ -970,6 +981,9 @@ fn resolve_element_styles( world .document_state .note_element_style_resolutions(ancestor_resolution_count.saturating_add(1)); + if let Some(root_style) = resolved_root_style { + *retained.root_style.borrow_mut() = Some(root_style); + } styles }); world @@ -983,7 +997,7 @@ fn materialize_ancestor_styles_for_resolution( element: E, world: &DocumentStyleWorld, validation_path: Option<&[StyleValidationPathEntry]>, -) -> u64 +) -> (u64, Option>) where E: TElement, { @@ -1010,6 +1024,7 @@ where .note_ancestor_style_validation_visits(ancestors.len() as u64); let mut resolution_count = 0_u64; + let mut resolved_root_style = None; let mut force_descendants = false; for (ancestor, required_generation) in ancestors { let handle = DomHandle::new(ancestor.as_node().debug_id()); @@ -1028,6 +1043,14 @@ where ancestor.ensure_data().styles = ElementStyles::default(); } let styles = resolve_style(context, ancestor, RuleInclusion::All, None, None); + if let Some(root_style) = styles.primary.as_ref().filter(|computed| { + computed + .flags + .contains(ComputedValueFlags::IS_ROOT_ELEMENT_STYLE) + }) { + synchronize_root_style_device(context.shared.stylist.device(), root_style); + resolved_root_style = Some(root_style.clone()); + } resolution_count = resolution_count.saturating_add(1); unsafe { let mut data = ancestor.ensure_data(); @@ -1040,7 +1063,7 @@ where .mark_element_current(handle, required_generation); force_descendants = true; } - resolution_count + (resolution_count, resolved_root_style) } /// Maps the memoized DOM path suffix back to Stylo ancestors. diff --git a/moli-renderer-v8/src/style_engine/retained.rs b/moli-renderer-v8/src/style_engine/retained.rs index 51fd1b51c83..f8600903954 100644 --- a/moli-renderer-v8/src/style_engine/retained.rs +++ b/moli-renderer-v8/src/style_engine/retained.rs @@ -2,7 +2,9 @@ use std::collections::HashSet; use std::sync::atomic::{AtomicU64, Ordering}; use style::{ author_styles::AuthorStyles, + device::Device, invalidation::stylesheets::StylesheetInvalidationSet, + properties::ComputedValues, servo_arc::Arc as ServoArc, shared_lock::{SharedRwLock, StylesheetGuards}, stylesheets::{CustomMediaMap, DocumentStyleSheet, Origin, OriginSet, UrlExtraData}, @@ -165,6 +167,7 @@ pub(super) fn build_retained_style_system( stylist_identity: NEXT_STYLIST_IDENTITY.fetch_add(1, Ordering::Relaxed), key, stylist, + root_style: Default::default(), document_stylesheets, shadow_scopes, stylesheet_resources, @@ -383,6 +386,9 @@ fn update_document_scope( if device_changed { let device = new_style_device_with_viewport(key.style_viewport(), key.environment, key.quirks_mode); + if let Some(root_style) = retained.root_style.borrow().as_ref() { + synchronize_root_style_device(&device, root_style); + } let guard = shared_lock.read(); let guards = StylesheetGuards::same(&guard); device_affected_origins = retained.stylist.set_device(device, &guards); @@ -437,6 +443,32 @@ fn update_document_scope( } } +/// Synchronizes the state Stylo uses for root font-relative units. +/// +/// Full Stylo traversal performs this in `matching.rs`, but Moli deliberately +/// uses the single-node `resolve_style` API and therefore must mirror the +/// update when the root element is materialized. Keeping this helper at the +/// Device boundary also lets a rebuilt Device inherit the current root state +/// before any descendant is resolved against it. +pub(super) fn synchronize_root_style_device( + device: &Device, + root_style: &ServoArc, +) { + device.set_root_style(root_style); + let font = root_style.get_font(); + let font_size = font.clone_font_size().computed_size(); + device.set_root_font_size(root_style.effective_zoom.unzoom(font_size.px())); + + let line_height = device + .calc_line_height(font, root_style.writing_mode, None) + .0; + device.set_root_line_height(root_style.effective_zoom.unzoom(line_height.px())); + + if device.used_root_font_metrics() { + device.update_root_font_metrics(); + } +} + fn refresh_retained_derived_state( retained: &mut RetainedStyleSystem, device_changed: bool, diff --git a/moli-renderer-v8/src/style_engine/state.rs b/moli-renderer-v8/src/style_engine/state.rs index 6bb6016f8dc..c6cdcc37fe6 100644 --- a/moli-renderer-v8/src/style_engine/state.rs +++ b/moli-renderer-v8/src/style_engine/state.rs @@ -6,6 +6,7 @@ use std::{ use moli_selector::StyloSourceInvalidationFallbackReason; use selectors::context::SelectorCaches; use style::{ + properties::ComputedValues, servo_arc::Arc as ServoArc, stylist::{CascadeData, Stylist}, }; @@ -31,6 +32,11 @@ pub(super) struct RetainedStyleSystem { pub(super) stylist_identity: u64, pub(super) key: StyleWorldKey, pub(super) stylist: Stylist, + /// Last computed style for this Document's root element. Stylo normally + /// updates the Device's root-relative unit state during a full traversal; + /// Moli resolves demanded paths one element at a time, so retain the root + /// style explicitly both for those resolutions and for Device rebuilds. + pub(super) root_style: RefCell>>, pub(super) document_stylesheets: ActiveStylesheetCollection, pub(super) shadow_scopes: Vec, pub(super) stylesheet_resources: StylesheetResourceManifest,