Merge pull request #2947 from lightpanda-io/dom-pr-06-window-event-createevent

webapi: window.event rules, Window/Document handlers, createEvent, timeStamp
This commit is contained in:
Karl Seguin
2026-07-15 10:49:57 +08:00
committed by GitHub
14 changed files with 203 additions and 57 deletions

View File

@@ -28,6 +28,7 @@ const Node = @import("webapi/Node.zig");
const Event = @import("webapi/Event.zig");
const EventTarget = @import("webapi/EventTarget.zig");
const Element = @import("webapi/Element.zig");
const ShadowRoot = @import("webapi/ShadowRoot.zig");
const log = lp.log;
const Allocator = std.mem.Allocator;
@@ -111,10 +112,27 @@ pub fn dispatchOpts(self: *EventManager, target: *EventTarget, event: *Event, co
switch (target._type) {
.node => |node| try self.dispatchNode(node, event, opts),
.xhr => |xhr| try self.dispatchDirect(target, event, xhr.inlineHandler(event._type_string), .{ .context = "dispatch" }),
.window => |w| try self.dispatchDirect(target, event, windowInlineHandler(w, event._type_string), .{ .context = "dispatch" }),
else => try self.dispatchDirect(target, event, null, .{ .context = "dispatch" }),
}
}
// Resolves the Window's property event handler for the given event type.
fn windowInlineHandler(window: *@import("webapi/Window.zig"), typ: lp.String) ?js.Function.Global {
const global_event_handlers = @import("webapi/global_event_handlers.zig");
const handler_type = global_event_handlers.fromEventType(typ.str()) orelse return null;
return switch (handler_type) {
.onerror => window._on_error,
.onload => window._on_load,
.onblur => window._on_blur,
.onfocus => window._on_focus,
.onresize => window._on_resize,
.onscroll => window._on_scroll,
else => null,
};
}
// There are a lot of events that can be attached via addEventListener or as
// a property, like the XHR events, or window.onload. You might think that the
// property is just a shortcut for calling addEventListener, but they are distinct.
@@ -144,8 +162,6 @@ pub fn hasDirectListeners(self: *EventManager, target: *EventTarget, typ: []cons
}
fn dispatchNode(self: *EventManager, target: *Node, event: *Event, comptime opts: DispatchOpts) !void {
const ShadowRoot = @import("webapi/ShadowRoot.zig");
{
const et = target.asEventTarget();
event._target = et;
@@ -284,6 +300,10 @@ fn dispatchNode(self: *EventManager, target: *Node, event: *Event, comptime opts
was_handled = true;
event._current_target = target_et;
const prev_current_event = window._current_event;
window._current_event = currentEventForTarget(target_et, event);
defer window._current_event = prev_current_event;
// Inline handlers (e.g. onclick property) follow the same "report,
// don't propagate" rule as addEventListener listeners — see Listener.run.
var caught: js.TryCatch.Caught = undefined;
@@ -331,6 +351,10 @@ fn dispatchNode(self: *EventManager, target: *Node, event: *Event, comptime opts
was_handled = true;
event._current_target = current_target;
const prev_current_event = window._current_event;
window._current_event = currentEventForTarget(current_target, event);
defer window._current_event = prev_current_event;
const original_target = event._target;
if (event._needs_retargeting) {
event._target = getAdjustedTarget(original_target, current_target);
@@ -360,6 +384,10 @@ fn dispatchNode(self: *EventManager, target: *Node, event: *Event, comptime opts
}
}
fn currentEventForTarget(target: *EventTarget, event: *Event) ?*Event {
return if (rootIsShadowRoot(target)) null else event;
}
const DispatchPhaseOpts = struct {
capture_only: ?bool = null,
apply_ignore: bool = false,
@@ -376,6 +404,11 @@ fn dispatchPhase(self: *EventManager, list: *std.DoublyLinkedList, current_targe
const frame = self.frame;
const base = &self.base;
const window = frame.window;
const prev_current_event = window._current_event;
window._current_event = currentEventForTarget(current_target, event);
defer window._current_event = prev_current_event;
// Track dispatch depth for deferred removal
base.dispatch_depth += 1;
defer {
@@ -477,6 +510,17 @@ fn getInlineHandler(self: *EventManager, target: *EventTarget, event: *Event) ?j
// Look up the inline handler for this target
const html_element = switch (target._type) {
.node => |n| n.is(Element.Html) orelse return null,
// The Window stores its event handlers in dedicated fields; an event
// propagating to the window must fire them too.
.window => |w| return switch (handler_type) {
.onerror => w._on_error,
.onload => w._on_load,
.onblur => w._on_blur,
.onfocus => w._on_focus,
.onresize => w._on_resize,
.onscroll => w._on_scroll,
else => null,
},
else => return null,
};
@@ -489,8 +533,6 @@ fn getInlineHandler(self: *EventManager, target: *EventTarget, event: *Event) ?j
// DOM spec "retarget": walk original_target out of shadow trees until the
// node is visible from current_target's tree.
fn getAdjustedTarget(original_target: ?*EventTarget, current_target: *EventTarget) ?*EventTarget {
const ShadowRoot = @import("webapi/ShadowRoot.zig");
const orig_node = switch ((original_target orelse return null)._type) {
.node => |n| n,
else => return original_target,
@@ -512,8 +554,6 @@ fn getAdjustedTarget(original_target: ?*EventTarget, current_target: *EventTarge
}
fn isShadowIncludingInclusiveAncestor(ancestor: *Node, node: *Node) bool {
const ShadowRoot = @import("webapi/ShadowRoot.zig");
var n: ?*Node = node;
while (n) |cur| {
if (cur == ancestor) {
@@ -531,8 +571,6 @@ fn isShadowIncludingInclusiveAncestor(ancestor: *Node, node: *Node) bool {
// Whether the target's tree root (without crossing shadow boundaries) is a
// shadow root. Used for the spec's post-dispatch "clear targets" step.
fn rootIsShadowRoot(target_: ?*EventTarget) bool {
const ShadowRoot = @import("webapi/ShadowRoot.zig");
const target = target_ orelse return false;
var current: *Node = switch (target._type) {
.node => |n| n,

View File

@@ -237,8 +237,9 @@ fn AutoPrototypeChain(comptime types: []const type) type {
fn eventInit(arena: Allocator, typ: String, value: anytype) !Event {
// Round to 2ms for privacy (browsers do this)
const raw_timestamp = @import("../datetime.zig").milliTimestamp(.monotonic);
const time_stamp = (raw_timestamp / 2) * 2;
// Same (already coarsened) clock as the performance time origin, so the
// timeStamp getter can report it relative to that origin.
const time_stamp = @import("webapi/Performance.zig").highResTimestamp();
return .{
._rc = .{},

View File

@@ -21,6 +21,7 @@ const lp = @import("lightpanda");
const js = @import("../js/js.zig");
const Frame = @import("../Frame.zig");
const Window = @import("Window.zig");
const URL = @import("../URL.zig");
const idna = @import("../../sys/idna.zig");
const public_suffix_list = @import("../../data/public_suffix_list.zig");
@@ -89,6 +90,22 @@ pub fn setOnSelectionChange(self: *Document, listener: ?js.Function) !void {
}
}
// Stored in the frame's attribute-listener map (like element and ShadowRoot
// property handlers), which the dispatch propagation path consults for any
// event target.
pub fn getOnClick(self: *Document, frame: *Frame) ?js.Function.Global {
return (self._frame orelse frame)._event_target_attr_listeners.get(.{ .target = self.asEventTarget(), .handler = .onclick });
}
pub fn setOnClick(self: *Document, setter: ?Window.FunctionSetter, frame: *Frame) !void {
const owner = self._frame orelse frame;
if (Window.getFunctionFromSetter(setter)) |cb| {
try owner._event_target_attr_listeners.put(owner.arena, .{ .target = self.asEventTarget(), .handler = .onclick }, cb);
} else {
_ = owner._event_target_attr_listeners.remove(.{ .target = self.asEventTarget(), .handler = .onclick });
}
}
pub const Type = union(enum) {
generic,
html: *HTMLDocument,
@@ -468,56 +485,85 @@ pub fn createEvent(_: *const Document, event_type: []const u8, frame: *Frame) !*
}
const normalized = std.ascii.lowerString(&frame.buf, event_type);
if (std.mem.eql(u8, normalized, "event") or std.mem.eql(u8, normalized, "events") or std.mem.eql(u8, normalized, "htmlevents")) {
return Event.init("", null, frame._page);
}
const event: *Event = blk: {
if (std.mem.eql(u8, normalized, "event") or std.mem.eql(u8, normalized, "events") or std.mem.eql(u8, normalized, "htmlevents") or std.mem.eql(u8, normalized, "svgevents")) {
break :blk try Event.init("", null, frame._page);
}
if (std.mem.eql(u8, normalized, "customevent") or std.mem.eql(u8, normalized, "customevents")) {
const CustomEvent = @import("event/CustomEvent.zig");
return (try CustomEvent.init("", null, frame._page)).asEvent();
}
if (std.mem.eql(u8, normalized, "customevent") or std.mem.eql(u8, normalized, "customevents")) {
const CustomEvent = @import("event/CustomEvent.zig");
break :blk (try CustomEvent.init("", null, frame._page)).asEvent();
}
if (std.mem.eql(u8, normalized, "keyboardevent")) {
const KeyboardEvent = @import("event/KeyboardEvent.zig");
return (try KeyboardEvent.init("", null, frame)).asEvent();
}
if (std.mem.eql(u8, normalized, "keyboardevent")) {
const KeyboardEvent = @import("event/KeyboardEvent.zig");
break :blk (try KeyboardEvent.init("", null, frame)).asEvent();
}
if (std.mem.eql(u8, normalized, "inputevent")) {
const InputEvent = @import("event/InputEvent.zig");
return (try InputEvent.init("", null, frame)).asEvent();
}
if (std.mem.eql(u8, normalized, "inputevent")) {
const InputEvent = @import("event/InputEvent.zig");
break :blk (try InputEvent.init("", null, frame)).asEvent();
}
if (std.mem.eql(u8, normalized, "mouseevent") or std.mem.eql(u8, normalized, "mouseevents")) {
const MouseEvent = @import("event/MouseEvent.zig");
return (try MouseEvent.init("", null, frame)).asEvent();
}
if (std.mem.eql(u8, normalized, "mouseevent") or std.mem.eql(u8, normalized, "mouseevents")) {
const MouseEvent = @import("event/MouseEvent.zig");
break :blk (try MouseEvent.init("", null, frame)).asEvent();
}
if (std.mem.eql(u8, normalized, "messageevent")) {
const MessageEvent = @import("event/MessageEvent.zig");
return (try MessageEvent.init("", null, frame._page)).asEvent();
}
if (std.mem.eql(u8, normalized, "dragevent")) {
const DragEvent = @import("event/DragEvent.zig");
break :blk (try DragEvent.init("", null, frame)).asEvent();
}
if (std.mem.eql(u8, normalized, "uievent") or std.mem.eql(u8, normalized, "uievents")) {
const UIEvent = @import("event/UIEvent.zig");
return (try UIEvent.init("", null, frame)).asEvent();
}
if (std.mem.eql(u8, normalized, "messageevent")) {
const MessageEvent = @import("event/MessageEvent.zig");
break :blk (try MessageEvent.init("", null, frame._page)).asEvent();
}
if (std.mem.eql(u8, normalized, "focusevent") or std.mem.eql(u8, normalized, "focusevents")) {
const FocusEvent = @import("event/FocusEvent.zig");
return (try FocusEvent.init("", null, frame)).asEvent();
}
if (std.mem.eql(u8, normalized, "hashchangeevent")) {
const HashChangeEvent = @import("event/HashChangeEvent.zig");
break :blk (try HashChangeEvent.init("", null, frame)).asEvent();
}
if (std.mem.eql(u8, normalized, "textevent") or std.mem.eql(u8, normalized, "textevents")) {
const TextEvent = @import("event/TextEvent.zig");
return (try TextEvent.init("", null, frame)).asEvent();
}
if (std.mem.eql(u8, normalized, "uievent") or std.mem.eql(u8, normalized, "uievents")) {
const UIEvent = @import("event/UIEvent.zig");
break :blk (try UIEvent.init("", null, frame)).asEvent();
}
if (std.mem.eql(u8, normalized, "compositionevent")) {
const CompositionEvent = @import("event/CompositionEvent.zig");
return (try CompositionEvent.init("", null, frame)).asEvent();
}
if (std.mem.eql(u8, normalized, "focusevent") or std.mem.eql(u8, normalized, "focusevents")) {
const FocusEvent = @import("event/FocusEvent.zig");
break :blk (try FocusEvent.init("", null, frame)).asEvent();
}
return error.NotSupported;
if (std.mem.eql(u8, normalized, "textevent") or std.mem.eql(u8, normalized, "textevents")) {
const TextEvent = @import("event/TextEvent.zig");
break :blk (try TextEvent.init("", null, frame)).asEvent();
}
if (std.mem.eql(u8, normalized, "compositionevent")) {
const CompositionEvent = @import("event/CompositionEvent.zig");
break :blk (try CompositionEvent.init("", null, frame)).asEvent();
}
// Aliases the spec requires createEvent to support but whose
// interfaces aren't implemented yet: return a plain Event so the
// caller can at least initialize and dispatch it.
if (std.mem.eql(u8, normalized, "beforeunloadevent") or
std.mem.eql(u8, normalized, "devicemotionevent") or
std.mem.eql(u8, normalized, "deviceorientationevent") or
std.mem.eql(u8, normalized, "storageevent"))
{
log.info(.not_implemented, "createEvent interface", .{ .type = event_type });
break :blk try Event.init("", null, frame._page);
}
return error.NotSupported;
};
// createEvent returns an uninitialized event: dispatching it before one
// of the init*Event calls throws an InvalidStateError.
event._initialized = false;
return event;
}
pub fn createTreeWalker(_: *const Document, root: *Node, what_to_show: ?js.Value, filter: ?DOMTreeWalker.FilterOpts, frame: *Frame) !*DOMTreeWalker {
@@ -1265,6 +1311,7 @@ pub const JsApi = struct {
}
pub const onselectionchange = bridge.accessor(Document.getOnSelectionChange, Document.setOnSelectionChange, .{});
pub const onclick = bridge.accessor(Document.getOnClick, Document.setOnClick, .{});
pub const URL = bridge.accessor(Document.getURL, null, .{});
pub const location = bridge.accessor(Document.getLocation, Document.setLocation, .{});
pub const documentURI = bridge.accessor(Document.getURL, null, .{});

View File

@@ -50,6 +50,13 @@ _needs_retargeting: bool = false,
_is_trusted: bool = false,
_in_passive_listener: bool = false,
_listeners_did_throw: bool = false, // IndexedDB needs to abort on callback throw
// Per spec, events created via document.createEvent are not initialized
// until one of the init*Event methods runs; dispatching one throws an
// InvalidStateError. Events created any other way start initialized.
_initialized: bool = true,
// Time origin of the event's relevant global, captured at creation when
// known; 0 means "use the accessing realm's origin" (see getTimeStamp).
_time_origin: u64 = 0,
// There's a period of time between creating an event and handing it off to v8
// where things can fail. If it does fail, we need to deinit the event. The timing
@@ -109,9 +116,9 @@ pub fn initTrusted(typ: String, opts_: ?Options, page: *Page) !*Event {
fn initWithTrusted(arena: Allocator, typ: String, opts_: ?Options, comptime trusted: bool) !*Event {
const opts = opts_ orelse Options{};
// Round to 2ms for privacy (browsers do this)
const raw_timestamp = @import("../../datetime.zig").milliTimestamp(.monotonic);
const time_stamp = (raw_timestamp / 2) * 2;
// Same (already coarsened) clock as the performance time origin, so the
// timeStamp getter can report it relative to that origin.
const time_stamp = @import("Performance.zig").highResTimestamp();
const event = try arena.create(Event);
event.* = .{
@@ -137,6 +144,7 @@ pub fn initEvent(
return;
}
self._initialized = true;
self._type_string = try String.init(self._arena, event_string, .{});
self._bubbles = bubbles orelse false;
self._cancelable = cancelable orelse false;
@@ -257,8 +265,12 @@ pub fn getEventPhase(self: *const Event) u8 {
return @intFromEnum(self._event_phase);
}
pub fn getTimeStamp(self: *const Event) u64 {
return self._time_stamp;
pub fn getTimeStamp(self: *const Event, exec: *js.Execution) f64 {
const origin = if (self._time_origin != 0) self._time_origin else exec.performance()._time_origin;
if (self._time_stamp <= origin) {
return 0.0;
}
return @as(f64, @floatFromInt(self._time_stamp - origin)) / 1000.0;
}
pub fn setTrusted(self: *Event) void {
@@ -470,7 +482,14 @@ pub const JsApi = struct {
pub var class_id: bridge.ClassId = undefined;
};
pub const constructor = bridge.constructor(Event.init, .{});
pub const constructor = bridge.constructor(struct {
fn wrap(typ: []const u8, opts_: ?Options, exec: *js.Execution) !*Event {
const event = try Event.init(typ, opts_, exec.page);
// capture the realm's time
event._time_origin = exec.performance()._time_origin;
return event;
}
}.wrap, .{});
pub const @"type" = bridge.accessor(Event.getType, null, .{});
pub const bubbles = bridge.accessor(Event.getBubbles, null, .{});
pub const cancelable = bridge.accessor(Event.getCancelable, null, .{});

View File

@@ -69,6 +69,10 @@ pub fn dispatchEvent(self: *EventTarget, event: *Event, exec: *js.Execution) !bo
if (event._event_phase != .none) {
return error.InvalidStateError;
}
if (!event._initialized) {
return error.InvalidStateError;
}
event._is_trusted = false;
switch (exec.js.global) {

View File

@@ -47,7 +47,7 @@ _delivery_scheduled: bool = false,
/// Get high-resolution timestamp in microseconds, rounded to 5μs increments
/// to match browser behavior (prevents fingerprinting)
fn highResTimestamp() u64 {
pub fn highResTimestamp() u64 {
const ts = datetime.timespec();
const micros = @as(u64, @intCast(ts.sec)) * 1_000_000 + @as(u64, @intCast(@divTrunc(ts.nsec, 1_000)));
// Round to nearest 5 microseconds (like Firefox default)

View File

@@ -425,6 +425,21 @@ pub fn setOnScroll(self: *Window, setter: ?FunctionSetter) void {
self._on_scroll = getFunctionFromSetter(setter);
}
// Stored in the frame's attribute-listener map (like element and ShadowRoot
// property handlers), which the dispatch propagation path consults for any
// event target.
pub fn getOnClick(self: *Window) ?js.Function.Global {
return self._frame._event_target_attr_listeners.get(.{ .target = self.asEventTarget(), .handler = .onclick });
}
pub fn setOnClick(self: *Window, setter: ?FunctionSetter) !void {
if (getFunctionFromSetter(setter)) |cb| {
try self._frame._event_target_attr_listeners.put(self._frame.arena, .{ .target = self.asEventTarget(), .handler = .onclick }, cb);
} else {
_ = self._frame._event_target_attr_listeners.remove(.{ .target = self.asEventTarget(), .handler = .onclick });
}
}
// The "window-reflecting body element event handler set" (HTML spec): these
// event handlers of body and frameset elements are aliases for the Window's.
// Returns the Window storage slot for the given content attribute name, or
@@ -1159,6 +1174,7 @@ pub const JsApi = struct {
pub const onfocus = bridge.accessor(Window.getOnFocus, Window.setOnFocus, .{});
pub const onresize = bridge.accessor(Window.getOnResize, Window.setOnResize, .{});
pub const onscroll = bridge.accessor(Window.getOnScroll, Window.setOnScroll, .{});
pub const onclick = bridge.accessor(Window.getOnClick, Window.setOnClick, .{});
pub const onmessage = bridge.accessor(Window.getOnMessage, Window.setOnMessage, .{});
pub const onrejectionhandled = bridge.accessor(Window.getOnRejectionHandled, Window.setOnRejectionHandled, .{});
pub const onunhandledrejection = bridge.accessor(Window.getOnUnhandledRejection, Window.setOnUnhandledRejection, .{});

View File

@@ -79,6 +79,7 @@ pub fn initCompositionEvent(
}
const arena = event._arena;
event._initialized = true;
event._type_string = try String.init(arena, typ, .{});
event._bubbles = bubbles orelse false;
event._cancelable = cancelable orelse false;

View File

@@ -73,6 +73,7 @@ pub fn initCustomEvent(
// This function can only be called after the constructor has called.
// So we assume proto is initialized already by constructor.
self._proto._initialized = true;
self._proto._type_string = try String.init(self._proto._arena, event_string, .{});
self._proto._bubbles = bubbles orelse false;
self._proto._cancelable = cancelable orelse false;

View File

@@ -428,6 +428,7 @@ pub fn initKeyboardEvent(
}
const arena = event._arena;
event._initialized = true;
event._type_string = try String.init(arena, typ, .{});
event._bubbles = bubbles orelse false;
event._cancelable = cancelable orelse false;

View File

@@ -239,6 +239,7 @@ pub fn initMouseEvent(
return;
}
event._initialized = true;
event._type_string = try String.init(event._arena, typ, .{});
event._bubbles = bubbles orelse false;
event._cancelable = cancelable orelse false;

View File

@@ -83,6 +83,7 @@ pub fn initTextEvent(
}
const arena = event._arena;
event._initialized = true;
event._type_string = try String.init(arena, typ, .{});
event._bubbles = bubbles orelse false;
event._cancelable = cancelable orelse false;

View File

@@ -146,6 +146,7 @@ pub fn initUIEvent(
return;
}
event._initialized = true;
event._type_string = try String.init(event._arena, typ, .{});
event._bubbles = bubbles orelse false;
event._cancelable = cancelable orelse false;

View File

@@ -16,6 +16,7 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
const lp = @import("lightpanda");
const js = @import("../../js/js.zig");
const EventTarget = @import("../EventTarget.zig");
@@ -83,6 +84,20 @@ pub fn dispatch(self: *XMLHttpRequestEventTarget, comptime event_type: DispatchT
);
}
// Resolves the property event handler for the given event type, so that a
// script-dispatched event (target.dispatchEvent) fires it like the internal
// dispatch path does.
pub fn inlineHandler(self: *const XMLHttpRequestEventTarget, typ: lp.String) ?js.Function.Global {
if (typ.eql(comptime .wrap("abort"))) return self._on_abort;
if (typ.eql(comptime .wrap("error"))) return self._on_error;
if (typ.eql(comptime .wrap("load"))) return self._on_load;
if (typ.eql(comptime .wrap("loadend"))) return self._on_load_end;
if (typ.eql(comptime .wrap("loadstart"))) return self._on_load_start;
if (typ.eql(comptime .wrap("progress"))) return self._on_progress;
if (typ.eql(comptime .wrap("timeout"))) return self._on_timeout;
return null;
}
pub fn getOnAbort(self: *const XMLHttpRequestEventTarget) ?js.Function.Global {
return self._on_abort;
}