From f72cdf33cff7601cbca6d2eb4c90faf1ab7f62c8 Mon Sep 17 00:00:00 2001 From: Francis Bouvier Date: Fri, 10 Jul 2026 20:49:23 +0200 Subject: [PATCH] webapi: retarget relatedTarget and reset shadow-tree targets pre-dispatch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/browser/EventManager.zig | 39 +++++++++++++++++++++++++++++++++++- src/browser/webapi/Event.zig | 13 ++++++++++++ 2 files changed, 51 insertions(+), 1 deletion(-) diff --git a/src/browser/EventManager.zig b/src/browser/EventManager.zig index 7cffc2d79..5087ae527 100644 --- a/src/browser/EventManager.zig +++ b/src/browser/EventManager.zig @@ -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) diff --git a/src/browser/webapi/Event.zig b/src/browser/webapi/Event.zig index 298370fc7..a0323c86f 100644 --- a/src/browser/webapi/Event.zig +++ b/src/browser/webapi/Event.zig @@ -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,