From de177aa1135d8e91a685f957d1837a34c5775095 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): refresh geometry and preserve inline fragments --- README.md | 4 +- docs/README.de.md | 2 +- docs/README.es.md | 2 +- docs/README.fr.md | 2 +- docs/README.ja.md | 2 +- docs/README.zh-CN.md | 2 +- moli-layout/src/builder.rs | 19 + moli-layout/src/inline.rs | 106 +- 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 | 918 +++++++++++++++++- .../src/domains/dom/tests/geometry.rs | 33 +- .../range_algorithms/geometry.rs | 3 + .../context_host/host_environment.rs | 139 ++- .../context_host/image_resources/mod.rs | 4 + .../src/native_bridge/context_host/layout.rs | 63 +- .../context_host/layout_state.rs | 24 +- .../src/native_bridge/document/hit_test.rs | 2 + .../element/geometry/provider.rs | 4 +- .../runtime/page_vm/tests/rendering_update.rs | 68 ++ .../src/script_vm/element_click.rs | 2 +- moli-renderer-v8/src/script_vm/layout.rs | 1 + .../tests/dom_elements/dom_surface.rs | 291 ++++++ .../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 | 61 ++ .../src/script_vm/tests/dom_xhr/misc.rs | 89 +- .../src/script_vm/tests/element_click.rs | 130 +-- .../src/script_vm/tests/mouse_snapshot.rs | 13 +- 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 + 34 files changed, 2408 insertions(+), 198 deletions(-) create mode 100644 moli-renderer-v8/src/script_vm/tests/dom_xhr/computed_style/root_font_units.rs diff --git a/README.md b/README.md index 74bebb3e439..688757201ef 100644 --- a/README.md +++ b/README.md @@ -249,7 +249,9 @@ Layout is an on-demand snapshot rather than continuously maintained state. The first geometry request (a cold start) builds a working layout tree from the current DOM/style, freezes its canonical geometry into an immutable, DOM-independent `FrozenLayoutTree`, and retains only that latest tree. Ordinary -geometry reads may reuse it even if the page has changed. Screenshots always +geometry reads reuse it while layout inputs are unchanged. Input changes mark +the retained tree dirty without running layout; the next geometry request +rebuilds it once from the current inputs. Screenshots always rebuild and replace the frozen tree. Each screencast subscription remembers only an opaque visual-state token: an unchanged token suppresses the poll, while a changed token triggers one fresh frame. Paint results are never reused. diff --git a/docs/README.de.md b/docs/README.de.md index fc2a5635b98..03257df5f31 100644 --- a/docs/README.de.md +++ b/docs/README.de.md @@ -195,7 +195,7 @@ Rechenintensive Browser-Operationen sind in Moli standardmäßig deaktiviert und | `--image`, `--font`, `--audio`, `--video`, `--media`, `--text-track` | Eine bestimmte optionale Ressourcengruppe einzeln aktivieren | | `--profile-dir`, `--http-cache-dir`, `--cookie-file` | Persistenz gezielt aktivieren, je nach Bedarf des Workloads | -Das Layoutergebnis ist ein bei Bedarf erzeugter Snapshot, kein dauerhaft gepflegter Zustand: Die erste Geometrieanfrage (Kaltstart) baut aus dem aktuellen DOM/Stil einen temporären Arbeitsbaum auf und friert dessen kanonische Geometrie in einen unveränderlichen, vom DOM unabhängigen `FrozenLayoutTree` ein — nur dieser jeweils neueste Baum wird vorgehalten. Normale Geometrieabfragen können ihn auch nach Seitenänderungen weiterverwenden; Screenshots und Screencasts dagegen bauen jedes Mal neu auf, ersetzen den eingefrorenen Baum und greifen nie auf alte Zeichenergebnisse zurück. +Das Layoutergebnis ist ein bei Bedarf erzeugter Snapshot, kein dauerhaft gepflegter Zustand: Die erste Geometrieanfrage (Kaltstart) baut aus dem aktuellen DOM/Stil einen temporären Arbeitsbaum auf und friert dessen kanonische Geometrie in einen unveränderlichen, vom DOM unabhängigen `FrozenLayoutTree` ein — nur dieser jeweils neueste Baum wird vorgehalten. Normale Geometrieabfragen verwenden ihn wieder, solange die Layouteingaben unverändert sind. Änderungen markieren den Baum als veraltet, ohne sofort ein Layout auszuführen; erst die nächste Geometrieanfrage baut ihn neu auf. Screenshots und Screencasts dagegen bauen jedes Mal neu auf, ersetzen den eingefrorenen Baum und greifen nie auf alte Zeichenergebnisse zurück. ## Architektur diff --git a/docs/README.es.md b/docs/README.es.md index 99eb389c60a..89d758b7d71 100644 --- a/docs/README.es.md +++ b/docs/README.es.md @@ -196,7 +196,7 @@ En Moli, las operaciones caras del navegador hay que activarlas explícitamente: | `--image`, `--font`, `--audio`, `--video`, `--media`, `--text-track` | Activa por separado una familia concreta de recursos opcionales | | `--profile-dir`, `--http-cache-dir`, `--cookie-file` | Activa de forma selectiva la persistencia que necesite tu carga de trabajo | -El resultado del layout es una instantánea tomada bajo demanda, no un estado que se mantiene todo el tiempo: la primera petición de geometría (cold start) construye un árbol de trabajo temporal a partir del DOM y los estilos actuales, congela su geometría canónica en un `FrozenLayoutTree` inmutable e independiente del DOM, y se queda solo con ese árbol, el más reciente. Las lecturas de geometría normales pueden reutilizarlo aunque la página haya cambiado entretanto. Las capturas de pantalla siempre reconstruyen y sustituyen el árbol congelado. Cada suscripción de screencast solo conserva un token opaco del estado visual: un token sin cambios evita el trabajo, mientras que un cambio genera un frame nuevo. Los resultados de pintado nunca se reutilizan. +El resultado del layout es una instantánea tomada bajo demanda, no un estado que se mantiene todo el tiempo: la primera petición de geometría (cold start) construye un árbol de trabajo temporal a partir del DOM y los estilos actuales, congela su geometría canónica en un `FrozenLayoutTree` inmutable e independiente del DOM, y se queda solo con ese árbol, el más reciente. Las lecturas de geometría normales lo reutilizan mientras no cambien las entradas del layout. Los cambios marcan el árbol como desactualizado sin calcular el layout de inmediato; la siguiente petición de geometría lo reconstruye una sola vez. Las capturas de pantalla siempre reconstruyen y sustituyen el árbol congelado. Cada suscripción de screencast solo conserva un token opaco del estado visual: un token sin cambios evita el trabajo, mientras que un cambio genera un frame nuevo. Los resultados de pintado nunca se reutilizan. ## Arquitectura diff --git a/docs/README.fr.md b/docs/README.fr.md index e0174bb4f91..7d0978d9623 100644 --- a/docs/README.fr.md +++ b/docs/README.fr.md @@ -196,7 +196,7 @@ Dans Moli, les opérations coûteuses du navigateur ne sont jamais activées par | `--image`, `--font`, `--audio`, `--video`, `--media`, `--text-track` | Active une famille spécifique de ressources optionnelles | | `--profile-dir`, `--http-cache-dir`, `--cookie-file` | Active, au cas par cas, la persistance dont la charge de travail a besoin | -Le résultat de la mise en page est un instantané produit à la demande, pas un état maintenu en continu : la première requête de géométrie (« à froid ») construit un arbre de travail temporaire à partir du DOM et des styles courants, fige sa géométrie canonique dans un `FrozenLayoutTree` immuable et indépendant du DOM, puis ne conserve que ce dernier. Les lectures de géométrie ultérieures peuvent réutiliser cet arbre figé, même si la page a changé entre-temps. Les captures d'écran reconstruisent toujours et remplacent l'arbre figé. Chaque abonnement de screencast ne mémorise qu'un jeton opaque d'état visuel : un jeton inchangé supprime le travail, tandis qu'un changement produit une image fraîche. Les résultats de rendu ne sont jamais réutilisés. +Le résultat de la mise en page est un instantané produit à la demande, pas un état maintenu en continu : la première requête de géométrie (« à froid ») construit un arbre de travail temporaire à partir du DOM et des styles courants, fige sa géométrie canonique dans un `FrozenLayoutTree` immuable et indépendant du DOM, puis ne conserve que ce dernier. Les lectures de géométrie réutilisent cet arbre tant que les données de mise en page restent inchangées. Une modification marque l’arbre comme périmé sans recalcul immédiat ; la prochaine requête de géométrie le reconstruit une seule fois. Les captures d'écran reconstruisent toujours et remplacent l'arbre figé. Chaque abonnement de screencast ne mémorise qu'un jeton opaque d'état visuel : un jeton inchangé supprime le travail, tandis qu'un changement produit une image fraîche. Les résultats de rendu ne sont jamais réutilisés. ## Architecture diff --git a/docs/README.ja.md b/docs/README.ja.md index a245c0b1a95..e38cef060ea 100644 --- a/docs/README.ja.md +++ b/docs/README.ja.md @@ -194,7 +194,7 @@ Moli では、処理負荷の高い機能は明示的に有効にした場合だ | `--image`、`--font`、`--audio`、`--video`、`--media`、`--text-track` | 指定した種類のデータを取得します。 | | `--profile-dir`、`--http-cache-dir`、`--cookie-file` | 必要な保存機能だけを選んで有効にします。 | -レイアウト結果は常に更新される状態ではなく、要求を受けた時点の状態です。最初に位置・寸法を要求されたときは、現在の DOM とスタイルから一時的な作業用レイアウト木を構築し、その確定済みジオメトリを DOM から独立した不変の `FrozenLayoutTree` に凍結して、最新の木だけを保持します。その後はページが変化していても通常の位置・寸法取得で古い木を再利用する場合があります。一方、画面画像と連続画面配信は毎回再構築して凍結木を置き換え、古い描画結果を再利用しません。 +レイアウト結果は常に更新される状態ではなく、要求を受けた時点の状態です。最初に位置・寸法を要求されたときは、現在の DOM とスタイルから一時的な作業用レイアウト木を構築し、その確定済みジオメトリを DOM から独立した不変の `FrozenLayoutTree` に凍結して、最新の木だけを保持します。レイアウトの入力が変わらない間は、通常の位置・寸法取得でこの木を再利用します。入力が変わってもすぐにはレイアウトを計算せず、木を古い状態としてマークし、次の位置・寸法要求で一度だけ再構築します。一方、画面画像と連続画面配信は毎回再構築して凍結木を置き換え、古い描画結果を再利用しません。 ## 仕組み diff --git a/docs/README.zh-CN.md b/docs/README.zh-CN.md index 5cfa8a7bcb5..dc4889cf01d 100644 --- a/docs/README.zh-CN.md +++ b/docs/README.zh-CN.md @@ -194,7 +194,7 @@ Moli 是 Lexmount 旗下的开源无头浏览器;Lexmount Browser 则是围绕 | `--image`、`--font`、`--audio`、`--video`、`--media`、`--text-track` | 单独启用某一类可选资源 | | `--profile-dir`、`--http-cache-dir`、`--cookie-file` | 按工作负载需要,选择性开启持久化能力 | -布局结果是按需采样的一份快照,而不是持续维护的状态:第一次几何请求(冷启动)会根据当前 DOM/样式构建临时工作布局树,再把工作树丢弃后无法重算的几何冻结成一棵与 DOM 无关、不可变的 `FrozenLayoutTree`,长期只保留最新的这一棵树。在此之后,即便页面发生了变化,普通几何读取也可能复用旧树。截图始终重新构建并替换冻结树。每个屏幕串流订阅只记录一个不透明的视觉状态 token:状态未变时不产帧,状态变化时才新鲜渲染一帧;绘制结果不会被复用。 +布局结果是按需采样的一份快照,而不是持续维护的状态:第一次几何请求(冷启动)会根据当前 DOM/样式构建临时工作布局树,再把工作树丢弃后无法重算的几何冻结成一棵与 DOM 无关、不可变的 `FrozenLayoutTree`,长期只保留最新的这一棵树。布局输入未变时,普通几何读取复用这棵树;输入变化只标记旧树失效,不立即执行布局,下一次几何请求才按当前输入重新构建一次。截图始终重新构建并替换冻结树。每个屏幕串流订阅只记录一个不透明的视觉状态 token:状态未变时不产帧,状态变化时才新鲜渲染一帧;绘制结果不会被复用。 ## 架构 diff --git a/moli-layout/src/builder.rs b/moli-layout/src/builder.rs index 2145844ef21..ce2091d378a 100644 --- a/moli-layout/src/builder.rs +++ b/moli-layout/src/builder.rs @@ -622,6 +622,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) { @@ -632,11 +635,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())); @@ -644,6 +660,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 8110acc1677..b1129d46f92 100644 --- a/moli-layout/src/inline.rs +++ b/moli-layout/src/inline.rs @@ -35,6 +35,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( @@ -89,6 +99,9 @@ pub(crate) struct InlineTextUnit { pub(crate) ancestors: Vec, pub(crate) sources: Vec, pub(crate) control: bool, + /// A collapsed CSS space, removable again at a wrapped line boundary. + /// Preserved spaces and non-breaking spaces must retain their geometry. + pub(crate) collapsed_space: bool, pub(crate) break_spaces_opportunity: bool, } @@ -436,13 +449,57 @@ 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() + .iter() + .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.collapsed_space) + }) + .filter(|unit| unit.collapsed_space) + .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, @@ -453,6 +510,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, @@ -477,6 +542,9 @@ pub(crate) fn build_inline_fragments( let run_metrics = run.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 = cluster .glyphs() .next() @@ -2248,6 +2316,7 @@ impl InlineNormalizer { ancestors: pending.ancestors, sources: pending.sources, control: false, + collapsed_space: true, break_spaces_opportunity: false, }, ); @@ -2349,6 +2418,7 @@ impl InlineNormalizer { ancestors: ancestors.to_vec(), sources, control, + collapsed_space: false, break_spaces_opportunity: false, }); } @@ -2737,6 +2807,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.collapsed_space) + .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 f6fe5716a25..ab839257849 100644 --- a/moli-layout/src/pass.rs +++ b/moli-layout/src/pass.rs @@ -305,11 +305,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 bd6275c6e65..cb65f5b0c60 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(); @@ -744,6 +745,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); } @@ -1312,12 +1457,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, @@ -1347,10 +1512,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 e6d92b304c4..db08ea5a034 100644 --- a/moli-layout/src/taffy_tree.rs +++ b/moli-layout/src/taffy_tree.rs @@ -78,9 +78,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 @@ -97,15 +102,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; @@ -113,13 +124,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; } @@ -1621,6 +1641,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 @@ -1780,6 +1808,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); } @@ -2833,7 +2868,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 dd294f93daf..d9c6a4c8a20 100644 --- a/moli-layout/src/world.rs +++ b/moli-layout/src/world.rs @@ -593,6 +593,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 @@ -602,6 +610,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. @@ -627,6 +638,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, viewport_scroll_policy: ViewportScrollPolicy::default(), @@ -905,6 +919,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 e696d3bec3e..44c93be4a1f 100644 --- a/moli-layout/tests/phase5_output_contract.rs +++ b/moli-layout/tests/phase5_output_contract.rs @@ -4,11 +4,11 @@ use moli_layout::{ DocumentLayoutServices, LayoutControlSurfaceHit, LayoutDisplay, LayoutElementCategory, LayoutElementSemantics, 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, ResolvedLayoutElementStyles, - ResolvedLayoutStyle, build_layout_pass, + LayoutQuery, LayoutQueryAnswer, LayoutQueryBatch, LayoutRect, LayoutReplacedKind, + LayoutScrollbarColors, LayoutScrollbarGutter, LayoutScrollbarPart, LayoutScrollbarWidth, + LayoutSource, LayoutSourceKind, LayoutStyleResolver, LayoutTransform2D, LayoutViewport, + PaintBrush, PaintCaptureRequest, PaintColor, PaintFragment, PaintShape, + ResolvedLayoutElementStyles, ResolvedLayoutStyle, build_layout_pass, }; use style::Atom; use taffy::{ @@ -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!( @@ -803,6 +803,180 @@ fn auto_scrollbar_feedback_reveals_the_perpendicular_axis() { assert!(extent.vertical_scrollbar.is_some()); } +#[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, @@ -841,11 +1015,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 @@ -861,10 +1047,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() @@ -886,14 +1071,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" @@ -924,16 +1108,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![ @@ -2465,6 +2715,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![ @@ -2741,6 +3034,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![ @@ -2770,9 +3640,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/src/domains/dom/tests/geometry.rs b/moli-protocol/src/domains/dom/tests/geometry.rs index f672598b409..240df55d14d 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-renderer-v8/src/context_bootstrap/range_algorithms/geometry.rs b/moli-renderer-v8/src/context_bootstrap/range_algorithms/geometry.rs index c602e8d8bb2..f88d14aadbc 100644 --- a/moli-renderer-v8/src/context_bootstrap/range_algorithms/geometry.rs +++ b/moli-renderer-v8/src/context_bootstrap/range_algorithms/geometry.rs @@ -122,6 +122,9 @@ fn native_range_client_rects<'s>( if queries.is_empty() { return Some(Ok(Vec::new())); } + if let Err(error) = host.ensure_current_layout() { + return Some(Err(error)); + } let answers = match observable_geometry_batch( host, document, 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 d7a56a2eeb0..85924810d42 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 @@ -14,9 +14,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, @@ -309,7 +309,7 @@ impl JsContextHost { stylesheet_id, ); if refreshed { - self.clear_layout_rect_cache(); + self.mark_layout_input_dirty(); self.queue_stylesheet_source_css_projection(owner); } refreshed @@ -355,11 +355,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.mark_layout_input_dirty(); + } } fn refresh_linked_live_stylesheet_attributes(&mut self, owner: DomHandle) { @@ -383,7 +385,7 @@ impl JsContextHost { owner, stylesheet.id(), ); - self.clear_layout_rect_cache(); + self.mark_layout_input_dirty(); } fn apply_page_network_policy_to_document_loader(&self, loader: &DocumentResourceLoader) { @@ -795,6 +797,7 @@ impl JsContextHost { if self.emulated_media != *overrides { self.style_engine .bump_target_context_epoch_for_document(self.document_handle()); + self.mark_layout_input_dirty(); } self.emulated_media = overrides.clone(); } @@ -808,11 +811,27 @@ 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.style_engine .bump_target_context_epoch_for_document(self.document_handle()); + self.mark_layout_input_dirty(); + } + 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 } @@ -872,6 +891,7 @@ impl JsContextHost { document: DomHandle, installations: Vec, ) { + self.mark_layout_input_dirty(); self.style_engine .set_document_adopted_style_sheet_installations(document, installations); } @@ -1056,7 +1076,7 @@ impl JsContextHost { owner, stylesheet_id, ) { - self.clear_layout_rect_cache(); + self.mark_layout_input_dirty(); self.queue_stylesheet_source_css_projection(owner); let host_ptr = self as *mut Self; unsafe { &mut *self.runtime }.apply_linked_cssom_source_change(host_ptr, owner); @@ -1081,7 +1101,7 @@ impl JsContextHost { stylesheet_id, ); if refreshed { - self.clear_layout_rect_cache(); + self.mark_layout_input_dirty(); } refreshed } @@ -1103,19 +1123,24 @@ 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.mark_layout_input_dirty(); + } + changed } pub(crate) fn install_linked_stylesheet( &mut self, operation: crate::document_runtime::InstallLinkedStylesheet, ) { - self.clear_layout_rect_cache(); + self.mark_layout_input_dirty(); let (owner, request_url, prepared) = operation.into_parts(); let live_stylesheet = if prepared.has_import_rules() { self.dom_host() @@ -1252,7 +1277,7 @@ impl JsContextHost { root: DomHandle, installations: Vec, ) { - self.clear_layout_rect_cache(); + self.mark_layout_input_dirty(); let dom_host = self.dom_host() as *const _; self.style_engine .set_shadow_root_adopted_style_sheet_installations_with_host( @@ -1281,13 +1306,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.mark_layout_input_dirty(); + } + result } pub(crate) fn validate_css_custom_property_registration_for_document( @@ -1856,6 +1884,14 @@ impl JsContextHost { previous: Option, next: Option, ) { + if previous != next + && previous + .into_iter() + .chain(next) + .any(|element| self.dom_host().is_connected(element)) + { + self.mark_layout_input_dirty(); + } let dom_host = self.dom_host() as *const _; let emulated_media = self.emulated_media().clone(); let viewport = self.style_viewport(); @@ -1874,6 +1910,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.mark_layout_input_dirty(); + } let dom_host = self.dom_host() as *const _; let emulated_media = self.emulated_media().clone(); let viewport = self.style_viewport(); @@ -1893,6 +1937,14 @@ impl JsContextHost { previous: Option, next: Option, ) { + if previous != next + && previous + .into_iter() + .chain(next) + .any(|element| self.dom_host().is_connected(element)) + { + self.mark_layout_input_dirty(); + } let dom_host = self.dom_host() as *const _; let emulated_media = self.emulated_media().clone(); let viewport = self.style_viewport(); @@ -1912,6 +1964,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.mark_layout_input_dirty(); + } let dom_host = self.dom_host() as *const _; let emulated_media = self.emulated_media().clone(); let viewport = self.style_viewport(); @@ -1978,6 +2036,9 @@ impl JsContextHost { state_names: Vec, old_custom_states: Vec, ) { + if !state_names.is_empty() && self.dom_host().is_connected(element) { + self.mark_layout_input_dirty(); + } let dom_host = self.dom_host() as *const _; let emulated_media = self.emulated_media().clone(); let viewport = self.style_viewport(); @@ -2241,8 +2302,8 @@ impl JsContextHost { } pub(crate) fn note_style_mutation_effects(&mut self, effects: &[StyleMutationEffect]) { - if style_mutation_effects_affect_layout_metric(effects) { - self.clear_layout_rect_cache(); + if style_mutation_effects_affect_layout_metric(self.dom_host(), effects) { + self.mark_layout_input_dirty(); } let validity_containers = self.validity_containers_for_child_list_mutations(effects); let dom_host = self.dom_host() as *const _; @@ -2267,14 +2328,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.mark_layout_input_dirty(); + } 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.mark_layout_input_dirty(); + } let dom_host = self.dom_host() as *const _; self.style_engine .invalidate_style_subtree(unsafe { &*dom_host }, root); @@ -2573,15 +2638,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..7d56c1858db 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.mark_layout_input_dirty(); 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.mark_layout_input_dirty(); + } let Some(pending) = self.pending_image_load_events.get_mut(&task_id.element()) else { return false; }; diff --git a/moli-renderer-v8/src/native_bridge/context_host/layout.rs b/moli-renderer-v8/src/native_bridge/context_host/layout.rs index 83978dc1835..273826aaf60 100644 --- a/moli-renderer-v8/src/native_bridge/context_host/layout.rs +++ b/moli-renderer-v8/src/native_bridge/context_host/layout.rs @@ -206,10 +206,8 @@ impl JsContextHost { .inferred_frame_style_viewport_cache_observability() } - /// Lazily publishes the first screen layout for this main Document. - /// - /// Every consumer shares the same recursive tree. Missing nodes or newly - /// navigated frames in an existing tree must not refresh the whole page. + /// Lazily publishes the first screen layout. Input dispatch keeps using + /// this frozen tree until the next explicit visual publication. pub(crate) fn ensure_initial_layout(&self) -> Result<(), LayoutError> { if !self.layout_policy.uses_real_layout() { return Ok(()); @@ -237,6 +235,44 @@ impl JsContextHost { Ok(()) } + /// Direct DOM and protocol geometry reads require the current layout + /// inputs. The previous tree remains owned for sampled consumers until a + /// replacement pass succeeds. Reads inside an input event keep that + /// event's already selected hit-test frame stable. + pub(crate) fn ensure_current_layout(&self) -> Result<(), LayoutError> { + if self.current_input_event().is_some() { + return self.ensure_initial_layout(); + } + if !self.layout_policy.uses_real_layout() { + return Ok(()); + } + if self.layout_pass_active.get() { + return Err(LayoutError::ReentrantLayoutPass); + } + let document = self.document_handle(); + let viewport = self.layout_viewport_for_document(document); + let state = self.document_layout_state.borrow(); + let current = !state.latest_layout_is_dirty() + && state + .latest_layout(document) + .is_some_and(|tree| tree.viewport == viewport); + drop(state); + if current { + return Ok(()); + } + let pass = self + .build_layout_pass_for_document( + document, + LayoutPassRequest::new( + viewport, + moli_layout::LayoutFlushReason::SynchronousGeometry, + ), + )? + .ok_or(LayoutError::NoLayoutRoot)?; + self.publish_layout_pass_for_document(document, pass); + Ok(()) + } + pub(crate) fn build_layout_pass_for_document( &self, document: DomHandle, @@ -404,6 +440,17 @@ impl JsContextHost { } } + fn with_current_layout_tree_for_document( + &self, + document: DomHandle, + inspect: impl FnOnce(&FrozenLayoutTree) -> T, + ) -> Option { + if self.document_layout_state.borrow().latest_layout_is_dirty() { + return None; + } + self.with_latest_layout_tree_for_document(document, inspect) + } + /// Inspects the member tree for one exact Document in the single latest /// recursively frozen snapshot. /// @@ -513,6 +560,14 @@ impl JsContextHost { .mark_visual_state_dirty(); } + pub(crate) fn mark_layout_input_dirty(&self) { + debug_assert!(!self.layout_pass_active.get()); + self.clear_layout_rect_cache(); + self.document_layout_state + .borrow_mut() + .mark_latest_layout_dirty(); + } + pub(crate) fn document_web_font_resources_are_current( &self, generation: crate::style_engine::StylesheetResourceGeneration, diff --git a/moli-renderer-v8/src/native_bridge/context_host/layout_state.rs b/moli-renderer-v8/src/native_bridge/context_host/layout_state.rs index dee9842b842..26e436d2d96 100644 --- a/moli-renderer-v8/src/native_bridge/context_host/layout_state.rs +++ b/moli-renderer-v8/src/native_bridge/context_host/layout_state.rs @@ -43,6 +43,7 @@ pub(super) struct DocumentLayoutState { web_font_resource_generation: Option, visual_state_generation: u64, latest_layout: LatestLayoutTreeCache, + latest_layout_is_dirty: bool, /// Last used content viewport published by each live iframe owner's /// parent layout. Blink keeps the equivalent size on LocalFrameView; it is /// separate from the single latest-tree slot because a later fresh layout @@ -141,6 +142,15 @@ impl DocumentLayoutState { tree: FrozenLayoutTree, ) { self.latest_layout.publish(document, tree); + self.latest_layout_is_dirty = false; + } + + pub(super) const fn latest_layout_is_dirty(&self) -> bool { + self.latest_layout_is_dirty + } + + pub(super) fn mark_latest_layout_dirty(&mut self) { + self.latest_layout_is_dirty = true; } pub(super) fn clear_latest_layout(&mut self) { @@ -235,15 +245,24 @@ impl DocumentLayoutState { &mut self, resources: impl IntoIterator, ) { + let registered_before = self.services.web_font_count(); self.web_fonts .retain_active_slots(resources, &mut self.services); + if self.services.web_font_count() != registered_before { + self.mark_latest_layout_dirty(); + } } pub(super) fn admit( &mut self, resource: StylesheetLoadBlockingResource, ) -> Option { - self.web_fonts.admit(resource, &mut self.services) + let registered_before = self.services.web_font_count(); + let admitted = self.web_fonts.admit(resource, &mut self.services); + if self.services.web_font_count() != registered_before { + self.mark_latest_layout_dirty(); + } + admitted } pub(super) fn complete( @@ -251,6 +270,9 @@ impl DocumentLayoutState { terminal: CompletedStylesheetWebFont, ) -> DocumentWebFontCompletion { let completion = self.web_fonts.complete(terminal, &mut self.services); + if matches!(&completion, DocumentWebFontCompletion::Registered(_)) { + self.mark_latest_layout_dirty(); + } if !matches!(&completion, DocumentWebFontCompletion::Stale) { self.mark_visual_state_dirty(); } diff --git a/moli-renderer-v8/src/native_bridge/document/hit_test.rs b/moli-renderer-v8/src/native_bridge/document/hit_test.rs index cf0a1c18b21..dc50c3e1803 100644 --- a/moli-renderer-v8/src/native_bridge/document/hit_test.rs +++ b/moli-renderer-v8/src/native_bridge/document/hit_test.rs @@ -528,6 +528,7 @@ fn element_at_point( x: f64, y: f64, ) -> Result, moli_layout::LayoutError> { + runtime.ensure_current_layout()?; // A single-point query intentionally keeps a foremost text hit and maps // it to its web-exposed parent element. This is observably different from // the penetrating-list filtering performed by `elements_at_point`. @@ -556,6 +557,7 @@ fn elements_at_point( x: f64, y: f64, ) -> Result, moli_layout::LayoutError> { + runtime.ensure_current_layout()?; let (metrics, hits) = match observable_hit_test_all( runtime, document, diff --git a/moli-renderer-v8/src/native_bridge/element/geometry/provider.rs b/moli-renderer-v8/src/native_bridge/element/geometry/provider.rs index 4b16c9d6628..427ae914ad4 100644 --- a/moli-renderer-v8/src/native_bridge/element/geometry/provider.rs +++ b/moli-renderer-v8/src/native_bridge/element/geometry/provider.rs @@ -33,7 +33,7 @@ pub(crate) fn observable_geometry_query( document: DomHandle, query: &LayoutQuery, ) -> Result, LayoutError> { - runtime.ensure_initial_layout()?; + runtime.ensure_current_layout()?; published_geometry_query(runtime, document, query) } @@ -108,6 +108,7 @@ pub(crate) fn observable_sources_with_fragments( if sources.is_empty() { return Ok(HashSet::new()); } + runtime.ensure_current_layout()?; let queries = sources .iter() .copied() @@ -276,6 +277,7 @@ pub(crate) fn observable_caret_position( document: DomHandle, point: LayoutPoint, ) -> Result>, LayoutError> { + runtime.ensure_current_layout()?; let answers = observable_geometry_batch( runtime, document, 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 d1abff05fb0..39b578962df 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,74 @@ use crate::page_task_queue::{ }; use crate::script_vm::MainDocumentLifecycleBody; +#[tokio::test(flavor = "current_thread")] +async fn interactive_geometry_refreshes_after_dom_insertion_without_screenshot() { + 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: f64 = page_vm + .vm_mut() + .eval("document.getElementById('added').getBoundingClientRect().width")? + .parse()?; + assert!(added_width > 0.0, "new DOM geometry must not use the old tree"); + let dom_publish = page_vm.vm().layout_snapshot_cache_observability_for_test().2; + assert_eq!(dom_publish, first_publish + 1); + 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: i32 = page_vm + .vm_mut() + .eval("document.getElementById('offset').offsetWidth")? + .parse()?; + assert!(offset_width > 0, "box metrics must use the new DOM tree"); + 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"); + 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 refresh only after a DOM mutation"); +} + fn viewport_screencast_request( known_visual_state: Option, ) -> crate::runtime::RendererCaptureScreencastFrameRequest { diff --git a/moli-renderer-v8/src/script_vm/element_click.rs b/moli-renderer-v8/src/script_vm/element_click.rs index c66efcbb779..a10081ebdff 100644 --- a/moli-renderer-v8/src/script_vm/element_click.rs +++ b/moli-renderer-v8/src/script_vm/element_click.rs @@ -35,7 +35,7 @@ impl ScriptVm { if !host.layout_policy().uses_real_layout() { return Ok(ClickTarget::DomActivation); } - host.ensure_initial_layout().map_err(layout_error)?; + host.ensure_current_layout().map_err(layout_error)?; let document = host .layout_document_for_source(handle) .ok_or(ClickError::StaleNode)?; diff --git a/moli-renderer-v8/src/script_vm/layout.rs b/moli-renderer-v8/src/script_vm/layout.rs index d09f99f5fe4..3f5ee1aa131 100644 --- a/moli-renderer-v8/src/script_vm/layout.rs +++ b/moli-renderer-v8/src/script_vm/layout.rs @@ -404,6 +404,7 @@ impl ScriptVm { batch: &moli_layout::LayoutQueryBatch, ) -> Result, moli_layout::LayoutError> { let host = self._context_host.borrow(); + host.ensure_current_layout()?; crate::native_bridge::element::observable_geometry_batch(&host, document, batch) } 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..384bcbe0375 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,294 @@ fn drain_image_load_event_bodies_for_test(vm: &mut ScriptVm) -> usize { count } mod extracted; + +#[test] +fn offset_parent_refreshes_a_connected_insertion_before_any_other_geometry_read() { + 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":true,"width":240}"#, + "offsetParent must refresh a dirty tree before answering for the inserted element" + ); + assert_eq!( + vm.layout_pass_observability_for_test().1, + passes_after_initial_layout + 1, + "offsetParent and the following width read should share one fresh pass" + ); + + 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, + "clean geometry reads must reuse the refreshed 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_refreshes_pseudo_class_inputs_on_demand() { + 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, expected) in [ + ("target.focus({preventScroll: true})", "80"), + ("target.checked = true", "120"), + ("target.checked = false", "80"), + ("target.blur()", "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(), + expected, + "geometry must reflect changed pseudo class: {mutation}" + ); + 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_refreshes_attribute_selectors_and_cssom_on_demand() { + 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, expected) in [ + ("target.setAttribute('data-wide', '')", "80"), + ( + "document.getElementById('rules').sheet.insertRule('#target[data-wide] { width: 120px }', 2)", + "120", + ), + ("target.removeAttribute('data-wide')", "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(), + expected + ); + 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_refreshes_inserted_and_existing_nodes_on_demand() { + 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 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("geometry after a connected insertion should evaluate"); + + assert_eq!( + vm.layout_pass_observability_for_test().1, + passes_before + 1, + "all post-mutation geometry reads should share one fresh 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(), + before_top, + "removing previously connected siblings must invalidate old geometry" + ); + assert_eq!(vm.layout_pass_observability_for_test().1, passes_before + 2); +} 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 fa40bf0f742..c0c02fc7080 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 @@ -60,4 +60,5 @@ mod content_and_invalidation; mod cross_document_and_animations; 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..3adbd5f1868 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,64 @@ fn document_import_node_clones_dom_parser_svg_snapshot() { "http://www.w3.org/2000/svg|symbol|icon|path|M0 0h1v1" ); } + +#[test] +fn inner_text_refreshes_new_sources_on_demand_without_waiting_for_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"), + "ab", + "innerText must sample newly connected text without requiring a screenshot" + ); + assert_eq!(vm.layout_pass_observability_for_test().1, passes_before + 2); + assert_eq!(vm.eval("target.innerText").unwrap(), "ab"); + assert_eq!( + vm.layout_pass_observability_for_test().1, + passes_before + 2, + "repeated innerText should reuse clean 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 + 3); + assert_eq!( + vm.eval("target.innerText") + .expect("innerText should read the refreshed geometry snapshot"), + "ab" + ); + assert_eq!(vm.layout_pass_observability_for_test().1, passes_before + 3); + + let cache_after = vm.layout_snapshot_cache_observability_for_test(); + assert_eq!(cache_after.0, cache_before.0 + 2); + assert_eq!(cache_after.1, cache_before.1 + 2); + 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 3a2ea2cd981..3cea4f4d1d8 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 @@ -1464,7 +1464,84 @@ return [ } #[test] -fn geometry_getters_reuse_latest_layout_across_nodes_and_mutation() { +fn adopted_stylesheet_geometry_refreshes_across_script_turns_without_rendering() { + 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"); + 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 expected = format!("{width}|{width}|{width}"); + assert_eq!( + vm.eval(read).unwrap(), + expected, + "shadow={shadow} {mutation}" + ); + assert_eq!( + vm._context_host + .borrow() + .layout_pass_observability_for_test() + .1, + before + 1, + "first geometry read must refresh exactly once: shadow={shadow} {mutation}" + ); + assert_eq!(vm.eval(read).unwrap(), expected); + assert_eq!( + vm._context_host + .borrow() + .layout_pass_observability_for_test() + .1, + before + 1, + "a later clean script turn must reuse layout" + ); + } + } +} + +#[test] +fn geometry_getters_refresh_after_mutation_and_reuse_clean_layout() { let mut vm = new_storage_test_vm("https://oneshot-layout-demand.test/"); let passes_before = vm ._context_host @@ -1512,9 +1589,9 @@ yield; // Publish this scene before reading its geometry. })() "#, ) - .expect("latest layout snapshot reads should evaluate"); + .expect("current layout geometry reads should evaluate"); - assert_eq!(result, "38|8|38|8|38|38|0"); + assert_eq!(result, "38|8|38|8|38|43|8"); let passes = vm ._context_host .borrow() @@ -1522,13 +1599,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, + "the explicit publication and the first post-insertion read each build layout" ); let cache_after = vm.layout_snapshot_cache_observability_for_test(); assert_eq!(cache_after.0, cache_before.0 + 11); 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..01470ba6b04 100644 --- a/moli-renderer-v8/src/script_vm/tests/element_click.rs +++ b/moli-renderer-v8/src/script_vm/tests/element_click.rs @@ -1,5 +1,5 @@ use super::*; -use crate::runtime::{RendererElementClickTarget, RendererPointerEventProperties}; +use crate::runtime::{RendererElementClickError, RendererElementClickTarget}; #[test] fn native_element_click_initializes_layout_once() { @@ -19,87 +19,55 @@ fn native_element_click_initializes_layout_once() { } #[test] -fn native_element_click_reuses_frozen_layout_after_dom_and_style_changes() { - for mutation in [ - "target.style.left='300px'", - "const veil=document.createElement('div');veil.style.cssText='position:fixed;inset:0;z-index:999';document.body.appendChild(veil)", - "const style=document.createElement('style');document.head.appendChild(style);style.sheet.insertRule('button {pointer-events:none}')", - ] { - let mut reference = None; - for native_sequence in [false, true] { - let mut vm = new_parsed_test_vm( - "https://click-snapshot.test/", - r#" - - "#, - ); - vm.eval( - r#"window.clicks=0;window.events=[]; - document.getElementById('target').onclick=()=>clicks++; - for(const type of ['mousemove','mousedown','mouseup','click']) - document.addEventListener(type,e=>events.push([type,e.target.id]));"#, - ) - .expect("install click listener"); - let target = vm.document_runtime.get_element_by_id("target").unwrap(); - let before = vm.layout_pass_observability_for_test().1; - publish_layout_for_test(&mut vm); - assert_eq!(vm.layout_pass_observability_for_test().1, before + 1); - let RendererElementClickTarget::Pointer(first) = - vm.prepare_element_click(target).unwrap() - else { - panic!("real layout must prepare pointer input"); - }; - assert_eq!(vm.layout_pass_observability_for_test().1, before + 1); +fn native_element_click_uses_current_layout_after_dom_and_style_changes() { + let mut vm = new_parsed_test_vm( + "https://click-current-layout.test/", + r#" + + "#, + ); + vm.eval("window.clicks=0;document.getElementById('target').onclick=()=>clicks++") + .expect("install click listener"); + let target = vm.document_runtime.get_element_by_id("target").unwrap(); + let RendererElementClickTarget::Pointer(first) = vm.prepare_element_click(target).unwrap() + else { + panic!("real layout must prepare pointer input"); + }; - vm.eval(&format!( - "(()=>{{const target=document.getElementById('target');{mutation}}})()" - )) - .expect("mutate live DOM/style without requesting a fresh snapshot"); - let RendererElementClickTarget::Pointer(click) = - vm.prepare_element_click(target).unwrap() - else { - panic!("real layout must prepare pointer input"); - }; - assert_eq!((click.root_x, click.root_y), (first.root_x, first.root_y)); - assert_eq!( - vm.layout_pass_observability_for_test().1, - before + 1, - "warm preparation must reuse the frozen tree: {mutation}" - ); - if native_sequence { - vm.dispatch_prepared_element_click(click).unwrap(); - } else { - // Both native sequences and individual mouse commands consume - // the prepared snapshot without adding a layout demand. - for (event, buttons) in [("mousemove", 0), ("mousedown", 1), ("mouseup", 0)] { - vm.dispatch_mouse_event_at_point_with_pointer( - click.root_x, - click.root_y, - event, - 0, - Some(buttons), - i32::from(event != "mousemove"), - 0.0, - 0.0, - RendererPointerEventProperties::default(), - ) - .unwrap(); - } - } - let passes = vm.layout_pass_observability_for_test().1 - before; - assert_eq!( - passes, 1, - "the entire mouse sequence must reuse one snapshot: {mutation}" - ); - let events = vm.eval("JSON.stringify([clicks,events])").unwrap(); - if let Some((existing_passes, existing_events)) = &reference { - assert!(passes <= *existing_passes, "extra layout for {mutation}"); - assert_eq!(&events, existing_events, "{mutation}"); - } else { - reference = Some((passes, events)); - } - } - } + vm.eval("document.getElementById('target').style.left='300px'") + .expect("move target after initial layout"); + let RendererElementClickTarget::Pointer(moved) = vm.prepare_element_click(target).unwrap() + else { + panic!("moved target should remain clickable"); + }; + assert!( + moved.root_x > first.root_x, + "click must use the new position" + ); + vm.dispatch_prepared_element_click(moved).unwrap(); + assert_eq!(vm.eval("String(clicks)").unwrap(), "1"); + + vm.eval("const veil=document.createElement('div');veil.id='veil';veil.style.cssText='position:fixed;inset:0;z-index:999';document.body.appendChild(veil)") + .expect("add overlay after a successful click"); + assert!( + matches!( + vm.prepare_element_click(target), + Err(RendererElementClickError::Obscured) + ), + "an overlay introduced after layout must block click preparation" + ); + assert_eq!(vm.eval("String(clicks)").unwrap(), "1"); + + vm.eval("document.getElementById('veil').remove();const style=document.createElement('style');document.head.appendChild(style);style.sheet.insertRule('button {pointer-events:none}')") + .expect("disable pointer events through a new stylesheet"); + assert!( + matches!( + vm.prepare_element_click(target), + Err(RendererElementClickError::Obscured) + ), + "a live stylesheet must make the target unclickable" + ); + assert_eq!(vm.eval("String(clicks)").unwrap(), "1"); } #[test] diff --git a/moli-renderer-v8/src/script_vm/tests/mouse_snapshot.rs b/moli-renderer-v8/src/script_vm/tests/mouse_snapshot.rs index 0fce9dc55e6..c31d968fb5e 100644 --- a/moli-renderer-v8/src/script_vm/tests/mouse_snapshot.rs +++ b/moli-renderer-v8/src/script_vm/tests/mouse_snapshot.rs @@ -107,7 +107,7 @@ fn mouse_click_consumes_snapshot_through_hover_focus_and_dom_mutation() { } #[test] -fn mouse_and_geometry_reuse_the_snapshot_until_fresh_paint() { +fn mouse_input_keeps_its_snapshot_while_dom_geometry_refreshes() { let mut vm = new_parsed_test_vm( "https://mouse-snapshot.test/", r#" @@ -122,13 +122,14 @@ fn mouse_and_geometry_reuse_the_snapshot_until_fresh_paint() { mouse(&mut vm, "mousemove").unwrap(); assert_eq!(vm.eval("JSON.stringify(hits)").unwrap(), r#"["target"]"#); assert_eq!(vm.layout_pass_observability_for_test().1, before); - // DOM and hover changes do not invalidate ordinary geometry reads. + // Input hit testing used the published frame; a direct DOM geometry read + // now asks for the changed style's current box. assert_eq!( vm.eval("String(target.getBoundingClientRect().left)") .unwrap(), - "20" + "300" ); - assert_eq!(vm.layout_pass_observability_for_test().1, before); + assert_eq!(vm.layout_pass_observability_for_test().1, before + 1); vm.screenshot_layout_snapshot(moli_layout::PaintViewport::new(1920, 1080, 1.0)) .unwrap() .unwrap(); @@ -137,10 +138,10 @@ fn mouse_and_geometry_reuse_the_snapshot_until_fresh_paint() { .unwrap(), "300" ); - assert_eq!(vm.layout_pass_observability_for_test().1, before + 1); + assert_eq!(vm.layout_pass_observability_for_test().1, before + 2); mouse(&mut vm, "mousemove").unwrap(); assert_eq!(vm.eval("JSON.stringify(hits)").unwrap(), r#"["target",""]"#); - assert_eq!(vm.layout_pass_observability_for_test().1, before + 1); + assert_eq!(vm.layout_pass_observability_for_test().1, before + 2); } #[test] 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,