Reuse composite layout snapshots for iframe input

This commit is contained in:
ldm0
2026-08-22 19:44:54 +08:00
committed by Donough Liu
parent e3d1accf53
commit 03a80259eb
14 changed files with 655 additions and 146 deletions
@@ -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<N>, Option<PaintSnapshot>) {
(self.tree, self.paint_snapshot)
}
pub fn retention_metrics(&self) -> LayoutTreeRetentionMetrics {
self.tree.retention_metrics()
}
+78 -4
View File
@@ -27,6 +27,18 @@ pub struct FrozenLayoutBox<N> {
pub hit_source: Option<N>,
}
/// 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<N>
where
N: Copy + Debug + Eq + Hash,
{
pub source: N,
pub tree: Box<FrozenLayoutTree<N>>,
}
impl<N> Deref for FrozenLayoutBox<N> {
type Target = LayoutBoxGeometry;
@@ -65,6 +77,7 @@ pub struct FrozenLayoutTree<N>
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<LayoutClipNode>,
embedded_frames: Vec<FrozenEmbeddedFrame<N>>,
embedded_frames_complete: bool,
}
impl<N> FrozenLayoutTree<N>
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<N>> {
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<T>(capacity: usize) -> usize {
capacity.saturating_mul(std::mem::size_of::<T>())
@@ -92,18 +124,35 @@ where
layout_box.fragments.capacity(),
))
});
let estimated_geometry_bytes = std::mem::size_of::<Self>()
let own_estimated_geometry_bytes = std::mem::size_of::<Self>()
.saturating_add(allocation::<FrozenLayoutBox<N>>(self.boxes.capacity()))
.saturating_add(allocation::<LayoutFragment>(self.fragments.capacity()))
.saturating_add(allocation::<(N, LayoutOutputBoxId)>(
self.scroll_proxy_links.capacity(),
))
.saturating_add(allocation::<LayoutClipNode>(self.clip_chain.capacity()))
.saturating_add(allocation::<FrozenEmbeddedFrame<N>>(
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<N>> {
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<Item = &FrozenEmbeddedFrame<N>> {
self.embedded_frames.iter()
}
}
impl<N> FrozenLayoutTree<N>
@@ -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<LayoutClipNode>,
embedded_frames: Vec<FrozenEmbeddedFrame<N>>,
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,
}
}
}
+12 -11
View File
@@ -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,
+50 -14
View File
@@ -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<N>
where
N: Copy + std::fmt::Debug + Eq + std::hash::Hash,
{
pub tree: FrozenLayoutTree<N>,
pub paint: Option<PaintSnapshot>,
}
impl<N> EmbeddedFrameSnapshot<N>
where
N: Copy + std::fmt::Debug + Eq + std::hash::Hash,
{
#[must_use]
pub const fn new(tree: FrozenLayoutTree<N>, paint: Option<PaintSnapshot>) -> 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<N> {
/// 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<N>
where
N: Copy + std::fmt::Debug + Eq + std::hash::Hash,
{
fn render_embedded_frame(
&mut self,
frame: N,
viewport: LayoutViewport,
) -> Result<Option<PaintSnapshot>, LayoutError>;
) -> Result<Option<EmbeddedFrameSnapshot<N>>, LayoutError>;
}
struct NoEmbeddedFrames;
impl<N> EmbeddedFrameRenderer<N> for NoEmbeddedFrames {
impl<N> EmbeddedFrameRenderer<N> for NoEmbeddedFrames
where
N: Copy + std::fmt::Debug + Eq + std::hash::Hash,
{
fn render_embedded_frame(
&mut self,
_frame: N,
_viewport: LayoutViewport,
) -> Result<Option<PaintSnapshot>, LayoutError> {
) -> Result<Option<EmbeddedFrameSnapshot<N>>, 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<S, R, F>(
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(),
)
}
+31 -4
View File
@@ -17,11 +17,13 @@ use crate::{
pub(crate) fn finish_layout_pass<N>(
world: &LayoutWorld<N>,
source_root: N,
viewport: LayoutViewport,
reason: LayoutFlushReason,
started: Instant,
paint_capture: Option<PaintCaptureRequest>,
embedded_frames: &mut HashMap<LayoutBoxId, crate::PaintSnapshot>,
mut embedded_frames: HashMap<LayoutBoxId, (N, crate::EmbeddedFrameSnapshot<N>)>,
embedded_frames_complete: bool,
) -> Result<LayoutPassResult<N>, 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::<HashMap<_, _>>();
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<N> {
fn into_frozen_tree(
self,
source_root: N,
content_size: LayoutSize,
embedded_frames: Vec<crate::FrozenEmbeddedFrame<N>>,
embedded_frames_complete: bool,
) -> FrozenLayoutTree<N> {
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,
)
}
}
+15 -6
View File
@@ -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<DomHandle>,
embedded_document_services: &'a mut HashMap<DomHandle, DocumentLayoutServices>,
@@ -171,7 +173,8 @@ impl EmbeddedFrameRenderer<DomHandle> for NativeEmbeddedFrameRenderer<'_> {
&mut self,
frame: DomHandle,
viewport: LayoutViewport,
) -> Result<Option<PaintSnapshot>, moli_layout::LayoutError> {
) -> Result<Option<moli_layout::EmbeddedFrameSnapshot<DomHandle>>, 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<DomHandle> 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)))
}
}
@@ -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<DomHandle>) -> T,
) -> Option<T> {
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<DomHandle>,
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;
@@ -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<LatestFrozenLayout>,
@@ -25,6 +26,10 @@ impl LatestLayoutTreeCache {
.map(|snapshot| snapshot.tree.as_ref())
}
pub(super) fn get_for_root(&self, root: DomHandle) -> Option<&FrozenLayoutTree<DomHandle>> {
self.latest.as_ref()?.tree.tree_for_root(root)
}
pub(super) fn publish(&mut self, document: DomHandle, tree: FrozenLayoutTree<DomHandle>) {
self.latest = Some(LatestFrozenLayout {
document,
@@ -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<DomHandle, DocumentLayoutServices>,
@@ -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<DomHandle, LayoutViewport>,
}
@@ -94,6 +95,13 @@ impl DocumentLayoutState {
self.latest_layout.get(document)
}
pub(super) fn latest_layout_for_root(
&self,
root: DomHandle,
) -> Option<&FrozenLayoutTree<DomHandle>> {
self.latest_layout.get_for_root(root)
}
pub(super) fn publish_latest_layout(
&mut self,
document: DomHandle,
@@ -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,
};
@@ -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,
@@ -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<InputHit>,
pub(crate) scrollbar: Option<LayoutScrollbarHit<DomHandle>>,
}
#[derive(Clone, Copy)]
struct FrameHitTest {
document: DomHandle,
@@ -70,18 +76,49 @@ pub(crate) fn observable_input_hit_test(
document: DomHandle,
point: LayoutPoint,
) -> Result<Option<InputHit>, 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<Option<LayoutScrollbarHit<DomHandle>>, 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<InputSurfaceHit, LayoutError> {
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<Option<DomHandle>, 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<DomHandle>,
point: LayoutPoint,
root_to_frame: LayoutTransform2D,
ignore_pointer_events_none: bool,
include_scrollbars: bool,
depth: usize,
) -> Result<Option<LayoutScrollbarHit<DomHandle>>, 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<DomHandle>,
point: LayoutPoint,
ignore_pointer_events_none: bool,
) -> Option<(LayoutHit<DomHandle>, 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(
@@ -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<Option<RendererInputDispatchOutcome>> {
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<DomHandle>,
event_name: &str,
button: i32,
) -> Result<RendererInputDispatchOutcome> {
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(
@@ -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 = '<button id="snapshot-target" style="position:fixed;left:20px;top:20px;width:80px;height:60px">target</button>';
'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 = `
<div id="wheel-scroller" style="position:absolute;left:10px;top:10px;width:160px;height:100px;overflow:auto">
<div id="wheel-content" style="width:140px;height:500px"></div>
</div>`;
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/");