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.
This commit is contained in:
ldm0
2026-10-11 18:06:47 +08:00
parent 8bd5dbbca3
commit 2a6d1ef176
9 changed files with 313 additions and 24 deletions
@@ -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,
@@ -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,
@@ -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<v8::Local<'s, v8::Object>> {
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,
)
@@ -324,6 +324,15 @@ pub(crate) fn draggable_source_at_point(
point: LayoutPoint,
) -> Result<Option<DomHandle>, 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)
@@ -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();
}
@@ -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<EventTargetHandle>,
}
impl CompatibilityMouseTarget {
pub(super) fn capture(runtime: &JsContextHost, target: DomHandle) -> Option<Self> {
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<DomHandle> {
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,
@@ -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)};
}};
};
@@ -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);
}
@@ -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;