From 86f9059f3f40815f955d617eacda69a581aeaa01 Mon Sep 17 00:00:00 2001 From: Francis Bouvier Date: Fri, 10 Jul 2026 20:10:52 +0200 Subject: [PATCH 1/5] webapi: window.event shadow-tree rule; property handlers on direct dispatch Fixes the 3 failing tests in WPT /dom/events/event-global.html (5/8 -> 8/8): - "window.event is undefined if the target is in a shadow tree" (and the window.onerror variant): per the DOM invoke algorithm, the window's current event is only set while invoking listeners whose invocation target is not in a shadow tree. window.event was set once for the whole dispatch; it is now set per invocation target (in dispatchPhase and around inline handler calls), left undefined for targets whose root is a shadow root. - "window.event is set to the current event, which is the event passed to dispatch (2)": script-dispatched events on non-node targets only ran addEventListener listeners. Property event handlers now fire too: XMLHttpRequestEventTarget resolves its on* fields by event type, and the Window's handler fields are consulted both when an event propagates to the window (getInlineHandler) and when one is dispatched directly on it. Coverage: /dom/events/event-global.html 5/8 -> 8/8. No regressions across /dom/events. Co-Authored-By: Claude Fable 5 --- src/browser/EventManager.zig | 60 +++++++++++++++++++ .../webapi/net/XMLHttpRequestEventTarget.zig | 15 +++++ 2 files changed, 75 insertions(+) diff --git a/src/browser/EventManager.zig b/src/browser/EventManager.zig index 4802d9fe7..0ac74e2fa 100644 --- a/src/browser/EventManager.zig +++ b/src/browser/EventManager.zig @@ -111,10 +111,30 @@ pub fn dispatchOpts(self: *EventManager, target: *EventTarget, event: *Event, co switch (target._type) { .node => |node| try self.dispatchNode(node, event, opts), + // Property event handlers (e.g. xhr.onload, window.onerror) fire for + // script-dispatched events too, not only for the internal dispatch + // paths which pass them explicitly. + .xhr => |xhr| try self.dispatchDirect(target, event, xhr.inlineHandler(event._type_string), .{ .context = "dispatch" }), + .window => |w| try self.dispatchDirect(target, event, windowInlineHandler(w, event._type_string), .{ .context = "dispatch" }), else => try self.dispatchDirect(target, event, null, .{ .context = "dispatch" }), } } +// Resolves the Window's property event handler for the given event type. +fn windowInlineHandler(window: *@import("webapi/Window.zig"), typ: lp.String) ?js.Function.Global { + const global_event_handlers = @import("webapi/global_event_handlers.zig"); + const handler_type = global_event_handlers.fromEventType(typ.str()) orelse return null; + return switch (handler_type) { + .onerror => window._on_error, + .onload => window._on_load, + .onblur => window._on_blur, + .onfocus => window._on_focus, + .onresize => window._on_resize, + .onscroll => window._on_scroll, + else => null, + }; +} + // There are a lot of events that can be attached via addEventListener or as // a property, like the XHR events, or window.onload. You might think that the // property is just a shortcut for calling addEventListener, but they are distinct. @@ -284,6 +304,10 @@ fn dispatchNode(self: *EventManager, target: *Node, event: *Event, comptime opts was_handled = true; event._current_target = target_et; + const prev_current_event = window._current_event; + window._current_event = currentEventForTarget(target_et, event); + defer window._current_event = prev_current_event; + // Inline handlers (e.g. onclick property) follow the same "report, // don't propagate" rule as addEventListener listeners — see Listener.run. var caught: js.TryCatch.Caught = undefined; @@ -331,6 +355,10 @@ fn dispatchNode(self: *EventManager, target: *Node, event: *Event, comptime opts was_handled = true; event._current_target = current_target; + const prev_current_event = window._current_event; + window._current_event = currentEventForTarget(current_target, event); + defer window._current_event = prev_current_event; + const original_target = event._target; if (event._needs_retargeting) { event._target = getAdjustedTarget(original_target, current_target); @@ -360,6 +388,22 @@ fn dispatchNode(self: *EventManager, target: *Node, event: *Event, comptime opts } } +// Per spec ("invocation target in shadow tree"), window.event is left +// undefined while invoking listeners whose target lives in a shadow tree. +fn currentEventForTarget(target: *EventTarget, event: *Event) ?*Event { + const ShadowRoot = @import("webapi/ShadowRoot.zig"); + switch (target._type) { + .node => |n| { + const root = n.getRootNode(.{}); + if (root.is(ShadowRoot) != null) { + return null; + } + }, + else => {}, + } + return event; +} + const DispatchPhaseOpts = struct { capture_only: ?bool = null, apply_ignore: bool = false, @@ -376,6 +420,11 @@ fn dispatchPhase(self: *EventManager, list: *std.DoublyLinkedList, current_targe const frame = self.frame; const base = &self.base; + const window = frame.window; + const prev_current_event = window._current_event; + window._current_event = currentEventForTarget(current_target, event); + defer window._current_event = prev_current_event; + // Track dispatch depth for deferred removal base.dispatch_depth += 1; defer { @@ -477,6 +526,17 @@ fn getInlineHandler(self: *EventManager, target: *EventTarget, event: *Event) ?j // Look up the inline handler for this target const html_element = switch (target._type) { .node => |n| n.is(Element.Html) orelse return null, + // The Window stores its event handlers in dedicated fields; an event + // propagating to the window must fire them too. + .window => |w| return switch (handler_type) { + .onerror => w._on_error, + .onload => w._on_load, + .onblur => w._on_blur, + .onfocus => w._on_focus, + .onresize => w._on_resize, + .onscroll => w._on_scroll, + else => null, + }, else => return null, }; diff --git a/src/browser/webapi/net/XMLHttpRequestEventTarget.zig b/src/browser/webapi/net/XMLHttpRequestEventTarget.zig index cfa258fc0..275586e19 100644 --- a/src/browser/webapi/net/XMLHttpRequestEventTarget.zig +++ b/src/browser/webapi/net/XMLHttpRequestEventTarget.zig @@ -16,6 +16,7 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . +const lp = @import("lightpanda"); const js = @import("../../js/js.zig"); const EventTarget = @import("../EventTarget.zig"); @@ -83,6 +84,20 @@ pub fn dispatch(self: *XMLHttpRequestEventTarget, comptime event_type: DispatchT ); } +// Resolves the property event handler for the given event type, so that a +// script-dispatched event (target.dispatchEvent) fires it like the internal +// dispatch path does. +pub fn inlineHandler(self: *const XMLHttpRequestEventTarget, typ: lp.String) ?js.Function.Global { + if (typ.eql(comptime .wrap("abort"))) return self._on_abort; + if (typ.eql(comptime .wrap("error"))) return self._on_error; + if (typ.eql(comptime .wrap("load"))) return self._on_load; + if (typ.eql(comptime .wrap("loadend"))) return self._on_load_end; + if (typ.eql(comptime .wrap("loadstart"))) return self._on_load_start; + if (typ.eql(comptime .wrap("progress"))) return self._on_progress; + if (typ.eql(comptime .wrap("timeout"))) return self._on_timeout; + return null; +} + pub fn getOnAbort(self: *const XMLHttpRequestEventTarget) ?js.Function.Global { return self._on_abort; } From a0418aed5c80d3548a5f03295121dc5b669c7ab7 Mon Sep 17 00:00:00 2001 From: Francis Bouvier Date: Fri, 10 Jul 2026 20:16:55 +0200 Subject: [PATCH 2/5] webapi: support onclick property handler on Window and Document MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes the failing test in WPT /dom/events/handler-count.html?window and ?document (1/2 -> 2/2 each): setting `window.onclick` or `document.onclick` did nothing, because neither interface exposed the accessor — the assignment created an inert expando and the handler never fired for clicks bubbling up from the page. Both now store the handler in the frame's attribute-listener map (the same mechanism as element and ShadowRoot property handlers), which the dispatch propagation path already consults for any event target. Non-callable values clear the handler per [LegacyTreatNonObjectAsNull]. Coverage: /dom/events/handler-count.html?window 1/2 -> 2/2, /dom/events/handler-count.html?document 1/2 -> 2/2. No regressions across /dom/events. Co-Authored-By: Claude Fable 5 --- src/browser/webapi/Document.zig | 17 +++++++++++++++++ src/browser/webapi/Window.zig | 16 ++++++++++++++++ 2 files changed, 33 insertions(+) diff --git a/src/browser/webapi/Document.zig b/src/browser/webapi/Document.zig index e78b1ea60..281328f25 100644 --- a/src/browser/webapi/Document.zig +++ b/src/browser/webapi/Document.zig @@ -21,6 +21,7 @@ const lp = @import("lightpanda"); const js = @import("../js/js.zig"); const Frame = @import("../Frame.zig"); +const Window = @import("Window.zig"); const URL = @import("../URL.zig"); const idna = @import("../../sys/idna.zig"); const public_suffix_list = @import("../../data/public_suffix_list.zig"); @@ -89,6 +90,21 @@ pub fn setOnSelectionChange(self: *Document, listener: ?js.Function) !void { } } +// Stored in the frame's attribute-listener map (like element and ShadowRoot +// property handlers), which the dispatch propagation path consults for any +// event target. +pub fn getOnClick(self: *Document, frame: *Frame) ?js.Function.Global { + return frame._event_target_attr_listeners.get(.{ .target = self.asEventTarget(), .handler = .onclick }); +} + +pub fn setOnClick(self: *Document, setter: ?Window.FunctionSetter, frame: *Frame) !void { + if (Window.getFunctionFromSetter(setter)) |cb| { + try frame._event_target_attr_listeners.put(frame.arena, .{ .target = self.asEventTarget(), .handler = .onclick }, cb); + } else { + _ = frame._event_target_attr_listeners.remove(.{ .target = self.asEventTarget(), .handler = .onclick }); + } +} + pub const Type = union(enum) { generic, html: *HTMLDocument, @@ -1265,6 +1281,7 @@ pub const JsApi = struct { } pub const onselectionchange = bridge.accessor(Document.getOnSelectionChange, Document.setOnSelectionChange, .{}); + pub const onclick = bridge.accessor(Document.getOnClick, Document.setOnClick, .{}); pub const URL = bridge.accessor(Document.getURL, null, .{}); pub const location = bridge.accessor(Document.getLocation, Document.setLocation, .{}); pub const documentURI = bridge.accessor(Document.getURL, null, .{}); diff --git a/src/browser/webapi/Window.zig b/src/browser/webapi/Window.zig index e374d0168..b83f10ee5 100644 --- a/src/browser/webapi/Window.zig +++ b/src/browser/webapi/Window.zig @@ -425,6 +425,21 @@ pub fn setOnScroll(self: *Window, setter: ?FunctionSetter) void { self._on_scroll = getFunctionFromSetter(setter); } +// Stored in the frame's attribute-listener map (like element and ShadowRoot +// property handlers), which the dispatch propagation path consults for any +// event target. +pub fn getOnClick(self: *Window, frame: *Frame) ?js.Function.Global { + return frame._event_target_attr_listeners.get(.{ .target = self.asEventTarget(), .handler = .onclick }); +} + +pub fn setOnClick(self: *Window, setter: ?FunctionSetter, frame: *Frame) !void { + if (getFunctionFromSetter(setter)) |cb| { + try frame._event_target_attr_listeners.put(frame.arena, .{ .target = self.asEventTarget(), .handler = .onclick }, cb); + } else { + _ = frame._event_target_attr_listeners.remove(.{ .target = self.asEventTarget(), .handler = .onclick }); + } +} + // The "window-reflecting body element event handler set" (HTML spec): these // event handlers of body and frameset elements are aliases for the Window's. // Returns the Window storage slot for the given content attribute name, or @@ -1159,6 +1174,7 @@ pub const JsApi = struct { pub const onfocus = bridge.accessor(Window.getOnFocus, Window.setOnFocus, .{}); pub const onresize = bridge.accessor(Window.getOnResize, Window.setOnResize, .{}); pub const onscroll = bridge.accessor(Window.getOnScroll, Window.setOnScroll, .{}); + pub const onclick = bridge.accessor(Window.getOnClick, Window.setOnClick, .{}); pub const onmessage = bridge.accessor(Window.getOnMessage, Window.setOnMessage, .{}); pub const onrejectionhandled = bridge.accessor(Window.getOnRejectionHandled, Window.setOnRejectionHandled, .{}); pub const onunhandledrejection = bridge.accessor(Window.getOnUnhandledRejection, Window.setOnUnhandledRejection, .{}); From ad9e1d8dc6767bb224d26e5ebb2a90f7fdcd2dbb Mon Sep 17 00:00:00 2001 From: Francis Bouvier Date: Fri, 10 Jul 2026 20:24:48 +0200 Subject: [PATCH 3/5] webapi: uninitialized-event dispatch throws; more createEvent aliases Fixes the 20 failing tests in WPT /dom/events/EventTarget- dispatchEvent.html (5/25 -> 25/25), all of the form "If the event's initialized flag is not set, an InvalidStateError must be thrown". Per the DOM spec, an event created via document.createEvent has its initialized flag unset until one of the legacy init*Event methods runs, and dispatchEvent must throw an InvalidStateError for it. Lightpanda dispatched such events happily. Event now carries an _initialized flag: true by default (constructor-created and internal events), cleared by document.createEvent, and set by initEvent, initCustomEvent, initUIEvent, initMouseEvent, initKeyboardEvent, initCompositionEvent and initTextEvent. EventTarget.dispatchEvent rejects uninitialized events. document.createEvent also gains the spec-required aliases it was missing: DragEvent, HashChangeEvent and SVGEvents, plus BeforeUnloadEvent, DeviceMotionEvent, DeviceOrientationEvent and StorageEvent mapped to a plain Event (logged as not_implemented) until those interfaces exist. Coverage: /dom/events/EventTarget-dispatchEvent.html 5/25 -> 25/25. No regressions across /dom/events. Co-Authored-By: Claude Fable 5 --- src/browser/webapi/Document.zig | 109 +++++++++++------- src/browser/webapi/Event.zig | 5 + src/browser/webapi/EventTarget.zig | 5 + src/browser/webapi/event/CompositionEvent.zig | 1 + src/browser/webapi/event/CustomEvent.zig | 1 + src/browser/webapi/event/KeyboardEvent.zig | 1 + src/browser/webapi/event/MouseEvent.zig | 1 + src/browser/webapi/event/TextEvent.zig | 1 + src/browser/webapi/event/UIEvent.zig | 1 + 9 files changed, 85 insertions(+), 40 deletions(-) diff --git a/src/browser/webapi/Document.zig b/src/browser/webapi/Document.zig index 281328f25..e3b711b7f 100644 --- a/src/browser/webapi/Document.zig +++ b/src/browser/webapi/Document.zig @@ -484,56 +484,85 @@ pub fn createEvent(_: *const Document, event_type: []const u8, frame: *Frame) !* } const normalized = std.ascii.lowerString(&frame.buf, event_type); - if (std.mem.eql(u8, normalized, "event") or std.mem.eql(u8, normalized, "events") or std.mem.eql(u8, normalized, "htmlevents")) { - return Event.init("", null, frame._page); - } + const event: *Event = blk: { + if (std.mem.eql(u8, normalized, "event") or std.mem.eql(u8, normalized, "events") or std.mem.eql(u8, normalized, "htmlevents") or std.mem.eql(u8, normalized, "svgevents")) { + break :blk try Event.init("", null, frame._page); + } - if (std.mem.eql(u8, normalized, "customevent") or std.mem.eql(u8, normalized, "customevents")) { - const CustomEvent = @import("event/CustomEvent.zig"); - return (try CustomEvent.init("", null, frame._page)).asEvent(); - } + if (std.mem.eql(u8, normalized, "customevent") or std.mem.eql(u8, normalized, "customevents")) { + const CustomEvent = @import("event/CustomEvent.zig"); + break :blk (try CustomEvent.init("", null, frame._page)).asEvent(); + } - if (std.mem.eql(u8, normalized, "keyboardevent")) { - const KeyboardEvent = @import("event/KeyboardEvent.zig"); - return (try KeyboardEvent.init("", null, frame)).asEvent(); - } + if (std.mem.eql(u8, normalized, "keyboardevent")) { + const KeyboardEvent = @import("event/KeyboardEvent.zig"); + break :blk (try KeyboardEvent.init("", null, frame)).asEvent(); + } - if (std.mem.eql(u8, normalized, "inputevent")) { - const InputEvent = @import("event/InputEvent.zig"); - return (try InputEvent.init("", null, frame)).asEvent(); - } + if (std.mem.eql(u8, normalized, "inputevent")) { + const InputEvent = @import("event/InputEvent.zig"); + break :blk (try InputEvent.init("", null, frame)).asEvent(); + } - if (std.mem.eql(u8, normalized, "mouseevent") or std.mem.eql(u8, normalized, "mouseevents")) { - const MouseEvent = @import("event/MouseEvent.zig"); - return (try MouseEvent.init("", null, frame)).asEvent(); - } + if (std.mem.eql(u8, normalized, "mouseevent") or std.mem.eql(u8, normalized, "mouseevents")) { + const MouseEvent = @import("event/MouseEvent.zig"); + break :blk (try MouseEvent.init("", null, frame)).asEvent(); + } - if (std.mem.eql(u8, normalized, "messageevent")) { - const MessageEvent = @import("event/MessageEvent.zig"); - return (try MessageEvent.init("", null, frame._page)).asEvent(); - } + if (std.mem.eql(u8, normalized, "dragevent")) { + const DragEvent = @import("event/DragEvent.zig"); + break :blk (try DragEvent.init("", null, frame)).asEvent(); + } - if (std.mem.eql(u8, normalized, "uievent") or std.mem.eql(u8, normalized, "uievents")) { - const UIEvent = @import("event/UIEvent.zig"); - return (try UIEvent.init("", null, frame)).asEvent(); - } + if (std.mem.eql(u8, normalized, "messageevent")) { + const MessageEvent = @import("event/MessageEvent.zig"); + break :blk (try MessageEvent.init("", null, frame._page)).asEvent(); + } - if (std.mem.eql(u8, normalized, "focusevent") or std.mem.eql(u8, normalized, "focusevents")) { - const FocusEvent = @import("event/FocusEvent.zig"); - return (try FocusEvent.init("", null, frame)).asEvent(); - } + if (std.mem.eql(u8, normalized, "hashchangeevent")) { + const HashChangeEvent = @import("event/HashChangeEvent.zig"); + break :blk (try HashChangeEvent.init("", null, frame)).asEvent(); + } - if (std.mem.eql(u8, normalized, "textevent") or std.mem.eql(u8, normalized, "textevents")) { - const TextEvent = @import("event/TextEvent.zig"); - return (try TextEvent.init("", null, frame)).asEvent(); - } + if (std.mem.eql(u8, normalized, "uievent") or std.mem.eql(u8, normalized, "uievents")) { + const UIEvent = @import("event/UIEvent.zig"); + break :blk (try UIEvent.init("", null, frame)).asEvent(); + } - if (std.mem.eql(u8, normalized, "compositionevent")) { - const CompositionEvent = @import("event/CompositionEvent.zig"); - return (try CompositionEvent.init("", null, frame)).asEvent(); - } + if (std.mem.eql(u8, normalized, "focusevent") or std.mem.eql(u8, normalized, "focusevents")) { + const FocusEvent = @import("event/FocusEvent.zig"); + break :blk (try FocusEvent.init("", null, frame)).asEvent(); + } - return error.NotSupported; + if (std.mem.eql(u8, normalized, "textevent") or std.mem.eql(u8, normalized, "textevents")) { + const TextEvent = @import("event/TextEvent.zig"); + break :blk (try TextEvent.init("", null, frame)).asEvent(); + } + + if (std.mem.eql(u8, normalized, "compositionevent")) { + const CompositionEvent = @import("event/CompositionEvent.zig"); + break :blk (try CompositionEvent.init("", null, frame)).asEvent(); + } + + // Aliases the spec requires createEvent to support but whose + // interfaces aren't implemented yet: return a plain Event so the + // caller can at least initialize and dispatch it. + if (std.mem.eql(u8, normalized, "beforeunloadevent") or + std.mem.eql(u8, normalized, "devicemotionevent") or + std.mem.eql(u8, normalized, "deviceorientationevent") or + std.mem.eql(u8, normalized, "storageevent")) + { + log.info(.not_implemented, "createEvent interface", .{ .type = event_type }); + break :blk try Event.init("", null, frame._page); + } + + return error.NotSupported; + }; + + // createEvent returns an uninitialized event: dispatching it before one + // of the init*Event calls throws an InvalidStateError. + event._initialized = false; + return event; } pub fn createTreeWalker(_: *const Document, root: *Node, what_to_show: ?js.Value, filter: ?DOMTreeWalker.FilterOpts, frame: *Frame) !*DOMTreeWalker { diff --git a/src/browser/webapi/Event.zig b/src/browser/webapi/Event.zig index 53179e125..c592e2793 100644 --- a/src/browser/webapi/Event.zig +++ b/src/browser/webapi/Event.zig @@ -50,6 +50,10 @@ _needs_retargeting: bool = false, _is_trusted: bool = false, _in_passive_listener: bool = false, _listeners_did_throw: bool = false, // IndexedDB needs to abort on callback throw +// Per spec, events created via document.createEvent are not initialized +// until one of the init*Event methods runs; dispatching one throws an +// InvalidStateError. Events created any other way start initialized. +_initialized: bool = true, // There's a period of time between creating an event and handing it off to v8 // where things can fail. If it does fail, we need to deinit the event. The timing @@ -137,6 +141,7 @@ pub fn initEvent( return; } + self._initialized = true; self._type_string = try String.init(self._arena, event_string, .{}); self._bubbles = bubbles orelse false; self._cancelable = cancelable orelse false; diff --git a/src/browser/webapi/EventTarget.zig b/src/browser/webapi/EventTarget.zig index 7fe689396..e8422f99c 100644 --- a/src/browser/webapi/EventTarget.zig +++ b/src/browser/webapi/EventTarget.zig @@ -69,6 +69,11 @@ pub fn dispatchEvent(self: *EventTarget, event: *Event, exec: *js.Execution) !bo if (event._event_phase != .none) { return error.InvalidStateError; } + // An event created by document.createEvent stays uninitialized until an + // init*Event call; dispatching it is an error. + if (!event._initialized) { + return error.InvalidStateError; + } event._is_trusted = false; switch (exec.js.global) { diff --git a/src/browser/webapi/event/CompositionEvent.zig b/src/browser/webapi/event/CompositionEvent.zig index fb3b4fd4d..93493d418 100644 --- a/src/browser/webapi/event/CompositionEvent.zig +++ b/src/browser/webapi/event/CompositionEvent.zig @@ -79,6 +79,7 @@ pub fn initCompositionEvent( } const arena = event._arena; + event._initialized = true; event._type_string = try String.init(arena, typ, .{}); event._bubbles = bubbles orelse false; event._cancelable = cancelable orelse false; diff --git a/src/browser/webapi/event/CustomEvent.zig b/src/browser/webapi/event/CustomEvent.zig index e1412a5d7..61f96e941 100644 --- a/src/browser/webapi/event/CustomEvent.zig +++ b/src/browser/webapi/event/CustomEvent.zig @@ -73,6 +73,7 @@ pub fn initCustomEvent( // This function can only be called after the constructor has called. // So we assume proto is initialized already by constructor. + self._proto._initialized = true; self._proto._type_string = try String.init(self._proto._arena, event_string, .{}); self._proto._bubbles = bubbles orelse false; self._proto._cancelable = cancelable orelse false; diff --git a/src/browser/webapi/event/KeyboardEvent.zig b/src/browser/webapi/event/KeyboardEvent.zig index 73ff74706..5c66cde3d 100644 --- a/src/browser/webapi/event/KeyboardEvent.zig +++ b/src/browser/webapi/event/KeyboardEvent.zig @@ -428,6 +428,7 @@ pub fn initKeyboardEvent( } const arena = event._arena; + event._initialized = true; event._type_string = try String.init(arena, typ, .{}); event._bubbles = bubbles orelse false; event._cancelable = cancelable orelse false; diff --git a/src/browser/webapi/event/MouseEvent.zig b/src/browser/webapi/event/MouseEvent.zig index eac879547..819d6fe9c 100644 --- a/src/browser/webapi/event/MouseEvent.zig +++ b/src/browser/webapi/event/MouseEvent.zig @@ -239,6 +239,7 @@ pub fn initMouseEvent( return; } + event._initialized = true; event._type_string = try String.init(event._arena, typ, .{}); event._bubbles = bubbles orelse false; event._cancelable = cancelable orelse false; diff --git a/src/browser/webapi/event/TextEvent.zig b/src/browser/webapi/event/TextEvent.zig index 8a5e238f8..f03cdd0dc 100644 --- a/src/browser/webapi/event/TextEvent.zig +++ b/src/browser/webapi/event/TextEvent.zig @@ -83,6 +83,7 @@ pub fn initTextEvent( } const arena = event._arena; + event._initialized = true; event._type_string = try String.init(arena, typ, .{}); event._bubbles = bubbles orelse false; event._cancelable = cancelable orelse false; diff --git a/src/browser/webapi/event/UIEvent.zig b/src/browser/webapi/event/UIEvent.zig index c5145e7f7..ded62c17a 100644 --- a/src/browser/webapi/event/UIEvent.zig +++ b/src/browser/webapi/event/UIEvent.zig @@ -146,6 +146,7 @@ pub fn initUIEvent( return; } + event._initialized = true; event._type_string = try String.init(event._arena, typ, .{}); event._bubbles = bubbles orelse false; event._cancelable = cancelable orelse false; From 40fa69ecf6ad14d45097d53bce8df771f58aea7e Mon Sep 17 00:00:00 2001 From: Francis Bouvier Date: Fri, 10 Jul 2026 20:36:25 +0200 Subject: [PATCH 4/5] webapi: Event.timeStamp relative to the performance time origin Fixes all 4 failing tests in WPT /dom/events/Event-timestamp-high-resolution.html: event.timeStamp returned a raw monotonic-clock value (milliseconds since boot) instead of a DOMHighResTimeStamp sharing performance.now()'s time origin. - Events now record their creation time with the same (already coarsened) clock Performance uses, and the timeStamp getter reports it in milliseconds relative to the time origin. - Per spec the origin is the *event's relevant global*'s: the JS Event constructor captures the creating realm's origin so cross-realm reads (Event-timestamp-cross-realm-getter.html) stay correct; events without a captured origin fall back to the accessing realm's, which is the creating realm in all other paths. Coverage: /dom/events/Event-timestamp-high-resolution.html 0/4 -> 4/4, Event-timestamp-cross-realm-getter.html stays 1/1. No regressions across /dom/events; Event-dispatch-single-activation-behavior.html now runs (0/0 crash -> 3/132). Co-Authored-By: Claude Fable 5 --- src/browser/Factory.zig | 5 +++-- src/browser/webapi/Event.zig | 32 ++++++++++++++++++++++++------ src/browser/webapi/Performance.zig | 2 +- 3 files changed, 30 insertions(+), 9 deletions(-) diff --git a/src/browser/Factory.zig b/src/browser/Factory.zig index 00626ed12..5065b7141 100644 --- a/src/browser/Factory.zig +++ b/src/browser/Factory.zig @@ -237,8 +237,9 @@ fn AutoPrototypeChain(comptime types: []const type) type { fn eventInit(arena: Allocator, typ: String, value: anytype) !Event { // Round to 2ms for privacy (browsers do this) - const raw_timestamp = @import("../datetime.zig").milliTimestamp(.monotonic); - const time_stamp = (raw_timestamp / 2) * 2; + // Same (already coarsened) clock as the performance time origin, so the + // timeStamp getter can report it relative to that origin. + const time_stamp = @import("webapi/Performance.zig").highResTimestamp(); return .{ ._rc = .{}, diff --git a/src/browser/webapi/Event.zig b/src/browser/webapi/Event.zig index c592e2793..b4207be2a 100644 --- a/src/browser/webapi/Event.zig +++ b/src/browser/webapi/Event.zig @@ -54,6 +54,9 @@ _listeners_did_throw: bool = false, // IndexedDB needs to abort on callback thro // until one of the init*Event methods runs; dispatching one throws an // InvalidStateError. Events created any other way start initialized. _initialized: bool = true, +// Time origin of the event's relevant global, captured at creation when +// known; 0 means "use the accessing realm's origin" (see getTimeStamp). +_time_origin: u64 = 0, // There's a period of time between creating an event and handing it off to v8 // where things can fail. If it does fail, we need to deinit the event. The timing @@ -104,6 +107,15 @@ pub fn init(typ: []const u8, opts_: ?Options, page: *Page) !*Event { return initWithTrusted(arena, str, opts_, false); } +// The JS constructor entry point: also captures the creating realm's time +// origin so timeStamp stays relative to the event's relevant global even +// when read from another realm. +fn initFromJs(typ: []const u8, opts_: ?Options, exec: *js.Execution) !*Event { + const event = try init(typ, opts_, exec.page); + event._time_origin = exec.performance()._time_origin; + return event; +} + pub fn initTrusted(typ: String, opts_: ?Options, page: *Page) !*Event { const arena = try page.getArena(.tiny, "Event.trusted"); errdefer page.releaseArena(arena); @@ -113,9 +125,9 @@ pub fn initTrusted(typ: String, opts_: ?Options, page: *Page) !*Event { fn initWithTrusted(arena: Allocator, typ: String, opts_: ?Options, comptime trusted: bool) !*Event { const opts = opts_ orelse Options{}; - // Round to 2ms for privacy (browsers do this) - const raw_timestamp = @import("../../datetime.zig").milliTimestamp(.monotonic); - const time_stamp = (raw_timestamp / 2) * 2; + // Same (already coarsened) clock as the performance time origin, so the + // timeStamp getter can report it relative to that origin. + const time_stamp = @import("Performance.zig").highResTimestamp(); const event = try arena.create(Event); event.* = .{ @@ -262,8 +274,16 @@ pub fn getEventPhase(self: *const Event) u8 { return @intFromEnum(self._event_phase); } -pub fn getTimeStamp(self: *const Event) u64 { - return self._time_stamp; +// A DOMHighResTimeStamp in milliseconds, relative to the relevant global's +// time origin (the same clock as performance.now()). When the creating +// realm's origin wasn't captured, fall back to the accessing realm's, which +// is the same realm in all but cross-realm accesses. +pub fn getTimeStamp(self: *const Event, exec: *js.Execution) f64 { + const origin = if (self._time_origin != 0) self._time_origin else exec.performance()._time_origin; + if (self._time_stamp <= origin) { + return 0.0; + } + return @as(f64, @floatFromInt(self._time_stamp - origin)) / 1000.0; } pub fn setTrusted(self: *Event) void { @@ -475,7 +495,7 @@ pub const JsApi = struct { pub var class_id: bridge.ClassId = undefined; }; - pub const constructor = bridge.constructor(Event.init, .{}); + pub const constructor = bridge.constructor(Event.initFromJs, .{}); pub const @"type" = bridge.accessor(Event.getType, null, .{}); pub const bubbles = bridge.accessor(Event.getBubbles, null, .{}); pub const cancelable = bridge.accessor(Event.getCancelable, null, .{}); diff --git a/src/browser/webapi/Performance.zig b/src/browser/webapi/Performance.zig index f9cd4e77b..41edffd14 100644 --- a/src/browser/webapi/Performance.zig +++ b/src/browser/webapi/Performance.zig @@ -47,7 +47,7 @@ _delivery_scheduled: bool = false, /// Get high-resolution timestamp in microseconds, rounded to 5μs increments /// to match browser behavior (prevents fingerprinting) -fn highResTimestamp() u64 { +pub fn highResTimestamp() u64 { const ts = datetime.timespec(); const micros = @as(u64, @intCast(ts.sec)) * 1_000_000 + @as(u64, @intCast(@divTrunc(ts.nsec, 1_000))); // Round to nearest 5 microseconds (like Firefox default) From 54ae8800b2fde17028610a20b42d66ce786187f4 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Wed, 15 Jul 2026 10:24:22 +0800 Subject: [PATCH 5/5] Use Window frame to set click listener, not invocation frame Same with document Use existing `rootIsShadowRoot` helper in EventManager --- src/browser/EventManager.zig | 26 ++------------------------ src/browser/webapi/Document.zig | 7 ++++--- src/browser/webapi/Event.zig | 22 ++++++++-------------- src/browser/webapi/EventTarget.zig | 3 +-- src/browser/webapi/Window.zig | 10 +++++----- 5 files changed, 20 insertions(+), 48 deletions(-) diff --git a/src/browser/EventManager.zig b/src/browser/EventManager.zig index 0ac74e2fa..7cffc2d79 100644 --- a/src/browser/EventManager.zig +++ b/src/browser/EventManager.zig @@ -28,6 +28,7 @@ const Node = @import("webapi/Node.zig"); const Event = @import("webapi/Event.zig"); const EventTarget = @import("webapi/EventTarget.zig"); const Element = @import("webapi/Element.zig"); +const ShadowRoot = @import("webapi/ShadowRoot.zig"); const log = lp.log; const Allocator = std.mem.Allocator; @@ -111,9 +112,6 @@ pub fn dispatchOpts(self: *EventManager, target: *EventTarget, event: *Event, co switch (target._type) { .node => |node| try self.dispatchNode(node, event, opts), - // Property event handlers (e.g. xhr.onload, window.onerror) fire for - // script-dispatched events too, not only for the internal dispatch - // paths which pass them explicitly. .xhr => |xhr| try self.dispatchDirect(target, event, xhr.inlineHandler(event._type_string), .{ .context = "dispatch" }), .window => |w| try self.dispatchDirect(target, event, windowInlineHandler(w, event._type_string), .{ .context = "dispatch" }), else => try self.dispatchDirect(target, event, null, .{ .context = "dispatch" }), @@ -164,8 +162,6 @@ pub fn hasDirectListeners(self: *EventManager, target: *EventTarget, typ: []cons } fn dispatchNode(self: *EventManager, target: *Node, event: *Event, comptime opts: DispatchOpts) !void { - const ShadowRoot = @import("webapi/ShadowRoot.zig"); - { const et = target.asEventTarget(); event._target = et; @@ -388,20 +384,8 @@ fn dispatchNode(self: *EventManager, target: *Node, event: *Event, comptime opts } } -// Per spec ("invocation target in shadow tree"), window.event is left -// undefined while invoking listeners whose target lives in a shadow tree. fn currentEventForTarget(target: *EventTarget, event: *Event) ?*Event { - const ShadowRoot = @import("webapi/ShadowRoot.zig"); - switch (target._type) { - .node => |n| { - const root = n.getRootNode(.{}); - if (root.is(ShadowRoot) != null) { - return null; - } - }, - else => {}, - } - return event; + return if (rootIsShadowRoot(target)) null else event; } const DispatchPhaseOpts = struct { @@ -549,8 +533,6 @@ fn getInlineHandler(self: *EventManager, target: *EventTarget, event: *Event) ?j // DOM spec "retarget": walk original_target out of shadow trees until the // node is visible from current_target's tree. fn getAdjustedTarget(original_target: ?*EventTarget, current_target: *EventTarget) ?*EventTarget { - const ShadowRoot = @import("webapi/ShadowRoot.zig"); - const orig_node = switch ((original_target orelse return null)._type) { .node => |n| n, else => return original_target, @@ -572,8 +554,6 @@ fn getAdjustedTarget(original_target: ?*EventTarget, current_target: *EventTarge } fn isShadowIncludingInclusiveAncestor(ancestor: *Node, node: *Node) bool { - const ShadowRoot = @import("webapi/ShadowRoot.zig"); - var n: ?*Node = node; while (n) |cur| { if (cur == ancestor) { @@ -591,8 +571,6 @@ fn isShadowIncludingInclusiveAncestor(ancestor: *Node, node: *Node) bool { // Whether the target's tree root (without crossing shadow boundaries) is a // shadow root. Used for the spec's post-dispatch "clear targets" step. fn rootIsShadowRoot(target_: ?*EventTarget) bool { - const ShadowRoot = @import("webapi/ShadowRoot.zig"); - const target = target_ orelse return false; var current: *Node = switch (target._type) { .node => |n| n, diff --git a/src/browser/webapi/Document.zig b/src/browser/webapi/Document.zig index e3b711b7f..10548f3b8 100644 --- a/src/browser/webapi/Document.zig +++ b/src/browser/webapi/Document.zig @@ -94,14 +94,15 @@ pub fn setOnSelectionChange(self: *Document, listener: ?js.Function) !void { // property handlers), which the dispatch propagation path consults for any // event target. pub fn getOnClick(self: *Document, frame: *Frame) ?js.Function.Global { - return frame._event_target_attr_listeners.get(.{ .target = self.asEventTarget(), .handler = .onclick }); + return (self._frame orelse frame)._event_target_attr_listeners.get(.{ .target = self.asEventTarget(), .handler = .onclick }); } pub fn setOnClick(self: *Document, setter: ?Window.FunctionSetter, frame: *Frame) !void { + const owner = self._frame orelse frame; if (Window.getFunctionFromSetter(setter)) |cb| { - try frame._event_target_attr_listeners.put(frame.arena, .{ .target = self.asEventTarget(), .handler = .onclick }, cb); + try owner._event_target_attr_listeners.put(owner.arena, .{ .target = self.asEventTarget(), .handler = .onclick }, cb); } else { - _ = frame._event_target_attr_listeners.remove(.{ .target = self.asEventTarget(), .handler = .onclick }); + _ = owner._event_target_attr_listeners.remove(.{ .target = self.asEventTarget(), .handler = .onclick }); } } diff --git a/src/browser/webapi/Event.zig b/src/browser/webapi/Event.zig index b4207be2a..298370fc7 100644 --- a/src/browser/webapi/Event.zig +++ b/src/browser/webapi/Event.zig @@ -107,15 +107,6 @@ pub fn init(typ: []const u8, opts_: ?Options, page: *Page) !*Event { return initWithTrusted(arena, str, opts_, false); } -// The JS constructor entry point: also captures the creating realm's time -// origin so timeStamp stays relative to the event's relevant global even -// when read from another realm. -fn initFromJs(typ: []const u8, opts_: ?Options, exec: *js.Execution) !*Event { - const event = try init(typ, opts_, exec.page); - event._time_origin = exec.performance()._time_origin; - return event; -} - pub fn initTrusted(typ: String, opts_: ?Options, page: *Page) !*Event { const arena = try page.getArena(.tiny, "Event.trusted"); errdefer page.releaseArena(arena); @@ -274,10 +265,6 @@ pub fn getEventPhase(self: *const Event) u8 { return @intFromEnum(self._event_phase); } -// A DOMHighResTimeStamp in milliseconds, relative to the relevant global's -// time origin (the same clock as performance.now()). When the creating -// realm's origin wasn't captured, fall back to the accessing realm's, which -// is the same realm in all but cross-realm accesses. pub fn getTimeStamp(self: *const Event, exec: *js.Execution) f64 { const origin = if (self._time_origin != 0) self._time_origin else exec.performance()._time_origin; if (self._time_stamp <= origin) { @@ -495,7 +482,14 @@ pub const JsApi = struct { pub var class_id: bridge.ClassId = undefined; }; - pub const constructor = bridge.constructor(Event.initFromJs, .{}); + pub const constructor = bridge.constructor(struct { + fn wrap(typ: []const u8, opts_: ?Options, exec: *js.Execution) !*Event { + const event = try Event.init(typ, opts_, exec.page); + // capture the realm's time + event._time_origin = exec.performance()._time_origin; + return event; + } + }.wrap, .{}); pub const @"type" = bridge.accessor(Event.getType, null, .{}); pub const bubbles = bridge.accessor(Event.getBubbles, null, .{}); pub const cancelable = bridge.accessor(Event.getCancelable, null, .{}); diff --git a/src/browser/webapi/EventTarget.zig b/src/browser/webapi/EventTarget.zig index e8422f99c..ce130a143 100644 --- a/src/browser/webapi/EventTarget.zig +++ b/src/browser/webapi/EventTarget.zig @@ -69,8 +69,7 @@ pub fn dispatchEvent(self: *EventTarget, event: *Event, exec: *js.Execution) !bo if (event._event_phase != .none) { return error.InvalidStateError; } - // An event created by document.createEvent stays uninitialized until an - // init*Event call; dispatching it is an error. + if (!event._initialized) { return error.InvalidStateError; } diff --git a/src/browser/webapi/Window.zig b/src/browser/webapi/Window.zig index b83f10ee5..c42f1da6a 100644 --- a/src/browser/webapi/Window.zig +++ b/src/browser/webapi/Window.zig @@ -428,15 +428,15 @@ pub fn setOnScroll(self: *Window, setter: ?FunctionSetter) void { // Stored in the frame's attribute-listener map (like element and ShadowRoot // property handlers), which the dispatch propagation path consults for any // event target. -pub fn getOnClick(self: *Window, frame: *Frame) ?js.Function.Global { - return frame._event_target_attr_listeners.get(.{ .target = self.asEventTarget(), .handler = .onclick }); +pub fn getOnClick(self: *Window) ?js.Function.Global { + return self._frame._event_target_attr_listeners.get(.{ .target = self.asEventTarget(), .handler = .onclick }); } -pub fn setOnClick(self: *Window, setter: ?FunctionSetter, frame: *Frame) !void { +pub fn setOnClick(self: *Window, setter: ?FunctionSetter) !void { if (getFunctionFromSetter(setter)) |cb| { - try frame._event_target_attr_listeners.put(frame.arena, .{ .target = self.asEventTarget(), .handler = .onclick }, cb); + try self._frame._event_target_attr_listeners.put(self._frame.arena, .{ .target = self.asEventTarget(), .handler = .onclick }, cb); } else { - _ = frame._event_target_attr_listeners.remove(.{ .target = self.asEventTarget(), .handler = .onclick }); + _ = self._frame._event_target_attr_listeners.remove(.{ .target = self.asEventTarget(), .handler = .onclick }); } }