From f72cdf33cff7601cbca6d2eb4c90faf1ab7f62c8 Mon Sep 17 00:00:00 2001 From: Francis Bouvier Date: Fri, 10 Jul 2026 20:49:23 +0200 Subject: [PATCH 1/6] 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, From 6587778c12f5d307c2f4cb77f6f7e936194380b2 Mon Sep 17 00:00:00 2001 From: Francis Bouvier Date: Fri, 10 Jul 2026 21:14:03 +0200 Subject: [PATCH 2/6] webapi: spec-conformant click activation target selection Fixes 3 failing tests in WPT /dom/events/Event-dispatch-click.html (29/33 -> 32/33) and both tests in /dom/events/no-focus-events-at- clicking-editable-content-in-link.html (1/2 -> 2/2). - Only a MouseEvent click is an activation event. - The activation target is the nearest inclusive ancestor with activation behavior (ancestors only for bubbling events), shared by ActivationState and handleClick via findClickActivationTarget. - Clicks on editable content don't activate enclosing links; mousedown focuses the editing host (CDP and WebDriver paths). - Interactive submission of a disconnected form does nothing; the programmatic form.submit() stays exempt per HTML 'cannot navigate'. The remaining Event-dispatch-click.html failure needs javascript: URL navigation. Coverage: Event-dispatch-click.html 29/33 -> 32/33, no-focus-events-at-clicking-editable-content-in-link.html 1/2 -> 2/2. No regressions across /dom/events. Co-Authored-By: Claude Fable 5 --- src/browser/EventManager.zig | 23 ++++++++--- src/browser/Frame.zig | 8 ++++ src/browser/frame/user_input.zig | 71 ++++++++++++++++++++++++++++++++ src/browser/webapi/WebDriver.zig | 3 ++ 4 files changed, 100 insertions(+), 5 deletions(-) diff --git a/src/browser/EventManager.zig b/src/browser/EventManager.zig index 5087ae527..a03d36d47 100644 --- a/src/browser/EventManager.zig +++ b/src/browser/EventManager.zig @@ -233,9 +233,16 @@ fn dispatchNode(self: *EventManager, target: *Node, event: *Event, comptime opts if (event._prevent_default) { // can't return in a defer (╯°□°)╯︵ ┻━┻ } else if (event._type_string.eql(comptime .wrap("click"))) { - Frame.user_input.handleClick(frame, target) catch |err| { - log.warn(.event, "frame.click", .{ .err = err }); - }; + // Per spec, only a MouseEvent "click" is an activation event, and + // the activation target is the nearest inclusive ancestor with + // activation behavior (ancestors only for bubbling events). + if (event.is(@import("webapi/event/MouseEvent.zig")) != null) { + if (Frame.user_input.findClickActivationTarget(target, event._bubbles)) |activation_target| { + Frame.user_input.handleClick(frame, activation_target) catch |err| { + log.warn(.event, "frame.click", .{ .err = err }); + }; + } + } } else if (event._type_string.eql(comptime .wrap("keydown"))) { Frame.user_input.handleKeydown(frame, target, event) catch |err| { log.warn(.event, "frame.keydown", .{ .err = err }); @@ -656,12 +663,18 @@ const ActivationState = struct { const Input = Element.Html.Input; - fn create(event: *const Event, target: *Node, frame: *Frame) !?ActivationState { + fn create(event: *Event, target: *Node, frame: *Frame) !?ActivationState { if (event._type_string.eql(comptime .wrap("click")) == false) { return null; } - const input = target.is(Element.Html.Input) orelse return null; + // Per spec, only a MouseEvent "click" is an activation event. + if (event.is(@import("webapi/event/MouseEvent.zig")) == null) { + return null; + } + + const activation_node = Frame.user_input.findClickActivationTarget(target, event._bubbles) orelse return null; + const input = activation_node.is(Element.Html.Input) orelse return null; if (input._input_type != .checkbox and input._input_type != .radio) { return null; } diff --git a/src/browser/Frame.zig b/src/browser/Frame.zig index 4e7e5b34f..3a3924f26 100644 --- a/src/browser/Frame.zig +++ b/src/browser/Frame.zig @@ -3151,6 +3151,14 @@ const SubmitFormOpts = struct { pub fn submitForm(self: *Frame, submitter_: ?*Element, form_: ?*Element.Html.Form, submit_opts: SubmitFormOpts) !void { const form = form_ orelse return; + // Interactive submission (submit button, requestSubmit) of a + // disconnected form does nothing; the programmatic form.submit() is + // exempt from the connectedness requirement (HTML "cannot navigate" + // only applies its connected clause to non-form elements). + if (submit_opts.fire_event and !form.asElement().asNode().isConnected()) { + return; + } + // see the `_constructing_entry_list` field documentation if (form._constructing_entry_list) { return; diff --git a/src/browser/frame/user_input.zig b/src/browser/frame/user_input.zig index a9c761a8f..32f0c4456 100644 --- a/src/browser/frame/user_input.zig +++ b/src/browser/frame/user_input.zig @@ -78,6 +78,7 @@ pub fn triggerMousePress(frame: *Frame, x: f64, y: f64, button: i32) !void { }); } try dispatchMouseEventOn(frame, target, "mousedown", x, y, button, 0); + try focusEditingHostForMouseDown(frame, target); } pub fn triggerMouseMove(frame: *Frame, x: f64, y: f64) !void { @@ -200,6 +201,76 @@ fn deltaToScroll(d: f64) i32 { } // callback when the "click" event reaches the frame. +// Whether the element has a click activation behavior that handleClick +// implements. +fn hasClickActivationBehavior(node: *Node) bool { + const element = node.is(Element) orelse return false; + const html_element = element.is(Element.Html) orelse return false; + return switch (html_element._type) { + .anchor => element.getAttributeSafe(comptime .wrap("href")) != null, + .input, .button, .select, .textarea, .label => true, + .generic => |generic| generic._tag == .summary, + else => false, + }; +} + +// Clicks on editable content are for editing: they don't activate the +// element or any enclosing link. +// "contenteditable" is 15 bytes — past the comptime SSO limit — so the +// String wrap runs at runtime, mirroring Html.getIsContentEditable. +fn isEditingHost(node: *Node) bool { + const element = node.is(Element) orelse return false; + const value = element.getAttributeSafe(.wrap("contenteditable")) orelse return false; + return !std.ascii.eqlIgnoreCase(value, "false"); +} + +// A mousedown on editable content focuses its editing host: the outermost +// element of the contiguous editable chain containing the target. +pub fn focusEditingHostForMouseDown(frame: *Frame, target: *Element) !void { + var node: ?*Node = target.asNode(); + var editable: ?*Node = null; + while (node) |n| : (node = n._parent) { + if (isEditingHost(n)) { + editable = n; + break; + } + } + var host = editable orelse return; + while (host._parent) |p| { + if (!isEditingHost(p)) { + break; + } + host = p; + } + const host_element = host.is(Element) orelse return; + try host_element.focus(frame); +} + +// Per the DOM dispatch algorithm, a click's activation target is the event +// target itself when it has activation behavior, otherwise — for bubbling +// events only — the nearest ancestor that has one. +pub fn findClickActivationTarget(target: *Node, bubbles: bool) ?*Node { + if (isEditingHost(target)) { + return null; + } + if (hasClickActivationBehavior(target)) { + return target; + } + if (!bubbles) { + return null; + } + var node = target._parent; + while (node) |n| : (node = n._parent) { + if (isEditingHost(n)) { + return null; + } + if (hasClickActivationBehavior(n)) { + return n; + } + } + return null; +} + pub fn handleClick(frame: *Frame, target: *Node) !void { // TODO: Also support elements when implement const element = target.is(Element) orelse return; diff --git a/src/browser/webapi/WebDriver.zig b/src/browser/webapi/WebDriver.zig index 643cd6d98..e494c4b21 100644 --- a/src/browser/webapi/WebDriver.zig +++ b/src/browser/webapi/WebDriver.zig @@ -173,6 +173,9 @@ fn performPointerSource(source: js.Object, frame: *Frame) !void { const button = readI32(action, "button", 0); dispatchPointer(el, "pointerdown", button, 1, frame); dispatchMouse(el, "mousedown", button, 1, frame); + Frame.user_input.focusEditingHostForMouseDown(frame, el) catch |err| { + log.warn(.app, "webdriver editable focus", .{ .err = err }); + }; } else if (action_type.eql(comptime .wrap("pointerUp"))) { const el = target orelse continue; const button = readI32(action, "button", 0); From 2c64c7fdbecfd05c762003db674d3b19201a68f5 Mon Sep 17 00:00:00 2001 From: Francis Bouvier Date: Fri, 10 Jul 2026 21:18:27 +0200 Subject: [PATCH 3/6] webapi: real clicks on disabled form controls dispatch nothing Fixes the "Real clicks on disabled elements must not dispatch events" subtest of WPT /dom/events/Event-dispatch-on-disabled-elements.html (verified in isolation): a testdriver click on a disabled button/input/select/textarea must not fire any pointer or mouse events, while a click on the re-enabled element must. WebDriver.click now checks the disabled state like HTMLElement.click does. The file's overall score is unchanged (4/9) because its four CSS transition/animation subtests hang awaiting animation events (no CSS animation engine in the renderless browser) and, being sequential promise_tests, also mask this subtest's result in the full run. Co-Authored-By: Claude Fable 5 --- src/browser/webapi/WebDriver.zig | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/browser/webapi/WebDriver.zig b/src/browser/webapi/WebDriver.zig index e494c4b21..77bc445b8 100644 --- a/src/browser/webapi/WebDriver.zig +++ b/src/browser/webapi/WebDriver.zig @@ -56,6 +56,18 @@ pub fn getComputedLabel(_: *const WebDriver, element: *Element, frame: *Frame) ! // synchronously so the events are observable when the testdriver promise // resolves. pub fn click(_: *const WebDriver, element: *Element, frame: *Frame) !void { + // Disabled form controls don't receive real click events at all. + if (element.is(Element.Html)) |html| { + switch (html._type) { + inline .button, .input, .textarea, .select => |i| { + if (i.getDisabled()) { + return; + } + }, + else => {}, + } + } + dispatchPointer(element, "pointerdown", 0, 1, frame); dispatchMouse(element, "mousedown", 0, 1, frame); dispatchPointer(element, "pointerup", 0, 0, frame); From 46e9497d35b11b03a7acfc5760440dc9151e938c Mon Sep 17 00:00:00 2001 From: Francis Bouvier Date: Fri, 10 Jul 2026 21:24:22 +0200 Subject: [PATCH 4/6] webapi: scroll-blocking listeners are passive by default on root targets Fixes the 32 failing tests in WPT /dom/events/passive-by-default.html (68/100 -> 100/100): per the DOM spec's "default passive value", touchstart, touchmove, wheel and mousewheel listeners added to the Window, the Document, or the html or body elements default to {passive: true} (so their preventDefault is ignored), unless the options explicitly say {passive: false}. addEventListener's options dictionary now keeps `passive` optional so an absent or undefined member can fall back to the default passive value computed from the event type and target; an explicit boolean keeps its meaning. Coverage: /dom/events/passive-by-default.html 68/100 -> 100/100. No regressions across /dom/events. Co-Authored-By: Claude Fable 5 --- src/browser/webapi/EventTarget.zig | 39 +++++++++++++++++++++++++++--- 1 file changed, 35 insertions(+), 4 deletions(-) diff --git a/src/browser/webapi/EventTarget.zig b/src/browser/webapi/EventTarget.zig index ce130a143..0da77ee3b 100644 --- a/src/browser/webapi/EventTarget.zig +++ b/src/browser/webapi/EventTarget.zig @@ -93,14 +93,45 @@ const AddEventListenerOptions = union(enum) { // The signal is kept as a raw js.Value so that an explicit null (or any // non-AbortSignal value) can be told apart from an absent or undefined // member, and rejected with a TypeError per the dictionary conversion. + // passive is optional so that an absent (or undefined) member falls back + // to the type- and target-dependent default passive value. const Options = struct { once: bool = false, capture: bool = false, - passive: bool = false, + passive: ?bool = null, signal: ?js.Value = null, }; }; +// The DOM spec's "default passive value": listeners for the scroll-blocking +// event types are passive by default on the window, the document, and the +// html and body elements. +fn defaultPassiveValue(self: *EventTarget, typ: []const u8) bool { + const scroll_blocking = std.mem.eql(u8, typ, "touchstart") or + std.mem.eql(u8, typ, "touchmove") or + std.mem.eql(u8, typ, "wheel") or + std.mem.eql(u8, typ, "mousewheel"); + if (!scroll_blocking) { + return false; + } + + switch (self._type) { + .window => return true, + .node => |n| { + const Element = @import("Element.zig"); + if (n._type == .document) { + return true; + } + const element = n.is(Element) orelse return false; + return switch (element.getTag()) { + .html, .body => true, + else => false, + }; + }, + else => return false, + } +} + pub const EventListenerCallback = union(enum) { function: js.Function, object: js.Object, @@ -109,12 +140,12 @@ pub fn addEventListener(self: *EventTarget, typ: []const u8, callback_: ?EventLi // Convert the options before the null-callback early return: per spec, // the dictionary conversion throws even when the callback is null. const options = blk: { - const o = opts_ orelse break :blk RegisterOptions{}; + const o = opts_ orelse break :blk RegisterOptions{ .passive = self.defaultPassiveValue(typ) }; break :blk switch (o) { .options => |opts| RegisterOptions{ .once = opts.once, .capture = opts.capture, - .passive = opts.passive, + .passive = opts.passive orelse self.defaultPassiveValue(typ), .signal = signal: { const signal = opts.signal orelse break :signal null; if (signal.isUndefined()) { @@ -123,7 +154,7 @@ pub fn addEventListener(self: *EventTarget, typ: []const u8, callback_: ?EventLi break :signal try signal.toZig(*AbortSignal); }, }, - .capture => |capture| RegisterOptions{ .capture = capture }, + .capture => |capture| RegisterOptions{ .capture = capture, .passive = self.defaultPassiveValue(typ) }, }; }; From 09a40926346d6a2bb2004a6f527b1563577af96b Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Wed, 15 Jul 2026 11:32:39 +0800 Subject: [PATCH 5/6] use StaticStringMap for lookup --- src/browser/Frame.zig | 7 ++----- src/browser/frame/user_input.zig | 2 +- src/browser/webapi/EventTarget.zig | 12 +++++++----- src/browser/webapi/WebDriver.zig | 1 - 4 files changed, 10 insertions(+), 12 deletions(-) diff --git a/src/browser/Frame.zig b/src/browser/Frame.zig index 3a3924f26..e55af9316 100644 --- a/src/browser/Frame.zig +++ b/src/browser/Frame.zig @@ -3151,11 +3151,8 @@ const SubmitFormOpts = struct { pub fn submitForm(self: *Frame, submitter_: ?*Element, form_: ?*Element.Html.Form, submit_opts: SubmitFormOpts) !void { const form = form_ orelse return; - // Interactive submission (submit button, requestSubmit) of a - // disconnected form does nothing; the programmatic form.submit() is - // exempt from the connectedness requirement (HTML "cannot navigate" - // only applies its connected clause to non-form elements). - if (submit_opts.fire_event and !form.asElement().asNode().isConnected()) { + if (submit_opts.fire_event and form.asNode().isConnected() == false) { + // interactive submission (e.g. submit button) noop if the form is disconnected return; } diff --git a/src/browser/frame/user_input.zig b/src/browser/frame/user_input.zig index 32f0c4456..3248974eb 100644 --- a/src/browser/frame/user_input.zig +++ b/src/browser/frame/user_input.zig @@ -221,7 +221,7 @@ fn hasClickActivationBehavior(node: *Node) bool { fn isEditingHost(node: *Node) bool { const element = node.is(Element) orelse return false; const value = element.getAttributeSafe(.wrap("contenteditable")) orelse return false; - return !std.ascii.eqlIgnoreCase(value, "false"); + return std.ascii.eqlIgnoreCase(value, "false") == false; } // A mousedown on editable content focuses its editing host: the outermost diff --git a/src/browser/webapi/EventTarget.zig b/src/browser/webapi/EventTarget.zig index 0da77ee3b..454a88c64 100644 --- a/src/browser/webapi/EventTarget.zig +++ b/src/browser/webapi/EventTarget.zig @@ -107,11 +107,13 @@ const AddEventListenerOptions = union(enum) { // event types are passive by default on the window, the document, and the // html and body elements. fn defaultPassiveValue(self: *EventTarget, typ: []const u8) bool { - const scroll_blocking = std.mem.eql(u8, typ, "touchstart") or - std.mem.eql(u8, typ, "touchmove") or - std.mem.eql(u8, typ, "wheel") or - std.mem.eql(u8, typ, "mousewheel"); - if (!scroll_blocking) { + const scroll_blocking_event_types = std.StaticStringMap(void).initComptime(.{ + .{"touchstart", {}}, + .{"touchmove", {}}, + .{"wheel", {}}, + .{"mousewheel", {}}, + }); + if (scroll_blocking_event_types.has(typ) == false) { return false; } diff --git a/src/browser/webapi/WebDriver.zig b/src/browser/webapi/WebDriver.zig index 77bc445b8..140df74ea 100644 --- a/src/browser/webapi/WebDriver.zig +++ b/src/browser/webapi/WebDriver.zig @@ -56,7 +56,6 @@ pub fn getComputedLabel(_: *const WebDriver, element: *Element, frame: *Frame) ! // synchronously so the events are observable when the testdriver promise // resolves. pub fn click(_: *const WebDriver, element: *Element, frame: *Frame) !void { - // Disabled form controls don't receive real click events at all. if (element.is(Element.Html)) |html| { switch (html._type) { inline .button, .input, .textarea, .select => |i| { From eab33428fb724fd09e86c36e9680786d77310072 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Wed, 15 Jul 2026 11:42:18 +0800 Subject: [PATCH 6/6] zig fmt --- src/browser/webapi/EventTarget.zig | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/browser/webapi/EventTarget.zig b/src/browser/webapi/EventTarget.zig index 454a88c64..f0c25e1c3 100644 --- a/src/browser/webapi/EventTarget.zig +++ b/src/browser/webapi/EventTarget.zig @@ -108,10 +108,10 @@ const AddEventListenerOptions = union(enum) { // html and body elements. fn defaultPassiveValue(self: *EventTarget, typ: []const u8) bool { const scroll_blocking_event_types = std.StaticStringMap(void).initComptime(.{ - .{"touchstart", {}}, - .{"touchmove", {}}, - .{"wheel", {}}, - .{"mousewheel", {}}, + .{ "touchstart", {} }, + .{ "touchmove", {} }, + .{ "wheel", {} }, + .{ "mousewheel", {} }, }); if (scroll_blocking_event_types.has(typ) == false) { return false;