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;