From f1498f2da91392f70266cf96571b22f42e9a5b12 Mon Sep 17 00:00:00 2001 From: Francis Bouvier Date: Fri, 10 Jul 2026 17:39:12 +0200 Subject: [PATCH 1/6] webapi: throw TypeError for addEventListener({ signal: null }) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes 2 failing tests in WPT /dom/events/AddEventListenerOptions- signal.any.html (and its .worker variant): "Passing null as the signal should throw" (with a valid and with a null listener). The AddEventListenerOptions dictionary declares `signal` as a non-nullable AbortSignal, so per Web IDL an explicit null (or any non-AbortSignal value) must be rejected with a TypeError during the dictionary conversion — even when the listener argument is null. The bridge collapsed both a missing member and an explicit null into a Zig null, silently ignoring the signal. EventTarget.addEventListener now receives the signal member as a raw js.Value: an absent or undefined member means "no signal", anything else is converted to an AbortSignal (throwing a TypeError when it isn't one), and the conversion runs before the null-callback early return to match the spec's argument-conversion ordering. Coverage: /dom/events/AddEventListenerOptions-signal.any.html 9/11 -> 11/11, /dom/events/AddEventListenerOptions-signal.any.worker.html 9/11 -> 11/11. Co-Authored-By: Claude Fable 5 --- src/browser/webapi/EventTarget.zig | 42 +++++++++++++++++++++++------- 1 file changed, 33 insertions(+), 9 deletions(-) diff --git a/src/browser/webapi/EventTarget.zig b/src/browser/webapi/EventTarget.zig index 2265822a2..7fe689396 100644 --- a/src/browser/webapi/EventTarget.zig +++ b/src/browser/webapi/EventTarget.zig @@ -23,6 +23,7 @@ const Page = @import("../Page.zig"); const EventManager = @import("../EventManager.zig"); const Event = @import("Event.zig"); +const AbortSignal = @import("AbortSignal.zig"); const RegisterOptions = EventManager.RegisterOptions; @@ -83,7 +84,17 @@ pub fn dispatchEvent(self: *EventTarget, event: *Event, exec: *js.Execution) !bo const AddEventListenerOptions = union(enum) { capture: bool, - options: RegisterOptions, + options: Options, + + // 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. + const Options = struct { + once: bool = false, + capture: bool = false, + passive: bool = false, + signal: ?js.Value = null, + }; }; pub const EventListenerCallback = union(enum) { @@ -91,6 +102,27 @@ pub const EventListenerCallback = union(enum) { object: js.Object, }; pub fn addEventListener(self: *EventTarget, typ: []const u8, callback_: ?EventListenerCallback, opts_: ?AddEventListenerOptions, exec: *js.Execution) !void { + // 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{}; + break :blk switch (o) { + .options => |opts| RegisterOptions{ + .once = opts.once, + .capture = opts.capture, + .passive = opts.passive, + .signal = signal: { + const signal = opts.signal orelse break :signal null; + if (signal.isUndefined()) { + break :signal null; + } + break :signal try signal.toZig(*AbortSignal); + }, + }, + .capture => |capture| RegisterOptions{ .capture = capture }, + }; + }; + const callback = callback_ orelse return; const em_callback: EventManager.Callback = switch (callback) { @@ -98,14 +130,6 @@ pub fn addEventListener(self: *EventTarget, typ: []const u8, callback_: ?EventLi .function => |func| .{ .function = func }, }; - const options = blk: { - const o = opts_ orelse break :blk RegisterOptions{}; - break :blk switch (o) { - .options => |opts| opts, - .capture => |capture| RegisterOptions{ .capture = capture }, - }; - }; - switch (exec.js.global) { inline else => |g| _ = try g._event_manager.register(self, typ, em_callback, options), } From ca92bb6a1bbd490a33bb4eb17224dc19cb5c2dce Mon Sep 17 00:00:00 2001 From: Francis Bouvier Date: Fri, 10 Jul 2026 17:48:32 +0200 Subject: [PATCH 2/6] webapi: window-reflecting body/frameset event handlers Fixes 24 failing tests in WPT /dom/events/Body-FrameSet-Event- Handlers.html (24/48 -> 48/48). Per the HTML spec, the onblur, onerror, onfocus, onload, onresize and onscroll event handlers of body and frameset elements are aliases for the Window's handlers (the "window-reflecting body element event handler set"), and setting the corresponding content attribute on those elements must activate the Window's handler too. Two groups of failures: - "Set ...": assigning a non-callable (e.g. "") to the IDL attribute threw "invalid argument" instead of storing null, because the setters took ?js.Function.Global. Per [LegacyTreatNonObjectAsNull], the Body/FrameSet setters now use Window.FunctionSetter (function or anything-else-means-null), like the existing Window handler setters. - "Forward ... to Window": only body.onload was forwarded. Window now stores onblur/onfocus/onresize/onscroll handlers too (and exposes the matching accessors, which it lacked entirely), and Body/FrameSet implement getters/setters forwarding all six handlers to the Window of the current frame. Setting or removing one of the six content attributes (at parse time via Build.complete or at runtime via the attributeChange/attributeRemove build hooks) compiles the handler body and stores it on the Window, so window.onX === element.onX. Coverage: /dom/events/Body-FrameSet-Event-Handlers.html 24/48 -> 48/48. Co-Authored-By: Claude Fable 5 --- src/browser/webapi/Window.zig | 74 +++++++++++++++++- src/browser/webapi/element/html/Body.zig | 72 +++++++++++++++--- src/browser/webapi/element/html/FrameSet.zig | 80 ++++++++++++++++++++ 3 files changed, 214 insertions(+), 12 deletions(-) diff --git a/src/browser/webapi/Window.zig b/src/browser/webapi/Window.zig index ecbb3e182..3ad9aef36 100644 --- a/src/browser/webapi/Window.zig +++ b/src/browser/webapi/Window.zig @@ -82,6 +82,10 @@ _on_pageshow: ?js.Function.Global = null, _on_popstate: ?js.Function.Global = null, _on_hashchange: ?js.Function.Global = null, _on_error: ?js.Function.Global = null, +_on_blur: ?js.Function.Global = null, +_on_focus: ?js.Function.Global = null, +_on_resize: ?js.Function.Global = null, +_on_scroll: ?js.Function.Global = null, _on_message: ?js.Function.Global = null, _on_rejection_handled: ?js.Function.Global = null, _on_unhandled_rejection: ?js.Function.Global = null, @@ -388,6 +392,68 @@ pub fn setOnError(self: *Window, setter: ?FunctionSetter) void { self._on_error = getFunctionFromSetter(setter); } +pub fn getOnBlur(self: *const Window) ?js.Function.Global { + return self._on_blur; +} + +pub fn setOnBlur(self: *Window, setter: ?FunctionSetter) void { + self._on_blur = getFunctionFromSetter(setter); +} + +pub fn getOnFocus(self: *const Window) ?js.Function.Global { + return self._on_focus; +} + +pub fn setOnFocus(self: *Window, setter: ?FunctionSetter) void { + self._on_focus = getFunctionFromSetter(setter); +} + +pub fn getOnResize(self: *const Window) ?js.Function.Global { + return self._on_resize; +} + +pub fn setOnResize(self: *Window, setter: ?FunctionSetter) void { + self._on_resize = getFunctionFromSetter(setter); +} + +pub fn getOnScroll(self: *const Window) ?js.Function.Global { + return self._on_scroll; +} + +pub fn setOnScroll(self: *Window, setter: ?FunctionSetter) void { + self._on_scroll = getFunctionFromSetter(setter); +} + +// 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 +// null if the attribute isn't part of the set. +pub fn windowReflectingHandler(self: *Window, name: lp.String) ?*?js.Function.Global { + if (name.eql(comptime .wrap("onblur"))) return &self._on_blur; + if (name.eql(comptime .wrap("onerror"))) return &self._on_error; + if (name.eql(comptime .wrap("onfocus"))) return &self._on_focus; + if (name.eql(comptime .wrap("onload"))) return &self._on_load; + if (name.eql(comptime .wrap("onresize"))) return &self._on_resize; + if (name.eql(comptime .wrap("onscroll"))) return &self._on_scroll; + return null; +} + +// Applies a window-reflecting content attribute (set on a body or frameset +// element) to the Window's event handler. A null value clears the handler. +pub fn setWindowReflectingHandlerFromAttribute(self: *Window, name: lp.String, value: ?[]const u8, frame: *Frame) void { + const slot = self.windowReflectingHandler(name) orelse return; + const expr = value orelse { + slot.* = null; + return; + }; + if (frame.js.stringToPersistedFunction(expr, &.{"event"}, &.{})) |func| { + slot.* = func; + } else |err| { + log.err(.js, "window reflecting handler", .{ .err = err, .str = expr }); + slot.* = null; + } +} + pub fn getOnMessage(self: *const Window) ?js.Function.Global { return self._on_message; } @@ -1010,7 +1076,7 @@ const PostMessageCallback = struct { } }; -const FunctionSetter = union(enum) { +pub const FunctionSetter = union(enum) { func: js.Function.Global, anything: js.Value, }; @@ -1018,7 +1084,7 @@ const FunctionSetter = union(enum) { // window.onload = {}; doesn't fail, but it doesn't do anything. // seems like setting to null is ok (though, at least on Firefix, it preserves // the original value, which we could do, but why?) -fn getFunctionFromSetter(setter_: ?FunctionSetter) ?js.Function.Global { +pub fn getFunctionFromSetter(setter_: ?FunctionSetter) ?js.Function.Global { const setter = setter_ orelse return null; return switch (setter) { .func => |func| func, // Already a Global from bridge auto-conversion @@ -1076,6 +1142,10 @@ pub const JsApi = struct { pub const onpopstate = bridge.accessor(Window.getOnPopState, Window.setOnPopState, .{}); pub const onhashchange = bridge.accessor(Window.getOnHashChange, Window.setOnHashChange, .{}); pub const onerror = bridge.accessor(Window.getOnError, Window.setOnError, .{}); + pub const onblur = bridge.accessor(Window.getOnBlur, Window.setOnBlur, .{}); + 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 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, .{}); diff --git a/src/browser/webapi/element/html/Body.zig b/src/browser/webapi/element/html/Body.zig index faed2adf8..464ea83be 100644 --- a/src/browser/webapi/element/html/Body.zig +++ b/src/browser/webapi/element/html/Body.zig @@ -22,9 +22,10 @@ const Frame = @import("../../../Frame.zig"); const Node = @import("../../Node.zig"); const Element = @import("../../Element.zig"); +const Window = @import("../../Window.zig"); const HtmlElement = @import("../Html.zig"); -const log = lp.log; +const String = lp.String; const Body = @This(); @@ -37,15 +38,50 @@ pub fn asNode(self: *Body) *Node { return self.asElement().asNode(); } -/// Special-case: `body.onload` is actually an alias for `window.onload`. -pub fn setOnLoad(_: *Body, callback: ?js.Function.Global, frame: *Frame) !void { - frame.window._on_load = callback; +// Special-case: the "window-reflecting body element event handler set" +// (blur, error, focus, load, resize, scroll) are aliases for the Window's +// event handlers. +pub fn getOnBlur(_: *Body, frame: *Frame) ?js.Function.Global { + return frame.window._on_blur; +} +pub fn setOnBlur(_: *Body, setter: ?Window.FunctionSetter, frame: *Frame) !void { + frame.window._on_blur = Window.getFunctionFromSetter(setter); +} + +pub fn getOnError(_: *Body, frame: *Frame) ?js.Function.Global { + return frame.window._on_error; +} +pub fn setOnError(_: *Body, setter: ?Window.FunctionSetter, frame: *Frame) !void { + frame.window._on_error = Window.getFunctionFromSetter(setter); +} + +pub fn getOnFocus(_: *Body, frame: *Frame) ?js.Function.Global { + return frame.window._on_focus; +} +pub fn setOnFocus(_: *Body, setter: ?Window.FunctionSetter, frame: *Frame) !void { + frame.window._on_focus = Window.getFunctionFromSetter(setter); } -/// Special-case: `body.onload` is actually an alias for `window.onload`. pub fn getOnLoad(_: *Body, frame: *Frame) ?js.Function.Global { return frame.window._on_load; } +pub fn setOnLoad(_: *Body, setter: ?Window.FunctionSetter, frame: *Frame) !void { + frame.window._on_load = Window.getFunctionFromSetter(setter); +} + +pub fn getOnResize(_: *Body, frame: *Frame) ?js.Function.Global { + return frame.window._on_resize; +} +pub fn setOnResize(_: *Body, setter: ?Window.FunctionSetter, frame: *Frame) !void { + frame.window._on_resize = Window.getFunctionFromSetter(setter); +} + +pub fn getOnScroll(_: *Body, frame: *Frame) ?js.Function.Global { + return frame.window._on_scroll; +} +pub fn setOnScroll(_: *Body, setter: ?Window.FunctionSetter, frame: *Frame) !void { + frame.window._on_scroll = Window.getFunctionFromSetter(setter); +} pub const JsApi = struct { pub const bridge = js.Bridge(Body); @@ -56,17 +92,33 @@ pub const JsApi = struct { pub var class_id: bridge.ClassId = undefined; }; + pub const onblur = bridge.accessor(getOnBlur, setOnBlur, .{ .null_as_undefined = false }); + pub const onerror = bridge.accessor(getOnError, setOnError, .{ .null_as_undefined = false }); + pub const onfocus = bridge.accessor(getOnFocus, setOnFocus, .{ .null_as_undefined = false }); pub const onload = bridge.accessor(getOnLoad, setOnLoad, .{ .null_as_undefined = false }); + pub const onresize = bridge.accessor(getOnResize, setOnResize, .{ .null_as_undefined = false }); + pub const onscroll = bridge.accessor(getOnScroll, setOnScroll, .{ .null_as_undefined = false }); }; pub const Build = struct { + const window_reflecting_attributes = [_][]const u8{ + "onblur", "onerror", "onfocus", "onload", "onresize", "onscroll", + }; + pub fn complete(node: *Node, frame: *Frame) !void { const el = node.as(Element); - const on_load = el.getAttributeSafe(comptime .wrap("onload")) orelse return; - if (frame.js.stringToPersistedFunction(on_load, &.{"event"}, &.{})) |func| { - frame.window._on_load = func; - } else |err| { - log.err(.js, "body.onload", .{ .err = err, .str = on_load }); + inline for (window_reflecting_attributes) |attr| { + if (el.getAttributeSafe(comptime .wrap(attr))) |value| { + frame.window.setWindowReflectingHandlerFromAttribute(comptime .wrap(attr), value, frame); + } } } + + pub fn attributeChange(_: *Element, name: String, value: String, frame: *Frame) !void { + frame.window.setWindowReflectingHandlerFromAttribute(name, value.str(), frame); + } + + pub fn attributeRemove(_: *Element, name: String, frame: *Frame) !void { + frame.window.setWindowReflectingHandlerFromAttribute(name, null, frame); + } }; diff --git a/src/browser/webapi/element/html/FrameSet.zig b/src/browser/webapi/element/html/FrameSet.zig index 4e84a25c3..bec26401f 100644 --- a/src/browser/webapi/element/html/FrameSet.zig +++ b/src/browser/webapi/element/html/FrameSet.zig @@ -1,9 +1,14 @@ +const lp = @import("lightpanda"); + const js = @import("../../../js/js.zig"); const Frame = @import("../../../Frame.zig"); const Node = @import("../../Node.zig"); const Element = @import("../../Element.zig"); +const Window = @import("../../Window.zig"); const HtmlElement = @import("../Html.zig"); +const String = lp.String; + const FrameSet = @This(); _proto: *HtmlElement, @@ -15,6 +20,51 @@ pub fn asNode(self: *FrameSet) *Node { return self.asElement().asNode(); } +// Special-case: the "window-reflecting body element event handler set" +// (blur, error, focus, load, resize, scroll) are aliases for the Window's +// event handlers, on frameset elements just like on body elements. +pub fn getOnBlur(_: *FrameSet, frame: *Frame) ?js.Function.Global { + return frame.window._on_blur; +} +pub fn setOnBlur(_: *FrameSet, setter: ?Window.FunctionSetter, frame: *Frame) !void { + frame.window._on_blur = Window.getFunctionFromSetter(setter); +} + +pub fn getOnError(_: *FrameSet, frame: *Frame) ?js.Function.Global { + return frame.window._on_error; +} +pub fn setOnError(_: *FrameSet, setter: ?Window.FunctionSetter, frame: *Frame) !void { + frame.window._on_error = Window.getFunctionFromSetter(setter); +} + +pub fn getOnFocus(_: *FrameSet, frame: *Frame) ?js.Function.Global { + return frame.window._on_focus; +} +pub fn setOnFocus(_: *FrameSet, setter: ?Window.FunctionSetter, frame: *Frame) !void { + frame.window._on_focus = Window.getFunctionFromSetter(setter); +} + +pub fn getOnLoad(_: *FrameSet, frame: *Frame) ?js.Function.Global { + return frame.window._on_load; +} +pub fn setOnLoad(_: *FrameSet, setter: ?Window.FunctionSetter, frame: *Frame) !void { + frame.window._on_load = Window.getFunctionFromSetter(setter); +} + +pub fn getOnResize(_: *FrameSet, frame: *Frame) ?js.Function.Global { + return frame.window._on_resize; +} +pub fn setOnResize(_: *FrameSet, setter: ?Window.FunctionSetter, frame: *Frame) !void { + frame.window._on_resize = Window.getFunctionFromSetter(setter); +} + +pub fn getOnScroll(_: *FrameSet, frame: *Frame) ?js.Function.Global { + return frame.window._on_scroll; +} +pub fn setOnScroll(_: *FrameSet, setter: ?Window.FunctionSetter, frame: *Frame) !void { + frame.window._on_scroll = Window.getFunctionFromSetter(setter); +} + pub fn getCols(self: *FrameSet) []const u8 { return self.asElement().getAttributeSafe(comptime .wrap("cols")) orelse ""; } @@ -42,6 +92,36 @@ pub const JsApi = struct { pub const cols = bridge.accessor(FrameSet.getCols, FrameSet.setCols, .{ .ce_reactions = true }); pub const rows = bridge.accessor(FrameSet.getRows, FrameSet.setRows, .{ .ce_reactions = true }); + + pub const onblur = bridge.accessor(getOnBlur, setOnBlur, .{ .null_as_undefined = false }); + pub const onerror = bridge.accessor(getOnError, setOnError, .{ .null_as_undefined = false }); + pub const onfocus = bridge.accessor(getOnFocus, setOnFocus, .{ .null_as_undefined = false }); + pub const onload = bridge.accessor(getOnLoad, setOnLoad, .{ .null_as_undefined = false }); + pub const onresize = bridge.accessor(getOnResize, setOnResize, .{ .null_as_undefined = false }); + pub const onscroll = bridge.accessor(getOnScroll, setOnScroll, .{ .null_as_undefined = false }); +}; + +pub const Build = struct { + const window_reflecting_attributes = [_][]const u8{ + "onblur", "onerror", "onfocus", "onload", "onresize", "onscroll", + }; + + pub fn complete(node: *Node, frame: *Frame) !void { + const el = node.as(Element); + inline for (window_reflecting_attributes) |attr| { + if (el.getAttributeSafe(comptime .wrap(attr))) |value| { + frame.window.setWindowReflectingHandlerFromAttribute(comptime .wrap(attr), value, frame); + } + } + } + + pub fn attributeChange(_: *Element, name: String, value: String, frame: *Frame) !void { + frame.window.setWindowReflectingHandlerFromAttribute(name, value.str(), frame); + } + + pub fn attributeRemove(_: *Element, name: String, frame: *Frame) !void { + frame.window.setWindowReflectingHandlerFromAttribute(name, null, frame); + } }; const testing = @import("../../../../testing.zig"); From 0872b863839c16eaa47dc3f1c9fd5277d3666fd2 Mon Sep 17 00:00:00 2001 From: Francis Bouvier Date: Fri, 10 Jul 2026 18:41:11 +0200 Subject: [PATCH 3/6] js: propagate pending JS exceptions from argument conversion Fixes 1 failing test in WPT /dom/events/Event-constructors.any.html (and its .worker variant): "Event constructors 2", which does `new Event({ toString: function() { throw test_error; } })` and expects the exact test_error object to propagate. When a JS value's conversion runs user code that throws (here, ToString invoking a throwing toString method), the conversion fails with error.JsException while the user's exception is pending in the isolate. The argument-conversion catch folded that into error.InvalidArgument, and handleError then threw a brand new "TypeError: invalid argument" over the pending exception, so the script observed the wrong error. error.JsException is now passed through the argument-conversion catch, and handleError leaves the isolate untouched for it (like the existing TryCatchRethrow case), letting the original exception surface. Coverage: /dom/events/Event-constructors.any.html 13/14 -> 14/14, /dom/events/Event-constructors.any.worker.html 13/14 -> 14/14. Co-Authored-By: Claude Fable 5 --- src/browser/js/Caller.zig | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/browser/js/Caller.zig b/src/browser/js/Caller.zig index 3caae9054..416d70cc0 100644 --- a/src/browser/js/Caller.zig +++ b/src/browser/js/Caller.zig @@ -515,6 +515,10 @@ fn handleError(comptime T: type, comptime F: type, local: *const Local, err: any const js_err: *const v8.Value = switch (err) { error.TryCatchRethrow => return, + // A JS exception is already pending in the isolate (e.g. a value's + // toString threw during argument conversion); throwing anything here + // would replace the original exception the script expects to see. + error.JsException => return, error.InvalidArgument => isolate.createTypeError("invalid argument"), error.TypeError => isolate.createTypeError(""), error.RangeError => isolate.createRangeError(""), @@ -1016,6 +1020,11 @@ fn getArgs(comptime F: type, comptime offset: usize, local: *const Local, info: // type instantiation of jsValueToZig may not include such errors // in its inferred error set. @field(args, tupleFieldName(field_index)) = local.jsValueToZig(param.type.?, js_val) catch |err| { + if (err == error.JsException) { + // an exception thrown by user code (e.g. a toString + // getter) is pending; propagate it untouched + return err; + } const DOMException = @import("../webapi/DOMException.zig"); if (DOMException.fromError(err) != null) { return err; From d2cc336c741a0780291f61e23ac26441472597a1 Mon Sep 17 00:00:00 2001 From: Francis Bouvier Date: Tue, 14 Jul 2026 14:58:28 +0200 Subject: [PATCH 4/6] Update src/browser/webapi/Window.zig Co-authored-by: Karl Seguin --- src/browser/webapi/Window.zig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/browser/webapi/Window.zig b/src/browser/webapi/Window.zig index 3ad9aef36..a8a87cc75 100644 --- a/src/browser/webapi/Window.zig +++ b/src/browser/webapi/Window.zig @@ -428,7 +428,7 @@ pub fn setOnScroll(self: *Window, setter: ?FunctionSetter) void { // 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 // null if the attribute isn't part of the set. -pub fn windowReflectingHandler(self: *Window, name: lp.String) ?*?js.Function.Global { +fn windowReflectingHandler(self: *Window, name: lp.String) ?*?js.Function.Global { if (name.eql(comptime .wrap("onblur"))) return &self._on_blur; if (name.eql(comptime .wrap("onerror"))) return &self._on_error; if (name.eql(comptime .wrap("onfocus"))) return &self._on_focus; From dba25389c6852fab17f03e83739a42571c1a0d90 Mon Sep 17 00:00:00 2001 From: Francis Bouvier Date: Tue, 14 Jul 2026 15:19:39 +0200 Subject: [PATCH 5/6] webapi: window-reflecting handlers target the body's own window Review follow-up on #2944: the injected `frame` parameter of a bridged function is the frame of the executing JS context - the caller's realm - not the frame owning the element. The window-reflecting body/frameset event handlers (onblur, onerror, onfocus, onload, onresize, onscroll) used it directly, so a same-origin script reaching into another frame, e.g. the parent doing iframe.contentDocument.body.onblur = cb; stored the handler on the parent's window instead of the iframe's. Per the HTML spec these handlers alias the Window of the element's node document, whichever realm performs the access. All Body/FrameSet getters and setters now resolve that Window through the existing Node.ownerFrame (element -> ownerDocument -> frame, falling back to the caller's frame for detached/frameless documents), via a shared reflectedWindow helper. The Build hooks (parse-complete, attributeChange, attributeRemove) do the same, and additionally pass the owner frame to setWindowReflectingHandlerFromAttribute so attribute handler bodies are compiled in the element's document's realm. Unit test added: tests/frames/window_reflecting_handlers.html covers the IDL property path and the content-attribute path (parse-time and runtime) across realms, asserting the handler lands on the iframe's window while the parent's window stays untouched. It fails without this fix. Co-Authored-By: Karl Seguin --- .../frames/support/window_reflecting.html | 6 ++ .../frames/window_reflecting_handlers.html | 53 ++++++++++++++ src/browser/webapi/element/html/Body.zig | 71 +++++++++++-------- src/browser/webapi/element/html/FrameSet.zig | 71 +++++++++++-------- 4 files changed, 141 insertions(+), 60 deletions(-) create mode 100644 src/browser/tests/frames/support/window_reflecting.html create mode 100644 src/browser/tests/frames/window_reflecting_handlers.html diff --git a/src/browser/tests/frames/support/window_reflecting.html b/src/browser/tests/frames/support/window_reflecting.html new file mode 100644 index 000000000..b84a50668 --- /dev/null +++ b/src/browser/tests/frames/support/window_reflecting.html @@ -0,0 +1,6 @@ + + + +

x

+ diff --git a/src/browser/tests/frames/window_reflecting_handlers.html b/src/browser/tests/frames/window_reflecting_handlers.html new file mode 100644 index 000000000..0150f13d1 --- /dev/null +++ b/src/browser/tests/frames/window_reflecting_handlers.html @@ -0,0 +1,53 @@ + + + + + + + + + + + diff --git a/src/browser/webapi/element/html/Body.zig b/src/browser/webapi/element/html/Body.zig index 464ea83be..4101261cd 100644 --- a/src/browser/webapi/element/html/Body.zig +++ b/src/browser/webapi/element/html/Body.zig @@ -41,46 +41,54 @@ pub fn asNode(self: *Body) *Node { // Special-case: the "window-reflecting body element event handler set" // (blur, error, focus, load, resize, scroll) are aliases for the Window's // event handlers. -pub fn getOnBlur(_: *Body, frame: *Frame) ?js.Function.Global { - return frame.window._on_blur; -} -pub fn setOnBlur(_: *Body, setter: ?Window.FunctionSetter, frame: *Frame) !void { - frame.window._on_blur = Window.getFunctionFromSetter(setter); + +// The aliased Window is the one of the element's node document's frame — not +// the caller's frame, which differs when a same-origin script reaches into +// another frame (e.g. the parent setting iframeDoc.body.onblur). +fn reflectedWindow(self: *Body, frame: *Frame) *Window { + return self.asElement().ownerFrame(frame).window; } -pub fn getOnError(_: *Body, frame: *Frame) ?js.Function.Global { - return frame.window._on_error; +pub fn getOnBlur(self: *Body, frame: *Frame) ?js.Function.Global { + return self.reflectedWindow(frame)._on_blur; } -pub fn setOnError(_: *Body, setter: ?Window.FunctionSetter, frame: *Frame) !void { - frame.window._on_error = Window.getFunctionFromSetter(setter); +pub fn setOnBlur(self: *Body, setter: ?Window.FunctionSetter, frame: *Frame) !void { + self.reflectedWindow(frame)._on_blur = Window.getFunctionFromSetter(setter); } -pub fn getOnFocus(_: *Body, frame: *Frame) ?js.Function.Global { - return frame.window._on_focus; +pub fn getOnError(self: *Body, frame: *Frame) ?js.Function.Global { + return self.reflectedWindow(frame)._on_error; } -pub fn setOnFocus(_: *Body, setter: ?Window.FunctionSetter, frame: *Frame) !void { - frame.window._on_focus = Window.getFunctionFromSetter(setter); +pub fn setOnError(self: *Body, setter: ?Window.FunctionSetter, frame: *Frame) !void { + self.reflectedWindow(frame)._on_error = Window.getFunctionFromSetter(setter); } -pub fn getOnLoad(_: *Body, frame: *Frame) ?js.Function.Global { - return frame.window._on_load; +pub fn getOnFocus(self: *Body, frame: *Frame) ?js.Function.Global { + return self.reflectedWindow(frame)._on_focus; } -pub fn setOnLoad(_: *Body, setter: ?Window.FunctionSetter, frame: *Frame) !void { - frame.window._on_load = Window.getFunctionFromSetter(setter); +pub fn setOnFocus(self: *Body, setter: ?Window.FunctionSetter, frame: *Frame) !void { + self.reflectedWindow(frame)._on_focus = Window.getFunctionFromSetter(setter); } -pub fn getOnResize(_: *Body, frame: *Frame) ?js.Function.Global { - return frame.window._on_resize; +pub fn getOnLoad(self: *Body, frame: *Frame) ?js.Function.Global { + return self.reflectedWindow(frame)._on_load; } -pub fn setOnResize(_: *Body, setter: ?Window.FunctionSetter, frame: *Frame) !void { - frame.window._on_resize = Window.getFunctionFromSetter(setter); +pub fn setOnLoad(self: *Body, setter: ?Window.FunctionSetter, frame: *Frame) !void { + self.reflectedWindow(frame)._on_load = Window.getFunctionFromSetter(setter); } -pub fn getOnScroll(_: *Body, frame: *Frame) ?js.Function.Global { - return frame.window._on_scroll; +pub fn getOnResize(self: *Body, frame: *Frame) ?js.Function.Global { + return self.reflectedWindow(frame)._on_resize; } -pub fn setOnScroll(_: *Body, setter: ?Window.FunctionSetter, frame: *Frame) !void { - frame.window._on_scroll = Window.getFunctionFromSetter(setter); +pub fn setOnResize(self: *Body, setter: ?Window.FunctionSetter, frame: *Frame) !void { + self.reflectedWindow(frame)._on_resize = Window.getFunctionFromSetter(setter); +} + +pub fn getOnScroll(self: *Body, frame: *Frame) ?js.Function.Global { + return self.reflectedWindow(frame)._on_scroll; +} +pub fn setOnScroll(self: *Body, setter: ?Window.FunctionSetter, frame: *Frame) !void { + self.reflectedWindow(frame)._on_scroll = Window.getFunctionFromSetter(setter); } pub const JsApi = struct { @@ -107,18 +115,21 @@ pub const Build = struct { pub fn complete(node: *Node, frame: *Frame) !void { const el = node.as(Element); + const owner = node.ownerFrame(frame); inline for (window_reflecting_attributes) |attr| { if (el.getAttributeSafe(comptime .wrap(attr))) |value| { - frame.window.setWindowReflectingHandlerFromAttribute(comptime .wrap(attr), value, frame); + owner.window.setWindowReflectingHandlerFromAttribute(comptime .wrap(attr), value, owner); } } } - pub fn attributeChange(_: *Element, name: String, value: String, frame: *Frame) !void { - frame.window.setWindowReflectingHandlerFromAttribute(name, value.str(), frame); + pub fn attributeChange(el: *Element, name: String, value: String, frame: *Frame) !void { + const owner = el.ownerFrame(frame); + owner.window.setWindowReflectingHandlerFromAttribute(name, value.str(), owner); } - pub fn attributeRemove(_: *Element, name: String, frame: *Frame) !void { - frame.window.setWindowReflectingHandlerFromAttribute(name, null, frame); + pub fn attributeRemove(el: *Element, name: String, frame: *Frame) !void { + const owner = el.ownerFrame(frame); + owner.window.setWindowReflectingHandlerFromAttribute(name, null, owner); } }; diff --git a/src/browser/webapi/element/html/FrameSet.zig b/src/browser/webapi/element/html/FrameSet.zig index bec26401f..2643580a4 100644 --- a/src/browser/webapi/element/html/FrameSet.zig +++ b/src/browser/webapi/element/html/FrameSet.zig @@ -23,46 +23,54 @@ pub fn asNode(self: *FrameSet) *Node { // Special-case: the "window-reflecting body element event handler set" // (blur, error, focus, load, resize, scroll) are aliases for the Window's // event handlers, on frameset elements just like on body elements. -pub fn getOnBlur(_: *FrameSet, frame: *Frame) ?js.Function.Global { - return frame.window._on_blur; -} -pub fn setOnBlur(_: *FrameSet, setter: ?Window.FunctionSetter, frame: *Frame) !void { - frame.window._on_blur = Window.getFunctionFromSetter(setter); + +// The aliased Window is the one of the element's node document's frame — not +// the caller's frame, which differs when a same-origin script reaches into +// another frame (e.g. the parent setting a handler on a child frameset). +fn reflectedWindow(self: *FrameSet, frame: *Frame) *Window { + return self.asElement().ownerFrame(frame).window; } -pub fn getOnError(_: *FrameSet, frame: *Frame) ?js.Function.Global { - return frame.window._on_error; +pub fn getOnBlur(self: *FrameSet, frame: *Frame) ?js.Function.Global { + return self.reflectedWindow(frame)._on_blur; } -pub fn setOnError(_: *FrameSet, setter: ?Window.FunctionSetter, frame: *Frame) !void { - frame.window._on_error = Window.getFunctionFromSetter(setter); +pub fn setOnBlur(self: *FrameSet, setter: ?Window.FunctionSetter, frame: *Frame) !void { + self.reflectedWindow(frame)._on_blur = Window.getFunctionFromSetter(setter); } -pub fn getOnFocus(_: *FrameSet, frame: *Frame) ?js.Function.Global { - return frame.window._on_focus; +pub fn getOnError(self: *FrameSet, frame: *Frame) ?js.Function.Global { + return self.reflectedWindow(frame)._on_error; } -pub fn setOnFocus(_: *FrameSet, setter: ?Window.FunctionSetter, frame: *Frame) !void { - frame.window._on_focus = Window.getFunctionFromSetter(setter); +pub fn setOnError(self: *FrameSet, setter: ?Window.FunctionSetter, frame: *Frame) !void { + self.reflectedWindow(frame)._on_error = Window.getFunctionFromSetter(setter); } -pub fn getOnLoad(_: *FrameSet, frame: *Frame) ?js.Function.Global { - return frame.window._on_load; +pub fn getOnFocus(self: *FrameSet, frame: *Frame) ?js.Function.Global { + return self.reflectedWindow(frame)._on_focus; } -pub fn setOnLoad(_: *FrameSet, setter: ?Window.FunctionSetter, frame: *Frame) !void { - frame.window._on_load = Window.getFunctionFromSetter(setter); +pub fn setOnFocus(self: *FrameSet, setter: ?Window.FunctionSetter, frame: *Frame) !void { + self.reflectedWindow(frame)._on_focus = Window.getFunctionFromSetter(setter); } -pub fn getOnResize(_: *FrameSet, frame: *Frame) ?js.Function.Global { - return frame.window._on_resize; +pub fn getOnLoad(self: *FrameSet, frame: *Frame) ?js.Function.Global { + return self.reflectedWindow(frame)._on_load; } -pub fn setOnResize(_: *FrameSet, setter: ?Window.FunctionSetter, frame: *Frame) !void { - frame.window._on_resize = Window.getFunctionFromSetter(setter); +pub fn setOnLoad(self: *FrameSet, setter: ?Window.FunctionSetter, frame: *Frame) !void { + self.reflectedWindow(frame)._on_load = Window.getFunctionFromSetter(setter); } -pub fn getOnScroll(_: *FrameSet, frame: *Frame) ?js.Function.Global { - return frame.window._on_scroll; +pub fn getOnResize(self: *FrameSet, frame: *Frame) ?js.Function.Global { + return self.reflectedWindow(frame)._on_resize; } -pub fn setOnScroll(_: *FrameSet, setter: ?Window.FunctionSetter, frame: *Frame) !void { - frame.window._on_scroll = Window.getFunctionFromSetter(setter); +pub fn setOnResize(self: *FrameSet, setter: ?Window.FunctionSetter, frame: *Frame) !void { + self.reflectedWindow(frame)._on_resize = Window.getFunctionFromSetter(setter); +} + +pub fn getOnScroll(self: *FrameSet, frame: *Frame) ?js.Function.Global { + return self.reflectedWindow(frame)._on_scroll; +} +pub fn setOnScroll(self: *FrameSet, setter: ?Window.FunctionSetter, frame: *Frame) !void { + self.reflectedWindow(frame)._on_scroll = Window.getFunctionFromSetter(setter); } pub fn getCols(self: *FrameSet) []const u8 { @@ -108,19 +116,22 @@ pub const Build = struct { pub fn complete(node: *Node, frame: *Frame) !void { const el = node.as(Element); + const owner = node.ownerFrame(frame); inline for (window_reflecting_attributes) |attr| { if (el.getAttributeSafe(comptime .wrap(attr))) |value| { - frame.window.setWindowReflectingHandlerFromAttribute(comptime .wrap(attr), value, frame); + owner.window.setWindowReflectingHandlerFromAttribute(comptime .wrap(attr), value, owner); } } } - pub fn attributeChange(_: *Element, name: String, value: String, frame: *Frame) !void { - frame.window.setWindowReflectingHandlerFromAttribute(name, value.str(), frame); + pub fn attributeChange(el: *Element, name: String, value: String, frame: *Frame) !void { + const owner = el.ownerFrame(frame); + owner.window.setWindowReflectingHandlerFromAttribute(name, value.str(), owner); } - pub fn attributeRemove(_: *Element, name: String, frame: *Frame) !void { - frame.window.setWindowReflectingHandlerFromAttribute(name, null, frame); + pub fn attributeRemove(el: *Element, name: String, frame: *Frame) !void { + const owner = el.ownerFrame(frame); + owner.window.setWindowReflectingHandlerFromAttribute(name, null, owner); } }; From 0c0e3626f37d7eb2c43581cd508fac3557938337 Mon Sep 17 00:00:00 2001 From: Francis Bouvier Date: Tue, 14 Jul 2026 15:31:58 +0200 Subject: [PATCH 6/6] webapi: take *Frame in HTMLCollection.getIndexes Same review pattern as the getNames enumerators: the bridge can inject *Frame directly, so the manual switch on exec.js.global is unnecessary. getIndexes was the one enumerator the earlier suggestions missed. Co-Authored-By: Karl Seguin --- src/browser/webapi/collections/HTMLCollection.zig | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/src/browser/webapi/collections/HTMLCollection.zig b/src/browser/webapi/collections/HTMLCollection.zig index fa6a71f32..e67395945 100644 --- a/src/browser/webapi/collections/HTMLCollection.zig +++ b/src/browser/webapi/collections/HTMLCollection.zig @@ -212,13 +212,9 @@ pub const JsApi = struct { configurable: bool, }; - fn getIndexes(self: *HTMLCollection, exec: *const Execution) !js.Array { - const frame = switch (exec.js.global) { - .frame => |f| f, - .worker => unreachable, - }; + fn getIndexes(self: *HTMLCollection, frame: *Frame) !js.Array { const len = self.length(frame); - var arr = exec.js.local.?.newArray(len); + var arr = frame.js.local.?.newArray(len); for (0..len) |i| { _ = try arr.set(@intCast(i), i, .{}); }