From 2a6d1ef1768c836cd023dba55dee768296cdafdb Mon Sep 17 00:00:00 2001 From: ldm0 Date: Sun, 4 Oct 2026 09:33:01 +0800 Subject: [PATCH] fix(input): retarget compatibility mouse events after pointer mutations Capture the original pointer propagation path and resolve the mapped mouse event against surviving nodes in the same document after listener mutations. Construct mapped mouse events in the target document realm and retire drag startup normally when the document root was removed. Cover removal, shadow/slot ancestry, reparenting, detached adoption, native relation lookup, capture, and child-realm identity across 408 input scenarios. --- moli-renderer-v8/src/native_bridge/element.rs | 4 +- .../src/native_bridge/element/events.rs | 8 +- .../element/events/constructors.rs | 6 +- .../element/geometry/hit_test.rs | 9 ++ .../src/script_vm/input_dispatch.rs | 52 ++++--- .../src/script_vm/input_helpers.rs | 33 ++++- .../tests/browser_api/compat_mouse_targets.js | 133 ++++++++++++++++++ .../tests/browser_api/compat_mouse_targets.rs | 91 ++++++++++++ .../src/script_vm/tests/browser_api/mod.rs | 1 + 9 files changed, 313 insertions(+), 24 deletions(-) create mode 100644 moli-renderer-v8/src/script_vm/tests/browser_api/compat_mouse_targets.js create mode 100644 moli-renderer-v8/src/script_vm/tests/browser_api/compat_mouse_targets.rs diff --git a/moli-renderer-v8/src/native_bridge/element.rs b/moli-renderer-v8/src/native_bridge/element.rs index 2cfdc1e9f7..4e880ddae4 100644 --- a/moli-renderer-v8/src/native_bridge/element.rs +++ b/moli-renderer-v8/src/native_bridge/element.rs @@ -289,8 +289,8 @@ pub(crate) use events::{ NodePublicEventDispatchOutcome, TextEditInputType, TouchEventPoint, construct_activation_pointer_event, construct_clipboard_event, construct_command_event, construct_drag_event_with_related_target, construct_form_data_event, construct_input_event, - construct_interest_event, construct_keyboard_event, - construct_mouse_event_with_detail_and_modifiers, construct_mouse_event_with_modifiers, + construct_interest_event, construct_keyboard_event, construct_mouse_event_for_target, + construct_mouse_event_with_detail_and_modifiers, construct_mouse_event_with_related_target_and_modifiers, construct_pointer_event, construct_pointer_event_with_modifiers, construct_pointer_event_with_related_target, construct_pointer_event_with_related_target_and_modifiers, construct_simple_event, diff --git a/moli-renderer-v8/src/native_bridge/element/events.rs b/moli-renderer-v8/src/native_bridge/element/events.rs index 5c6d6c2d68..5d1e491bd2 100644 --- a/moli-renderer-v8/src/native_bridge/element/events.rs +++ b/moli-renderer-v8/src/native_bridge/element/events.rs @@ -14,10 +14,10 @@ pub(crate) use self::constructors::{ TextEditInputType, TouchEventPoint, construct_activation_pointer_event, construct_clipboard_event, construct_command_event, construct_drag_event_with_related_target, construct_form_data_event, construct_input_event, construct_interest_event, - construct_keyboard_event, construct_mouse_event_with_detail_and_modifiers, - construct_mouse_event_with_modifiers, construct_mouse_event_with_related_target_and_modifiers, - construct_pointer_event, construct_pointer_event_with_modifiers, - construct_pointer_event_with_related_target, + construct_keyboard_event, construct_mouse_event_for_target, + construct_mouse_event_with_detail_and_modifiers, + construct_mouse_event_with_related_target_and_modifiers, construct_pointer_event, + construct_pointer_event_with_modifiers, construct_pointer_event_with_related_target, construct_pointer_event_with_related_target_and_modifiers, construct_simple_event, construct_simple_event_for_target, construct_submit_event, construct_toggle_event, construct_touch_event, construct_touch_event_with_points, construct_wheel_event, diff --git a/moli-renderer-v8/src/native_bridge/element/events/constructors.rs b/moli-renderer-v8/src/native_bridge/element/events/constructors.rs index 292623266c..47b631fb8b 100644 --- a/moli-renderer-v8/src/native_bridge/element/events/constructors.rs +++ b/moli-renderer-v8/src/native_bridge/element/events/constructors.rs @@ -477,8 +477,10 @@ fn modifier_key_state(modifiers: u8) -> ModifierKeyState { } } -pub(crate) fn construct_mouse_event_with_modifiers<'s>( +pub(crate) fn construct_mouse_event_for_target<'s>( scope: &mut v8::PinScope<'s, '_>, + runtime_ptr: *mut JsContextHost, + target: DomHandle, event_type: &str, x: f64, y: f64, @@ -486,6 +488,8 @@ pub(crate) fn construct_mouse_event_with_modifiers<'s>( buttons: i32, modifiers: u8, ) -> Option> { + let context = node_owner_document_relevant_context(scope, runtime_ptr, target)?; + let scope = &mut v8::ContextScope::new(scope, context); construct_mouse_event_with_detail_and_related_target( scope, event_type, x, y, 0, button, buttons, modifiers, None, ) 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 adcdc120d3..4f48773322 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 @@ -324,6 +324,15 @@ pub(crate) fn draggable_source_at_point( point: LayoutPoint, ) -> Result, LayoutError> { let host = runtime.dom_host(); + // A pointer listener can remove the document's root before drag startup. + // There is then no rendered source to query, even if a press is pending. + if host + .dom() + .document_element_handle_for_document(document) + .is_none() + { + return Ok(None); + } let eligible = |handle| { host.is_connected(handle) && host.owner_document_handle(handle) == Some(document) diff --git a/moli-renderer-v8/src/script_vm/input_dispatch.rs b/moli-renderer-v8/src/script_vm/input_dispatch.rs index dd2e81ecc1..d416fc49ee 100644 --- a/moli-renderer-v8/src/script_vm/input_dispatch.rs +++ b/moli-renderer-v8/src/script_vm/input_dispatch.rs @@ -5,8 +5,8 @@ use std::rc::Rc; use super::input_dispatch_outcome; use super::input_helpers::{ - MouseFrameCapture, MouseReleaseFollowUp, PendingMouseDrag, PendingMousePress, - mouse_button_mask, single_changed_mouse_button, + CompatibilityMouseTarget, MouseFrameCapture, MouseReleaseFollowUp, PendingMouseDrag, + PendingMousePress, mouse_button_mask, single_changed_mouse_button, }; use super::inspector::{ current_selection_state, is_space_key, key_target_info, option_is_disabled, radio_group_members, @@ -22,10 +22,10 @@ use crate::native_bridge::element::{ activate_handle_after_pointer_release, activate_handle_via_click, activate_handle_via_click_with_detail_and_modifiers, cache_input_files_from_selected_files, captured_frame_input_surface_hit_test, construct_activation_pointer_event, - construct_keyboard_event, construct_mouse_event_with_detail_and_modifiers, - construct_mouse_event_with_modifiers, construct_mouse_event_with_related_target_and_modifiers, - construct_pointer_event, construct_pointer_event_with_modifiers, - construct_pointer_event_with_related_target, + construct_keyboard_event, construct_mouse_event_for_target, + construct_mouse_event_with_detail_and_modifiers, + construct_mouse_event_with_related_target_and_modifiers, construct_pointer_event, + construct_pointer_event_with_modifiers, construct_pointer_event_with_related_target, 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, drag_start_frame_input_surface_hit_test, @@ -1074,6 +1074,9 @@ impl ScriptVm { let _ = dispatch_public_event(scope, runtime_ptr, handle, event); } } + let compatibility_mouse_target = pointer_event_name.and_then(|_| { + CompatibilityMouseTarget::capture(unsafe { &*runtime_ptr }, pointer_dispatch_handle) + }); if let Some(pointer_event_name) = pointer_event_name && let Some(event) = construct_pointer_event_with_modifiers( scope, @@ -1119,23 +1122,40 @@ impl ScriptVm { let suppress_current_mouse_event = suppress_compat_mouse_events && can_suppress_compat_mouse_event(event_name); if !suppress_current_mouse_event { - let event = if event_name == "wheel" { - construct_wheel_event( + let mouse_dispatch_handle = if pointer_event_name.is_some() { + compatibility_mouse_target + .as_ref() + .and_then(|target| target.resolve(unsafe { &*runtime_ptr })) + } else { + Some(pointer_dispatch_handle) + }; + let event = match mouse_dispatch_handle { + Some(_) if event_name == "wheel" => construct_wheel_event( scope, event_name, client_x, client_y, delta_x, delta_y, button, buttons, modifiers, - ) - } else { - construct_mouse_event_with_modifiers( - scope, event_name, client_x, client_y, button, buttons, modifiers, - ) + ), + Some(target) => construct_mouse_event_for_target( + scope, + runtime_ptr, + target, + event_name, + client_x, + client_y, + button, + buttons, + modifiers, + ), + None => None, }; - if let Some(event) = event { + if let Some(event) = event + && let Some(mouse_dispatch_handle) = mouse_dispatch_handle + { if event_name == "mousedown" { unsafe { &mut *runtime_ptr } - .notify_close_watcher_input_activation(pointer_dispatch_handle); + .notify_close_watcher_input_activation(mouse_dispatch_handle); } let dispatched = - dispatch_public_event(scope, runtime_ptr, pointer_dispatch_handle, event); + dispatch_public_event(scope, runtime_ptr, mouse_dispatch_handle, event); if event_name == "mousedown" { mouse_down_allows_default = dispatched.allows_default(); } diff --git a/moli-renderer-v8/src/script_vm/input_helpers.rs b/moli-renderer-v8/src/script_vm/input_helpers.rs index df67701f57..8f39db3d67 100644 --- a/moli-renderer-v8/src/script_vm/input_helpers.rs +++ b/moli-renderer-v8/src/script_vm/input_helpers.rs @@ -1,12 +1,43 @@ use super::ScriptVm; use std::collections::HashSet; -use crate::document_runtime::DomHandle; +use crate::document_runtime::{DomHandle, EventTargetHandle}; use crate::host::ModuleFailurePolicy; use crate::module_runtime::{ModuleGraphHandle, ModuleLoadError, ModuleLoadStage}; use crate::native_bridge::JsContextHost; use crate::types::ScriptErrorValue; +/// The pointer event's original ancestry, before author listeners mutate it. +pub(super) struct CompatibilityMouseTarget { + document: DomHandle, + path: Vec, +} + +impl CompatibilityMouseTarget { + pub(super) fn capture(runtime: &JsContextHost, target: DomHandle) -> Option { + Some(Self { + document: runtime.dom_host().owner_document_handle(target)?, + path: runtime.build_propagation_path(EventTargetHandle::Node(target), true), + }) + } + + pub(super) fn resolve(&self, runtime: &JsContextHost) -> Option { + let dom = runtime.dom_host(); + self.path.iter().find_map(|target| { + let EventTargetHandle::Node(handle) = *target else { + return None; + }; + // ShadowRoot participates in propagation, but a native mouse + // target must be a surviving element or its Document. + (dom.node(handle) + .is_some_and(|node| node.is_element() || node.is_document()) + && dom.owner_document_handle(handle) == Some(self.document) + && dom.is_connected_to_document(handle)) + .then_some(handle) + }) + } +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(super) struct PendingMousePress { pub(super) handle: DomHandle, diff --git a/moli-renderer-v8/src/script_vm/tests/browser_api/compat_mouse_targets.js b/moli-renderer-v8/src/script_vm/tests/browser_api/compat_mouse_targets.js new file mode 100644 index 0000000000..5a00148322 --- /dev/null +++ b/moli-renderer-v8/src/script_vm/tests/browser_api/compat_mouse_targets.js @@ -0,0 +1,133 @@ +globalThis.__installCompatMouseTargetProbe = function (mode, trigger, captured, inChild) { + const root = window; + document.documentElement.style.cssText = 'margin:0;padding:0'; + document.body.style.cssText = 'margin:0;padding:0'; + let win = root, doc = document; + if (inChild) { + const frame = document.createElement('iframe'); + frame.style.cssText = 'position:fixed;left:0;top:0;width:600px;height:420px;border:0'; + document.body.appendChild(frame); + win = frame.contentWindow; doc = frame.contentDocument; + } + doc.documentElement.style.cssText = 'margin:0;padding:0'; + doc.body.style.cssText = 'margin:0;padding:0;user-select:none'; + const nodes = [doc, win], names = new Map([[doc, 'document'], [win, 'window']]); + function element(name, parent) { + const node = doc.createElement('div'); node.id = name; + names.set(node, name); nodes.push(node); parent.appendChild(node); return node; + } + const parent = element('parent', doc.body); + parent.style.cssText = 'position:fixed;left:30px;top:30px;width:450px;height:180px'; + const branch = element('branch', parent); + branch.style.cssText = 'position:fixed;left:40px;top:40px;width:180px;height:150px'; + const other = element('other', doc.body); + other.style.cssText = 'position:fixed;left:300px;top:240px;width:180px;height:150px'; + let holder = branch, host = null, slot = null, slotParent = null; + if (mode.startsWith('shadow-') || mode === 'nested-shadow-host' || mode.startsWith('slotted-')) { + host = element('host', branch); + host.style.cssText = 'position:fixed;left:40px;top:40px;width:180px;height:150px'; + const shadow = host.attachShadow({mode:mode.endsWith('closed') ? 'closed' : 'open'}); + nodes.push(shadow); names.set(shadow, 'shadow'); + if (mode === 'nested-shadow-host') { + const innerHost = element('innerHost', shadow); + innerHost.style.cssText = host.style.cssText; + const innerShadow = innerHost.attachShadow({mode:'closed'}); + nodes.push(innerShadow); names.set(innerShadow, 'innerShadow'); + holder = innerShadow; + host = innerHost; + } else if (mode.startsWith('slotted-')) { + slotParent = element('slotParent', shadow); + slot = doc.createElement('slot'); slot.id = 'slot'; slotParent.appendChild(slot); + nodes.push(slot); names.set(slot, 'slot'); + holder = host; + } else { + holder = shadow; + } + } + const source = element('source', holder); + source.style.cssText = 'position:fixed;left:50px;top:50px;width:100px;height:100px;background:#acf'; + const expected = mode === 'remove-target' || mode === 'remove-and-throw' || mode === 'poison-native-relations' || mode === 'adopt-detached-document' ? branch : + mode === 'remove-parent' ? parent : + mode.startsWith('shadow-host-') ? branch : + mode.startsWith('shadow-child-') ? host : + mode === 'nested-shadow-host' ? holder.host.parentNode.host : + mode === 'slotted-target' ? slot : + mode === 'remove-document-element' ? doc : source; + const beforeDocument = doc; + const observations = [], events = [], seen = new WeakMap(), checks = []; + let phase = 'hover', getterReads = 0, mutated = false; + const mouseType = trigger === 'down' ? 'mousedown' : trigger === 'up' ? 'mouseup' : 'mousemove'; + const pointerType = trigger === 'down' ? 'pointerdown' : trigger === 'up' ? 'pointerup' : 'pointermove'; + function observe(e) { + if (phase !== trigger || ![mouseType, pointerType].includes(e.type)) return; + let row = seen.get(e); + if (!row) { + row = {type:e.type, button:e.button, buttons:e.buttons, clientX:e.clientX, clientY:e.clientY, + trusted:e.isTrusted, bubbles:e.bubbles, cancelable:e.cancelable, composed:e.composed, + realm:e instanceof win.MouseEvent, ownerRealm:win === root || !(e instanceof root.MouseEvent), + view:e.view === win, matchedExpected:false, matchedSource:false}; + seen.set(e, row); events.push(row); + } + const path = e.composedPath(); + observations.push({type:e.type, listener:names.get(e.currentTarget), target:names.get(e.target), + path:path.map(node => names.get(node) || node.nodeName || 'unknown')}); + if (e.currentTarget === expected && e.target === expected && path[0] === expected) row.matchedExpected = true; + if (e.currentTarget === source && e.target === source && path[0] === source) row.matchedSource = true; + } + for (const node of nodes) { + node.addEventListener(mouseType, observe, true); + node.addEventListener(pointerType, observe, true); + } + source.addEventListener('pointerdown', e => { if (captured) source.setPointerCapture(e.pointerId); }); + source.addEventListener(pointerType, () => { + if (phase !== trigger || mutated) return; + mutated = true; + if (['remove-target', 'remove-and-throw', 'poison-native-relations', 'slotted-target'].includes(mode) || mode.startsWith('shadow-child-')) source.remove(); + if (mode === 'remove-parent') branch.remove(); + if (mode.startsWith('shadow-host-') || mode === 'nested-shadow-host') host.remove(); + if (mode === 'slotted-slot') slot.remove(); + if (mode === 'slotted-slot-parent') slotParent.remove(); + if (mode === 'reparent-same-document') other.appendChild(source); + if (mode === 'remove-reinsert') { source.remove(); holder.appendChild(source); } + if (mode === 'adopt-detached-document') beforeDocument.implementation.createHTMLDocument('').body.appendChild(source); + if (mode === 'remove-document-element') beforeDocument.documentElement.remove(); + if (mode === 'remove-and-throw') throw new win.Error('intentional removed-target listener exception'); + }); + win.addEventListener('error', e => e.preventDefault()); + win.addEventListener('contextmenu', e => e.preventDefault()); + if (mode === 'poison-native-relations') { + for (const node of [source, branch, parent]) { + for (const key of ['parentNode', 'ownerDocument', 'isConnected', 'assignedSlot']) { + Object.defineProperty(node, key, {get() {getterReads++; throw new win.Error(key);}}); + } + } + } + function check(name, actual, wanted) { + checks.push({name, actual, expected:wanted, pass:JSON.stringify(actual) === JSON.stringify(wanted)}); + } + return {down:[80,80], up:[85,80], prepare(value) {phase = value;}, finish(pointer) { + const mouse = events.filter(e => e.type === mouseType), pointerEvents = events.filter(e => e.type === pointerType); + check('pointer event at the original target', pointerEvents.length, 1); + if (pointerEvents.length) check('original pointer target preserved', pointerEvents[0].matchedSource, true); + check('listener mutation ran', mutated, true); + check('one compatibility mouse event', mouse.length, 1); + if (mouse.length) { + const event = mouse[0]; + check('compatibility target from original path', event.matchedExpected, true); + check('mouse button', event.button, 0); + check('mouse buttons', event.buttons, trigger === 'up' ? 0 : 1); + check('mouse coordinates', [event.clientX,event.clientY], trigger === 'down' ? [80,80] : [85,80]); + check('trusted compatibility event', event.trusted, true); + check('compatibility bubbles', event.bubbles, true); + check('compatibility cancelable', event.cancelable, true); + check('compatibility composed', event.composed, true); + check('owner MouseEvent realm', event.realm, true); + check('child constructor identity', event.ownerRealm, true); + check('view identifies original Window', event.view, true); + check('pointer precedes compatibility event', events.indexOf(pointerEvents[0]) < events.indexOf(event), true); + } + check('native relation lookup does not call author getters', getterReads, 0); + return {mode,trigger,captured,inChild,pointer,expectedTarget:names.get(expected),events,observations,checks, + getterReads,mutated,passed:checks.filter(c=>c.pass).length,total:checks.length,complete:checks.every(c=>c.pass)}; + }}; +}; diff --git a/moli-renderer-v8/src/script_vm/tests/browser_api/compat_mouse_targets.rs b/moli-renderer-v8/src/script_vm/tests/browser_api/compat_mouse_targets.rs new file mode 100644 index 0000000000..1c0f47ebfb --- /dev/null +++ b/moli-renderer-v8/src/script_vm/tests/browser_api/compat_mouse_targets.rs @@ -0,0 +1,91 @@ +use super::*; + +fn assert_compatibility_mouse_targets(in_child: bool) { + for pointer_type in ["mouse", "pen"] { + for captured in [false, true] { + for trigger in ["down", "move", "up"] { + for mode in [ + "keep", + "remove-target", + "remove-parent", + "shadow-child-open", + "shadow-child-closed", + "shadow-host-open", + "shadow-host-closed", + "nested-shadow-host", + "slotted-target", + "slotted-slot", + "slotted-slot-parent", + "reparent-same-document", + "remove-reinsert", + "adopt-detached-document", + "remove-and-throw", + "poison-native-relations", + "remove-document-element", + ] { + let mut vm = + new_storage_html_test_vm("https://compat-mouse-target.test/audit.html"); + vm.eval(&format!( + "{}; globalThis.__compatResult=__installCompatMouseTargetProbe('{mode}','{trigger}',{captured},{in_child}); 'ready'", + include_str!("compat_mouse_targets.js") + )).unwrap(); + vm.publish_layout_for_test().unwrap(); + for (event, phase, x, buttons) in [ + ("mousemove", "hover", 80.0, 0), + ("mousedown", "down", 80.0, 1), + ("mousemove", "move", 85.0, 1), + ("mouseup", "up", 85.0, 0), + ] { + vm.eval(&format!("__compatResult.prepare('{phase}'); 'ready'")) + .unwrap(); + vm.dispatch_mouse_event_at_point_with_pointer_and_modifiers( + x, + 80.0, + event, + if event == "mousemove" { -1 } else { 0 }, + Some(buttons), + i32::from(event != "mousemove"), + 0.0, + 0.0, + crate::runtime::RendererPointerEventProperties { + pointer_id: 17, + pointer_type: pointer_type.to_owned(), + pressure: if buttons == 0 { 0.0 } else { 0.5 }, + ..Default::default() + }, + 0, + ) + .unwrap_or_else(|error| { + panic!("{mode}, {trigger}, {pointer_type}, capture {captured}, child {in_child}, phase {phase}: {error}") + }); + // This case verifies the single mapped event. Later + // input would require a new render after tree removal. + if phase == trigger { + break; + } + } + assert_eq!( + vm.eval(&format!( + "globalThis.__compatFinished=__compatResult.finish('{pointer_type}'); __compatFinished.complete" + )) + .unwrap(), + "true", + "{mode}, {trigger}, {pointer_type}, capture {captured}, child {in_child}: {}", + vm.eval("JSON.stringify(__compatFinished.checks.filter(c=>!c.pass))") + .unwrap() + ); + } + } + } + } +} + +#[test] +fn compatibility_mouse_targets_follow_the_preceding_pointer_path_after_removal() { + assert_compatibility_mouse_targets(false); +} + +#[test] +fn compatibility_mouse_targets_preserve_child_document_identity_and_realm() { + assert_compatibility_mouse_targets(true); +} diff --git a/moli-renderer-v8/src/script_vm/tests/browser_api/mod.rs b/moli-renderer-v8/src/script_vm/tests/browser_api/mod.rs index be58aac951..3d16a7f790 100644 --- a/moli-renderer-v8/src/script_vm/tests/browser_api/mod.rs +++ b/moli-renderer-v8/src/script_vm/tests/browser_api/mod.rs @@ -9,6 +9,7 @@ mod click_targets; mod clipboard_exec_copy; mod clipboard_input; mod clipboard_storage; +mod compat_mouse_targets; mod console; mod crypto_misc; mod crypto_subtle_aes;