From 058c3b5c184f430d74e777867ab73053df036db5 Mon Sep 17 00:00:00 2001 From: lanyue-llk <270302213+lanyue-llk@users.noreply.github.com> Date: Tue, 22 Sep 2026 12:00:46 +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/layout_tree/pass_result.rs | 1 + 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 | 910 +++++++++++++++++- .../src/domains/dom/tests/geometry.rs | 33 +- .../context_host/host_environment.rs | 140 ++- .../context_host/image_resources/mod.rs | 4 + .../src/native_bridge/context_host/layout.rs | 21 +- .../context_host/layout_snapshot.rs | 6 +- .../context_host/layout_state.rs | 26 +- .../element/activation/default_action.rs | 2 +- .../src/native_bridge/element/focus.rs | 6 +- .../native_bridge/element/geometry/metrics.rs | 42 +- .../native_bridge/element/geometry/rects.rs | 4 +- .../native_bridge/element/geometry/scroll.rs | 10 +- .../element/geometry/scroll_into_view.rs | 14 +- .../page_vm/tests/computed_size/sampling.rs | 38 +- .../tests/grid_resolved_track_values.rs | 68 +- .../runtime/page_vm/tests/rendering_update.rs | 251 ++++- .../tests/dom_elements/dom_surface.rs | 291 ++++++ .../script_vm/tests/dom_xhr/computed_style.rs | 185 ++++ .../src/script_vm/tests/dom_xhr/dom.rs | 42 +- .../src/script_vm/tests/dom_xhr/misc.rs | 92 +- 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 + 36 files changed, 2558 insertions(+), 202 deletions(-) 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/layout_tree/pass_result.rs b/moli-layout/src/layout_tree/pass_result.rs index 0cbe9b7c2c1..19c4b83ae18 100644 --- a/moli-layout/src/layout_tree/pass_result.rs +++ b/moli-layout/src/layout_tree/pass_result.rs @@ -25,6 +25,7 @@ pub enum LayoutFlushReason { Screenshot, Screencast, SynchronousGeometry, + DomGeometry, CdpGeometry, ObserverDelivery, HitTest, diff --git a/moli-layout/src/pass.rs b/moli-layout/src/pass.rs index ebeb6fb0a36..241a84f7f3d 100644 --- a/moli-layout/src/pass.rs +++ b/moli-layout/src/pass.rs @@ -314,11 +314,13 @@ where /// /// Every iteration enters Taffy through the synthetic viewport root, but that /// is a scheduling entry point rather than an unconditional full-tree layout. -/// `invalidate_scrollbar_feedback` clears only changed boxes and their numeric -/// ancestors. All other subtrees keep valid Taffy cache entries and return +/// `invalidate_scrollbar_feedback` clears a changed local scroll container's +/// subtree and numeric ancestor path. A viewport gutter invalidates the whole +/// tree because it changes the initial containing block. Unaffected branches +/// of a local scrollbar change keep valid Taffy cache entries and return /// immediately. This is the same important boundary as Blink's corrective /// scrollbar relayout: pay a follow-up pass only after state changes, and only -/// recompute the paths whose available space can have changed. +/// recompute boxes whose available space can have changed. #[derive(Default)] struct NumericLayoutMetrics { pass_count: usize, diff --git a/moli-layout/src/projection.rs b/moli-layout/src/projection.rs index ded04acbe50..af84a165cf7 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}; @@ -53,6 +53,7 @@ where projection.build_scroll_extents(&overflow); projection.build_coordinate_spaces()?; projection.build_fragments(); + projection.build_split_block_fragments(); projection.assign_clip_and_paint_order(); let content_size = projection.document_content_size(); @@ -750,6 +751,150 @@ where } } + fn build_split_block_fragments(&mut self) { + // Construction records only contiguous, promoted in-flow block runs. + // Their middle continuation uses the existing flow bounds and parent + // content width, not transformed descendant ink or a new numeric box. + // In particular this must not introduce an auto-height containing block. + for run in &self.world.inline_split_block_runs { + let first = &self.world.boxes[run.first.index()]; + let last = &self.world.boxes[run.last.index()]; + let Some(parent) = first.parent else { + continue; + }; + let content = self.boxes[parent.index()].content_box; + let basis = self.split_block_relative_inset_basis(parent); + let direction = self.world.boxes[parent.index()].style.direction(); + let first_offset = + crate::inline::relative_inset_offset(&first.style.taffy, basis, direction); + let last_offset = + crate::inline::relative_inset_offset(&last.style.taffy, basis, direction); + let top = first.final_layout.location.y - first_offset.y; + let bottom = + last.final_layout.location.y - last_offset.y + last.final_layout.size.height; + let rect = LayoutRect::new(content.x, top, content.width, (bottom - top).max(0.0)); + self.reposition_empty_inline_continuation(run.preceding_inline, parent, top); + let fragment = self.push_fragment(LayoutFragment { + id: LayoutFragmentId::from_index(0), + kind: LayoutFragmentKind::Box { + box_id: LayoutOutputBoxId::from_index(run.preceding_inline.index()), + }, + rect, + box_model: Some(LayoutFragmentBoxModel { + content: rect, + padding: rect, + border: rect, + margin: rect, + }), + coordinate_space: LayoutCoordinateSpaceId::from_index(parent.index() + 1), + clip_chain: None, + paint_order: None, + }); + self.register_box_fragment(run.preceding_inline.index(), fragment); + } + } + + /// An empty leading continuation follows escaped margins into the first + /// block's flow position; it carries no independent line strut. + fn reposition_empty_inline_continuation( + &mut self, + inline: LayoutBoxId, + parent: LayoutBoxId, + top: f32, + ) { + let coordinate_space = LayoutCoordinateSpaceId::from_index(parent.index() + 1); + let document_to_parent = self.coordinate_spaces[coordinate_space.index()] + .layout + .local_to_document_ignoring_css_transforms + .inverse(); + for id in &self.boxes[inline.index()].fragments { + let fragment = &mut self.fragments[id.index()]; + if !matches!(fragment.kind, LayoutFragmentKind::InlineBox { .. }) + || fragment.rect.width != 0.0 + || fragment.rect.height != 0.0 + { + continue; + } + let original = self.coordinate_spaces[fragment.coordinate_space.index()] + .layout + .local_to_document_ignoring_css_transforms + .map_point(LayoutPoint::new(fragment.rect.x, fragment.rect.y)); + let x = document_to_parent + .map(|inverse| inverse.map_point(original).x) + .unwrap_or(fragment.rect.x); + let boundary = LayoutRect::new(x, top, 0.0, 0.0); + fragment.rect = boundary; + fragment.coordinate_space = coordinate_space; + fragment.box_model = Some(LayoutFragmentBoxModel { + content: boundary, + padding: boundary, + border: boundary, + margin: boundary, + }); + } + } + + fn split_block_relative_inset_basis(&self, parent: LayoutBoxId) -> taffy::Size> { + let content = self.boxes[parent.index()].content_box; + let Some(inputs) = self + .world + .inline_split_parent_inputs + .get(&parent) + .copied() + .flatten() + else { + return taffy::Size { + width: Some(content.width), + height: None, + }; + }; + let layout_box = &self.world.boxes[parent.index()]; + let style = &layout_box.style.taffy; + let percentage_basis = inputs + .constraint_space(layout_box.style.writing_mode()) + .margin_padding_percentage_basis(); + let padding = style + .padding + .resolve_or_zero(percentage_basis, crate::style::resolve_stylo_calc_value); + let border = style + .border + .resolve_or_zero(percentage_basis, crate::style::resolve_stylo_calc_value); + let padding_border = (padding + border).sum_axes(); + let adjustment = if style.box_sizing == taffy::BoxSizing::ContentBox { + padding_border + } else { + taffy::Size::ZERO + }; + // Match the block backend's relative-inset definiteness, not its + // broader percentage-size/min-height basis. The actual incoming + // definite dimensions win; auto/min-height alone remain indefinite. + let preferred = if inputs.sizing_mode == taffy::SizingMode::InherentSize { + let size = style + .size + .maybe_resolve(inputs.parent_size, crate::style::resolve_stylo_calc_value) + .maybe_add(adjustment); + block_relative_inset_preferred_size( + size, + style.size.map(|dimension| dimension.is_auto()), + layout_box.style.writing_mode(), + inputs.block_auto_behavior, + layout_box.resolved_aspect_ratio(), + padding_border, + ) + } else { + taffy::Size::NONE + }; + let vertical_inset = layout_box.final_layout.size.height - content.height; + taffy::Size { + width: Some(content.width), + height: inputs + .definite_dimensions + .height + .or(preferred.height) + .map(|height| (height - vertical_inset).max(0.0)), + } + } + fn register_box_fragment(&mut self, box_index: usize, fragment: LayoutFragmentId) { self.boxes[box_index].fragments.push(fragment); } @@ -1320,12 +1465,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, @@ -1355,10 +1520,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 dd10860bf4c..0ad24b24976 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), ) }) } @@ -748,8 +748,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!( @@ -766,6 +766,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, @@ -804,11 +978,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 @@ -846,13 +1032,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, 25.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_some()); - 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" @@ -883,16 +1069,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![ @@ -2424,6 +2676,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![ @@ -2699,6 +2994,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![ @@ -2727,9 +3599,13 @@ fn split_inline_continuations_remain_mapped_to_the_originating_element() { let output = build(&source, &mut styles); 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 072322f83f4..bbebbeb5ec8 100644 --- a/moli-protocol/src/domains/dom/tests/geometry.rs +++ b/moli-protocol/src/domains/dom/tests/geometry.rs @@ -1389,9 +1389,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; @@ -1544,9 +1544,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; @@ -1624,11 +1624,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}" ); } @@ -1636,9 +1647,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; @@ -1793,7 +1804,7 @@ async fn scroll_into_view_if_needed_observes_live_style_change_before_scrolling( 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.process_async(json!({ 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 e8dd2865aad..6504233873a 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); } @@ -1042,6 +1062,7 @@ impl JsContextHost { owner, stylesheet_id, ) { + self.mark_layout_input_dirty(); self.style_engine .mark_owner_live_stylesheet_cssom_authoritative_with_host( unsafe { &*dom_host }, @@ -1069,7 +1090,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); @@ -1094,7 +1115,7 @@ impl JsContextHost { stylesheet_id, ); if refreshed { - self.clear_layout_rect_cache(); + self.mark_layout_input_dirty(); } refreshed } @@ -1116,19 +1137,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() @@ -1265,7 +1291,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( @@ -1289,13 +1315,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( @@ -1864,6 +1893,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(); @@ -1882,6 +1919,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(); @@ -1901,6 +1946,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(); @@ -1920,6 +1973,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(); @@ -1986,6 +2045,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(); @@ -2227,8 +2289,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 dom_host = self.dom_host() as *const _; let emulated_media = self.emulated_media().clone(); @@ -2242,14 +2304,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); @@ -2548,15 +2614,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 17102034334..12f72b0c593 100644 --- a/moli-renderer-v8/src/native_bridge/context_host/layout.rs +++ b/moli-renderer-v8/src/native_bridge/context_host/layout.rs @@ -367,12 +367,23 @@ impl JsContextHost { { return None; } - self.with_latest_layout_tree_for_document(document, |tree| { + self.with_current_layout_tree_for_document(document, |tree| { layout_tree_satisfies_request(tree, reason, viewport).then(|| inspect(tree)) }) .flatten() } + 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. /// @@ -541,6 +552,14 @@ impl JsContextHost { .clear_latest_layout(); } + 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_snapshot.rs b/moli-renderer-v8/src/native_bridge/context_host/layout_snapshot.rs index 972d445fd11..cdcab2dd787 100644 --- a/moli-renderer-v8/src/native_bridge/context_host/layout_snapshot.rs +++ b/moli-renderer-v8/src/native_bridge/context_host/layout_snapshot.rs @@ -23,9 +23,9 @@ struct LatestFrozenLayout { /// /// This owns exactly one recursively frozen snapshot. It has no separately /// keyed child tree, working layout world, source index, hit-test index, Taffy -/// cache, style borrow, pass diagnostics, paint snapshot, timer, or invalidation -/// policy. The captured browser environment lets geometry demands check reuse -/// without discarding the sample used by snapshot-only CSSOM readers. +/// cache, style borrow, pass diagnostics, paint snapshot, timer, or incremental +/// invalidation state. Ambient environment and the owning layout state's dirty +/// flag fence geometry reuse; detached DOM mutations need not rebuild it. #[derive(Default)] pub(super) struct LatestLayoutTreeCache { latest: Option, 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 5e3e90f7bee..0d2fa825fa8 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 @@ -135,6 +136,17 @@ impl DocumentLayoutState { environment: LayoutEnvironment, ) { self.latest_layout.publish(document, tree, environment); + self.latest_layout_is_dirty = false; + } + + pub(super) const fn latest_layout_is_dirty(&self) -> bool { + self.latest_layout_is_dirty + } + + /// Retain the frozen tree for sampled consumers without permitting exact + /// geometry queries to reuse answers from before a layout input changed. + pub(super) fn mark_latest_layout_dirty(&mut self) { + self.latest_layout_is_dirty = true; } pub(super) fn latest_layout_matches_environment(&self, environment: LayoutEnvironment) -> bool { @@ -233,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( @@ -249,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/element/activation/default_action.rs b/moli-renderer-v8/src/native_bridge/element/activation/default_action.rs index cf3e0a54b83..6f5cf6d9b4a 100644 --- a/moli-renderer-v8/src/native_bridge/element/activation/default_action.rs +++ b/moli-renderer-v8/src/native_bridge/element/activation/default_action.rs @@ -1704,7 +1704,7 @@ fn image_submitter_coordinate( let rect = observable_bounding_client_rect( runtime, handle, - moli_layout::LayoutFlushReason::SynchronousGeometry, + moli_layout::LayoutFlushReason::DomGeometry, )?; Ok(( image_submitter_coordinate_component(client_x - rect.left, rect.width), diff --git a/moli-renderer-v8/src/native_bridge/element/focus.rs b/moli-renderer-v8/src/native_bridge/element/focus.rs index 51e7d093e19..8fc3b6d2117 100644 --- a/moli-renderer-v8/src/native_bridge/element/focus.rs +++ b/moli-renderer-v8/src/native_bridge/element/focus.rs @@ -117,11 +117,7 @@ fn element_is_scrollable(runtime: &JsContextHost, handle: DomHandle) -> bool { if !scrolls_x && !scrolls_y { return false; } - match observable_element_metrics( - runtime, - handle, - moli_layout::LayoutFlushReason::SynchronousGeometry, - ) { + match observable_element_metrics(runtime, handle, moli_layout::LayoutFlushReason::DomGeometry) { Ok(Some(metrics)) => { metrics.is_scroll_container && (scrolls_x && metrics.maximum_scroll_offset.x > metrics.minimum_scroll_offset.x diff --git a/moli-renderer-v8/src/native_bridge/element/geometry/metrics.rs b/moli-renderer-v8/src/native_bridge/element/geometry/metrics.rs index 72ac2560ddd..29ab53c7772 100644 --- a/moli-renderer-v8/src/native_bridge/element/geometry/metrics.rs +++ b/moli-renderer-v8/src/native_bridge/element/geometry/metrics.rs @@ -27,7 +27,7 @@ fn node_scroll_position_value<'s>( let metrics = observable_element_metrics( unsafe { &*runtime_ptr }, handle, - moli_layout::LayoutFlushReason::SynchronousGeometry, + moli_layout::LayoutFlushReason::DomGeometry, )?; return Ok(metrics .map(|metrics| { @@ -63,25 +63,21 @@ fn node_scroll_position_setter_for_object<'s>( } let runtime = unsafe { &mut *runtime_ptr }; let (minimum, maximum) = if runtime.layout_policy().uses_real_layout() { - observable_element_metrics( - runtime, - handle, - moli_layout::LayoutFlushReason::SynchronousGeometry, - )? - .map(|metrics| { - if horizontal { - ( - f64::from(metrics.minimum_scroll_offset.x), - f64::from(metrics.maximum_scroll_offset.x), - ) - } else { - ( - f64::from(metrics.minimum_scroll_offset.y), - f64::from(metrics.maximum_scroll_offset.y), - ) - } - }) - .unwrap_or((0.0, 0.0)) + observable_element_metrics(runtime, handle, moli_layout::LayoutFlushReason::DomGeometry)? + .map(|metrics| { + if horizontal { + ( + f64::from(metrics.minimum_scroll_offset.x), + f64::from(metrics.maximum_scroll_offset.x), + ) + } else { + ( + f64::from(metrics.minimum_scroll_offset.y), + f64::from(metrics.maximum_scroll_offset.y), + ) + } + }) + .unwrap_or((0.0, 0.0)) } else { // Mock intentionally preserves the old synthetic geometry behavior: // non-negative scroll values are stored even without real overflow. @@ -234,7 +230,7 @@ fn scroll_node_to<'s>( let metrics = observable_element_metrics( unsafe { &*runtime_ptr }, handle, - moli_layout::LayoutFlushReason::SynchronousGeometry, + moli_layout::LayoutFlushReason::DomGeometry, )?; let Some(metrics) = metrics else { return Ok(()); @@ -276,7 +272,7 @@ fn node_box_metric_from_object<'s>( let metrics = observable_element_metrics( unsafe { &*runtime_ptr }, handle, - moli_layout::LayoutFlushReason::SynchronousGeometry, + moli_layout::LayoutFlushReason::DomGeometry, )?; return Ok(metrics .as_ref() @@ -565,7 +561,7 @@ pub(in crate::native_bridge) fn node_offset_parent_getter_function<'s>( let metrics = match observable_element_metrics( runtime, handle, - moli_layout::LayoutFlushReason::SynchronousGeometry, + moli_layout::LayoutFlushReason::DomGeometry, ) { Ok(metrics) => metrics, Err(error) => { diff --git a/moli-renderer-v8/src/native_bridge/element/geometry/rects.rs b/moli-renderer-v8/src/native_bridge/element/geometry/rects.rs index 16c8c4a6269..11aae9cdcfe 100644 --- a/moli-renderer-v8/src/native_bridge/element/geometry/rects.rs +++ b/moli-renderer-v8/src/native_bridge/element/geometry/rects.rs @@ -60,7 +60,7 @@ pub(in crate::native_bridge) fn node_get_bounding_client_rect_callback( let rect = match observable_bounding_client_rect( unsafe { &*runtime_ptr }, handle, - moli_layout::LayoutFlushReason::SynchronousGeometry, + moli_layout::LayoutFlushReason::DomGeometry, ) { Ok(rect) => rect, Err(error) => { @@ -92,7 +92,7 @@ pub(in crate::native_bridge) fn node_get_client_rects_callback( let rects = match observable_client_rects( unsafe { &*runtime_ptr }, handle, - moli_layout::LayoutFlushReason::SynchronousGeometry, + moli_layout::LayoutFlushReason::DomGeometry, ) { Ok(rects) => rects, Err(error) => { diff --git a/moli-renderer-v8/src/native_bridge/element/geometry/scroll.rs b/moli-renderer-v8/src/native_bridge/element/geometry/scroll.rs index 0815889d1a0..67d88cf7e33 100644 --- a/moli-renderer-v8/src/native_bridge/element/geometry/scroll.rs +++ b/moli-renderer-v8/src/native_bridge/element/geometry/scroll.rs @@ -208,7 +208,7 @@ pub(crate) fn perform_wheel_scroll_default_action( let Some(mut geometry) = observable_scroll_into_view_geometry( runtime, target, - moli_layout::LayoutFlushReason::SynchronousGeometry, + moli_layout::LayoutFlushReason::DomGeometry, )? else { return Ok(false); @@ -217,11 +217,9 @@ pub(crate) fn perform_wheel_scroll_default_action( // ScrollIntoView geometry begins at the target's parent. Include the // target itself so an empty overflow scroller still responds when its own // background is the hit-test result. - if let Some(metrics) = observable_element_metrics( - runtime, - target, - moli_layout::LayoutFlushReason::SynchronousGeometry, - )? && metrics.is_scroll_container + if let Some(metrics) = + observable_element_metrics(runtime, target, moli_layout::LayoutFlushReason::DomGeometry)? + && metrics.is_scroll_container && geometry .scroll_containers .iter() diff --git a/moli-renderer-v8/src/native_bridge/element/geometry/scroll_into_view.rs b/moli-renderer-v8/src/native_bridge/element/geometry/scroll_into_view.rs index bd048f91815..168babb3de8 100644 --- a/moli-renderer-v8/src/native_bridge/element/geometry/scroll_into_view.rs +++ b/moli-renderer-v8/src/native_bridge/element/geometry/scroll_into_view.rs @@ -121,7 +121,7 @@ fn scroll_node_into_view_with_params( let Some(mut geometry) = observable_scroll_into_view_geometry( runtime, target, - moli_layout::LayoutFlushReason::SynchronousGeometry, + moli_layout::LayoutFlushReason::DomGeometry, )? else { return Ok(None); @@ -282,12 +282,10 @@ fn perform_bubbling_scroll_into_view( let Some(frame) = runtime.child_browsing_context_host_for_document_handle(document) else { return Ok(changed); }; - let Some(frame_content) = observable_box_model( - runtime, - frame, - moli_layout::LayoutFlushReason::SynchronousGeometry, - )? - .map(|model| model.content) else { + let Some(frame_content) = + observable_box_model(runtime, frame, moli_layout::LayoutFlushReason::DomGeometry)? + .map(|model| model.content) + else { return Ok(changed); }; let Some(parent_target_rects) = convert_quads_to_parent_frame( @@ -300,7 +298,7 @@ fn perform_bubbling_scroll_into_view( let Some(mut parent_geometry) = observable_scroll_into_view_geometry( runtime, frame, - moli_layout::LayoutFlushReason::SynchronousGeometry, + moli_layout::LayoutFlushReason::DomGeometry, )? else { return Ok(changed); diff --git a/moli-renderer-v8/src/runtime/page_vm/tests/computed_size/sampling.rs b/moli-renderer-v8/src/runtime/page_vm/tests/computed_size/sampling.rs index 869d4b0131a..715845abe05 100644 --- a/moli-renderer-v8/src/runtime/page_vm/tests/computed_size/sampling.rs +++ b/moli-renderer-v8/src/runtime/page_vm/tests/computed_size/sampling.rs @@ -72,7 +72,7 @@ async fn computed_size_held_getters_observe_grid_sampling_without_dom_mutation() } #[tokio::test(flavor = "current_thread")] -async fn computed_size_grid_sampling_reuses_old_geometry_until_explicit_refresh() { +async fn computed_size_grid_sampling_refreshes_dirty_geometry_on_first_demand() { run_page_vm_async_test(async move { let mut page = page_with_size_fixture(GRID)?; page.set_viewport_surface(Some(crate::protocol_types::ViewportSurface { @@ -106,6 +106,12 @@ async fn computed_size_grid_sampling_reuses_old_geometry_until_explicit_refresh( 'mutated' "#, )?; + assert_eq!(page.vm().layout_pass_observability_for_test().1, passes + 1); + assert_eq!( + page.vm().layout_snapshot_cache_observability_for_test(), + sampled, + "style mutation must only mark the retained snapshot dirty" + ); assert_eq!( read_without_layout(&mut page, "held.color")?, json!("rgb(255, 0, 0)") @@ -116,13 +122,33 @@ async fn computed_size_grid_sampling_reuses_old_geometry_until_explicit_refresh( ); assert_eq!( page.vm_mut().eval("held.gridTemplateColumns")?, - "40px 120px" + "100px 100px" ); - assert_eq!(page.vm().layout_pass_observability_for_test().1, passes + 1); + assert_eq!(page.vm().layout_pass_observability_for_test().1, passes + 2); + assert_eq!( + held_sizes(&mut page)?, + json!(["200px", "40px", "200px", "40px"]) + ); + let refreshed = page.vm().layout_snapshot_cache_observability_for_test(); assert_eq!( - page.vm().layout_snapshot_cache_observability_for_test().2, - sampled.2 + refreshed.2, + sampled.2 + 1, + "the first exact demand must publish one replacement tree" ); + + page.vm_mut().eval("'clean turn'")?; + assert_eq!(page.vm().layout_pass_observability_for_test().1, passes + 2); + assert_eq!( + page.vm().layout_snapshot_cache_observability_for_test(), + refreshed, + "a clean turn must keep the replacement tree reusable" + ); + assert_eq!( + page.vm_mut().eval("held.gridTemplateColumns")?, + "100px 100px" + ); + assert_eq!(page.vm().layout_pass_observability_for_test().1, passes + 2); + publish_size_layout(&mut page)?; assert_eq!( held_sizes(&mut page)?, @@ -132,7 +158,7 @@ async fn computed_size_grid_sampling_reuses_old_geometry_until_explicit_refresh( page.vm_mut().eval("held.gridTemplateColumns")?, "100px 100px" ); - assert_eq!(page.vm().layout_pass_observability_for_test().1, passes + 2); + assert_eq!(page.vm().layout_pass_observability_for_test().1, passes + 3); Ok::<_, anyhow::Error>(()) }) .await diff --git a/moli-renderer-v8/src/runtime/page_vm/tests/grid_resolved_track_values.rs b/moli-renderer-v8/src/runtime/page_vm/tests/grid_resolved_track_values.rs index 4804f8a3b15..83485f053a4 100644 --- a/moli-renderer-v8/src/runtime/page_vm/tests/grid_resolved_track_values.rs +++ b/moli-renderer-v8/src/runtime/page_vm/tests/grid_resolved_track_values.rs @@ -1,7 +1,7 @@ use super::*; #[tokio::test(flavor = "current_thread")] -async fn computed_style_serializes_used_grid_tracks_from_the_frozen_layout_tree() { +async fn computed_style_refreshes_dirty_used_grid_tracks_on_exact_demand() { run_page_vm_async_test(async move { let loader = crate::network::ResourceRequestClient::new(&FetchConfig::default()).expect("loader"); @@ -89,20 +89,82 @@ vertical:getComputedStyle(document.getElementById('vertical')).gridTemplateColum "resolved horizontal Grid longhands must expose used tracks while preserving expanded line names, without publishing physical-axis values for vertical Grid", ); + let passes_before_mutation = page_vm.vm().layout_pass_observability_for_test().1; + let cache_before_mutation = page_vm + .vm() + .layout_snapshot_cache_observability_for_test(); + page_vm .vm_mut() .eval("document.getElementById('named').style.cssText='width:400px;grid-template-columns:[new] 1fr 1fr';'mutated'")?; + assert_eq!( + page_vm.vm().layout_pass_observability_for_test().1, + passes_before_mutation, + "a style mutation must mark layout dirty without eagerly rebuilding it", + ); + assert_eq!( + page_vm + .vm() + .layout_snapshot_cache_observability_for_test(), + cache_before_mutation, + "marking the current frozen tree dirty must not discard or replace it eagerly", + ); + assert_eq!( page_vm.vm_mut().eval( "getComputedStyle(document.getElementById('named')).gridTemplateColumns", )?, - "[a] 21px [b c] 22px [d] 23px [e c] 22px [d] 23px [e f] 189px [g]", - "a synchronous style read must stay on the last published layout epoch", + "[new] 200px 200px", + "an exact used-track read must refresh a dirty layout before answering", ); + let passes_after_demand = page_vm.vm().layout_pass_observability_for_test().1; + let cache_after_demand = page_vm + .vm() + .layout_snapshot_cache_observability_for_test(); + assert_eq!(passes_after_demand, passes_before_mutation + 1); + assert_eq!(cache_after_demand.0, cache_before_mutation.0); + assert_eq!(cache_after_demand.1, cache_before_mutation.1 + 1); + assert_eq!(cache_after_demand.2, cache_before_mutation.2 + 1); + + page_vm.vm_mut().eval("'clean turn'")?; + assert_eq!( + page_vm.vm().layout_pass_observability_for_test().1, + passes_after_demand, + "a clean script turn must retain the freshly published layout", + ); + assert_eq!( + page_vm.vm_mut().eval( + "getComputedStyle(document.getElementById('named')).gridTemplateColumns", + )?, + "[new] 200px 200px", + ); + let cache_after_reuse = page_vm + .vm() + .layout_snapshot_cache_observability_for_test(); + assert_eq!( + page_vm.vm().layout_pass_observability_for_test().1, + passes_after_demand, + "a repeated exact read across a clean turn must reuse the frozen tree", + ); + assert_eq!(cache_after_reuse.0, cache_after_demand.0 + 1); + assert_eq!(cache_after_reuse.1, cache_after_demand.1); + assert_eq!(cache_after_reuse.2, cache_after_demand.2); + page_vm .vm_mut() .screenshot_layout_snapshot(moli_layout::PaintViewport::new(400, 300, 1.0))? .expect("updated used Grid track CSSOM screenshot layout"); + assert_eq!( + page_vm.vm().layout_pass_observability_for_test().1, + passes_after_demand + 1, + "a screenshot remains a force-fresh paint boundary even when the tree is clean", + ); + let cache_after_screenshot = page_vm + .vm() + .layout_snapshot_cache_observability_for_test(); + assert_eq!(cache_after_screenshot.0, cache_after_reuse.0); + assert_eq!(cache_after_screenshot.1, cache_after_reuse.1); + assert_eq!(cache_after_screenshot.2, cache_after_reuse.2 + 1); assert_eq!( page_vm.vm_mut().eval( "getComputedStyle(document.getElementById('named')).gridTemplateColumns", 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 b26979a9dee..3d2e9970ec0 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 @@ -11,6 +11,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 { @@ -116,7 +184,49 @@ getComputedStyle(document.getElementById('fallback')).display } #[tokio::test(flavor = "current_thread")] -async fn geometry_batch_reuses_latest_tree_at_same_viewport_until_fresh_paint_replaces_it() { +async fn viewport_scrollbar_feedback_relayouts_text_bearing_absolute_descendant() { + 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/scrollbar-absolute-descendant.html")?, + ); + page_vm.vm_mut().eval( + r#" +document.head.innerHTML = ``; +document.body.innerHTML = `
`; +'installed' +"#, + )?; + page_vm + .vm_mut() + .prime_document_lifecycle_processing_and_record_stylesheet_network_results(); + + let geometry = page_vm.vm_mut().eval( + r#"JSON.stringify((()=>{const menu=document.getElementById('menu').getBoundingClientRect(),target=document.getElementById('target').getBoundingClientRect();return {menu:[menu.width,menu.height],target:[target.width,target.height]}})())"#, + )?; + assert_eq!( + geometry, + r#"{"menu":[120,35],"target":[120,35]}"#, + "a viewport scrollbar corrective pass must not reuse the text-bearing descendant's size-only cache entry" + ); + Ok::<_, anyhow::Error>(()) + }) + .await + .expect("viewport scrollbar absolute-descendant fixture should run"); +} + +#[tokio::test(flavor = "current_thread")] +async fn geometry_batch_refreshes_dirty_tree_on_demand_and_reuses_clean_tree() { run_page_vm_async_test(async move { let loader = crate::network::ResourceRequestClient::new(&FetchConfig::default()).expect("loader"); @@ -229,10 +339,18 @@ document.body.innerHTML = '
< page_vm .vm_mut() .eval("document.getElementById('target').style.width='180px'; 'mutated'")?; + let after_mutation = page_vm.vm().layout_pass_observability_for_test(); + let cache_after_mutation = page_vm + .vm() + .layout_snapshot_cache_observability_for_test(); + assert_eq!(after_mutation.1, after_first.1); + assert_eq!(cache_after_mutation, cache_after_first); + + let second_viewport = moli_layout::LayoutViewport::new(480, 300, 2.0); let second = moli_layout::GeometryProvider::answer( page_vm.vm_mut(), moli_layout::LayoutFlushReason::SynchronousGeometry, - viewport, + second_viewport, &batch, )?; let after_second = page_vm.vm().layout_pass_observability_for_test(); @@ -240,24 +358,23 @@ document.body.innerHTML = '
< .vm() .layout_snapshot_cache_observability_for_test(); assert!(!after_second.0); - assert_eq!(after_second.1, before.1 + 1); - assert_eq!(cache_after_second.0, cache_before.0 + 1); - assert_eq!(cache_after_second.1, cache_before.1 + 1); - assert_eq!(cache_after_second.2, cache_before.2 + 1); + assert_eq!(after_second.1, before.1 + 2); + assert_eq!(cache_after_second.0, cache_before.0); + assert_eq!(cache_after_second.1, cache_before.1 + 2); + assert_eq!(cache_after_second.2, cache_before.2 + 2); let second_width = match &second.answers[1] { moli_layout::LayoutQueryAnswer::BoxModel(Some(model)) => { model.border.bounding_rect().width } answer => panic!("unexpected box-model answer: {answer:?}"), }; - assert!((second_width - 120.0).abs() <= 0.05, "{second_width}"); - assert_eq!(after_second.2, after_first.2); - assert_eq!(after_second.3, after_first.3); - assert_eq!(second.metrics, first.metrics); + assert!((second_width - 180.0).abs() <= 0.05, "{second_width}"); + assert_eq!(second.metrics.reason, moli_layout::LayoutFlushReason::SynchronousGeometry); + assert_eq!(second.metrics.paint_operation_count, 0); assert!(matches!( second.answers[0], moli_layout::LayoutQueryAnswer::DocumentMetrics(metrics) - if metrics.viewport == viewport + if metrics.viewport == second_viewport )); let snapshot = page_vm @@ -269,10 +386,10 @@ document.body.innerHTML = '
< .vm() .layout_snapshot_cache_observability_for_test(); assert!(!after_screenshot.0); - assert_eq!(after_screenshot.1, before.1 + 2); - assert_eq!(cache_after_screenshot.0, cache_before.0 + 1); - assert_eq!(cache_after_screenshot.1, cache_before.1 + 1); - assert_eq!(cache_after_screenshot.2, cache_before.2 + 2); + assert_eq!(after_screenshot.1, before.1 + 3); + assert_eq!(cache_after_screenshot.0, cache_before.0); + assert_eq!(cache_after_screenshot.1, cache_before.1 + 2); + assert_eq!(cache_after_screenshot.2, cache_before.2 + 3); let (_, retention) = cache_after_screenshot .3 .expect("fresh paint layout should publish its frozen tree"); @@ -293,6 +410,20 @@ document.body.innerHTML = '
< && diagnostic.code != "scroll-paint-deferred" })); + page_vm.vm_mut().eval("'clean turn'")?; + assert_eq!( + page_vm.vm().layout_pass_observability_for_test().1, + after_screenshot.1, + "a clean script turn must not eagerly rebuild layout" + ); + assert_eq!( + page_vm + .vm() + .layout_snapshot_cache_observability_for_test(), + cache_after_screenshot, + "turn-exit side-cache cleanup must retain the clean frozen tree" + ); + let third = moli_layout::GeometryProvider::answer( page_vm.vm_mut(), moli_layout::LayoutFlushReason::SynchronousGeometry, @@ -303,10 +434,10 @@ document.body.innerHTML = '
< let cache_after_third = page_vm .vm() .layout_snapshot_cache_observability_for_test(); - assert_eq!(after_third.1, before.1 + 2); - assert_eq!(cache_after_third.0, cache_before.0 + 2); - assert_eq!(cache_after_third.1, cache_before.1 + 1); - assert_eq!(cache_after_third.2, cache_before.2 + 2); + assert_eq!(after_third.1, before.1 + 3); + assert_eq!(cache_after_third.0, cache_before.0 + 1); + assert_eq!(cache_after_third.1, cache_before.1 + 2); + assert_eq!(cache_after_third.2, cache_before.2 + 3); let third_width = match &third.answers[1] { moli_layout::LayoutQueryAnswer::BoxModel(Some(model)) => { model.border.bounding_rect().width @@ -371,23 +502,83 @@ document.body.innerHTML = '
'; page_vm .vm_mut() .eval("document.getElementById('target').style.width='80px'; 'mutated'")?; - let stale = moli_layout::GeometryProvider::answer( + assert_eq!( + page_vm.vm().layout_pass_observability_for_test().1, + passes_before + 1, + "the style mutation must not eagerly rebuild layout", + ); + assert_eq!( + page_vm + .vm() + .layout_snapshot_cache_observability_for_test(), + cache_after_first, + "marking the first frame dirty must retain its frozen tree until demand", + ); + + let refreshed_on_demand = moli_layout::GeometryProvider::answer( page_vm.vm_mut(), moli_layout::LayoutFlushReason::SynchronousGeometry, moli_layout::LayoutViewport::new(320, 200, 1.0), &batch, )?; - let stale_width = match &stale.answers[0] { + let refreshed_on_demand_width = match &refreshed_on_demand.answers[0] { moli_layout::LayoutQueryAnswer::BoxModel(Some(model)) => { model.border.bounding_rect().width } - answer => panic!("unexpected stale box-model answer: {answer:?}"), + answer => panic!("unexpected refreshed box-model answer: {answer:?}"), }; - assert!((stale_width - 40.0).abs() <= 0.05, "{stale_width}"); + assert!( + (refreshed_on_demand_width - 80.0).abs() <= 0.05, + "{refreshed_on_demand_width}" + ); assert_eq!( page_vm.vm().layout_pass_observability_for_test().1, - passes_before + 1 + passes_before + 2 ); + let cache_after_demand = page_vm + .vm() + .layout_snapshot_cache_observability_for_test(); + assert_eq!(cache_after_demand.0, cache_before.0); + assert_eq!(cache_after_demand.1, cache_before.1 + 1); + assert_eq!(cache_after_demand.2, cache_before.2 + 2); + + page_vm.vm_mut().eval("'clean turn'")?; + assert_eq!( + page_vm.vm().layout_pass_observability_for_test().1, + passes_before + 2, + "a clean script turn must not rebuild the demanded layout", + ); + assert_eq!( + page_vm + .vm() + .layout_snapshot_cache_observability_for_test(), + cache_after_demand, + "turn-exit side-cache cleanup must retain the clean frozen tree", + ); + let reused = moli_layout::GeometryProvider::answer( + page_vm.vm_mut(), + moli_layout::LayoutFlushReason::SynchronousGeometry, + moli_layout::LayoutViewport::new(320, 200, 1.0), + &batch, + )?; + let reused_width = match &reused.answers[0] { + moli_layout::LayoutQueryAnswer::BoxModel(Some(model)) => { + model.border.bounding_rect().width + } + answer => panic!("unexpected reused box-model answer: {answer:?}"), + }; + assert!((reused_width - 80.0).abs() <= 0.05, "{reused_width}"); + assert_eq!( + page_vm.vm().layout_pass_observability_for_test().1, + passes_before + 2, + "a repeated exact read across a clean turn must reuse the frozen tree", + ); + let cache_after_reuse = page_vm + .vm() + .layout_snapshot_cache_observability_for_test(); + assert_eq!(cache_after_reuse.0, cache_before.0 + 1); + assert_eq!(cache_after_reuse.1, cache_before.1 + 1); + assert_eq!(cache_after_reuse.2, cache_before.2 + 2); page_vm .vm_mut() @@ -398,14 +589,14 @@ document.body.innerHTML = '
'; .expect("second screencast frame layout"); assert_eq!( page_vm.vm().layout_pass_observability_for_test().1, - passes_before + 2 + passes_before + 3 ); let cache_after_second = page_vm .vm() .layout_snapshot_cache_observability_for_test(); assert_eq!(cache_after_second.0, cache_before.0 + 1); - assert_eq!(cache_after_second.1, cache_before.1); - assert_eq!(cache_after_second.2, cache_before.2 + 2); + assert_eq!(cache_after_second.1, cache_before.1 + 1); + assert_eq!(cache_after_second.2, cache_before.2 + 3); assert!(cache_after_second.3.is_some()); let refreshed = moli_layout::GeometryProvider::answer( @@ -423,14 +614,14 @@ document.body.innerHTML = '
'; assert!((refreshed_width - 80.0).abs() <= 0.05, "{refreshed_width}"); assert_eq!( page_vm.vm().layout_pass_observability_for_test().1, - passes_before + 2 + passes_before + 3 ); let cache_after_query = page_vm .vm() .layout_snapshot_cache_observability_for_test(); assert_eq!(cache_after_query.0, cache_before.0 + 2); - assert_eq!(cache_after_query.1, cache_before.1); - assert_eq!(cache_after_query.2, cache_before.2 + 2); + assert_eq!(cache_after_query.1, cache_before.1 + 1); + assert_eq!(cache_after_query.2, cache_before.2 + 3); Ok::<_, anyhow::Error>(()) }) .await 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 5f01fecaa61..532b614fb3f 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 @@ -1,5 +1,296 @@ use super::*; +#[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); +} + #[test] fn svg_create_rect_supports_capability_detection_and_detached_float_values() { let mut vm = new_storage_test_vm("https://svg-rect.test/"); 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 53df860ce13..b49bca7af67 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 @@ -2,6 +2,191 @@ use super::*; mod child_list; +#[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"); +} + #[test] fn element_current_css_zoom_observes_fresh_effective_style_for_rendered_boxes() { let mut vm = new_parsed_test_vm( diff --git a/moli-renderer-v8/src/script_vm/tests/dom_xhr/dom.rs b/moli-renderer-v8/src/script_vm/tests/dom_xhr/dom.rs index 805312e52c2..30aa1a78299 100644 --- a/moli-renderer-v8/src/script_vm/tests/dom_xhr/dom.rs +++ b/moli-renderer-v8/src/script_vm/tests/dom_xhr/dom.rs @@ -8591,8 +8591,11 @@ for (let index = 0; index < 128; index++) { ); assert_eq!(layout_after_first.1, layout_before.1 + 1); assert_eq!(layout_after_repeated.1, layout_after_first.1); - assert_eq!(layout_after_second.1, layout_after_first.1); - assert_eq!(layout_after_stylesheet_mutation.1, layout_after_first.1); + assert_eq!(layout_after_second.1, layout_after_first.1 + 1); + assert_eq!( + layout_after_stylesheet_mutation.1, + layout_after_second.1 + 1 + ); assert_eq!( update_materializations_after_first.saturating_sub(update_materializations_before), 1, @@ -8637,7 +8640,7 @@ for (let index = 0; index < 128; index++) { } #[test] -fn inner_text_new_sources_wait_for_a_fresh_paint_layout() { +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
", @@ -8659,31 +8662,42 @@ fn inner_text_new_sources_wait_for_a_fresh_paint_layout() { ); 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.eval( - "const added = document.createElement('span'); added.textContent = 'b'; target.append(added); target.innerText", - ) - .expect("the warm innerText read should evaluate"), - "a", - "a text source absent from the latest frozen layout tree remains unrendered until refresh" + 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" ); - assert_eq!(vm.layout_pass_observability_for_test().1, passes_before + 1); vm.screenshot_layout_snapshot(moli_layout::PaintViewport::new(320, 200, 1.0)) .expect("fresh paint layout should succeed") .expect("the fixture should have a layout root"); - assert_eq!(vm.layout_pass_observability_for_test().1, passes_before + 2); + assert_eq!(vm.layout_pass_observability_for_test().1, passes_before + 3); assert_eq!( vm.eval("target.innerText") .expect("innerText should read the refreshed geometry snapshot"), "ab" ); - assert_eq!(vm.layout_pass_observability_for_test().1, passes_before + 2); + assert_eq!(vm.layout_pass_observability_for_test().1, passes_before + 3); let cache_after = vm.layout_snapshot_cache_observability_for_test(); assert_eq!(cache_after.0, cache_before.0 + 2); - assert_eq!(cache_after.1, cache_before.1 + 1); - assert_eq!(cache_after.2, cache_before.2 + 2); + assert_eq!(cache_after.1, cache_before.1 + 2); + assert_eq!(cache_after.2, cache_before.2 + 3); } #[test] 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 c0cd87c3792..ac71967d752 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 @@ -1454,7 +1454,84 @@ body.setHTMLUnsafe(` } #[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 @@ -1503,18 +1580,21 @@ fn geometry_getters_reuse_latest_layout_across_nodes_and_mutation() { ) .expect("latest layout snapshot reads should evaluate"); - assert_eq!(result, "38|8|38|8|38|38|0"); + assert_eq!(result, "38|8|38|8|38|43|8"); let passes = vm ._context_host .borrow() .layout_pass_observability_for_test() .1 .saturating_sub(passes_before); - assert_eq!(passes, 1, "only the cold getter may build layout"); + assert_eq!( + passes, 2, + "cold and first post-mutation reads each build once" + ); let cache_after = vm.layout_snapshot_cache_observability_for_test(); - assert_eq!(cache_after.0, cache_before.0 + 10); - assert_eq!(cache_after.1, cache_before.1 + 1); - assert_eq!(cache_after.2, cache_before.2 + 1); + assert_eq!(cache_after.0, cache_before.0 + 9); + assert_eq!(cache_after.1, cache_before.1 + 2); + assert_eq!(cache_after.2, cache_before.2 + 2); assert!(cache_after.3.is_some()); } 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,