webapi: retarget relatedTarget and reset shadow-tree targets pre-dispatch

Fixes 5 failing tests across WPT /dom/events/relatedTarget.window.html
(0/6 -> 3/6) and /dom/events/shadow-relatedTarget.html (0/2 -> 2/2).
The remaining 3 relatedTarget.window.html subtests also fail in Chrome
stable (verified on wpt.fyi), so they are out of scope.

Per the DOM dispatch algorithm:

- The event's relatedTarget is retargeted against the dispatch target
  when dispatch starts (Event gains relatedTargetPtr() to reach the
  MouseEvent/FocusEvent storage generically), so listeners and
  post-dispatch reads observe e.g. the shadow host instead of a node
  inside a shadow tree.
- The "clear targets" decision (reset target and relatedTarget to null
  when they would expose nodes whose root is a shadow root) is now
  computed when the event path is built — on the pre-dispatch tree —
  instead of after dispatch, so listeners moving nodes out of the
  shadow tree during dispatch can't defeat it, and it now also applies
  to relatedTarget, not just target.

Coverage: /dom/events/relatedTarget.window.html 0/6 -> 3/6 (Chrome
parity), /dom/events/shadow-relatedTarget.html 0/2 -> 2/2. No
regressions across /dom/events.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Francis Bouvier
2026-07-10 20:49:23 +02:00
committed by Karl Seguin
parent 64b4276cfa
commit f72cdf33cf
2 changed files with 51 additions and 1 deletions

View File

@@ -166,6 +166,15 @@ fn dispatchNode(self: *EventManager, target: *Node, event: *Event, comptime opts
const et = target.asEventTarget();
event._target = et;
event._dispatch_target = et; // Store original target for composedPath()
// Retarget the relatedTarget against the dispatch target up front
// (DOM dispatch step 4); listeners observe the retargeted value and
// it survives the dispatch.
if (event.relatedTargetPtr()) |related_ptr| {
if (related_ptr.*) |related| {
related_ptr.* = getAdjustedTarget(related, et);
}
}
}
const frame = self.frame;
@@ -195,6 +204,7 @@ fn dispatchNode(self: *EventManager, target: *Node, event: *Event, comptime opts
var path_len: usize = 0;
var node_path_len: usize = 0;
var path_buffer: [128]*EventTarget = undefined;
var clear_targets = false;
// Defer runs even on early return - ensures event phase is reset
// and default actions execute (unless prevented)
@@ -203,7 +213,14 @@ fn dispatchNode(self: *EventManager, target: *Node, event: *Event, comptime opts
event._current_target = null;
event._stop_propagation = false;
event._stop_immediate_propagation = false;
if (event._needs_retargeting and node_path_len > 0) {
if (clear_targets) {
// Don't leak nodes living in a shadow tree: reset the targets
// (decided on the pre-dispatch tree, see below).
event._target = null;
if (event.relatedTargetPtr()) |related_ptr| {
related_ptr.* = null;
}
} else if (event._needs_retargeting and node_path_len > 0) {
const adjusted = getAdjustedTarget(event._dispatch_target, path_buffer[node_path_len - 1]);
event._target = if (rootIsShadowRoot(adjusted)) null else adjusted;
}
@@ -274,6 +291,26 @@ fn dispatchNode(self: *EventManager, target: *Node, event: *Event, comptime opts
}
}
// DOM dispatch: decide up front — on the pre-dispatch tree, so listener
// mutations can't affect it — whether target and relatedTarget must be
// reset after dispatch because they would expose nodes inside a shadow
// tree.
if (node_path_len > 0) {
const last = path_buffer[node_path_len - 1];
if (event._needs_retargeting) {
if (rootIsShadowRoot(getAdjustedTarget(event._dispatch_target, last))) {
clear_targets = true;
}
}
if (event.relatedTargetPtr()) |related_ptr| {
if (related_ptr.*) |related| {
if (rootIsShadowRoot(getAdjustedTarget(related, last))) {
clear_targets = true;
}
}
}
}
const path = path_buffer[0..path_len];
// Phase 1: Capturing phase (root → target, excluding target)

View File

@@ -169,6 +169,19 @@ pub fn as(self: *Event, comptime T: type) *T {
return self.is(T).?;
}
// Storage of the subtype's relatedTarget, for event types that have one.
// Used by dispatch for retargeting and shadow-tree resets.
pub fn relatedTargetPtr(self: *Event) ?*?*EventTarget {
switch (self._type) {
.ui_event => |ui| switch (ui._type) {
.mouse_event => |me| return &me._related_target,
.focus_event => |fe| return &fe._related_target,
else => return null,
},
else => return null,
}
}
pub fn is(self: *Event, comptime T: type) ?*T {
switch (self._type) {
.generic => return if (T == Event) self else null,