Merge pull request #2944 from lightpanda-io/dom-pr-03-handler-plumbing

webapi: listener option validation, window-reflecting handlers, exception propagation
This commit is contained in:
Karl Seguin
2026-07-15 07:08:39 +08:00
committed by GitHub
8 changed files with 341 additions and 29 deletions

View File

@@ -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;

View File

@@ -0,0 +1,6 @@
<!DOCTYPE html>
<!-- The onresize attribute exercises the parse-time path: it must land on
THIS frame's window, not the top frame's. -->
<body onresize="window.__attr_resize = true">
<p>x</p>
</body>

View File

@@ -0,0 +1,53 @@
<!DOCTYPE html>
<head></head>
<body>
<script src="../testing.js"></script>
<!--
The window-reflecting body element event handlers (onblur, onerror, onfocus,
onload, onresize, onscroll) alias the Window of the body's node document. A
same-origin script reaching into another frame (parent setting a handler on
an iframe's body) must target the iframe's window, not the window of the
realm running the assignment.
-->
<iframe id="wrh" src="support/window_reflecting.html"></iframe>
<script id="cross_realm_property_handlers">
testing.onload(() => {
const idf = document.getElementById('wrh');
const ibody = idf.contentDocument.body;
const iwin = idf.contentWindow;
const cb = () => {};
ibody.onblur = cb;
testing.expectEqual(cb, ibody.onblur);
testing.expectEqual(cb, iwin.onblur);
// the parent's window (the caller's realm) must be untouched
testing.expectTrue(window.onblur == null);
testing.expectTrue(document.body.onblur == null);
ibody.onblur = null;
testing.expectTrue(iwin.onblur == null);
});
</script>
<script id="cross_realm_attribute_handlers">
testing.onload(() => {
const idf = document.getElementById('wrh');
const ibody = idf.contentDocument.body;
const iwin = idf.contentWindow;
// Parse-time: the onresize attribute in the iframe's markup was compiled
// onto the iframe's window while the parent frame was the active one.
testing.expectEqual('function', typeof iwin.onresize);
testing.expectTrue(window.onresize == null);
// Runtime: setting the content attribute from the parent realm.
ibody.setAttribute('onscroll', 'window.__parent_set = true');
testing.expectEqual('function', typeof iwin.onscroll);
testing.expectTrue(window.onscroll == null);
ibody.removeAttribute('onscroll');
testing.expectTrue(iwin.onscroll == null);
});
</script>

View File

@@ -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),
}

View File

@@ -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.
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;
}
@@ -1013,7 +1079,7 @@ const PostMessageCallback = struct {
}
};
const FunctionSetter = union(enum) {
pub const FunctionSetter = union(enum) {
func: js.Function.Global,
anything: js.Value,
};
@@ -1021,7 +1087,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
@@ -1079,6 +1145,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, .{});

View File

@@ -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, .{});
}

View File

@@ -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,14 +38,57 @@ 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.
// 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;
}
/// 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 getOnBlur(self: *Body, frame: *Frame) ?js.Function.Global {
return self.reflectedWindow(frame)._on_blur;
}
pub fn setOnBlur(self: *Body, setter: ?Window.FunctionSetter, frame: *Frame) !void {
self.reflectedWindow(frame)._on_blur = Window.getFunctionFromSetter(setter);
}
pub fn getOnError(self: *Body, frame: *Frame) ?js.Function.Global {
return self.reflectedWindow(frame)._on_error;
}
pub fn setOnError(self: *Body, setter: ?Window.FunctionSetter, frame: *Frame) !void {
self.reflectedWindow(frame)._on_error = Window.getFunctionFromSetter(setter);
}
pub fn getOnFocus(self: *Body, frame: *Frame) ?js.Function.Global {
return self.reflectedWindow(frame)._on_focus;
}
pub fn setOnFocus(self: *Body, setter: ?Window.FunctionSetter, frame: *Frame) !void {
self.reflectedWindow(frame)._on_focus = Window.getFunctionFromSetter(setter);
}
pub fn getOnLoad(self: *Body, frame: *Frame) ?js.Function.Global {
return self.reflectedWindow(frame)._on_load;
}
pub fn setOnLoad(self: *Body, setter: ?Window.FunctionSetter, frame: *Frame) !void {
self.reflectedWindow(frame)._on_load = Window.getFunctionFromSetter(setter);
}
pub fn getOnResize(self: *Body, frame: *Frame) ?js.Function.Global {
return self.reflectedWindow(frame)._on_resize;
}
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 {
@@ -56,17 +100,36 @@ 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 });
const owner = node.ownerFrame(frame);
inline for (window_reflecting_attributes) |attr| {
if (el.getAttributeSafe(comptime .wrap(attr))) |value| {
owner.window.setWindowReflectingHandlerFromAttribute(comptime .wrap(attr), value, owner);
}
}
}
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(el: *Element, name: String, frame: *Frame) !void {
const owner = el.ownerFrame(frame);
owner.window.setWindowReflectingHandlerFromAttribute(name, null, owner);
}
};

View File

@@ -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,59 @@ 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.
// 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 getOnBlur(self: *FrameSet, frame: *Frame) ?js.Function.Global {
return self.reflectedWindow(frame)._on_blur;
}
pub fn setOnBlur(self: *FrameSet, setter: ?Window.FunctionSetter, frame: *Frame) !void {
self.reflectedWindow(frame)._on_blur = Window.getFunctionFromSetter(setter);
}
pub fn getOnError(self: *FrameSet, frame: *Frame) ?js.Function.Global {
return self.reflectedWindow(frame)._on_error;
}
pub fn setOnError(self: *FrameSet, setter: ?Window.FunctionSetter, frame: *Frame) !void {
self.reflectedWindow(frame)._on_error = Window.getFunctionFromSetter(setter);
}
pub fn getOnFocus(self: *FrameSet, frame: *Frame) ?js.Function.Global {
return self.reflectedWindow(frame)._on_focus;
}
pub fn setOnFocus(self: *FrameSet, setter: ?Window.FunctionSetter, frame: *Frame) !void {
self.reflectedWindow(frame)._on_focus = Window.getFunctionFromSetter(setter);
}
pub fn getOnLoad(self: *FrameSet, frame: *Frame) ?js.Function.Global {
return self.reflectedWindow(frame)._on_load;
}
pub fn setOnLoad(self: *FrameSet, setter: ?Window.FunctionSetter, frame: *Frame) !void {
self.reflectedWindow(frame)._on_load = Window.getFunctionFromSetter(setter);
}
pub fn getOnResize(self: *FrameSet, frame: *Frame) ?js.Function.Global {
return self.reflectedWindow(frame)._on_resize;
}
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 {
return self.asElement().getAttributeSafe(comptime .wrap("cols")) orelse "";
}
@@ -42,6 +100,39 @@ 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);
const owner = node.ownerFrame(frame);
inline for (window_reflecting_attributes) |attr| {
if (el.getAttributeSafe(comptime .wrap(attr))) |value| {
owner.window.setWindowReflectingHandlerFromAttribute(comptime .wrap(attr), value, owner);
}
}
}
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(el: *Element, name: String, frame: *Frame) !void {
const owner = el.ownerFrame(frame);
owner.window.setWindowReflectingHandlerFromAttribute(name, null, owner);
}
};
const testing = @import("../../../../testing.zig");