fix(layout): adapt WPT input to explicit published snapshots

This commit is contained in:
ldm0
2026-10-11 18:06:42 +08:00
parent b211359191
commit 0eec59dc38
20 changed files with 142 additions and 48 deletions
+24
View File
@@ -117,6 +117,30 @@ impl<N> FrozenLayoutTree<N>
where
N: Copy + Debug + Eq + Hash,
{
/// Retains a published viewport for an embedded Document without a root
/// element. The document identity is its source, and no box can receive
/// input or provide element geometry until a later layout is published.
pub fn empty_viewport(source_root: N, viewport: LayoutViewport) -> Self {
Self::new(
source_root,
viewport,
LayoutPoint::ZERO,
LayoutSize::new(viewport.css_width as f32, viewport.css_height as f32),
LayoutOutputBoxId::from_index(0),
Vec::new(),
Vec::new(),
Vec::new(),
FrozenCoordinateSpace {
owner: None,
local_to_viewport: super::model::LayoutTransform2D::IDENTITY,
local_to_viewport_ignoring_css_transforms:
super::model::LayoutTransform2D::IDENTITY,
},
Vec::new(),
Vec::new(),
)
}
/// Source root whose complete layout projection this member tree owns.
pub const fn source_root(&self) -> N {
self.source_root
@@ -14,6 +14,7 @@ async fn activate(
))
.await;
if matches!(mode, "trusted" | "location" | "nested-click") {
page.command("Page.captureScreenshot", json!({})).await;
for (kind, buttons) in [("mousePressed", 1), ("mouseReleased", 0)] {
page.command("Input.dispatchMouseEvent", json!({
"type": kind, "x": 20, "y": 20, "button": "left", "buttons": buttons, "clickCount": 1,
@@ -162,6 +162,7 @@ async fn navigation_focus_reset_respects_document_policy_and_manual_focus() {
.await;
// Establish the parent's focus with real input even though its
// policy also denies script-initiated focus without activation.
page.command("Page.captureScreenshot", json!({})).await;
for (kind, buttons) in [("mousePressed", 1), ("mouseReleased", 0)] {
page.command("Input.dispatchMouseEvent", json!({
"type": kind, "x": 310, "y": 10, "button": "left", "buttons": buttons, "clickCount": 1,
@@ -201,6 +202,7 @@ async fn navigation_focus_reset_preserves_trusted_initiation_after_activation_ex
"{PROBE}\nsetupNavigationFocusProbe(true, \"'none'\")"
))
.await;
page.command("Page.captureScreenshot", json!({})).await;
for (kind, buttons) in [("mousePressed", 1), ("mouseReleased", 0)] {
page.command("Input.dispatchMouseEvent", json!({
"type": kind, "x": 20, "y": 20, "button": "left", "buttons": buttons, "clickCount": 1,
@@ -2,8 +2,7 @@ use super::ordering::point_order_handles;
use super::*;
use crate::native_bridge::document::XHTML_NS;
use crate::native_bridge::element::{
StyleMode, observable_sources_with_fragments,
style_property_value,
StyleMode, observable_sources_with_fragments, style_property_value,
};
use crate::util::string_from_utf16_units_lossy;
use std::{cmp::Ordering, collections::HashSet};
+7 -1
View File
@@ -108,7 +108,13 @@ impl EmbeddedFrameRenderer<DomHandle> for NativeEmbeddedFrameRenderer<'_> {
.dom()
.document_element_handle_for_document(document)
else {
return Ok(None);
return Ok(Some(moli_layout::EmbeddedFrameSnapshot::new(
moli_layout::FrozenLayoutTree::empty_viewport(document, viewport),
self.capture_paint.then(|| {
moli_layout::PaintSnapshot::new(viewport, moli_layout::PaintColor::TRANSPARENT)
}),
Vec::new(),
)));
};
let mut services = self
.embedded_document_services
@@ -477,12 +477,13 @@ impl JsContextHost {
let root = self
.dom_host()
.dom()
.document_element_handle_for_document(document);
.document_element_handle_for_document(document)
.unwrap_or(document);
let state = self.document_layout_state.borrow();
state
.latest_layout(document)
.or_else(|| root.and_then(|root| state.latest_layout_for_root(root)))
.filter(|tree| Some(tree.source_root()) == root)
.or_else(|| state.latest_layout_for_root(root))
.filter(|tree| tree.source_root() == root)
.map(inspect)
}
@@ -558,7 +558,6 @@ fn element_at_point(
document,
moli_layout::LayoutPoint::new(x as f32, y as f32),
false,
moli_layout::LayoutFlushReason::HitTest,
)?;
if let Some(element) = hits.into_iter().find_map(|hit| {
let element = element_for_hit_source(runtime, hit.source)?;
@@ -24,13 +24,11 @@ use super::super::forms::{
use super::super::{
NodePublicEventDispatchOutcome, TextEditInputType, cache_input_files_from_selected_files,
closed_details_ancestors_to_reveal, construct_click_event_with_detail_and_modifiers,
construct_command_event,
construct_input_event, construct_simple_event, contenteditable_editing_host,
dispatch_beforeinput, dispatch_popover_hide_events, dispatch_popover_show_events,
dispatch_popover_toggle_events,
dispatch_public_event, element_attribute, element_has_attribute, form_associated_form_owner,
is_disabled_form_control, is_focusable, is_valid_submit_button,
label_activation_control_handle,
construct_command_event, construct_input_event, construct_simple_event,
contenteditable_editing_host, dispatch_beforeinput, dispatch_popover_hide_events,
dispatch_popover_show_events, dispatch_popover_toggle_events, dispatch_public_event,
element_attribute, element_has_attribute, form_associated_form_owner, is_disabled_form_control,
is_focusable, is_valid_submit_button, label_activation_control_handle,
perform_popover_invoker_default_action, perform_summary_click_default_action,
replace_text_control_selection, resolve_url_like_attribute,
resolved_reflected_element_attribute_handle, scroll_node_into_view_at_start,
@@ -13,7 +13,7 @@ use super::super::{
JsContextHost, document::set_detached_text_replacement_value,
node::node_runtime_and_handle_from_object_or_detached, throw_dom_exception,
};
use super::geometry::observable_client_rects;
use super::geometry::read_client_rects;
use super::{
html_element_getter_receiver, html_element_setter_receiver, observable_sources_with_fragments,
property_string_value,
@@ -832,12 +832,7 @@ fn node_inner_text(
&& rendered_state
.target_style()
.is_some_and(|style| style.display != ComputedDisplayKind::Contents)
&& observable_client_rects(
runtime,
handle,
moli_layout::LayoutFlushReason::SynchronousGeometry,
)?
.is_empty()
&& read_client_rects(runtime, handle)?.is_empty()
{
return Ok(node.text_content(runtime.dom_host().dom()));
}
@@ -8,8 +8,7 @@ pub(crate) use events::{
dispatch_text_control_event, queue_text_control_document_selection_change_event,
};
pub(crate) use selection::{
replace_text_control_selection,
text_control_set_selection_range_internal,
replace_text_control_selection, text_control_set_selection_range_internal,
text_control_set_selection_range_with_direction_internal,
};
pub(in crate::native_bridge) use selection::{
@@ -11,8 +11,8 @@ pub use client_rect::ClientRect;
#[cfg(test)]
pub(crate) use hit_test::observable_scrollbar_hit_test;
pub(crate) use hit_test::{
element_is_inert_for_hit_testing, InputSurfaceHit, input_surface_hit_test, observable_deep_hit_test, observable_hit_test,
observable_input_hit_test,
InputSurfaceHit, element_is_inert_for_hit_testing, input_surface_hit_test,
observable_deep_hit_test, observable_hit_test, observable_input_hit_test,
};
pub(in crate::native_bridge) use metrics::{
node_client_height_getter_function, node_client_left_getter_function,
@@ -1,6 +1,7 @@
use moli_layout::{
FrozenLayoutTree, LayoutControlSurfaceHit, LayoutError, LayoutHit, LayoutPaintedSurfaceHit,
LayoutPoint, LayoutQuery, LayoutQueryAnswer, LayoutQueryBatch, LayoutTransform2D, LayoutViewport,
LayoutPoint, LayoutQuery, LayoutQueryAnswer, LayoutQueryBatch, LayoutTransform2D,
LayoutViewport,
};
#[cfg(test)]
@@ -114,11 +115,9 @@ pub(crate) fn observable_scrollbar_hit_test(
document: DomHandle,
point: LayoutPoint,
) -> Result<Option<LayoutScrollbarHit<DomHandle>>, LayoutError> {
input_surface_hit_test(runtime, document, point, false, true).map(|hit| {
match hit.control {
Some(LayoutControlSurfaceHit::Scrollbar(scrollbar)) => Some(scrollbar),
Some(LayoutControlSurfaceHit::ScrollbarCorner(_)) | None => None,
}
input_surface_hit_test(runtime, document, point, false, true).map(|hit| match hit.control {
Some(LayoutControlSurfaceHit::Scrollbar(scrollbar)) => Some(scrollbar),
Some(LayoutControlSurfaceHit::ScrollbarCorner(_)) | None => None,
})
}
@@ -165,13 +164,7 @@ pub(crate) fn observable_deep_hit_test(
point: LayoutPoint,
ignore_pointer_events_none: bool,
) -> Result<Option<DomHandle>, LayoutError> {
let hit = input_surface_hit_test(
runtime,
document,
point,
ignore_pointer_events_none,
false,
)?;
let hit = input_surface_hit_test(runtime, document, point, ignore_pointer_events_none, false)?;
Ok(hit.document_or_element().map(|input| input.handle))
}
@@ -322,7 +315,8 @@ fn input_surface_hit_test_in_tree(
.dom_host()
.dom()
.document_element_handle_for_document(child_document)
!= Some(child_tree.source_root())
.unwrap_or(child_document)
!= child_tree.source_root()
{
return Err(LayoutError::NoLayoutSnapshot);
}
@@ -25,9 +25,9 @@ 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, input_surface_hit_test, is_text_control,
observable_input_hit_test, perform_auxiliary_link_default_action, perform_drop_default_action,
observable_input_hit_test, perform_auxiliary_link_default_action,
perform_clipboard_key_default_action, perform_drop_default_action,
perform_implicit_submission_from_control, perform_mouse_focus_default_action,
perform_clipboard_key_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,
@@ -18,6 +18,7 @@ navigator.clipboard.writeText('seed');'ready'
}
fn activated_copy_eval(vm: &mut StandaloneScriptVmHarness, script: &str) -> String {
vm.publish_layout_for_test().unwrap();
vm._context_host
.borrow_mut()
.begin_protocol_user_gesture_activation();
@@ -338,6 +338,7 @@ fn close_watcher_mouse_and_touch_activate_at_their_respective_input_phases() {
setup_close_watcher_input_document(&mut vm);
vm.eval("record('first').oncancel = e => e.preventDefault()")
.unwrap();
vm.publish_layout_for_test().unwrap();
if touch {
vm.dispatch_touch_event_at_point(10.0, 11.0, "touchstart", false)
.unwrap();
@@ -1425,6 +1425,7 @@ fn single_line_text_input_preserves_programmatic_scroll_across_select() {
)
.expect("the previous text input should be replaced");
refresh_layout_for_test(&mut vm);
vm.eval("__scrollInput.scrollLeft = 33")
.expect("text input should accept a programmatic scroll");
refresh_layout_for_test(&mut vm);
@@ -15,7 +15,6 @@ fn viewport_input_vm() -> StandaloneScriptVmHarness {
..Default::default()
}))
.unwrap();
vm.force_fresh_layout_reads_for_test();
vm.eval(
r#"
if (!document.documentElement) document.appendChild(document.createElement('html'));
@@ -37,6 +36,7 @@ fn viewport_input_vm() -> StandaloneScriptVmHarness {
}
fn click_viewport(vm: &mut StandaloneScriptVmHarness, x: f64, y: f64) {
vm.publish_layout_for_test().unwrap();
for (event, button, buttons) in [("mousemove", -1, 0), ("mousedown", 0, 1), ("mouseup", 0, 0)] {
vm.dispatch_mouse_event_at_point(x, y, event, button, Some(buttons), 0.0, 0.0)
.unwrap();
@@ -240,6 +240,7 @@ fn viewport_root_input_wheel_keeps_child_target_and_chains_at_boundaries() {
)
.unwrap();
vm.eval(setup).unwrap();
vm.publish_layout_for_test().unwrap();
vm.dispatch_mouse_event_at_point(80.0, 80.0, "wheel", -1, Some(0), 0.0, 120.0)
.unwrap();
let actual = vm
@@ -256,3 +257,46 @@ fn viewport_root_input_wheel_keeps_child_target_and_chains_at_boundaries() {
);
}
}
#[test]
fn viewport_root_input_requires_republication_when_an_empty_child_gains_a_root() {
let mut vm = viewport_input_vm();
vm.eval(
r#"
globalThis.frame = document.createElement('iframe');
frame.style.cssText = 'position:fixed;left:40px;top:40px;width:200px;height:50px;border:4px solid black';
document.body.appendChild(frame);
frame.contentDocument.documentElement.remove();
recordViewportClicks(frame.contentWindow, 'child');
"#,
)
.unwrap();
click_viewport(&mut vm, 80.0, 80.0);
assert_eq!(vm.eval("viewportClicks.length").unwrap(), "0");
vm.eval(
r#"
const child = frame.contentDocument;
const root = child.createElement('html');
root.style.cssText = 'margin:0;padding:0;height:0';
child.appendChild(root);
"#,
)
.unwrap();
let before = vm.layout_pass_observability_for_test().1;
let error = vm
.dispatch_mouse_event_at_point(80.0, 80.0, "mousedown", 0, Some(1), 0.0, 0.0)
.unwrap_err();
assert_eq!(
error.downcast_ref::<moli_layout::LayoutError>(),
Some(&moli_layout::LayoutError::NoLayoutSnapshot)
);
assert_eq!(vm.layout_pass_observability_for_test().1, before);
assert_eq!(vm.pressed_mouse_buttons, 0);
assert!(vm.pending_mouse_press.is_none());
click_viewport(&mut vm, 80.0, 80.0);
assert_eq!(
vm.eval("JSON.stringify(viewportClicks)").unwrap(),
r#"[["child","HTML",36,36,true]]"#
);
}
@@ -618,6 +618,7 @@ html, body { margin: 0; }
<div id="under"></div><div id="container"><div id="target"></div></div>"#,
);
vm.publish_layout_for_test().unwrap();
let result = vm
.eval(
r#"
@@ -635,13 +636,32 @@ html, body { margin: 0; }
container.inert = false;
values.push(top() === target);
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
return values.join('|');
})()
"#,
)
.expect("dynamic inert point-query probe should evaluate");
assert_eq!(result, "true|under|under,html|true");
vm.eval(
r#"
globalThis.svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
svg.setAttribute('style', 'position:fixed;inset:0;width:100px;height:100px');
const rect = document.createElementNS(svg.namespaceURI, 'rect');
rect.setAttribute('width', '100');
rect.setAttribute('height', '100');
svg.appendChild(rect);
document.body.appendChild(svg);
"#,
)
.unwrap();
vm.publish_layout_for_test().unwrap();
let result = vm
.eval(
r#"(() => {
const top = () => document.elementFromPoint(1, 1);
const target = document.getElementById('target');
const values = [];
const svgHit = top();
svg.setAttribute('inert', '');
values.push(top() === svgHit);
@@ -657,5 +677,5 @@ html, body { margin: 0; }
)
.expect("dynamic inert point-query probe should evaluate");
assert_eq!(result, "true|under|under,html|true|true|true");
assert_eq!(result, "true|true");
}
@@ -1955,6 +1955,8 @@ fn dom_rect_list_has_readonly_indexed_semantics_and_rejects_structured_clone() {
"<!doctype html><body><div id='node'>text</div></body>",
);
vm.publish_layout_for_test().unwrap();
let result = vm
.eval(
r#"
@@ -19,7 +19,7 @@ fn native_element_click_initializes_layout_once() {
}
#[test]
fn native_element_click_refreshes_stale_layout_after_dom_and_style_changes() {
fn native_element_click_reads_explicitly_republished_layout_after_dom_and_style_changes() {
for mutation in [
"target.style.left='300px'",
"const veil=document.createElement('div');veil.style.cssText='position:fixed;inset:0;z-index:999';document.body.appendChild(veil)",
@@ -55,11 +55,18 @@ fn native_element_click_refreshes_stale_layout_after_dom_and_style_changes() {
"(()=>{{const target=document.getElementById('target');{mutation}}})()"
))
.expect("mutate live DOM/style before preparing another click");
let _ = vm.prepare_element_click(target);
assert_eq!(
vm.layout_pass_observability_for_test().1,
before + 1,
"click preparation must not publish changed DOM or style"
);
publish_layout_for_test(&mut vm);
let prepared = vm.prepare_element_click(target);
let prepared_passes = vm.layout_pass_observability_for_test().1;
assert!(
prepared_passes > before + 1,
"a changed DOM or style must refresh the shared geometry snapshot: {mutation}"
"explicit publication must refresh the shared geometry snapshot: {mutation}"
);
if mutation != "target.style.left='300px'" {
assert!(
@@ -107,8 +114,8 @@ fn native_element_click_refreshes_stale_layout_after_dom_and_style_changes() {
}
let passes = vm.layout_pass_observability_for_test().1 - before;
assert_eq!(
passes, 1,
"the entire mouse sequence must reuse one snapshot: {mutation}"
passes, 2,
"the entire mouse sequence must reuse the republished snapshot: {mutation}"
);
let events = vm.eval("JSON.stringify([clicks,events])").unwrap();
if let Some((existing_passes, existing_events)) = &reference {