diff --git a/moli-layout/src/layout_tree/pass_result.rs b/moli-layout/src/layout_tree/pass_result.rs index af611fba00..7c51c3aec8 100644 --- a/moli-layout/src/layout_tree/pass_result.rs +++ b/moli-layout/src/layout_tree/pass_result.rs @@ -83,6 +83,13 @@ where self.tree } + /// Consumes one pass into the sole retainable tree plus optional one-shot + /// paint input. Embedded-frame composition uses this to nest the child tree + /// into its parent while separately consuming paint into the parent frame. + pub fn into_tree_and_paint_snapshot(self) -> (FrozenLayoutTree, Option) { + (self.tree, self.paint_snapshot) + } + pub fn retention_metrics(&self) -> LayoutTreeRetentionMetrics { self.tree.retention_metrics() } diff --git a/moli-layout/src/layout_tree/tree.rs b/moli-layout/src/layout_tree/tree.rs index 0236462ae7..9a6c2209e7 100644 --- a/moli-layout/src/layout_tree/tree.rs +++ b/moli-layout/src/layout_tree/tree.rs @@ -27,6 +27,18 @@ pub struct FrozenLayoutBox { pub hit_source: Option, } +/// One child browsing context owned by the same latest frozen snapshot as its +/// parent. It is deliberately recursive rather than an independently keyed +/// cache entry, so capturing input geometry does not publish a child over the +/// top-level tree. +pub struct FrozenEmbeddedFrame +where + N: Copy + Debug + Eq + Hash, +{ + pub source: N, + pub tree: Box>, +} + impl Deref for FrozenLayoutBox { type Target = LayoutBoxGeometry; @@ -65,6 +77,7 @@ pub struct FrozenLayoutTree where N: Copy + Debug + Eq + Hash, { + source_root: N, pub viewport: LayoutViewport, pub viewport_scroll: LayoutPoint, pub content_size: LayoutSize, @@ -76,12 +89,31 @@ where pub scroll_proxy_links: Vec<(N, LayoutOutputBoxId)>, viewport_coordinate_space: FrozenCoordinateSpace, pub clip_chain: Vec, + embedded_frames: Vec>, + embedded_frames_complete: bool, } impl FrozenLayoutTree where N: Copy + Debug + Eq + Hash, { + /// Source root whose complete layout projection this member tree owns. + pub const fn source_root(&self) -> N { + self.source_root + } + + /// Finds an exact Document/source-root member without a per-Document + /// index. Browsing-context depth is bounded, so this walks only the small + /// recursive frame projection rather than the member trees' boxes. + pub fn tree_for_root(&self, source_root: N) -> Option<&FrozenLayoutTree> { + if self.source_root == source_root { + return Some(self); + } + self.embedded_frames + .iter() + .find_map(|frame| frame.tree.tree_for_root(source_root)) + } + pub fn retention_metrics(&self) -> LayoutTreeRetentionMetrics { fn allocation(capacity: usize) -> usize { capacity.saturating_mul(std::mem::size_of::()) @@ -92,18 +124,35 @@ where layout_box.fragments.capacity(), )) }); - let estimated_geometry_bytes = std::mem::size_of::() + let own_estimated_geometry_bytes = std::mem::size_of::() .saturating_add(allocation::>(self.boxes.capacity())) .saturating_add(allocation::(self.fragments.capacity())) .saturating_add(allocation::<(N, LayoutOutputBoxId)>( self.scroll_proxy_links.capacity(), )) .saturating_add(allocation::(self.clip_chain.capacity())) + .saturating_add(allocation::>( + self.embedded_frames.capacity(), + )) .saturating_add(box_allocations); + let embedded = self.embedded_frames.iter().fold( + LayoutTreeRetentionMetrics::default(), + |total, frame| { + let child = frame.tree.retention_metrics(); + LayoutTreeRetentionMetrics { + box_count: total.box_count.saturating_add(child.box_count), + fragment_count: total.fragment_count.saturating_add(child.fragment_count), + estimated_geometry_bytes: total + .estimated_geometry_bytes + .saturating_add(child.estimated_geometry_bytes), + } + }, + ); LayoutTreeRetentionMetrics { - box_count: self.boxes.len(), - fragment_count: self.fragments.len(), - estimated_geometry_bytes, + box_count: self.boxes.len().saturating_add(embedded.box_count), + fragment_count: self.fragments.len().saturating_add(embedded.fragment_count), + estimated_geometry_bytes: own_estimated_geometry_bytes + .saturating_add(embedded.estimated_geometry_bytes), } } @@ -138,6 +187,25 @@ where .get(id.index()) .map(|layout_box| &layout_box.scroll_extent) } + + /// Returns the embedded child tree frozen into this same retained snapshot. + pub fn embedded_frame_tree(&self, source: N) -> Option<&FrozenLayoutTree> { + self.embedded_frames + .iter() + .find(|frame| frame.source == source) + .map(|frame| frame.tree.as_ref()) + } + + /// Whether every live child frame encountered by this pass was projected. + /// Hit-test demands require this bit before reusing a tree produced by a + /// geometry-only pass. + pub const fn embedded_frames_complete(&self) -> bool { + self.embedded_frames_complete + } + + pub fn embedded_frames(&self) -> impl ExactSizeIterator> { + self.embedded_frames.iter() + } } impl FrozenLayoutTree @@ -145,6 +213,7 @@ where N: Copy + Debug + Eq + Hash, { pub(crate) fn new( + source_root: N, viewport: LayoutViewport, viewport_scroll: LayoutPoint, content_size: LayoutSize, @@ -154,8 +223,11 @@ where scroll_proxy_links: Vec<(N, LayoutOutputBoxId)>, viewport_coordinate_space: FrozenCoordinateSpace, clip_chain: Vec, + embedded_frames: Vec>, + embedded_frames_complete: bool, ) -> Self { Self { + source_root, viewport, viewport_scroll, content_size, @@ -165,6 +237,8 @@ where scroll_proxy_links, viewport_coordinate_space, clip_chain, + embedded_frames, + embedded_frames_complete, } } } diff --git a/moli-layout/src/lib.rs b/moli-layout/src/lib.rs index d4871e891d..79b31e392d 100644 --- a/moli-layout/src/lib.rs +++ b/moli-layout/src/lib.rs @@ -45,23 +45,24 @@ pub use capture::{ }; pub use error::LayoutError; pub use layout_tree::{ - FrozenCoordinateSpace, FrozenLayoutBox, FrozenLayoutTree, GeometryProvider, LayoutAnswers, - LayoutBoxGeometry, LayoutBoxModel, LayoutCaretPosition, LayoutClipChainId, LayoutClipNode, - LayoutCoordinateSpaceId, LayoutDocumentMetrics, LayoutElementMetrics, LayoutFlushReason, - LayoutFragment, LayoutFragmentBoxModel, LayoutFragmentId, LayoutFragmentKind, LayoutHit, - LayoutIntersectionGeometry, LayoutNodeOutput, LayoutOutputBoxId, LayoutPassMetrics, - LayoutPassResult, LayoutPoint, LayoutQuad, LayoutQuery, LayoutQueryAnswer, LayoutQueryBatch, - LayoutRect, LayoutScrollContainerMetrics, LayoutScrollExtent, LayoutScrollIntoViewGeometry, - LayoutSize, LayoutTransform2D, LayoutTreeRetentionMetrics, LayoutViewport, - MAX_RETAINED_LAYOUT_BOXES, MAX_RETAINED_LAYOUT_FRAGMENTS, MAX_RETAINED_LAYOUT_TREE_BYTES, + FrozenCoordinateSpace, FrozenEmbeddedFrame, FrozenLayoutBox, FrozenLayoutTree, + GeometryProvider, LayoutAnswers, LayoutBoxGeometry, LayoutBoxModel, LayoutCaretPosition, + LayoutClipChainId, LayoutClipNode, LayoutCoordinateSpaceId, LayoutDocumentMetrics, + LayoutElementMetrics, LayoutFlushReason, LayoutFragment, LayoutFragmentBoxModel, + LayoutFragmentId, LayoutFragmentKind, LayoutHit, LayoutIntersectionGeometry, LayoutNodeOutput, + LayoutOutputBoxId, LayoutPassMetrics, LayoutPassResult, LayoutPoint, LayoutQuad, LayoutQuery, + LayoutQueryAnswer, LayoutQueryBatch, LayoutRect, LayoutScrollContainerMetrics, + LayoutScrollExtent, LayoutScrollIntoViewGeometry, LayoutSize, LayoutTransform2D, + LayoutTreeRetentionMetrics, LayoutViewport, MAX_RETAINED_LAYOUT_BOXES, + MAX_RETAINED_LAYOUT_FRAGMENTS, MAX_RETAINED_LAYOUT_TREE_BYTES, }; pub use normalize::{NormalizedBoxNode, NormalizedBoxTree, NormalizedFormattingContext}; pub use normalize_source::{ NormalizedLayoutSourceNode, NormalizedLayoutSourceTree, normalize_layout_source, }; pub use pass::{ - EmbeddedFrameRenderer, LayoutPassRequest, ScreenshotLayoutRequest, build_layout_pass, - build_layout_pass_with_embedded_frames, build_screenshot_snapshot, + EmbeddedFrameRenderer, EmbeddedFrameSnapshot, LayoutPassRequest, ScreenshotLayoutRequest, + build_layout_pass, build_layout_pass_with_embedded_frames, build_screenshot_snapshot, }; pub use scrollbar::{ LayoutScrollbarAxis, LayoutScrollbarColors, LayoutScrollbarGeometry, LayoutScrollbarGutter, diff --git a/moli-layout/src/pass.rs b/moli-layout/src/pass.rs index 84650ee229..feb6030e93 100644 --- a/moli-layout/src/pass.rs +++ b/moli-layout/src/pass.rs @@ -1,7 +1,7 @@ use crate::{ - DocumentLayoutServices, LayoutError, LayoutFlushReason, LayoutPassResult, LayoutScrollbarAxis, - LayoutSource, LayoutStyleResolver, LayoutViewport, PaintCaptureRequest, PaintSnapshot, - PaintViewport, build_layout_world, + DocumentLayoutServices, FrozenLayoutTree, LayoutError, LayoutFlushReason, LayoutPassResult, + LayoutScrollbarAxis, LayoutSource, LayoutStyleResolver, LayoutViewport, PaintCaptureRequest, + PaintSnapshot, PaintViewport, build_layout_world, form::prepare_form_controls, inline::prepare_inline_contexts, list::prepare_list_markers, @@ -11,6 +11,26 @@ use crate::{ use std::collections::HashMap; use std::time::Instant; +/// Owned products for one embedded browsing context in the same synchronous +/// layout demand as its parent. +pub struct EmbeddedFrameSnapshot +where + N: Copy + std::fmt::Debug + Eq + std::hash::Hash, +{ + pub tree: FrozenLayoutTree, + pub paint: Option, +} + +impl EmbeddedFrameSnapshot +where + N: Copy + std::fmt::Debug + Eq + std::hash::Hash, +{ + #[must_use] + pub const fn new(tree: FrozenLayoutTree, paint: Option) -> Self { + Self { tree, paint } + } +} + /// Renderer-owned bridge for one live embedded browsing context. /// /// The parent numeric layout has already completed when this callback runs, so @@ -18,23 +38,30 @@ use std::time::Instant; /// computed-style estimate. Implementations must return an owned, source-free /// snapshot and must not run JavaScript, lifecycle work, or an event-loop turn. /// Recursive child layout is allowed because every nested world remains local -/// to the same synchronous demand. -pub trait EmbeddedFrameRenderer { +/// to the same synchronous demand. The child tree is consumed into the single +/// parent-owned frozen snapshot; it is not a separately retained cache entry. +pub trait EmbeddedFrameRenderer +where + N: Copy + std::fmt::Debug + Eq + std::hash::Hash, +{ fn render_embedded_frame( &mut self, frame: N, viewport: LayoutViewport, - ) -> Result, LayoutError>; + ) -> Result>, LayoutError>; } struct NoEmbeddedFrames; -impl EmbeddedFrameRenderer for NoEmbeddedFrames { +impl EmbeddedFrameRenderer for NoEmbeddedFrames +where + N: Copy + std::fmt::Debug + Eq + std::hash::Hash, +{ fn render_embedded_frame( &mut self, _frame: N, _viewport: LayoutViewport, - ) -> Result, LayoutError> { + ) -> Result>, LayoutError> { Ok(None) } } @@ -79,6 +106,13 @@ impl LayoutPassRequest { self.paint_capture.is_some() } + /// Whether this demand needs a complete embedded-frame input projection. + /// Paint always composes child frames, while coordinate input needs their + /// source-bearing geometry without retaining independent child snapshots. + pub const fn requests_embedded_frames(self) -> bool { + self.requests_paint() || matches!(self.reason, LayoutFlushReason::HitTest) + } + /// Whether paint snapshots for this demand should include CSS backgrounds. /// Layout-only demands return `true` so recursive renderers retain their /// normal paint defaults when no capture policy exists. @@ -152,9 +186,9 @@ where /// Builds one complete layout result and resolves embedded frame pixels after /// the parent numeric layout has established their exact content viewports. /// -/// This is a one-shot composition seam, not a retained subframe tree. Child -/// snapshots are consumed into the parent snapshot before all layout worlds -/// are dropped. +/// This is a one-shot composition seam, not a per-Document cache. Child trees +/// are recursively owned by the one parent snapshot, child paint is consumed +/// into parent paint, and every working layout world is then dropped. pub fn build_layout_pass_with_embedded_frames( source: &S, styles: &mut R, @@ -174,7 +208,7 @@ where prepare_inline_contexts(&mut world, services); compute_world_layout_with_scrollbars(&mut world, request.viewport); let mut embedded_frames = HashMap::new(); - if request.requests_paint() { + if request.requests_embedded_frames() { for index in 0..world.boxes.len() { let layout_box = &world.boxes[index]; if !layout_box.element_semantics().is_some_and(|semantics| { @@ -207,17 +241,19 @@ where request.viewport.device_pixel_ratio, ); if let Some(snapshot) = frames.render_embedded_frame(source, viewport)? { - embedded_frames.insert(crate::LayoutBoxId::from_index(index), snapshot); + embedded_frames.insert(crate::LayoutBoxId::from_index(index), (source, snapshot)); } } } finish_layout_pass( &world, + source.root(), request.viewport, request.reason, started, request.paint_capture, - &mut embedded_frames, + embedded_frames, + request.requests_embedded_frames(), ) } diff --git a/moli-layout/src/projection.rs b/moli-layout/src/projection.rs index 0a6cbe87e0..6acba0d165 100644 --- a/moli-layout/src/projection.rs +++ b/moli-layout/src/projection.rs @@ -17,11 +17,13 @@ use crate::{ pub(crate) fn finish_layout_pass( world: &LayoutWorld, + source_root: N, viewport: LayoutViewport, reason: LayoutFlushReason, started: Instant, paint_capture: Option, - embedded_frames: &mut HashMap, + mut embedded_frames: HashMap)>, + embedded_frames_complete: bool, ) -> Result, LayoutError> where N: Copy + Debug + Eq + Hash, @@ -34,12 +36,16 @@ where projection.assign_clip_and_paint_order(); let content_size = projection.document_content_size(); + let mut embedded_paint = embedded_frames + .iter_mut() + .filter_map(|(id, (_, snapshot))| snapshot.paint.take().map(|paint| (*id, paint))) + .collect::>(); let mut paint_snapshot = paint_capture .map(|request| { request .resolve(viewport, projection.viewport_scroll, content_size) .map(|capture| { - crate::paint::project_paint_snapshot(&projection, capture, embedded_frames) + crate::paint::project_paint_snapshot(&projection, capture, &mut embedded_paint) }) }) .transpose()?; @@ -73,7 +79,19 @@ where .filter(|diagnostic| diagnostic.severity == PaintDiagnosticSeverity::Warning) .count(), }; - let tree = projection.into_frozen_tree(content_size); + let embedded_frames = embedded_frames + .into_values() + .map(|(source, snapshot)| crate::FrozenEmbeddedFrame { + source, + tree: Box::new(snapshot.tree), + }) + .collect(); + let tree = projection.into_frozen_tree( + source_root, + content_size, + embedded_frames, + embedded_frames_complete, + ); Ok(LayoutPassResult::new( tree, diagnostics, @@ -907,7 +925,13 @@ where ) } - fn into_frozen_tree(self, content_size: LayoutSize) -> FrozenLayoutTree { + fn into_frozen_tree( + self, + source_root: N, + content_size: LayoutSize, + embedded_frames: Vec>, + embedded_frames_complete: bool, + ) -> FrozenLayoutTree { let root_box = LayoutOutputBoxId::from_index(self.world.root.index()); let mut coordinate_spaces = self .coordinate_spaces @@ -941,6 +965,7 @@ where ) .collect(); FrozenLayoutTree::new( + source_root, self.viewport, self.viewport_scroll, content_size, @@ -950,6 +975,8 @@ where self.scroll_proxy_links, viewport_coordinate_space, self.clip_chain, + embedded_frames, + embedded_frames_complete, ) } } diff --git a/moli-renderer-v8/src/layout_renderer/mod.rs b/moli-renderer-v8/src/layout_renderer/mod.rs index 1934a8f364..d3d488667b 100644 --- a/moli-renderer-v8/src/layout_renderer/mod.rs +++ b/moli-renderer-v8/src/layout_renderer/mod.rs @@ -6,7 +6,7 @@ use std::collections::HashMap; use moli_layout::{ DocumentLayoutServices, EmbeddedFrameRenderer, LayoutPassRequest, LayoutPassResult, - LayoutSource, LayoutViewport, PaintSnapshot, build_layout_pass_with_embedded_frames, + LayoutSource, LayoutViewport, build_layout_pass_with_embedded_frames, }; use style::values::generics::image::GenericImage; @@ -148,6 +148,7 @@ fn build_native_layout_pass_recursive( let mut frames = NativeEmbeddedFrameRenderer { runtime, reason: request.reason, + capture_paint: request.requests_paint(), include_backgrounds: request.includes_backgrounds(), document_stack, embedded_document_services, @@ -161,6 +162,7 @@ fn build_native_layout_pass_recursive( struct NativeEmbeddedFrameRenderer<'a> { runtime: &'a JsContextHost, reason: moli_layout::LayoutFlushReason, + capture_paint: bool, include_backgrounds: bool, document_stack: &'a mut Vec, embedded_document_services: &'a mut HashMap, @@ -171,7 +173,8 @@ impl EmbeddedFrameRenderer for NativeEmbeddedFrameRenderer<'_> { &mut self, frame: DomHandle, viewport: LayoutViewport, - ) -> Result, moli_layout::LayoutError> { + ) -> Result>, moli_layout::LayoutError> + { const MAX_EMBEDDED_DOCUMENT_DEPTH: usize = 32; let Some(document) = self.runtime.child_browsing_context_document_handle(frame) else { @@ -194,19 +197,25 @@ impl EmbeddedFrameRenderer for NativeEmbeddedFrameRenderer<'_> { .embedded_document_services .remove(&document) .unwrap_or_default(); - let mut capture = moli_layout::PaintCaptureRequest::viewport(); - capture.include_backgrounds = self.include_backgrounds; + let request = if self.capture_paint { + let mut capture = moli_layout::PaintCaptureRequest::viewport(); + capture.include_backgrounds = self.include_backgrounds; + LayoutPassRequest::with_capture(viewport, self.reason, capture) + } else { + LayoutPassRequest::new(viewport, self.reason) + }; let result = build_native_layout_pass_recursive( self.runtime, root, &mut services, self.embedded_document_services, - LayoutPassRequest::with_capture(viewport, self.reason, capture), + request, self.document_stack, ); self.embedded_document_services.insert(document, services); let output = result?; - output.into_paint_snapshot().map(Some) + let (tree, paint) = output.into_tree_and_paint_snapshot(); + Ok(Some(moli_layout::EmbeddedFrameSnapshot::new(tree, paint))) } } 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 599a17409a..cef5ad8aa2 100644 --- a/moli-renderer-v8/src/native_bridge/context_host/layout.rs +++ b/moli-renderer-v8/src/native_bridge/context_host/layout.rs @@ -196,13 +196,12 @@ impl JsContextHost { if self.force_fresh_layout_reads_for_test { return false; } - self.document_layout_state - .borrow() - .latest_layout(document) + self.with_latest_layout_tree_for_document(document, |_| ()) .is_some() } - /// Inspects the latest frozen layout tree for one exact Document. + /// Inspects the member tree for one exact Document in the single latest + /// recursively frozen snapshot. /// /// The callback cannot retain the tree or force a refresh. Consumers /// such as lazy-image admission may combine this sampled geometry with @@ -213,8 +212,15 @@ impl JsContextHost { document: DomHandle, inspect: impl FnOnce(&FrozenLayoutTree) -> T, ) -> Option { + let root = self + .dom_host() + .dom() + .document_element_handle_for_document(document); let state = self.document_layout_state.borrow(); - state.latest_layout(document).map(inspect) + state + .latest_layout(document) + .or_else(|| root.and_then(|root| state.latest_layout_for_root(root))) + .map(inspect) } pub(crate) fn answer_layout_at_viewport( @@ -227,6 +233,41 @@ impl JsContextHost { self.answer_layout(document, reason, viewport, queries, true) } + /// Ensures one exact-viewport tree exists without manufacturing a geometry + /// query. Hit-test demands additionally require the complete embedded-frame + /// projection owned by that same frozen tree. + pub(crate) fn ensure_layout_at_viewport( + &self, + document: DomHandle, + reason: LayoutFlushReason, + viewport: LayoutViewport, + ) -> Result<(), LayoutError> { + #[cfg(test)] + let reuse_latest = !self.force_fresh_layout_reads_for_test; + #[cfg(not(test))] + let reuse_latest = true; + let cached = reuse_latest + && self + .with_latest_layout_tree_for_document(document, |tree| { + layout_tree_satisfies_request(tree, reason, viewport, true) + }) + .unwrap_or(false); + if cached { + self.layout_snapshot_cache_hits + .set(self.layout_snapshot_cache_hits.get().saturating_add(1)); + return Ok(()); + } + + self.layout_snapshot_cache_misses + .set(self.layout_snapshot_cache_misses.get().saturating_add(1)); + self.with_fresh_layout_pass_for_document( + document, + LayoutPassRequest::new(viewport, reason), + |_| Ok(()), + )? + .ok_or(LayoutError::NoLayoutRoot) + } + fn answer_layout( &self, document: DomHandle, @@ -240,15 +281,15 @@ impl JsContextHost { #[cfg(not(test))] let reuse_latest = true; let cached = if reuse_latest { - let state = self.document_layout_state.borrow(); - state - .latest_layout(document) - .filter(|tree| !exact_viewport || tree.viewport == viewport) - .and_then(|tree| { - self.last_layout_pass_metrics - .get() - .map(|metrics| self.answer_layout_queries(tree, metrics, viewport, queries)) - }) + self.with_latest_layout_tree_for_document(document, |tree| { + if !layout_tree_satisfies_request(tree, reason, viewport, exact_viewport) { + return None; + } + self.last_layout_pass_metrics + .get() + .map(|metrics| self.answer_layout_queries(tree, metrics, viewport, queries)) + }) + .flatten() } else { None }; @@ -424,6 +465,16 @@ impl JsContextHost { } } +fn layout_tree_satisfies_request( + tree: &FrozenLayoutTree, + reason: LayoutFlushReason, + viewport: LayoutViewport, + exact_viewport: bool, +) -> bool { + (!exact_viewport || tree.viewport == viewport) + && (!matches!(reason, LayoutFlushReason::HitTest) || tree.embedded_frames_complete()) +} + impl GeometryProvider for JsContextHost { type NodeId = DomHandle; 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 391e371279..fd82e987fd 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 @@ -9,9 +9,10 @@ struct LatestFrozenLayout { /// Single-slot storage for the latest successful frozen layout tree. /// -/// This owns exactly one frozen layout tree. It has no working layout world, -/// source index, hit-test index, Taffy cache, style borrow, pass diagnostics, -/// paint snapshot, timer, freshness stamp, or invalidation policy. +/// 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, freshness +/// stamp, or invalidation policy. #[derive(Default)] pub(super) struct LatestLayoutTreeCache { latest: Option, @@ -25,6 +26,10 @@ impl LatestLayoutTreeCache { .map(|snapshot| snapshot.tree.as_ref()) } + pub(super) fn get_for_root(&self, root: DomHandle) -> Option<&FrozenLayoutTree> { + self.latest.as_ref()?.tree.tree_for_root(root) + } + pub(super) fn publish(&mut self, document: DomHandle, tree: FrozenLayoutTree) { self.latest = Some(LatestFrozenLayout { document, 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 f7fb38487c..43c4f49531 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 @@ -17,8 +17,9 @@ use crate::{ /// layout tree and document-owned font/text sidecars survive a pass. /// Embedded documents receive separate Parley services so a main-document /// `@font-face` registration cannot leak across the browsing-context boundary. -/// The single snapshot slot may describe any exact Document in the current -/// document tree; its identity is stored alongside the tree. +/// The single snapshot slot records the exact root Document it was built for; +/// embedded Document member trees remain recursively owned by that same +/// snapshot instead of becoming separately keyed cache entries. pub(super) struct DocumentLayoutState { services: DocumentLayoutServices, embedded_document_services: HashMap, @@ -27,8 +28,8 @@ pub(super) struct DocumentLayoutState { latest_layout: LatestLayoutTreeCache, /// 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 parent and child - /// layout queries replace that slot while the frame view remains live. + /// separate from the single latest-tree slot because a later fresh layout + /// may replace that slot while the frame view remains live. frame_viewports: HashMap, } @@ -94,6 +95,13 @@ impl DocumentLayoutState { self.latest_layout.get(document) } + pub(super) fn latest_layout_for_root( + &self, + root: DomHandle, + ) -> Option<&FrozenLayoutTree> { + self.latest_layout.get_for_root(root) + } + pub(super) fn publish_latest_layout( &mut self, document: DomHandle, diff --git a/moli-renderer-v8/src/native_bridge/element.rs b/moli-renderer-v8/src/native_bridge/element.rs index 0e6832920f..0d42f4cc20 100644 --- a/moli-renderer-v8/src/native_bridge/element.rs +++ b/moli-renderer-v8/src/native_bridge/element.rs @@ -385,11 +385,13 @@ pub(crate) fn install_html_select_element_prototype_bindings( } pub use geometry::ClientRect; +#[cfg(test)] +pub(crate) use geometry::observable_scrollbar_hit_test; pub(crate) use geometry::{ apply_scroll_observable_effects, observable_bounding_client_rect, observable_caret_position, observable_deep_hit_test, observable_event_offset, observable_geometry_batch, - observable_hit_test_all, observable_input_hit_test, observable_scroll_adjusted_client_rect, - observable_scrollbar_hit_test, observable_sources_with_fragments, + observable_hit_test_all, observable_input_hit_test, observable_input_surface_hit_test, + observable_scroll_adjusted_client_rect, observable_sources_with_fragments, perform_scrollbar_scroll_default_action, perform_wheel_scroll_default_action, queue_scroll_observable_effects, scroll_node_into_view_at_start, }; diff --git a/moli-renderer-v8/src/native_bridge/element/geometry.rs b/moli-renderer-v8/src/native_bridge/element/geometry.rs index c24c7e8b46..c327828f79 100644 --- a/moli-renderer-v8/src/native_bridge/element/geometry.rs +++ b/moli-renderer-v8/src/native_bridge/element/geometry.rs @@ -8,8 +8,10 @@ mod scroll; mod scroll_into_view; pub use client_rect::ClientRect; +#[cfg(test)] +pub(crate) use hit_test::observable_scrollbar_hit_test; pub(crate) use hit_test::{ - observable_deep_hit_test, observable_input_hit_test, observable_scrollbar_hit_test, + observable_deep_hit_test, observable_input_hit_test, observable_input_surface_hit_test, }; pub(in crate::native_bridge) use metrics::{ node_client_height_getter_function, node_client_left_getter_function, diff --git a/moli-renderer-v8/src/native_bridge/element/geometry/hit_test.rs b/moli-renderer-v8/src/native_bridge/element/geometry/hit_test.rs index 6ed5d30d71..6c9dc25107 100644 --- a/moli-renderer-v8/src/native_bridge/element/geometry/hit_test.rs +++ b/moli-renderer-v8/src/native_bridge/element/geometry/hit_test.rs @@ -1,6 +1,6 @@ use moli_layout::{ - LayoutError, LayoutFlushReason, LayoutHit, LayoutPoint, LayoutQuery, LayoutQueryAnswer, - LayoutQueryBatch, LayoutScrollbarHit, LayoutTransform2D, LayoutViewport, + FrozenLayoutTree, LayoutError, LayoutFlushReason, LayoutHit, LayoutPoint, LayoutQuery, + LayoutQueryAnswer, LayoutQueryBatch, LayoutScrollbarHit, LayoutTransform2D, LayoutViewport, }; use super::provider::{ @@ -19,6 +19,12 @@ pub(crate) struct InputHit { pub(crate) root_to_frame: LayoutTransform2D, } +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub(crate) struct InputSurfaceHit { + pub(crate) input: Option, + pub(crate) scrollbar: Option>, +} + #[derive(Clone, Copy)] struct FrameHitTest { document: DomHandle, @@ -70,18 +76,49 @@ pub(crate) fn observable_input_hit_test( document: DomHandle, point: LayoutPoint, ) -> Result, LayoutError> { - input_hit_test(runtime, document, point, false) + observable_input_surface_hit_test(runtime, document, point, false, false).map(|hit| hit.input) } +#[cfg(test)] pub(crate) fn observable_scrollbar_hit_test( runtime: &JsContextHost, document: DomHandle, point: LayoutPoint, ) -> Result>, LayoutError> { + observable_input_surface_hit_test(runtime, document, point, false, true) + .map(|hit| hit.scrollbar) +} + +pub(crate) fn observable_input_surface_hit_test( + runtime: &JsContextHost, + document: DomHandle, + point: LayoutPoint, + ignore_pointer_events_none: bool, + include_scrollbars: bool, +) -> Result { if !runtime.layout_policy().uses_real_layout() { - return Ok(None); + return input_hit_test_via_documents(runtime, document, point, ignore_pointer_events_none) + .map(|input| InputSurfaceHit { + input, + scrollbar: None, + }); } - scrollbar_hit_test_in_frame(runtime, FrameHitTest::root(runtime, document, point), 0) + + let viewport = runtime.layout_viewport_for_document(document); + runtime.ensure_layout_at_viewport(document, LayoutFlushReason::HitTest, viewport)?; + runtime + .with_latest_layout_tree_for_document(document, |tree| { + input_surface_hit_test_in_tree( + runtime, + tree, + point, + LayoutTransform2D::IDENTITY, + ignore_pointer_events_none, + include_scrollbars, + 0, + ) + }) + .ok_or(LayoutError::NoLayoutRoot) } pub(crate) fn observable_deep_hit_test( @@ -90,10 +127,18 @@ pub(crate) fn observable_deep_hit_test( point: LayoutPoint, ignore_pointer_events_none: bool, ) -> Result, LayoutError> { - Ok(input_hit_test(runtime, document, point, ignore_pointer_events_none)?.map(|hit| hit.handle)) + Ok(observable_input_surface_hit_test( + runtime, + document, + point, + ignore_pointer_events_none, + false, + )? + .input + .map(|hit| hit.handle)) } -fn input_hit_test( +fn input_hit_test_via_documents( runtime: &JsContextHost, document: DomHandle, point: LayoutPoint, @@ -133,35 +178,112 @@ fn input_hit_test_in_frame( ) } -fn scrollbar_hit_test_in_frame( +fn input_surface_hit_test_in_tree( runtime: &JsContextHost, - frame: FrameHitTest, + tree: &FrozenLayoutTree, + point: LayoutPoint, + root_to_frame: LayoutTransform2D, + ignore_pointer_events_none: bool, + include_scrollbars: bool, depth: usize, -) -> Result>, LayoutError> { - // Publish a current tree through the same exact-viewport hit-test - // boundary used for DOM input. Scrollbar geometry intentionally remains a - // frozen-tree control query because it is user-agent chrome, not a DOM - // target. - let live_hit = live_hit_in_frame(runtime, frame, false)?; - if let Some(mut hit) = runtime - .with_latest_layout_tree_for_document(frame.document, |tree| { - tree.scrollbar_hit_test(frame.point) - }) - .flatten() - { - hit.viewport_to_local = hit.viewport_to_local.concatenate(frame.root_to_frame); - return Ok(Some(hit)); +) -> InputSurfaceHit { + if include_scrollbars && let Some(mut scrollbar) = tree.scrollbar_hit_test(point) { + scrollbar.viewport_to_local = scrollbar.viewport_to_local.concatenate(root_to_frame); + return InputSurfaceHit { + input: None, + scrollbar: Some(scrollbar), + }; } + + let Some((layout_hit, target)) = + live_hit_in_tree(runtime, tree, point, ignore_pointer_events_none) + else { + return InputSurfaceHit::default(); + }; + let target_hit = InputHit { + handle: target, + root_to_frame, + }; if depth >= CHILD_FRAME_DEPTH_LIMIT { - return Ok(None); + return InputSurfaceHit { + input: Some(target_hit), + scrollbar: None, + }; } - let Some((layout_hit, target)) = live_hit else { - return Ok(None); + let Some(child_tree) = tree.embedded_frame_tree(target) else { + return InputSurfaceHit { + input: Some(target_hit), + scrollbar: None, + }; }; - let Some(child) = frame.child(runtime, target, layout_hit) else { - return Ok(None); + if runtime + .child_browsing_context_document_handle(target) + .is_none() + { + return InputSurfaceHit { + input: Some(target_hit), + scrollbar: None, + }; + } + let Some(content_box) = layout_hit.local_content_box else { + return InputSurfaceHit { + input: Some(target_hit), + scrollbar: None, + }; }; - scrollbar_hit_test_in_frame(runtime, child, depth + 1) + if !content_box.contains(layout_hit.local_point) { + return InputSurfaceHit { + input: Some(target_hit), + scrollbar: None, + }; + } + let frame_to_child = LayoutTransform2D::translation(-content_box.x, -content_box.y) + .concatenate(layout_hit.viewport_to_local); + let child_hit = input_surface_hit_test_in_tree( + runtime, + child_tree, + frame_to_child.map_point(point), + frame_to_child.concatenate(root_to_frame), + ignore_pointer_events_none, + include_scrollbars, + depth + 1, + ); + if child_hit.input.is_some() || child_hit.scrollbar.is_some() { + child_hit + } else { + InputSurfaceHit { + input: Some(target_hit), + scrollbar: None, + } + } +} + +fn live_hit_in_tree( + runtime: &JsContextHost, + tree: &FrozenLayoutTree, + point: LayoutPoint, + ignore_pointer_events_none: bool, +) -> Option<(LayoutHit, DomHandle)> { + let first_hit = tree.hit_test(point, ignore_pointer_events_none); + let live_first_hit = first_hit.and_then(|hit| { + let target = element_for_hit_source(runtime, hit.source)?; + runtime + .dom_host() + .is_connected(target) + .then_some((hit, target)) + }); + if live_first_hit.is_some() || first_hit.is_none() { + return live_first_hit; + } + tree.hit_test_all(point, ignore_pointer_events_none) + .into_iter() + .find_map(|hit| { + let target = element_for_hit_source(runtime, hit.source)?; + runtime + .dom_host() + .is_connected(target) + .then_some((hit, target)) + }) } fn live_hit_in_frame( diff --git a/moli-renderer-v8/src/script_vm/input_dispatch.rs b/moli-renderer-v8/src/script_vm/input_dispatch.rs index 993647df62..5dee7d78d2 100644 --- a/moli-renderer-v8/src/script_vm/input_dispatch.rs +++ b/moli-renderer-v8/src/script_vm/input_dispatch.rs @@ -22,10 +22,11 @@ use crate::native_bridge::element::{ construct_pointer_event_with_related_target_and_modifiers, construct_simple_event, construct_touch_event, construct_touch_event_with_points, construct_wheel_event, contenteditable_editing_host, dispatch_public_event, observable_input_hit_test, - observable_scrollbar_hit_test, perform_drop_default_action, perform_mouse_focus_default_action, - perform_scrollbar_scroll_default_action, perform_wheel_scroll_default_action, - replace_contenteditable_selection, replace_text_control_selection, - select_contenteditable_contents, text_control_set_selection_range_internal, + observable_input_surface_hit_test, perform_drop_default_action, + perform_mouse_focus_default_action, perform_scrollbar_scroll_default_action, + perform_wheel_scroll_default_action, replace_contenteditable_selection, + replace_text_control_selection, select_contenteditable_contents, + text_control_set_selection_range_internal, text_control_set_selection_range_with_direction_internal, text_control_value, update_focus, }; use crate::runtime::{ @@ -475,17 +476,23 @@ impl ScriptVm { let buttons = buttons.unwrap_or(self.pressed_mouse_buttons); if let Some(outcome) = - self.dispatch_native_scrollbar_mouse_event(x, y, event_name, button, buttons)? + self.dispatch_active_scrollbar_mouse_event(x, y, event_name, button, buttons)? { return Ok(outcome); } let root_point = moli_layout::LayoutPoint::new(x as f32, y as f32); - let hit = observable_input_hit_test( + let surface_hit = observable_input_surface_hit_test( &self._context_host.borrow(), self.document_runtime.document_handle(), root_point, + false, + matches!(event_name, "mousedown" | "mouseup" | "mousemove"), )?; + if let Some(scrollbar) = surface_hit.scrollbar { + return self.dispatch_native_scrollbar_hit(scrollbar, event_name, button); + } + let hit = surface_hit.input; let hit_handle = hit.map(|hit| hit.handle); let pointer_event_name = pointer_event_name_for_mouse_event(event_name); let mut pending_pointer_capture_events = Vec::new(); @@ -973,7 +980,7 @@ impl ScriptVm { result } - fn dispatch_native_scrollbar_mouse_event( + fn dispatch_active_scrollbar_mouse_event( &mut self, x: f64, y: f64, @@ -981,54 +988,51 @@ impl ScriptVm { button: i32, buttons: i32, ) -> Result> { - if let Some(drag) = self.active_scrollbar_drag { - match event_name { - "mousemove" if buttons & 1 != 0 => { - let local = drag - .viewport_to_local - .map_point(moli_layout::LayoutPoint::new(x as f32, y as f32)); - let coordinate = match drag.scrollbar.axis { - moli_layout::LayoutScrollbarAxis::Horizontal => local.x, - moli_layout::LayoutScrollbarAxis::Vertical => local.y, - }; - let target = (drag.scrollbar.current_offset - + (coordinate - drag.pointer_origin) * drag.scrollbar.drag_ratio()) - .clamp(drag.scrollbar.minimum_offset, drag.scrollbar.maximum_offset); - self.with_default_context_scope(|scope, runtime_ptr| { - perform_scrollbar_scroll_default_action( - scope, - runtime_ptr, - drag.source, - drag.scrollbar.axis, - f64::from(target), - ); - Ok(()) - })?; - return Ok(Some(input_dispatch_outcome(true))); - } - "mouseup" if button == 0 => { - self.active_scrollbar_drag = None; - return Ok(Some(input_dispatch_outcome(true))); - } - "mousemove" => { - self.active_scrollbar_drag = None; - } - _ => return Ok(Some(input_dispatch_outcome(true))), - } - } - - if !matches!(event_name, "mousedown" | "mouseup" | "mousemove") { - return Ok(None); - } - let document = self.document_runtime.document_handle(); - let Some(hit) = observable_scrollbar_hit_test( - &self._context_host.borrow(), - document, - moli_layout::LayoutPoint::new(x as f32, y as f32), - )? - else { + let Some(drag) = self.active_scrollbar_drag else { return Ok(None); }; + match event_name { + "mousemove" if buttons & 1 != 0 => { + let local = drag + .viewport_to_local + .map_point(moli_layout::LayoutPoint::new(x as f32, y as f32)); + let coordinate = match drag.scrollbar.axis { + moli_layout::LayoutScrollbarAxis::Horizontal => local.x, + moli_layout::LayoutScrollbarAxis::Vertical => local.y, + }; + let target = (drag.scrollbar.current_offset + + (coordinate - drag.pointer_origin) * drag.scrollbar.drag_ratio()) + .clamp(drag.scrollbar.minimum_offset, drag.scrollbar.maximum_offset); + self.with_default_context_scope(|scope, runtime_ptr| { + perform_scrollbar_scroll_default_action( + scope, + runtime_ptr, + drag.source, + drag.scrollbar.axis, + f64::from(target), + ); + Ok(()) + })?; + Ok(Some(input_dispatch_outcome(true))) + } + "mouseup" if button == 0 => { + self.active_scrollbar_drag = None; + Ok(Some(input_dispatch_outcome(true))) + } + "mousemove" => { + self.active_scrollbar_drag = None; + Ok(None) + } + _ => Ok(Some(input_dispatch_outcome(true))), + } + } + + fn dispatch_native_scrollbar_hit( + &mut self, + hit: moli_layout::LayoutScrollbarHit, + event_name: &str, + button: i32, + ) -> Result { if event_name == "mousedown" && button == 0 { match hit.part { moli_layout::LayoutScrollbarPart::Thumb => { @@ -1068,7 +1072,7 @@ impl ScriptVm { } } } - Ok(Some(input_dispatch_outcome(true))) + Ok(input_dispatch_outcome(true)) } pub(crate) fn dispatch_touch_event_at_point( 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 0a823952fc..516fc890c9 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 @@ -587,6 +587,167 @@ fn transformed_constrained_iframe_routes_hover_click_and_wheel_in_child_coordina ); } +#[test] +fn iframe_input_reuses_one_top_level_snapshot_without_parent_child_ping_pong() { + let mut vm = new_storage_test_vm("https://iframe-input-snapshot.test/"); + 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: 800, + screen_height: 600, + screen_avail_width: 800, + screen_avail_height: 600, + })) + .expect("iframe input snapshot viewport should update"); + vm.eval( + r#" + if (!document.documentElement) { + document.appendChild(document.createElement('html')); + } + if (!document.body) { + document.documentElement.appendChild(document.createElement('body')); + } + document.documentElement.style.cssText = 'margin:0;padding:0'; + document.body.style.cssText = 'margin:0;padding:0'; + const frame = document.createElement('iframe'); + frame.id = 'snapshot-frame'; + frame.style.cssText = 'position:absolute;left:100px;top:80px;width:240px;height:180px;border:0;padding:0'; + document.body.appendChild(frame); + const child = frame.contentDocument; + child.documentElement.style.cssText = 'margin:0;padding:0'; + child.body.style.cssText = 'margin:0;padding:0'; + child.body.innerHTML = ''; + 'installed' + "#, + ) + .expect("iframe input snapshot fixture should initialize"); + + assert_eq!( + vm.eval("document.body.offsetWidth > 0") + .expect("parent geometry should evaluate"), + "true" + ); + let passes_before = vm.layout_pass_observability_for_test().1; + vm.dispatch_mouse_event_at_point(140.0, 130.0, "mousemove", -1, Some(0), 0.0, 0.0) + .expect("cold child hover should dispatch"); + let passes_after_cold = vm.layout_pass_observability_for_test().1; + assert_eq!( + passes_after_cold, + passes_before + 1, + "input must upgrade a geometry-only tree through one shared recursive layout demand" + ); + + // The first hover transition invalidates its sampled tree because :hover + // may affect geometry. Rebuilding for the same target publishes one complete + // top-level snapshot; a third move can then reuse it without a child publish + // evicting the parent. + vm.dispatch_mouse_event_at_point(140.0, 130.0, "mousemove", -1, Some(0), 0.0, 0.0) + .expect("stable child hover should rebuild once"); + let passes_after_rebuild = vm.layout_pass_observability_for_test().1; + assert_eq!(passes_after_rebuild, passes_after_cold + 1); + vm.dispatch_mouse_event_at_point(140.0, 130.0, "mousemove", -1, Some(0), 0.0, 0.0) + .expect("stable child hover should reuse the top-level snapshot"); + assert_eq!( + vm.layout_pass_observability_for_test().1, + passes_after_rebuild, + "an embedded hit must leave the top-level composite snapshot reusable" + ); + + let cached = vm + .layout_snapshot_cache_observability_for_test() + .3 + .expect("stable child input should retain one snapshot"); + assert_eq!(cached.0, vm.document_runtime.document_handle()); +} + +#[test] +fn iframe_wheel_batch_reuses_one_composite_snapshot_for_every_scroll_step() { + let mut vm = new_storage_test_vm("https://iframe-wheel-snapshot.test/"); + 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: 800, + screen_height: 600, + screen_avail_width: 800, + screen_avail_height: 600, + })) + .expect("iframe wheel snapshot viewport should update"); + vm.eval( + r#" + if (!document.documentElement) { + document.appendChild(document.createElement('html')); + } + if (!document.body) { + document.documentElement.appendChild(document.createElement('body')); + } + document.documentElement.style.cssText = 'margin:0;padding:0'; + document.body.style.cssText = 'margin:0;padding:0'; + const frame = document.createElement('iframe'); + frame.id = 'wheel-frame'; + frame.style.cssText = 'position:absolute;left:100px;top:80px;width:240px;height:180px;border:0;padding:0'; + document.body.appendChild(frame); + const child = frame.contentDocument; + child.documentElement.style.cssText = 'margin:0;padding:0'; + child.body.style.cssText = 'margin:0;padding:0'; + child.body.innerHTML = ` +
+
+
`; + child.defaultView.__wheelGeometryReads = 0; + child.getElementById('wheel-scroller').addEventListener('wheel', event => { + void event.currentTarget.offsetWidth; + child.defaultView.__wheelGeometryReads++; + }); + 'installed' + "#, + ) + .expect("iframe wheel snapshot fixture should initialize"); + + let passes_before = vm.layout_pass_observability_for_test().1; + vm.begin_batched_mouse_event_dispatch(); + for delta_y in [10.0, 20.0, 30.0] { + vm.dispatch_mouse_event_at_point_with_pointer_and_modifiers_without_checkpoint( + 130.0, + 110.0, + "wheel", + -1, + Some(0), + 0, + 0.0, + delta_y, + crate::runtime::RendererPointerEventProperties::default(), + 0, + ) + .expect("batched child wheel step should dispatch"); + } + assert_eq!( + vm.layout_pass_observability_for_test().1, + passes_before + 1, + "every wheel step must share the hit-test pass's recursively frozen snapshot" + ); + vm.finish_batched_mouse_event_dispatch(Ok(()), true) + .expect("batched child wheel effects should commit"); + assert_eq!( + vm.layout_pass_observability_for_test().1, + passes_before + 1, + "committing derived effects must not perform another layout" + ); + + assert_eq!( + vm.eval( + "(() => { const frame = document.getElementById('wheel-frame'); return [frame.contentDocument.getElementById('wheel-scroller').scrollTop, frame.contentWindow.__wheelGeometryReads].join('|'); })()" + ) + .expect("child scroll position should evaluate"), + "60|3" + ); +} + #[test] fn focusing_visible_child_target_does_not_scroll_partially_hidden_transformed_iframe() { let mut vm = new_storage_test_vm("https://iframe-focus-scroll.test/");