From 137ab4a55715cb1a686de6d13f832f074f8e7541 Mon Sep 17 00:00:00 2001 From: Halil Durak Date: Tue, 24 Feb 2026 13:40:45 +0300 Subject: [PATCH 01/10] dispatch `load` events that're attached after `documentIsComplete` --- src/browser/Page.zig | 63 ++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 61 insertions(+), 2 deletions(-) diff --git a/src/browser/Page.zig b/src/browser/Page.zig index 435685c7..1a8a8c25 100644 --- a/src/browser/Page.zig +++ b/src/browser/Page.zig @@ -737,8 +737,7 @@ fn _documentIsComplete(self: *Page) !void { } } } - // `_to_load` can be cleaned here. - self._to_load.clearAndFree(self.arena); + self._to_load.clearRetainingCapacity(); // Dispatch window.load event. const event = try Event.initTrusted(comptime .wrap("load"), .{}, self); @@ -1045,6 +1044,31 @@ pub fn iframeAddedCallback(self: *Page, iframe: *Element.Html.IFrame) !void { } } +pub fn linkAddedCallback(self: *Page, link: *Element.Html.Link) !void { + // if we're planning on navigating to another page, don't trigger load event. + if (self.isGoingAway()) { + return; + } + + const element = link.asElement(); + // Exit if rel not set. + const rel = element.getAttributeSafe(comptime .wrap("rel")) orelse return; + // Exit if rel is not stylesheet. + if (!std.mem.eql(u8, rel, "stylesheet")) return; + // Exit if href not set. + const href = element.getAttributeSafe(comptime .wrap("href")) orelse return; + if (href.len == 0) return; + + // If `_to_load` len was 0, we have to schedule a callback on scheduler. + const loads = &self._to_load; + const not_scheduled = loads.items.len == 0; + try loads.append(self.arena, link._proto); + + if (not_scheduled) { + try self.scheduleLoadEventDelivery(); + } +} + pub fn domChanged(self: *Page) void { self.version += 1; @@ -1217,6 +1241,36 @@ pub fn checkIntersections(self: *Page) !void { } } +pub fn scheduleLoadEventDelivery(self: *Page) !void { + // The dispatcher function. + const callback = struct { + fn callback(ptr: *anyopaque) anyerror!?u32 { + const page: *Page = @ptrCast(@alignCast(ptr)); + const has_dom_load_listener = page._event_manager.has_dom_load_listener; + for (page._to_load.items) |html_element| { + if (has_dom_load_listener or html_element.hasAttributeFunction(.onload, page)) { + const event = try Event.initTrusted(comptime .wrap("load"), .{}, page); + try page._event_manager.dispatch(html_element.asEventTarget(), event); + } + } + // We drained everything. + page._to_load.clearRetainingCapacity(); + + return null; + } + }.callback; + + return self.js.scheduler.add( + self, + callback, + 0, + .{ + .low_priority = false, + .name = "scheduleLoadEventDelivery", + }, + ); +} + pub fn scheduleMutationDelivery(self: *Page) !void { if (self._mutation_delivery_scheduled) { return; @@ -2842,6 +2896,11 @@ fn nodeIsReady(self: *Page, comptime from_parser: bool, node: *Node) !void { log.err(.page, "page.nodeIsReady", .{ .err = err, .element = "iframe", .type = self._type, .url = self.url }); return err; }; + } else if (node.is(Element.Html.Link)) |link| { + self.linkAddedCallback(link) catch |err| { + log.err(.page, "page.nodeIsReady", .{ .err = err, .element = "link", .type = self._type }); + return error.LinkLoadError; + }; } } From be858ac9ce9d97528fbf03b5fbcf6c7983162d36 Mon Sep 17 00:00:00 2001 From: Halil Durak Date: Tue, 24 Feb 2026 19:10:09 +0300 Subject: [PATCH 02/10] add `load` event related tests to `link.html` --- src/browser/tests/element/html/link.html | 49 ++++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/src/browser/tests/element/html/link.html b/src/browser/tests/element/html/link.html index 2031e5fe..6e09c1f8 100644 --- a/src/browser/tests/element/html/link.html +++ b/src/browser/tests/element/html/link.html @@ -19,3 +19,52 @@ l2.crossOrigin = ''; testing.expectEqual('anonymous', l2.crossOrigin); + + + + + + From e23604e08d639e1c26200cccf2db16254f3f32fa Mon Sep 17 00:00:00 2001 From: Halil Durak Date: Wed, 25 Feb 2026 16:04:58 +0300 Subject: [PATCH 03/10] introduce `dispatchLoad` and move load dispatching to `Session._wait` --- src/browser/Page.zig | 60 +++++++++-------------------------------- src/browser/Session.zig | 3 +++ 2 files changed, 16 insertions(+), 47 deletions(-) diff --git a/src/browser/Page.zig b/src/browser/Page.zig index 1a8a8c25..28d47af0 100644 --- a/src/browser/Page.zig +++ b/src/browser/Page.zig @@ -723,22 +723,13 @@ pub fn documentIsComplete(self: *Page) void { fn _documentIsComplete(self: *Page) !void { self.document._ready_state = .complete; + // Run load events before window.load. + try self.dispatchLoad(); + var ls: JS.Local.Scope = undefined; self.js.localScope(&ls); defer ls.deinit(); - { - // Dispatch `_to_load` events before window.load. - const has_dom_load_listener = self._event_manager.has_dom_load_listener; - for (self._to_load.items) |html_element| { - if (has_dom_load_listener or html_element.hasAttributeFunction(.onload, self)) { - const event = try Event.initTrusted(comptime .wrap("load"), .{}, self); - try self._event_manager.dispatch(html_element.asEventTarget(), event); - } - } - } - self._to_load.clearRetainingCapacity(); - // Dispatch window.load event. const event = try Event.initTrusted(comptime .wrap("load"), .{}, self); // This event is weird, it's dispatched directly on the window, but @@ -1059,14 +1050,7 @@ pub fn linkAddedCallback(self: *Page, link: *Element.Html.Link) !void { const href = element.getAttributeSafe(comptime .wrap("href")) orelse return; if (href.len == 0) return; - // If `_to_load` len was 0, we have to schedule a callback on scheduler. - const loads = &self._to_load; - const not_scheduled = loads.items.len == 0; - try loads.append(self.arena, link._proto); - - if (not_scheduled) { - try self.scheduleLoadEventDelivery(); - } + try self._to_load.append(self.arena, link._proto); } pub fn domChanged(self: *Page) void { @@ -1241,34 +1225,16 @@ pub fn checkIntersections(self: *Page) !void { } } -pub fn scheduleLoadEventDelivery(self: *Page) !void { - // The dispatcher function. - const callback = struct { - fn callback(ptr: *anyopaque) anyerror!?u32 { - const page: *Page = @ptrCast(@alignCast(ptr)); - const has_dom_load_listener = page._event_manager.has_dom_load_listener; - for (page._to_load.items) |html_element| { - if (has_dom_load_listener or html_element.hasAttributeFunction(.onload, page)) { - const event = try Event.initTrusted(comptime .wrap("load"), .{}, page); - try page._event_manager.dispatch(html_element.asEventTarget(), event); - } - } - // We drained everything. - page._to_load.clearRetainingCapacity(); - - return null; +pub fn dispatchLoad(self: *Page) !void { + const has_dom_load_listener = self._event_manager.has_dom_load_listener; + for (self._to_load.items) |html_element| { + if (has_dom_load_listener or html_element.hasAttributeFunction(.onload, self)) { + const event = try Event.initTrusted(comptime .wrap("load"), .{}, self); + try self._event_manager.dispatch(html_element.asEventTarget(), event); } - }.callback; - - return self.js.scheduler.add( - self, - callback, - 0, - .{ - .low_priority = false, - .name = "scheduleLoadEventDelivery", - }, - ); + } + // We drained everything. + self._to_load.clearRetainingCapacity(); } pub fn scheduleMutationDelivery(self: *Page) !void { diff --git a/src/browser/Session.zig b/src/browser/Session.zig index 18468933..540ba520 100644 --- a/src/browser/Session.zig +++ b/src/browser/Session.zig @@ -241,6 +241,9 @@ fn _wait(self: *Session, page: *Page, wait_ms: u32) !WaitResult { // it AFTER. const ms_to_next_task = try browser.runMacrotasks(); + // Each call to this runs scheduled load events. + try page.dispatchLoad(); + const http_active = http_client.active; const total_network_activity = http_active + http_client.intercepted; if (page._notified_network_almost_idle.check(total_network_activity <= 2)) { From bc6be22cb4472c06fafc11e8bd027d92218accd8 Mon Sep 17 00:00:00 2001 From: Halil Durak Date: Wed, 25 Feb 2026 16:05:07 +0300 Subject: [PATCH 04/10] update test --- src/browser/tests/element/html/link.html | 1 + 1 file changed, 1 insertion(+) diff --git a/src/browser/tests/element/html/link.html b/src/browser/tests/element/html/link.html index 6e09c1f8..6066403d 100644 --- a/src/browser/tests/element/html/link.html +++ b/src/browser/tests/element/html/link.html @@ -42,6 +42,7 @@ }); testing.expectEqual(true, result); }); + testing.expectEqual(true, true); } From 3f94fd90dd2b883d7ebfd13a5a4d85d34ab9cf38 Mon Sep 17 00:00:00 2001 From: Halil Durak Date: Wed, 25 Feb 2026 16:10:00 +0300 Subject: [PATCH 05/10] dispatch a load event when `href` set for `Link` element Also add `lazy-href-set` test. --- src/browser/tests/element/html/link.html | 15 +++++++++++++++ src/browser/webapi/element/html/Link.zig | 7 ++++++- 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/src/browser/tests/element/html/link.html b/src/browser/tests/element/html/link.html index 6066403d..bed5e6ab 100644 --- a/src/browser/tests/element/html/link.html +++ b/src/browser/tests/element/html/link.html @@ -69,3 +69,18 @@ testing.eventually(() => testing.expectEqual(false, fired)); } + + diff --git a/src/browser/webapi/element/html/Link.zig b/src/browser/webapi/element/html/Link.zig index 7f6b48fc..ca2b0cbe 100644 --- a/src/browser/webapi/element/html/Link.zig +++ b/src/browser/webapi/element/html/Link.zig @@ -50,7 +50,12 @@ pub fn getHref(self: *Link, page: *Page) ![]const u8 { } pub fn setHref(self: *Link, value: []const u8, page: *Page) !void { - try self.asElement().setAttributeSafe(comptime .wrap("href"), .wrap(value), page); + const element = self.asElement(); + try element.setAttributeSafe(comptime .wrap("href"), .wrap(value), page); + + if (element.asNode().isConnected()) { + try page.linkAddedCallback(self); + } } pub fn getRel(self: *Link) []const u8 { From 2da8b25b09af72d39a20c6606a20e1a15a06cf30 Mon Sep 17 00:00:00 2001 From: Halil Durak Date: Wed, 25 Feb 2026 16:10:25 +0300 Subject: [PATCH 06/10] add `LinkLoadError` to `CloneError` --- src/browser/webapi/Node.zig | 1 + 1 file changed, 1 insertion(+) diff --git a/src/browser/webapi/Node.zig b/src/browser/webapi/Node.zig index 9a413170..0d5e1570 100644 --- a/src/browser/webapi/Node.zig +++ b/src/browser/webapi/Node.zig @@ -722,6 +722,7 @@ const CloneError = error{ CloneError, IFrameLoadError, TooManyContexts, + LinkLoadError, }; pub fn cloneNode(self: *Node, deep_: ?bool, page: *Page) CloneError!*Node { const deep = deep_ orelse false; From f897cda6cd36743da7630b3de7e8f3b5eb817239 Mon Sep 17 00:00:00 2001 From: Halil Durak Date: Wed, 25 Feb 2026 16:33:48 +0300 Subject: [PATCH 07/10] dispatch `Style` element's load event from `nodeIsReady` --- src/browser/Page.zig | 28 +++++++++++++++++++++++ src/browser/webapi/Node.zig | 1 + src/browser/webapi/element/html/Style.zig | 7 ------ 3 files changed, 29 insertions(+), 7 deletions(-) diff --git a/src/browser/Page.zig b/src/browser/Page.zig index 28d47af0..333240ab 100644 --- a/src/browser/Page.zig +++ b/src/browser/Page.zig @@ -1053,6 +1053,29 @@ pub fn linkAddedCallback(self: *Page, link: *Element.Html.Link) !void { try self._to_load.append(self.arena, link._proto); } +pub fn styleAddedCallback(self: *Page, style: *Element.Html.Style) !void { + // if we're planning on navigating to another page, don't trigger load event. + if (self.isGoingAway()) { + return; + } + + try self._to_load.append(self.arena, style._proto); +} + +pub fn imageAddedCallback(self: *Page, image: *Element.Html.Image) !void { + // if we're planning on navigating to another page, don't trigger load event. + if (self.isGoingAway()) { + return; + } + + const element = image.asElement(); + // Exit if src not set. + const src = element.getAttributeSafe(comptime .wrap("src")) orelse return; + if (src.len == 0) return; + + try self._to_load.append(self.arena, image._proto); +} + pub fn domChanged(self: *Page) void { self.version += 1; @@ -2867,6 +2890,11 @@ fn nodeIsReady(self: *Page, comptime from_parser: bool, node: *Node) !void { log.err(.page, "page.nodeIsReady", .{ .err = err, .element = "link", .type = self._type }); return error.LinkLoadError; }; + } else if (node.is(Element.Html.Style)) |style| { + self.styleAddedCallback(style) catch |err| { + log.err(.page, "page.nodeIsReady", .{ .err = err, .element = "style", .type = self._type }); + return error.StyleLoadError; + }; } } diff --git a/src/browser/webapi/Node.zig b/src/browser/webapi/Node.zig index 0d5e1570..7849622a 100644 --- a/src/browser/webapi/Node.zig +++ b/src/browser/webapi/Node.zig @@ -723,6 +723,7 @@ const CloneError = error{ IFrameLoadError, TooManyContexts, LinkLoadError, + StyleLoadError, }; pub fn cloneNode(self: *Node, deep_: ?bool, page: *Page) CloneError!*Node { const deep = deep_ orelse false; diff --git a/src/browser/webapi/element/html/Style.zig b/src/browser/webapi/element/html/Style.zig index 3dbb288b..9d97e2cc 100644 --- a/src/browser/webapi/element/html/Style.zig +++ b/src/browser/webapi/element/html/Style.zig @@ -113,13 +113,6 @@ pub const JsApi = struct { pub const sheet = bridge.accessor(Style.getSheet, null, .{}); }; -pub const Build = struct { - pub fn created(node: *Node, page: *Page) !void { - // Push to `_to_load` to dispatch load event just before window load event. - return page._to_load.append(page.arena, node.as(Element.Html)); - } -}; - const testing = @import("../../../../testing.zig"); test "WebApi: Style" { try testing.htmlRunner("element/html/style.html", .{}); From 84bbb6efd4b9cbf5e00896648f91fb3a626779f7 Mon Sep 17 00:00:00 2001 From: Halil Durak Date: Wed, 25 Feb 2026 16:34:34 +0300 Subject: [PATCH 08/10] replacement w/ `imageAddedCallback` --- src/browser/webapi/element/html/Image.zig | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/src/browser/webapi/element/html/Image.zig b/src/browser/webapi/element/html/Image.zig index 1e33b3c0..6277a613 100644 --- a/src/browser/webapi/element/html/Image.zig +++ b/src/browser/webapi/element/html/Image.zig @@ -50,7 +50,10 @@ pub fn getSrc(self: *const Image, page: *Page) ![]const u8 { } pub fn setSrc(self: *Image, value: []const u8, page: *Page) !void { - try self.asElement().setAttributeSafe(comptime .wrap("src"), .wrap(value), page); + const element = self.asElement(); + try element.setAttributeSafe(comptime .wrap("src"), .wrap(value), page); + // No need to check if `Image` is connected to DOM; this is a special case. + return page.imageAddedCallback(self); } pub fn getAlt(self: *const Image) []const u8 { @@ -145,13 +148,7 @@ pub const JsApi = struct { pub const Build = struct { pub fn created(node: *Node, page: *Page) !void { const self = node.as(Image); - const image = self.asElement(); - // Exit if src not set. - // TODO: We might want to check if src point to valid image. - _ = image.getAttributeSafe(comptime .wrap("src")) orelse return; - - // Push to `_to_load` to dispatch load event just before window load event. - return page._to_load.append(page.arena, self._proto); + return page.imageAddedCallback(self); } }; From 721cf98486a73b953e5f8669388a3289938b5aa3 Mon Sep 17 00:00:00 2001 From: Halil Durak Date: Wed, 25 Feb 2026 16:35:21 +0300 Subject: [PATCH 09/10] update `Image` and `Style` tests --- src/browser/tests/element/html/image.html | 78 ++++++++++------------- src/browser/tests/element/html/style.html | 25 ++++++++ 2 files changed, 60 insertions(+), 43 deletions(-) diff --git a/src/browser/tests/element/html/image.html b/src/browser/tests/element/html/image.html index e7868229..92cd947d 100644 --- a/src/browser/tests/element/html/image.html +++ b/src/browser/tests/element/html/image.html @@ -114,48 +114,15 @@ } - - - - - + + + + diff --git a/src/browser/tests/element/html/style.html b/src/browser/tests/element/html/style.html index 713d8e2e..8abbb229 100644 --- a/src/browser/tests/element/html/style.html +++ b/src/browser/tests/element/html/style.html @@ -106,3 +106,28 @@ testing.expectEqual(true, style.disabled); } + + From c86c851c60366afe5cc431d04886d84746912fd7 Mon Sep 17 00:00:00 2001 From: Halil Durak Date: Thu, 26 Feb 2026 10:26:59 +0300 Subject: [PATCH 10/10] move `*addedCallback`s to respective types --- src/browser/Page.zig | 45 +---------------------- src/browser/webapi/element/html/Image.zig | 19 +++++++++- src/browser/webapi/element/html/Link.zig | 20 +++++++++- src/browser/webapi/element/html/Style.zig | 9 +++++ 4 files changed, 47 insertions(+), 46 deletions(-) diff --git a/src/browser/Page.zig b/src/browser/Page.zig index 333240ab..9075c2a5 100644 --- a/src/browser/Page.zig +++ b/src/browser/Page.zig @@ -1035,47 +1035,6 @@ pub fn iframeAddedCallback(self: *Page, iframe: *Element.Html.IFrame) !void { } } -pub fn linkAddedCallback(self: *Page, link: *Element.Html.Link) !void { - // if we're planning on navigating to another page, don't trigger load event. - if (self.isGoingAway()) { - return; - } - - const element = link.asElement(); - // Exit if rel not set. - const rel = element.getAttributeSafe(comptime .wrap("rel")) orelse return; - // Exit if rel is not stylesheet. - if (!std.mem.eql(u8, rel, "stylesheet")) return; - // Exit if href not set. - const href = element.getAttributeSafe(comptime .wrap("href")) orelse return; - if (href.len == 0) return; - - try self._to_load.append(self.arena, link._proto); -} - -pub fn styleAddedCallback(self: *Page, style: *Element.Html.Style) !void { - // if we're planning on navigating to another page, don't trigger load event. - if (self.isGoingAway()) { - return; - } - - try self._to_load.append(self.arena, style._proto); -} - -pub fn imageAddedCallback(self: *Page, image: *Element.Html.Image) !void { - // if we're planning on navigating to another page, don't trigger load event. - if (self.isGoingAway()) { - return; - } - - const element = image.asElement(); - // Exit if src not set. - const src = element.getAttributeSafe(comptime .wrap("src")) orelse return; - if (src.len == 0) return; - - try self._to_load.append(self.arena, image._proto); -} - pub fn domChanged(self: *Page) void { self.version += 1; @@ -2886,12 +2845,12 @@ fn nodeIsReady(self: *Page, comptime from_parser: bool, node: *Node) !void { return err; }; } else if (node.is(Element.Html.Link)) |link| { - self.linkAddedCallback(link) catch |err| { + link.linkAddedCallback(self) catch |err| { log.err(.page, "page.nodeIsReady", .{ .err = err, .element = "link", .type = self._type }); return error.LinkLoadError; }; } else if (node.is(Element.Html.Style)) |style| { - self.styleAddedCallback(style) catch |err| { + style.styleAddedCallback(self) catch |err| { log.err(.page, "page.nodeIsReady", .{ .err = err, .element = "style", .type = self._type }); return error.StyleLoadError; }; diff --git a/src/browser/webapi/element/html/Image.zig b/src/browser/webapi/element/html/Image.zig index 6277a613..c8fba91e 100644 --- a/src/browser/webapi/element/html/Image.zig +++ b/src/browser/webapi/element/html/Image.zig @@ -53,7 +53,7 @@ pub fn setSrc(self: *Image, value: []const u8, page: *Page) !void { const element = self.asElement(); try element.setAttributeSafe(comptime .wrap("src"), .wrap(value), page); // No need to check if `Image` is connected to DOM; this is a special case. - return page.imageAddedCallback(self); + return self.imageAddedCallback(page); } pub fn getAlt(self: *const Image) []const u8 { @@ -123,6 +123,21 @@ pub fn getComplete(_: *const Image) bool { return true; } +/// Used in `Page.nodeIsReady`. +pub fn imageAddedCallback(self: *Image, page: *Page) !void { + // if we're planning on navigating to another page, don't trigger load event. + if (page.isGoingAway()) { + return; + } + + const element = self.asElement(); + // Exit if src not set. + const src = element.getAttributeSafe(comptime .wrap("src")) orelse return; + if (src.len == 0) return; + + try page._to_load.append(page.arena, self._proto); +} + pub const JsApi = struct { pub const bridge = js.Bridge(Image); @@ -148,7 +163,7 @@ pub const JsApi = struct { pub const Build = struct { pub fn created(node: *Node, page: *Page) !void { const self = node.as(Image); - return page.imageAddedCallback(self); + return self.imageAddedCallback(page); } }; diff --git a/src/browser/webapi/element/html/Link.zig b/src/browser/webapi/element/html/Link.zig index ca2b0cbe..fe56bd89 100644 --- a/src/browser/webapi/element/html/Link.zig +++ b/src/browser/webapi/element/html/Link.zig @@ -54,7 +54,7 @@ pub fn setHref(self: *Link, value: []const u8, page: *Page) !void { try element.setAttributeSafe(comptime .wrap("href"), .wrap(value), page); if (element.asNode().isConnected()) { - try page.linkAddedCallback(self); + try self.linkAddedCallback(page); } } @@ -86,6 +86,24 @@ pub fn setCrossOrigin(self: *Link, value: []const u8, page: *Page) !void { return self.asElement().setAttributeSafe(comptime .wrap("crossOrigin"), .wrap(normalized), page); } +pub fn linkAddedCallback(self: *Link, page: *Page) !void { + // if we're planning on navigating to another page, don't trigger load event. + if (page.isGoingAway()) { + return; + } + + const element = self.asElement(); + // Exit if rel not set. + const rel = element.getAttributeSafe(comptime .wrap("rel")) orelse return; + // Exit if rel is not stylesheet. + if (!std.mem.eql(u8, rel, "stylesheet")) return; + // Exit if href not set. + const href = element.getAttributeSafe(comptime .wrap("href")) orelse return; + if (href.len == 0) return; + + try page._to_load.append(page.arena, self._proto); +} + pub const JsApi = struct { pub const bridge = js.Bridge(Link); diff --git a/src/browser/webapi/element/html/Style.zig b/src/browser/webapi/element/html/Style.zig index 9d97e2cc..131b7634 100644 --- a/src/browser/webapi/element/html/Style.zig +++ b/src/browser/webapi/element/html/Style.zig @@ -97,6 +97,15 @@ pub fn getSheet(self: *Style, page: *Page) !?*CSSStyleSheet { return sheet; } +pub fn styleAddedCallback(self: *Style, page: *Page) !void { + // if we're planning on navigating to another page, don't trigger load event. + if (page.isGoingAway()) { + return; + } + + try page._to_load.append(page.arena, self._proto); +} + pub const JsApi = struct { pub const bridge = js.Bridge(Style);