From 7f04ee5f0aacd64cb3fba513b77c14482111f611 Mon Sep 17 00:00:00 2001 From: Francis Bouvier Date: Fri, 10 Jul 2026 22:27:01 +0200 Subject: [PATCH 1/4] webapi: add BeforeUnload/Storage/DeviceMotion/DeviceOrientation/Touch events MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes all 21 failing tests in WPT /dom/nodes/Document-createEvent.https.html (258/279 -> 279/279), Document-createEvent-touchevent.window.html (0/3 -> 3/3) and, as a side effect, /dom/events/non-cancelable-when- passive/synthetic-events-cancelable.html (8/12 -> 12/12). - Five new event interfaces, following the existing patterns: BeforeUnloadEvent (returnValue, no constructor per spec), StorageEvent (key/oldValue/newValue/url + initStorageEvent; storageArea is always null), DeviceMotionEvent and DeviceOrientationEvent (nullable sensor values), and TouchEvent (UIEvent subclass with modifier keys and empty touch lists — there is no touch input source). - document.createEvent maps their aliases to real instances instead of plain Events, adds "touchevent", and drops the non-spec pluralized aliases CustomEvents/FocusEvents/TextEvents, which must throw NotSupportedError (the unit test asserting the old behavior is updated). - The "expose legacy touch event APIs" precondition requires ontouchstart & co. to exist: the four touch handlers are added to the global event handler set, HTMLElement and Document. Coverage: /dom/nodes/Document-createEvent.https.html 258/279 -> 279/279, Document-createEvent-touchevent.window.html 0/3 -> 3/3, synthetic-events-cancelable.html 8/12 -> 12/12. No regressions across /dom/events; 1002/1002 unit tests pass. Co-Authored-By: Claude Fable 5 --- src/browser/js/bridge.zig | 5 + src/browser/tests/event/custom_event.html | 6 +- src/browser/webapi/Document.zig | 61 +++++-- src/browser/webapi/Event.zig | 8 + src/browser/webapi/element/Html.zig | 36 +++++ .../webapi/event/BeforeUnloadEvent.zig | 89 +++++++++++ .../webapi/event/DeviceMotionEvent.zig | 96 +++++++++++ .../webapi/event/DeviceOrientationEvent.zig | 104 ++++++++++++ src/browser/webapi/event/StorageEvent.zig | 149 ++++++++++++++++++ src/browser/webapi/event/TouchEvent.zig | 124 +++++++++++++++ src/browser/webapi/event/UIEvent.zig | 2 + src/browser/webapi/global_event_handlers.zig | 4 + 12 files changed, 669 insertions(+), 15 deletions(-) create mode 100644 src/browser/webapi/event/BeforeUnloadEvent.zig create mode 100644 src/browser/webapi/event/DeviceMotionEvent.zig create mode 100644 src/browser/webapi/event/DeviceOrientationEvent.zig create mode 100644 src/browser/webapi/event/StorageEvent.zig create mode 100644 src/browser/webapi/event/TouchEvent.zig diff --git a/src/browser/js/bridge.zig b/src/browser/js/bridge.zig index 100e1520b..ff6091240 100644 --- a/src/browser/js/bridge.zig +++ b/src/browser/js/bridge.zig @@ -1091,6 +1091,11 @@ pub const PageJsApis = flattenTypes(&.{ @import("../webapi/event/PageTransitionEvent.zig"), @import("../webapi/event/PopStateEvent.zig"), @import("../webapi/event/HashChangeEvent.zig"), + @import("../webapi/event/BeforeUnloadEvent.zig"), + @import("../webapi/event/StorageEvent.zig"), + @import("../webapi/event/DeviceMotionEvent.zig"), + @import("../webapi/event/DeviceOrientationEvent.zig"), + @import("../webapi/event/TouchEvent.zig"), @import("../webapi/event/UIEvent.zig"), @import("../webapi/event/MouseEvent.zig"), @import("../webapi/event/PointerEvent.zig"), diff --git a/src/browser/tests/event/custom_event.html b/src/browser/tests/event/custom_event.html index 1835b205a..b4c87fab6 100644 --- a/src/browser/tests/event/custom_event.html +++ b/src/browser/tests/event/custom_event.html @@ -75,8 +75,10 @@ const event2 = document.createEvent('CUSTOMEVENT'); testing.expectEqual('', event2.type); - const event3 = document.createEvent('CustomEvents'); - testing.expectEqual('', event3.type); + // per spec, the pluralized legacy alias is not supported + let threw = false; + try { document.createEvent('CustomEvents'); } catch (e) { threw = e.name === 'NotSupportedError'; } + testing.expectEqual(true, threw); } diff --git a/src/browser/webapi/Document.zig b/src/browser/webapi/Document.zig index c0b270ff8..c4795d218 100644 --- a/src/browser/webapi/Document.zig +++ b/src/browser/webapi/Document.zig @@ -108,6 +108,24 @@ pub fn setOnClick(self: *Document, setter: ?Window.FunctionSetter, frame: *Frame } } +// Generates a getter/setter pair backed by the frame's attribute-listener +// map, like onclick above, for other document event handler properties. +fn handlerAccessor(comptime handler: @import("global_event_handlers.zig").Handler) type { + return struct { + pub fn get(self: *Document, frame: *Frame) ?js.Function.Global { + return frame._event_target_attr_listeners.get(.{ .target = self.asEventTarget(), .handler = handler }); + } + + pub fn set(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 = handler }, cb); + } else { + _ = frame._event_target_attr_listeners.remove(.{ .target = self.asEventTarget(), .handler = handler }); + } + } + }; +} + pub const Type = union(enum) { generic, html: *HTMLDocument, @@ -495,7 +513,7 @@ pub fn createEvent(_: *const Document, event_type: []const u8, frame: *Frame) !* break :blk try Event.init("", null, frame._page); } - if (std.mem.eql(u8, normalized, "customevent") or std.mem.eql(u8, normalized, "customevents")) { + if (std.mem.eql(u8, normalized, "customevent")) { const CustomEvent = @import("event/CustomEvent.zig"); break :blk (try CustomEvent.init("", null, frame._page)).asEvent(); } @@ -535,12 +553,12 @@ pub fn createEvent(_: *const Document, event_type: []const u8, frame: *Frame) !* break :blk (try UIEvent.init("", null, frame)).asEvent(); } - if (std.mem.eql(u8, normalized, "focusevent") or std.mem.eql(u8, normalized, "focusevents")) { + if (std.mem.eql(u8, normalized, "focusevent")) { const FocusEvent = @import("event/FocusEvent.zig"); break :blk (try FocusEvent.init("", null, frame)).asEvent(); } - if (std.mem.eql(u8, normalized, "textevent") or std.mem.eql(u8, normalized, "textevents")) { + if (std.mem.eql(u8, normalized, "textevent")) { const TextEvent = @import("event/TextEvent.zig"); break :blk (try TextEvent.init("", null, frame)).asEvent(); } @@ -550,16 +568,29 @@ pub fn createEvent(_: *const Document, event_type: []const u8, frame: *Frame) !* 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); + if (std.mem.eql(u8, normalized, "beforeunloadevent")) { + const BeforeUnloadEvent = @import("event/BeforeUnloadEvent.zig"); + break :blk (try BeforeUnloadEvent.init("", null, frame)).asEvent(); + } + + if (std.mem.eql(u8, normalized, "devicemotionevent")) { + const DeviceMotionEvent = @import("event/DeviceMotionEvent.zig"); + break :blk (try DeviceMotionEvent.init("", null, frame)).asEvent(); + } + + if (std.mem.eql(u8, normalized, "deviceorientationevent")) { + const DeviceOrientationEvent = @import("event/DeviceOrientationEvent.zig"); + break :blk (try DeviceOrientationEvent.init("", null, frame)).asEvent(); + } + + if (std.mem.eql(u8, normalized, "storageevent")) { + const StorageEvent = @import("event/StorageEvent.zig"); + break :blk (try StorageEvent.init("", null, frame)).asEvent(); + } + + if (std.mem.eql(u8, normalized, "touchevent")) { + const TouchEvent = @import("event/TouchEvent.zig"); + break :blk (try TouchEvent.init("", null, frame)).asEvent(); } return error.NotSupported; @@ -1409,6 +1440,10 @@ pub const JsApi = struct { pub const onselectionchange = bridge.accessor(Document.getOnSelectionChange, Document.setOnSelectionChange, .{}); pub const onclick = bridge.accessor(Document.getOnClick, Document.setOnClick, .{}); + pub const ontouchstart = bridge.accessor(handlerAccessor(.ontouchstart).get, handlerAccessor(.ontouchstart).set, .{}); + pub const ontouchend = bridge.accessor(handlerAccessor(.ontouchend).get, handlerAccessor(.ontouchend).set, .{}); + pub const ontouchmove = bridge.accessor(handlerAccessor(.ontouchmove).get, handlerAccessor(.ontouchmove).set, .{}); + pub const ontouchcancel = bridge.accessor(handlerAccessor(.ontouchcancel).get, handlerAccessor(.ontouchcancel).set, .{}); 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/Event.zig b/src/browser/webapi/Event.zig index a0323c86f..c344a761f 100644 --- a/src/browser/webapi/Event.zig +++ b/src/browser/webapi/Event.zig @@ -84,6 +84,10 @@ pub const Type = union(enum) { page_transition_event: *@import("event/PageTransitionEvent.zig"), pop_state_event: *@import("event/PopStateEvent.zig"), hash_change_event: *@import("event/HashChangeEvent.zig"), + before_unload_event: *@import("event/BeforeUnloadEvent.zig"), + storage_event: *@import("event/StorageEvent.zig"), + device_motion_event: *@import("event/DeviceMotionEvent.zig"), + device_orientation_event: *@import("event/DeviceOrientationEvent.zig"), ui_event: *@import("event/UIEvent.zig"), promise_rejection_event: *@import("event/PromiseRejectionEvent.zig"), submit_event: *@import("event/SubmitEvent.zig"), @@ -193,6 +197,10 @@ pub fn is(self: *Event, comptime T: type) ?*T { .page_transition_event => |e| return if (T == @import("event/PageTransitionEvent.zig")) e else null, .pop_state_event => |e| return if (T == @import("event/PopStateEvent.zig")) e else null, .hash_change_event => |e| return if (T == @import("event/HashChangeEvent.zig")) e else null, + .before_unload_event => |e| return if (T == @import("event/BeforeUnloadEvent.zig")) e else null, + .storage_event => |e| return if (T == @import("event/StorageEvent.zig")) e else null, + .device_motion_event => |e| return if (T == @import("event/DeviceMotionEvent.zig")) e else null, + .device_orientation_event => |e| return if (T == @import("event/DeviceOrientationEvent.zig")) e else null, .promise_rejection_event => |e| return if (T == @import("event/PromiseRejectionEvent.zig")) e else null, .submit_event => |e| return if (T == @import("event/SubmitEvent.zig")) e else null, .form_data_event => |e| return if (T == @import("event/FormDataEvent.zig")) e else null, diff --git a/src/browser/webapi/element/Html.zig b/src/browser/webapi/element/Html.zig index fead6c238..2caf21a2c 100644 --- a/src/browser/webapi/element/Html.zig +++ b/src/browser/webapi/element/Html.zig @@ -1227,6 +1227,38 @@ pub fn getOnToggle(self: *HtmlElement, frame: *Frame) !?js.Function.Global { return self.getAttributeFunction(.ontoggle, frame); } +pub fn setOnTouchCancel(self: *HtmlElement, callback: ?js.Function.Global, frame: *Frame) !void { + return self.setAttributeListener(.ontouchcancel, callback, frame); +} + +pub fn getOnTouchCancel(self: *HtmlElement, frame: *Frame) !?js.Function.Global { + return self.getAttributeFunction(.ontouchcancel, frame); +} + +pub fn setOnTouchEnd(self: *HtmlElement, callback: ?js.Function.Global, frame: *Frame) !void { + return self.setAttributeListener(.ontouchend, callback, frame); +} + +pub fn getOnTouchEnd(self: *HtmlElement, frame: *Frame) !?js.Function.Global { + return self.getAttributeFunction(.ontouchend, frame); +} + +pub fn setOnTouchMove(self: *HtmlElement, callback: ?js.Function.Global, frame: *Frame) !void { + return self.setAttributeListener(.ontouchmove, callback, frame); +} + +pub fn getOnTouchMove(self: *HtmlElement, frame: *Frame) !?js.Function.Global { + return self.getAttributeFunction(.ontouchmove, frame); +} + +pub fn setOnTouchStart(self: *HtmlElement, callback: ?js.Function.Global, frame: *Frame) !void { + return self.setAttributeListener(.ontouchstart, callback, frame); +} + +pub fn getOnTouchStart(self: *HtmlElement, frame: *Frame) !?js.Function.Global { + return self.getAttributeFunction(.ontouchstart, frame); +} + pub fn setOnTransitionCancel(self: *HtmlElement, callback: ?js.Function.Global, frame: *Frame) !void { return self.setAttributeListener(.ontransitioncancel, callback, frame); } @@ -1773,6 +1805,10 @@ pub const JsApi = struct { pub const onsuspend = bridge.accessor(HtmlElement.getOnSuspend, HtmlElement.setOnSuspend, .{}); pub const ontimeupdate = bridge.accessor(HtmlElement.getOnTimeUpdate, HtmlElement.setOnTimeUpdate, .{}); pub const ontoggle = bridge.accessor(HtmlElement.getOnToggle, HtmlElement.setOnToggle, .{}); + pub const ontouchcancel = bridge.accessor(HtmlElement.getOnTouchCancel, HtmlElement.setOnTouchCancel, .{}); + pub const ontouchend = bridge.accessor(HtmlElement.getOnTouchEnd, HtmlElement.setOnTouchEnd, .{}); + pub const ontouchmove = bridge.accessor(HtmlElement.getOnTouchMove, HtmlElement.setOnTouchMove, .{}); + pub const ontouchstart = bridge.accessor(HtmlElement.getOnTouchStart, HtmlElement.setOnTouchStart, .{}); pub const ontransitioncancel = bridge.accessor(HtmlElement.getOnTransitionCancel, HtmlElement.setOnTransitionCancel, .{}); pub const ontransitionend = bridge.accessor(HtmlElement.getOnTransitionEnd, HtmlElement.setOnTransitionEnd, .{}); pub const ontransitionrun = bridge.accessor(HtmlElement.getOnTransitionRun, HtmlElement.setOnTransitionRun, .{}); diff --git a/src/browser/webapi/event/BeforeUnloadEvent.zig b/src/browser/webapi/event/BeforeUnloadEvent.zig new file mode 100644 index 000000000..00b2fdbff --- /dev/null +++ b/src/browser/webapi/event/BeforeUnloadEvent.zig @@ -0,0 +1,89 @@ +// Copyright (C) 2023-2026 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +const std = @import("std"); +const lp = @import("lightpanda"); + +const js = @import("../../js/js.zig"); +const Frame = @import("../../Frame.zig"); + +const Event = @import("../Event.zig"); + +const String = lp.String; +const Allocator = std.mem.Allocator; + +// https://html.spec.whatwg.org/multipage/browsing-the-web.html#the-beforeunloadevent-interface +const BeforeUnloadEvent = @This(); + +_proto: *Event, +_return_value: []const u8 = "", + +const Options = Event.inheritOptions(BeforeUnloadEvent, struct {}); + +pub fn init(typ: []const u8, _opts: ?Options, frame: *Frame) !*BeforeUnloadEvent { + const arena = try frame.getArena(.tiny, "BeforeUnloadEvent"); + errdefer frame.releaseArena(arena); + const type_string = try String.init(arena, typ, .{}); + return initWithTrusted(arena, type_string, _opts, false, frame); +} + +pub fn initTrusted(typ: String, _opts: ?Options, frame: *Frame) !*BeforeUnloadEvent { + const arena = try frame.getArena(.tiny, "BeforeUnloadEvent.trusted"); + errdefer frame.releaseArena(arena); + return initWithTrusted(arena, typ, _opts, true, frame); +} + +fn initWithTrusted(arena: Allocator, typ: String, _opts: ?Options, trusted: bool, frame: *Frame) !*BeforeUnloadEvent { + const opts = _opts orelse Options{}; + + const event = try frame._factory.event( + arena, + typ, + BeforeUnloadEvent{ + ._proto = undefined, + }, + ); + + Event.populatePrototypes(event, opts, trusted); + return event; +} + +pub fn asEvent(self: *BeforeUnloadEvent) *Event { + return self._proto; +} + +pub fn getReturnValue(self: *const BeforeUnloadEvent) []const u8 { + return self._return_value; +} + +pub fn setReturnValue(self: *BeforeUnloadEvent, value: []const u8, frame: *Frame) !void { + self._return_value = try frame.dupeString(value); +} + +pub const JsApi = struct { + pub const bridge = js.Bridge(BeforeUnloadEvent); + + pub const Meta = struct { + pub const name = "BeforeUnloadEvent"; + pub const prototype_chain = bridge.prototypeChain(); + pub var class_id: bridge.ClassId = undefined; + }; + + // Per spec BeforeUnloadEvent has no constructor. + pub const returnValue = bridge.accessor(BeforeUnloadEvent.getReturnValue, BeforeUnloadEvent.setReturnValue, .{}); +}; diff --git a/src/browser/webapi/event/DeviceMotionEvent.zig b/src/browser/webapi/event/DeviceMotionEvent.zig new file mode 100644 index 000000000..bae8d8b9d --- /dev/null +++ b/src/browser/webapi/event/DeviceMotionEvent.zig @@ -0,0 +1,96 @@ +// Copyright (C) 2023-2026 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +const std = @import("std"); +const lp = @import("lightpanda"); + +const js = @import("../../js/js.zig"); +const Frame = @import("../../Frame.zig"); + +const Event = @import("../Event.zig"); + +const String = lp.String; +const Allocator = std.mem.Allocator; + +// https://w3c.github.io/deviceorientation/#devicemotionevent +const DeviceMotionEvent = @This(); + +_proto: *Event, +_interval: f64 = 0, + +const DeviceMotionEventOptions = struct { + interval: f64 = 0, +}; + +const Options = Event.inheritOptions(DeviceMotionEvent, DeviceMotionEventOptions); + +pub fn init(typ: []const u8, _opts: ?Options, frame: *Frame) !*DeviceMotionEvent { + const arena = try frame.getArena(.tiny, "DeviceMotionEvent"); + errdefer frame.releaseArena(arena); + const type_string = try String.init(arena, typ, .{}); + + const opts = _opts orelse Options{}; + const event = try frame._factory.event( + arena, + type_string, + DeviceMotionEvent{ + ._proto = undefined, + ._interval = opts.interval, + }, + ); + + Event.populatePrototypes(event, opts, false); + return event; +} + +pub fn asEvent(self: *DeviceMotionEvent) *Event { + return self._proto; +} + +// There is no motion sensor: the acceleration and rotation members are null. +pub fn getAcceleration(_: *const DeviceMotionEvent) ?bool { + return null; +} + +pub fn getAccelerationIncludingGravity(_: *const DeviceMotionEvent) ?bool { + return null; +} + +pub fn getRotationRate(_: *const DeviceMotionEvent) ?bool { + return null; +} + +pub fn getInterval(self: *const DeviceMotionEvent) f64 { + return self._interval; +} + +pub const JsApi = struct { + pub const bridge = js.Bridge(DeviceMotionEvent); + + pub const Meta = struct { + pub const name = "DeviceMotionEvent"; + pub const prototype_chain = bridge.prototypeChain(); + pub var class_id: bridge.ClassId = undefined; + }; + + pub const constructor = bridge.constructor(DeviceMotionEvent.init, .{}); + pub const acceleration = bridge.accessor(DeviceMotionEvent.getAcceleration, null, .{ .null_as_undefined = false }); + pub const accelerationIncludingGravity = bridge.accessor(DeviceMotionEvent.getAccelerationIncludingGravity, null, .{ .null_as_undefined = false }); + pub const rotationRate = bridge.accessor(DeviceMotionEvent.getRotationRate, null, .{ .null_as_undefined = false }); + pub const interval = bridge.accessor(DeviceMotionEvent.getInterval, null, .{}); +}; diff --git a/src/browser/webapi/event/DeviceOrientationEvent.zig b/src/browser/webapi/event/DeviceOrientationEvent.zig new file mode 100644 index 000000000..84fecb265 --- /dev/null +++ b/src/browser/webapi/event/DeviceOrientationEvent.zig @@ -0,0 +1,104 @@ +// Copyright (C) 2023-2026 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +const std = @import("std"); +const lp = @import("lightpanda"); + +const js = @import("../../js/js.zig"); +const Frame = @import("../../Frame.zig"); + +const Event = @import("../Event.zig"); + +const String = lp.String; +const Allocator = std.mem.Allocator; + +// https://w3c.github.io/deviceorientation/#deviceorientationevent +const DeviceOrientationEvent = @This(); + +_proto: *Event, +_alpha: ?f64 = null, +_beta: ?f64 = null, +_gamma: ?f64 = null, +_absolute: bool = false, + +const DeviceOrientationEventOptions = struct { + alpha: ?f64 = null, + beta: ?f64 = null, + gamma: ?f64 = null, + absolute: bool = false, +}; + +const Options = Event.inheritOptions(DeviceOrientationEvent, DeviceOrientationEventOptions); + +pub fn init(typ: []const u8, _opts: ?Options, frame: *Frame) !*DeviceOrientationEvent { + const arena = try frame.getArena(.tiny, "DeviceOrientationEvent"); + errdefer frame.releaseArena(arena); + const type_string = try String.init(arena, typ, .{}); + + const opts = _opts orelse Options{}; + const event = try frame._factory.event( + arena, + type_string, + DeviceOrientationEvent{ + ._proto = undefined, + ._alpha = opts.alpha, + ._beta = opts.beta, + ._gamma = opts.gamma, + ._absolute = opts.absolute, + }, + ); + + Event.populatePrototypes(event, opts, false); + return event; +} + +pub fn asEvent(self: *DeviceOrientationEvent) *Event { + return self._proto; +} + +pub fn getAlpha(self: *const DeviceOrientationEvent) ?f64 { + return self._alpha; +} + +pub fn getBeta(self: *const DeviceOrientationEvent) ?f64 { + return self._beta; +} + +pub fn getGamma(self: *const DeviceOrientationEvent) ?f64 { + return self._gamma; +} + +pub fn getAbsolute(self: *const DeviceOrientationEvent) bool { + return self._absolute; +} + +pub const JsApi = struct { + pub const bridge = js.Bridge(DeviceOrientationEvent); + + pub const Meta = struct { + pub const name = "DeviceOrientationEvent"; + pub const prototype_chain = bridge.prototypeChain(); + pub var class_id: bridge.ClassId = undefined; + }; + + pub const constructor = bridge.constructor(DeviceOrientationEvent.init, .{}); + pub const alpha = bridge.accessor(DeviceOrientationEvent.getAlpha, null, .{ .null_as_undefined = false }); + pub const beta = bridge.accessor(DeviceOrientationEvent.getBeta, null, .{ .null_as_undefined = false }); + pub const gamma = bridge.accessor(DeviceOrientationEvent.getGamma, null, .{ .null_as_undefined = false }); + pub const absolute = bridge.accessor(DeviceOrientationEvent.getAbsolute, null, .{}); +}; diff --git a/src/browser/webapi/event/StorageEvent.zig b/src/browser/webapi/event/StorageEvent.zig new file mode 100644 index 000000000..66465029c --- /dev/null +++ b/src/browser/webapi/event/StorageEvent.zig @@ -0,0 +1,149 @@ +// Copyright (C) 2023-2026 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +const std = @import("std"); +const lp = @import("lightpanda"); + +const js = @import("../../js/js.zig"); +const Frame = @import("../../Frame.zig"); + +const Event = @import("../Event.zig"); + +const String = lp.String; +const Allocator = std.mem.Allocator; + +// https://html.spec.whatwg.org/multipage/webstorage.html#the-storageevent-interface +const StorageEvent = @This(); + +_proto: *Event, +_key: ?[]const u8 = null, +_old_value: ?[]const u8 = null, +_new_value: ?[]const u8 = null, +_url: []const u8 = "", + +const StorageEventOptions = struct { + key: ?[]const u8 = null, + oldValue: ?[]const u8 = null, + newValue: ?[]const u8 = null, + url: []const u8 = "", +}; + +const Options = Event.inheritOptions(StorageEvent, StorageEventOptions); + +pub fn init(typ: []const u8, _opts: ?Options, frame: *Frame) !*StorageEvent { + const arena = try frame.getArena(.tiny, "StorageEvent"); + errdefer frame.releaseArena(arena); + const type_string = try String.init(arena, typ, .{}); + return initWithTrusted(arena, type_string, _opts, false, frame); +} + +pub fn initTrusted(typ: String, _opts: ?Options, frame: *Frame) !*StorageEvent { + const arena = try frame.getArena(.tiny, "StorageEvent.trusted"); + errdefer frame.releaseArena(arena); + return initWithTrusted(arena, typ, _opts, true, frame); +} + +fn initWithTrusted(arena: Allocator, typ: String, _opts: ?Options, trusted: bool, frame: *Frame) !*StorageEvent { + const opts = _opts orelse Options{}; + + const event = try frame._factory.event( + arena, + typ, + StorageEvent{ + ._proto = undefined, + ._key = if (opts.key) |k| try arena.dupe(u8, k) else null, + ._old_value = if (opts.oldValue) |v| try arena.dupe(u8, v) else null, + ._new_value = if (opts.newValue) |v| try arena.dupe(u8, v) else null, + ._url = try arena.dupe(u8, opts.url), + }, + ); + + Event.populatePrototypes(event, opts, trusted); + return event; +} + +pub fn asEvent(self: *StorageEvent) *Event { + return self._proto; +} + +pub fn getKey(self: *const StorageEvent) ?[]const u8 { + return self._key; +} + +pub fn getOldValue(self: *const StorageEvent) ?[]const u8 { + return self._old_value; +} + +pub fn getNewValue(self: *const StorageEvent) ?[]const u8 { + return self._new_value; +} + +pub fn getUrl(self: *const StorageEvent) []const u8 { + return self._url; +} + +// The initiating Storage object is not tracked; always null. +pub fn getStorageArea(_: *const StorageEvent) ?bool { + return null; +} + +pub fn initStorageEvent( + self: *StorageEvent, + typ: []const u8, + bubbles: ?bool, + cancelable: ?bool, + key: ?[]const u8, + old_value: ?[]const u8, + new_value: ?[]const u8, + url: ?[]const u8, + frame: *Frame, +) !void { + const event = self._proto; + if (event._event_phase != .none) { + return; + } + + 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; + self._key = if (key) |k| try arena.dupe(u8, k) else null; + self._old_value = if (old_value) |v| try arena.dupe(u8, v) else null; + self._new_value = if (new_value) |v| try arena.dupe(u8, v) else null; + self._url = if (url) |u| try arena.dupe(u8, u) else ""; + _ = frame; +} + +pub const JsApi = struct { + pub const bridge = js.Bridge(StorageEvent); + + pub const Meta = struct { + pub const name = "StorageEvent"; + pub const prototype_chain = bridge.prototypeChain(); + pub var class_id: bridge.ClassId = undefined; + }; + + pub const constructor = bridge.constructor(StorageEvent.init, .{}); + pub const key = bridge.accessor(StorageEvent.getKey, null, .{ .null_as_undefined = false }); + pub const oldValue = bridge.accessor(StorageEvent.getOldValue, null, .{ .null_as_undefined = false }); + pub const newValue = bridge.accessor(StorageEvent.getNewValue, null, .{ .null_as_undefined = false }); + pub const url = bridge.accessor(StorageEvent.getUrl, null, .{}); + pub const storageArea = bridge.accessor(StorageEvent.getStorageArea, null, .{ .null_as_undefined = false }); + pub const initStorageEvent = bridge.function(StorageEvent.initStorageEvent, .{}); +}; diff --git a/src/browser/webapi/event/TouchEvent.zig b/src/browser/webapi/event/TouchEvent.zig new file mode 100644 index 000000000..4956ed0ad --- /dev/null +++ b/src/browser/webapi/event/TouchEvent.zig @@ -0,0 +1,124 @@ +// Copyright (C) 2023-2026 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +const std = @import("std"); +const lp = @import("lightpanda"); + +const js = @import("../../js/js.zig"); +const Frame = @import("../../Frame.zig"); + +const Event = @import("../Event.zig"); +const UIEvent = @import("UIEvent.zig"); + +const String = lp.String; +const Allocator = std.mem.Allocator; + +// https://w3c.github.io/touch-events/#touchevent-interface +// There is no touch input source: the touch lists are always empty. +const TouchEvent = @This(); + +_proto: *UIEvent, +_alt_key: bool = false, +_meta_key: bool = false, +_ctrl_key: bool = false, +_shift_key: bool = false, + +pub const TouchEventOptions = struct { + altKey: bool = false, + metaKey: bool = false, + ctrlKey: bool = false, + shiftKey: bool = false, +}; + +pub const Options = Event.inheritOptions( + TouchEvent, + TouchEventOptions, +); + +pub fn init(typ: []const u8, _opts: ?Options, frame: *Frame) !*TouchEvent { + const arena = try frame.getArena(.tiny, "TouchEvent"); + errdefer frame.releaseArena(arena); + const type_string = try String.init(arena, typ, .{}); + + const opts = _opts orelse Options{}; + const event = try frame._factory.uiEvent( + arena, + type_string, + TouchEvent{ + ._proto = undefined, + ._alt_key = opts.altKey, + ._meta_key = opts.metaKey, + ._ctrl_key = opts.ctrlKey, + ._shift_key = opts.shiftKey, + }, + ); + + Event.populatePrototypes(event, opts, false); + return event; +} + +pub fn asEvent(self: *TouchEvent) *Event { + return self._proto.asEvent(); +} + +pub fn getTouches(_: *const TouchEvent) []const bool { + return &.{}; +} + +pub fn getTargetTouches(_: *const TouchEvent) []const bool { + return &.{}; +} + +pub fn getChangedTouches(_: *const TouchEvent) []const bool { + return &.{}; +} + +pub fn getAltKey(self: *const TouchEvent) bool { + return self._alt_key; +} + +pub fn getMetaKey(self: *const TouchEvent) bool { + return self._meta_key; +} + +pub fn getCtrlKey(self: *const TouchEvent) bool { + return self._ctrl_key; +} + +pub fn getShiftKey(self: *const TouchEvent) bool { + return self._shift_key; +} + +pub const JsApi = struct { + pub const bridge = js.Bridge(TouchEvent); + + pub const Meta = struct { + pub const name = "TouchEvent"; + pub const prototype_chain = bridge.prototypeChain(); + pub var class_id: bridge.ClassId = undefined; + }; + + pub const constructor = bridge.constructor(TouchEvent.init, .{}); + pub const touches = bridge.accessor(TouchEvent.getTouches, null, .{}); + pub const targetTouches = bridge.accessor(TouchEvent.getTargetTouches, null, .{}); + pub const changedTouches = bridge.accessor(TouchEvent.getChangedTouches, null, .{}); + pub const altKey = bridge.accessor(TouchEvent.getAltKey, null, .{}); + pub const metaKey = bridge.accessor(TouchEvent.getMetaKey, null, .{}); + pub const ctrlKey = bridge.accessor(TouchEvent.getCtrlKey, null, .{}); + pub const shiftKey = bridge.accessor(TouchEvent.getShiftKey, null, .{}); +}; diff --git a/src/browser/webapi/event/UIEvent.zig b/src/browser/webapi/event/UIEvent.zig index ded62c17a..72e8fc795 100644 --- a/src/browser/webapi/event/UIEvent.zig +++ b/src/browser/webapi/event/UIEvent.zig @@ -41,6 +41,7 @@ pub const Type = union(enum) { text_event: *@import("TextEvent.zig"), input_event: *@import("InputEvent.zig"), composition_event: *@import("CompositionEvent.zig"), + touch_event: *@import("TouchEvent.zig"), }; pub const UIEventOptions = struct { @@ -90,6 +91,7 @@ pub fn is(self: *UIEvent, comptime T: type) ?*T { .text_event => |e| return if (T == @import("TextEvent.zig")) e else null, .input_event => |e| return if (T == @import("InputEvent.zig")) e else null, .composition_event => |e| return if (T == @import("CompositionEvent.zig")) e else null, + .touch_event => |e| return if (T == @import("TouchEvent.zig")) e else null, } return null; } diff --git a/src/browser/webapi/global_event_handlers.zig b/src/browser/webapi/global_event_handlers.zig index 64843c26f..cd6555583 100644 --- a/src/browser/webapi/global_event_handlers.zig +++ b/src/browser/webapi/global_event_handlers.zig @@ -156,6 +156,10 @@ pub const Handler = enum(u7) { onsuspend, ontimeupdate, ontoggle, + ontouchcancel, + ontouchend, + ontouchmove, + ontouchstart, ontransitioncancel, ontransitionend, ontransitionrun, From 12eb1928154d703d3bbf78792b4855d1c5a21142 Mon Sep 17 00:00:00 2001 From: Francis Bouvier Date: Fri, 10 Jul 2026 22:32:11 +0200 Subject: [PATCH 2/4] webapi: only HTML-namespace elements are named by their name attribute Fixes the failing subtest "hasOwnProperty, getOwnPropertyDescriptor, getOwnPropertyNames" in WPT /dom/nodes/Document-getElementsByTagName.html (17/18 -> 18/18) and /dom/nodes/Element-getElementsByTagName.html (18/19 -> 19/19). Per the DOM spec's supported property names for HTMLCollection, the name attribute only exposes elements in the HTML namespace (ids expose any element). The live-collection getByName fallback matched any element with a name attribute; it now skips non-HTML elements, matching the enumerator which already had the guard. Coverage: Document-getElementsByTagName.html 17/18 -> 18/18, Element-getElementsByTagName.html 18/19 -> 19/19. /dom/collections stays fully green. Co-Authored-By: Claude Fable 5 --- src/browser/webapi/collections/node_live.zig | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/browser/webapi/collections/node_live.zig b/src/browser/webapi/collections/node_live.zig index ab96ed661..82769cd07 100644 --- a/src/browser/webapi/collections/node_live.zig +++ b/src/browser/webapi/collections/node_live.zig @@ -194,6 +194,11 @@ pub fn NodeLive(comptime mode: Mode) type { // (like length or getAtIndex) var tw = self._tw.clone(); while (self.nextTw(&tw)) |element| { + // Per spec, only HTML-namespace elements are exposed via + // their name attribute (ids expose any element). + if (element._namespace != .html) { + continue; + } const element_name = element.getAttributeSafe(comptime .wrap("name")) orelse continue; if (std.mem.eql(u8, element_name, name)) { return element; From 945c6d40cb811eef47b1b01b9ba535dd0a649d79 Mon Sep 17 00:00:00 2001 From: Francis Bouvier Date: Sat, 11 Jul 2026 13:18:51 +0200 Subject: [PATCH 3/4] js: create thrown exceptions in the receiver's relevant realm Fixes the 34 "wrong global" failures in WPT /dom/nodes/Element-matches.html (and the equivalent checks in the other selector test files): when a DOM method is called on a node from another realm (the test roots live in an iframe), the thrown exception must be created in the receiver's relevant realm, per Web IDL's "create an exception" using the context object's realm. The harness checks e.constructor === root.ownerDocument.defaultView .DOMException, which failed because we always built exceptions in the caller's context. The creation context of the wrapper can't be used to find that realm: wrappers are session-level (one JS wrapper per Zig object, shared across same-origin contexts). Instead, handleError now resolves the receiver DOM node's document frame: for functions whose receiver is a Node (or a type with an asNode() cast), errorLocal maps this -> node -> ownerDocument -> frame, and if that frame differs from the calling one, the error is constructed inside that frame's v8 context (entered/exited around the exception creation). TaggedOpaque.fromJS also learns to reject objects whose internal field does not hold an aligned TaggedOpaque pointer. Such objects exist: a custom element whose constructor throws was created from our template but never mapped to a Zig instance, and its internal field still holds a v8 tagged value. errorLocal probes the receiver on every error path, so without the check the @alignCast panics (caught by the unit test suite's custom_elements constructor.html). Coverage: /dom/nodes/Element-matches.html 605/669 -> 639/669. Co-Authored-By: Claude Fable 5 --- src/browser/js/Caller.zig | 89 ++++++++++++++++++++++++++++----- src/browser/js/TaggedOpaque.zig | 9 +++- 2 files changed, 85 insertions(+), 13 deletions(-) diff --git a/src/browser/js/Caller.zig b/src/browser/js/Caller.zig index eff8769e0..d5aeb85cf 100644 --- a/src/browser/js/Caller.zig +++ b/src/browser/js/Caller.zig @@ -500,6 +500,61 @@ fn nameToString(local: *const Local, comptime T: type, name: *const v8.Name) !T return try js.String.toSlice(.{ .local = local, .handle = handle }); } +// Per Web IDL, exceptions belong to the operation's relevant realm — the +// receiver's — which differs from the calling realm for cross-realm calls +// (v8 API callbacks run in the caller's context, and our JS wrappers are +// shared across the page's contexts). For DOM nodes, the relevant realm is +// the node document's frame; otherwise fall back to the calling realm. +fn errorLocal(comptime T: type, local: *const Local, info: anytype) Local { + if (@TypeOf(info) != FunctionCallbackInfo) { + return local.*; + } + + const Node = @import("../webapi/Node.zig"); + const Document = @import("../webapi/Document.zig"); + + const is_node_type = comptime blk: { + if (@typeInfo(T) != .@"struct") break :blk false; + if (T == Node) break :blk true; + break :blk @hasDecl(T, "JsApi") and @hasDecl(T, "asNode") and @TypeOf(T.asNode) == fn (*T) *Node; + }; + if (comptime !is_node_type) { + return local.*; + } + + const node: *Node = blk: { + const instance = TaggedOpaque.fromJS(*T, info.getThis()) catch return local.*; + if (comptime T == Node) { + break :blk instance; + } else { + break :blk instance.asNode(); + } + }; + + const frame = switch (local.ctx.global) { + .frame => |f| f, + .worker => return local.*, + }; + + const doc: *Document = node.ownerDocument(frame) orelse switch (node._type) { + .document => |d| d, + else => return local.*, + }; + const doc_frame = doc._frame orelse return local.*; + if (doc_frame == frame) { + return local.*; + } + + const ctx = doc_frame.js; + const local_v8_context: *const v8.Context = @ptrCast(v8.v8__Global__Get(&ctx.handle, ctx.isolate.handle)); + return .{ + .ctx = ctx, + .handle = local_v8_context, + .call_arena = ctx.call_arena, + .isolate = ctx.isolate, + }; +} + fn handleError(comptime T: type, comptime F: type, local: *const Local, err: anyerror, info: anytype) void { const isolate = local.isolate; @@ -513,18 +568,28 @@ 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(""), - error.OutOfMemory => isolate.createError("out of memory"), - error.IllegalConstructor => isolate.createError("Illegal Constructor"), - else => domExceptionToJs(local, err) orelse isolate.createError(@errorName(err)), + const err_local = errorLocal(T, local, info); + + const js_err: *const v8.Value = blk: { + // Error constructors use the isolate's current context: enter the + // receiver's realm so the exception gets its prototypes. + const entered = err_local.handle != local.handle; + if (entered) v8.v8__Context__Enter(err_local.handle); + defer if (entered) v8.v8__Context__Exit(err_local.handle); + + break :blk 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(""), + error.OutOfMemory => isolate.createError("out of memory"), + error.IllegalConstructor => isolate.createError("Illegal Constructor"), + else => domExceptionToJs(&err_local, err) orelse isolate.createError(@errorName(err)), + }; }; const js_exception = isolate.throwException(js_err); diff --git a/src/browser/js/TaggedOpaque.zig b/src/browser/js/TaggedOpaque.zig index 3a8250ce9..ca20b3ef5 100644 --- a/src/browser/js/TaggedOpaque.zig +++ b/src/browser/js/TaggedOpaque.zig @@ -106,7 +106,14 @@ pub fn fromJS(comptime R: type, js_obj_handle: *const v8.Object) !R { @compileError("unknown Zig type: " ++ @typeName(R)); } - const tao_ptr = v8.v8__Object__GetAlignedPointerFromInternalField(js_obj_handle, 0).?; + const tao_ptr = v8.v8__Object__GetAlignedPointerFromInternalField(js_obj_handle, 0) orelse return error.InvalidArgument; + // A wrapped object always embeds an aligned TaggedOpaque pointer. An + // unaligned value is a leftover v8 tagged value: the object was created + // from our template but never mapped to a Zig instance (e.g. a custom + // element whose constructor threw). + if (@intFromPtr(tao_ptr) % @alignOf(TaggedOpaque) != 0) { + return error.InvalidArgument; + } const tao: *TaggedOpaque = @ptrCast(@alignCast(tao_ptr)); const expected_type_index = bridge.JsApiLookup.getId(JsApi); From 58f5f857d9dad3fa3da73a4e1b67e4a0cce3eba1 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Wed, 15 Jul 2026 15:30:49 +0800 Subject: [PATCH 4/4] Detect Node-types that don't have an asNode() Don't set null_as_undefined = false..it's the default. --- src/browser/js/Caller.zig | 56 +++++++++++-------- src/browser/js/Snapshot.zig | 21 +------ src/browser/js/bridge.zig | 17 ++++++ src/browser/webapi/Document.zig | 38 +++++++------ .../webapi/event/DeviceMotionEvent.zig | 6 +- .../webapi/event/DeviceOrientationEvent.zig | 6 +- src/browser/webapi/event/StorageEvent.zig | 10 ++-- 7 files changed, 82 insertions(+), 72 deletions(-) diff --git a/src/browser/js/Caller.zig b/src/browser/js/Caller.zig index d5aeb85cf..a1844976f 100644 --- a/src/browser/js/Caller.zig +++ b/src/browser/js/Caller.zig @@ -510,31 +510,24 @@ fn errorLocal(comptime T: type, local: *const Local, info: anytype) Local { return local.*; } + const frame = switch (local.ctx.global) { + .frame => |f| f, + .worker => return local.*, + }; + const Node = @import("../webapi/Node.zig"); const Document = @import("../webapi/Document.zig"); const is_node_type = comptime blk: { - if (@typeInfo(T) != .@"struct") break :blk false; - if (T == Node) break :blk true; - break :blk @hasDecl(T, "JsApi") and @hasDecl(T, "asNode") and @TypeOf(T.asNode) == fn (*T) *Node; + if (@typeInfo(T) != .@"struct" or !@hasDecl(T, "JsApi")) break :blk false; + break :blk @import("bridge.zig").inheritsOrIs(T.JsApi, Node.JsApi); }; if (comptime !is_node_type) { return local.*; } - const node: *Node = blk: { - const instance = TaggedOpaque.fromJS(*T, info.getThis()) catch return local.*; - if (comptime T == Node) { - break :blk instance; - } else { - break :blk instance.asNode(); - } - }; - - const frame = switch (local.ctx.global) { - .frame => |f| f, - .worker => return local.*, - }; + const instance = TaggedOpaque.fromJS(*T, info.getThis()) catch return local.*; + const node = protoNode(T, instance); const doc: *Document = node.ownerDocument(frame) orelse switch (node._type) { .document => |d| d, @@ -546,7 +539,7 @@ fn errorLocal(comptime T: type, local: *const Local, info: anytype) Local { } const ctx = doc_frame.js; - const local_v8_context: *const v8.Context = @ptrCast(v8.v8__Global__Get(&ctx.handle, ctx.isolate.handle)); + const local_v8_context: *const v8.Context = @ptrCast(v8.v8__Global__Get(&ctx.handle, ctx.isolate.handle) orelse return local.*); return .{ .ctx = ctx, .handle = local_v8_context, @@ -555,6 +548,17 @@ fn errorLocal(comptime T: type, local: *const Local, info: anytype) Local { }; } +// Upcast a Node-descendant instance to *Node by walking the _proto chain. +// Not every node type defines an asNode() helper (e.g. Comment, Text), but +// inheritsOrIs guarantees Node is in the chain +fn protoNode(comptime T: type, instance: *T) *@import("../webapi/Node.zig") { + if (T == @import("../webapi/Node.zig")) { + return instance; + } + const Proto = @typeInfo(std.meta.fieldInfo(T, ._proto).type).pointer.child; + return protoNode(Proto, instance._proto); +} + fn handleError(comptime T: type, comptime F: type, local: *const Local, err: anyerror, info: anytype) void { const isolate = local.isolate; @@ -568,26 +572,32 @@ fn handleError(comptime T: type, comptime F: type, local: *const Local, err: any } } + // early exit + 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, + else => {}, + } + const err_local = errorLocal(T, local, info); const js_err: *const v8.Value = blk: { // Error constructors use the isolate's current context: enter the // receiver's realm so the exception gets its prototypes. - const entered = err_local.handle != local.handle; + const entered = err_local.ctx != local.ctx; if (entered) v8.v8__Context__Enter(err_local.handle); defer if (entered) v8.v8__Context__Exit(err_local.handle); break :blk 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(""), error.OutOfMemory => isolate.createError("out of memory"), error.IllegalConstructor => isolate.createError("Illegal Constructor"), + error.TryCatchRethrow, error.JsException => unreachable, // early exited a few lines up else => domExceptionToJs(&err_local, err) orelse isolate.createError(@errorName(err)), }; }; diff --git a/src/browser/js/Snapshot.zig b/src/browser/js/Snapshot.zig index 80b660de5..7441051ea 100644 --- a/src/browser/js/Snapshot.zig +++ b/src/browser/js/Snapshot.zig @@ -737,7 +737,7 @@ fn inheritsFromHtmlElement(comptime JsApi: type) bool { if (JsApi.bridge.type == HtmlElement) { return false; } - return inheritsOrIs(JsApi, HtmlElement.JsApi); + return bridge.inheritsOrIs(JsApi, HtmlElement.JsApi); } const Unforgeable = struct { @@ -874,7 +874,7 @@ fn attachClass(comptime JsApi: type, comptime flatten: bool, isolate: *v8.Isolat if (comptime flatten == false) { inline for (unforgeables) |u| { - if (comptime inheritsOrIs(JsApi, u.Owner)) { + if (comptime bridge.inheritsOrIs(JsApi, u.Owner)) { // unforgeables attributes aren't only applied directly on the // instance, they're also applied directly on every child instance attachAccessorProperty(u.name, u.accessor, isolate, template, signature, instance); @@ -949,23 +949,6 @@ const unforgeables: []const Unforgeable = blk: { break :blk list; }; -// Whether Child is Ancestor or inherits from it, following the _proto chain. -fn inheritsOrIs(comptime Child: type, comptime Ancestor: type) bool { - comptime { - const target = Ancestor.bridge.type; - var T = Child.bridge.type; - while (true) { - if (T == target) { - return true; - } - if (!@hasField(T, "_proto")) { - return false; - } - T = @typeInfo(std.meta.fieldInfo(T, ._proto).type).pointer.child; - } - } -} - // Attach JsApi members to a template (public for reuse). This is called on all // types. But, for globals (window, WGS) it's called twice. The first time, it's // called like any other interface. The 2nd time, it's called with flatten == true diff --git a/src/browser/js/bridge.zig b/src/browser/js/bridge.zig index ff6091240..7bbd62a48 100644 --- a/src/browser/js/bridge.zig +++ b/src/browser/js/bridge.zig @@ -1269,3 +1269,20 @@ pub const JsApis = blk: { } break :blk base ++ [_]type{@import("../webapi/WebDriver.zig").JsApi}; }; + +// Whether Child is Ancestor or inherits from it, following the _proto chain. +pub fn inheritsOrIs(comptime Child: type, comptime Ancestor: type) bool { + comptime { + const target = Ancestor.bridge.type; + var T = Child.bridge.type; + while (true) { + if (T == target) { + return true; + } + if (!@hasField(T, "_proto")) { + return false; + } + T = @typeInfo(std.meta.fieldInfo(T, ._proto).type).pointer.child; + } + } +} diff --git a/src/browser/webapi/Document.zig b/src/browser/webapi/Document.zig index c4795d218..b34de50ba 100644 --- a/src/browser/webapi/Document.zig +++ b/src/browser/webapi/Document.zig @@ -108,24 +108,6 @@ pub fn setOnClick(self: *Document, setter: ?Window.FunctionSetter, frame: *Frame } } -// Generates a getter/setter pair backed by the frame's attribute-listener -// map, like onclick above, for other document event handler properties. -fn handlerAccessor(comptime handler: @import("global_event_handlers.zig").Handler) type { - return struct { - pub fn get(self: *Document, frame: *Frame) ?js.Function.Global { - return frame._event_target_attr_listeners.get(.{ .target = self.asEventTarget(), .handler = handler }); - } - - pub fn set(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 = handler }, cb); - } else { - _ = frame._event_target_attr_listeners.remove(.{ .target = self.asEventTarget(), .handler = handler }); - } - } - }; -} - pub const Type = union(enum) { generic, html: *HTMLDocument, @@ -1530,6 +1512,26 @@ pub const JsApi = struct { return doc_frame.charset; } pub const referrer = bridge.property("", .{ .template = false }); + + // Generates a getter/setter pair backed by the frame's attribute-listener + // map, like onclick above, for other document event handler properties. + fn handlerAccessor(comptime handler: @import("global_event_handlers.zig").Handler) type { + return struct { + pub fn get(self: *Document, frame: *Frame) ?js.Function.Global { + const owner = self._frame orelse frame; + return owner._event_target_attr_listeners.get(.{ .target = self.asEventTarget(), .handler = handler }); + } + + pub fn set(self: *Document, setter: ?Window.FunctionSetter, frame: *Frame) !void { + const owner = self._frame orelse frame; + if (Window.getFunctionFromSetter(setter)) |cb| { + try owner._event_target_attr_listeners.put(owner.arena, .{ .target = self.asEventTarget(), .handler = handler }, cb); + } else { + _ = owner._event_target_attr_listeners.remove(.{ .target = self.asEventTarget(), .handler = handler }); + } + } + }; + } }; const testing = @import("../../testing.zig"); diff --git a/src/browser/webapi/event/DeviceMotionEvent.zig b/src/browser/webapi/event/DeviceMotionEvent.zig index bae8d8b9d..6da445f79 100644 --- a/src/browser/webapi/event/DeviceMotionEvent.zig +++ b/src/browser/webapi/event/DeviceMotionEvent.zig @@ -89,8 +89,8 @@ pub const JsApi = struct { }; pub const constructor = bridge.constructor(DeviceMotionEvent.init, .{}); - pub const acceleration = bridge.accessor(DeviceMotionEvent.getAcceleration, null, .{ .null_as_undefined = false }); - pub const accelerationIncludingGravity = bridge.accessor(DeviceMotionEvent.getAccelerationIncludingGravity, null, .{ .null_as_undefined = false }); - pub const rotationRate = bridge.accessor(DeviceMotionEvent.getRotationRate, null, .{ .null_as_undefined = false }); + pub const acceleration = bridge.accessor(DeviceMotionEvent.getAcceleration, null, .{}); + pub const accelerationIncludingGravity = bridge.accessor(DeviceMotionEvent.getAccelerationIncludingGravity, null, .{}); + pub const rotationRate = bridge.accessor(DeviceMotionEvent.getRotationRate, null, .{}); pub const interval = bridge.accessor(DeviceMotionEvent.getInterval, null, .{}); }; diff --git a/src/browser/webapi/event/DeviceOrientationEvent.zig b/src/browser/webapi/event/DeviceOrientationEvent.zig index 84fecb265..267611c85 100644 --- a/src/browser/webapi/event/DeviceOrientationEvent.zig +++ b/src/browser/webapi/event/DeviceOrientationEvent.zig @@ -97,8 +97,8 @@ pub const JsApi = struct { }; pub const constructor = bridge.constructor(DeviceOrientationEvent.init, .{}); - pub const alpha = bridge.accessor(DeviceOrientationEvent.getAlpha, null, .{ .null_as_undefined = false }); - pub const beta = bridge.accessor(DeviceOrientationEvent.getBeta, null, .{ .null_as_undefined = false }); - pub const gamma = bridge.accessor(DeviceOrientationEvent.getGamma, null, .{ .null_as_undefined = false }); + pub const alpha = bridge.accessor(DeviceOrientationEvent.getAlpha, null, .{}); + pub const beta = bridge.accessor(DeviceOrientationEvent.getBeta, null, .{}); + pub const gamma = bridge.accessor(DeviceOrientationEvent.getGamma, null, .{}); pub const absolute = bridge.accessor(DeviceOrientationEvent.getAbsolute, null, .{}); }; diff --git a/src/browser/webapi/event/StorageEvent.zig b/src/browser/webapi/event/StorageEvent.zig index 66465029c..bb299a2b9 100644 --- a/src/browser/webapi/event/StorageEvent.zig +++ b/src/browser/webapi/event/StorageEvent.zig @@ -111,7 +111,6 @@ pub fn initStorageEvent( old_value: ?[]const u8, new_value: ?[]const u8, url: ?[]const u8, - frame: *Frame, ) !void { const event = self._proto; if (event._event_phase != .none) { @@ -127,7 +126,6 @@ pub fn initStorageEvent( self._old_value = if (old_value) |v| try arena.dupe(u8, v) else null; self._new_value = if (new_value) |v| try arena.dupe(u8, v) else null; self._url = if (url) |u| try arena.dupe(u8, u) else ""; - _ = frame; } pub const JsApi = struct { @@ -140,10 +138,10 @@ pub const JsApi = struct { }; pub const constructor = bridge.constructor(StorageEvent.init, .{}); - pub const key = bridge.accessor(StorageEvent.getKey, null, .{ .null_as_undefined = false }); - pub const oldValue = bridge.accessor(StorageEvent.getOldValue, null, .{ .null_as_undefined = false }); - pub const newValue = bridge.accessor(StorageEvent.getNewValue, null, .{ .null_as_undefined = false }); + pub const key = bridge.accessor(StorageEvent.getKey, null, .{}); + pub const oldValue = bridge.accessor(StorageEvent.getOldValue, null, .{}); + pub const newValue = bridge.accessor(StorageEvent.getNewValue, null, .{}); pub const url = bridge.accessor(StorageEvent.getUrl, null, .{}); - pub const storageArea = bridge.accessor(StorageEvent.getStorageArea, null, .{ .null_as_undefined = false }); + pub const storageArea = bridge.accessor(StorageEvent.getStorageArea, null, .{}); pub const initStorageEvent = bridge.function(StorageEvent.initStorageEvent, .{}); };