Merge pull request #1640 from lightpanda-io/nikneym/load-events-after-doc-complete

Dispatch `load` events that're attached after `documentIsComplete`
This commit is contained in:
Pierre Tachoire
2026-02-26 11:01:46 +01:00
committed by GitHub
9 changed files with 208 additions and 72 deletions

View File

@@ -723,23 +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);
}
}
}
// `_to_load` can be cleaned here.
self._to_load.clearAndFree(self.arena);
// 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
@@ -1217,6 +1207,18 @@ pub fn checkIntersections(self: *Page) !void {
}
}
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);
}
}
// We drained everything.
self._to_load.clearRetainingCapacity();
}
pub fn scheduleMutationDelivery(self: *Page) !void {
if (self._mutation_delivery_scheduled) {
return;
@@ -2842,6 +2844,16 @@ 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| {
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| {
style.styleAddedCallback(self) catch |err| {
log.err(.page, "page.nodeIsReady", .{ .err = err, .element = "style", .type = self._type });
return error.StyleLoadError;
};
}
}

View File

@@ -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)) {

View File

@@ -114,48 +114,15 @@
}
</script>
<script id="load-trigger-event">
<body></body>
<script id="img-load-event">
{
// An img fires a load event when src is set.
const img = document.createElement("img");
let count = 0;
img.addEventListener("load", ({ bubbles, cancelBubble, cancelable, composed, isTrusted, target }) => {
testing.expectEqual(true, count < 3);
count++;
testing.expectEqual(false, bubbles);
testing.expectEqual(false, cancelBubble);
testing.expectEqual(false, cancelable);
testing.expectEqual(false, composed);
testing.expectEqual(true, isTrusted);
testing.expectEqual(img, target);
});
for (let i = 0; i < 3; i++) {
img.src = "https://cdn.lightpanda.io/website/assets/images/docs/hn.png";
testing.expectEqual("https://cdn.lightpanda.io/website/assets/images/docs/hn.png", img.src);
}
// Make sure count is incremented asynchronously.
testing.expectEqual(0, count);
}
</script>
<img
id="inline-img"
src="https://cdn.lightpanda.io/website/assets/images/docs/hn.png"
onload="(() => testing.expectEqual(true, true))()"
/>
<script id="inline-on-load">
{
const img = document.getElementById("inline-img");
testing.expectEqual(true, img.onload instanceof Function);
// Also call inline to double check.
img.onload();
// Make sure ones attached with `addEventListener` also executed.
let result = false;
testing.async(async () => {
const result = await new Promise(resolve => {
await new Promise(resolve => {
img.addEventListener("load", ({ bubbles, cancelBubble, cancelable, composed, isTrusted, target }) => {
testing.expectEqual(false, bubbles);
testing.expectEqual(false, cancelBubble);
@@ -163,13 +130,38 @@
testing.expectEqual(false, composed);
testing.expectEqual(true, isTrusted);
testing.expectEqual(img, target);
return resolve(true);
result = true;
return resolve();
});
img.src = "https://cdn.lightpanda.io/website/assets/images/docs/hn.png";
});
testing.expectEqual(true, result);
});
testing.eventually(() => testing.expectEqual(true, result));
}
</script>
<script id="img-no-load-without-src">
{
// An img without src should not fire a load event.
let fired = false;
const img = document.createElement("img");
img.addEventListener("load", () => { fired = true; });
document.body.appendChild(img);
testing.eventually(() => testing.expectEqual(false, fired));
}
</script>
<script id="lazy-src-set">
{
// Append to DOM first, then set src — load should still fire.
const img = document.createElement("img");
let result = false;
img.onload = () => result = true;
document.body.appendChild(img);
img.src = "https://cdn.lightpanda.io/website/assets/images/docs/hn.png";
testing.eventually(() => testing.expectEqual(true, result));
}
</script>

View File

@@ -19,3 +19,68 @@
l2.crossOrigin = '';
testing.expectEqual('anonymous', l2.crossOrigin);
</script>
<script id="link-load-event">
{
// A link with rel=stylesheet and a non-empty href fires a load event when appended to the DOM
const link = document.createElement('link');
link.rel = 'stylesheet';
link.href = 'https://lightpanda.io/opensource-browser/15';
testing.async(async () => {
const result = await new Promise(resolve => {
link.addEventListener('load', ({ bubbles, cancelBubble, cancelable, composed, isTrusted, target }) => {
testing.expectEqual(false, bubbles);
testing.expectEqual(false, cancelBubble);
testing.expectEqual(false, cancelable);
testing.expectEqual(false, composed);
testing.expectEqual(true, isTrusted);
testing.expectEqual(link, target);
resolve(true);
});
document.head.appendChild(link);
});
testing.expectEqual(true, result);
});
testing.expectEqual(true, true);
}
</script>
<script id="link-no-load-without-href">
{
// A link with rel=stylesheet but no href should not fire a load event
let fired = false;
const link = document.createElement('link');
link.rel = 'stylesheet';
link.addEventListener('load', () => { fired = true; });
document.head.appendChild(link);
testing.eventually(() => testing.expectEqual(false, fired));
}
</script>
<script id="link-no-load-wrong-rel">
{
// A link without rel=stylesheet should not fire a load event
let fired = false;
const link = document.createElement('link');
link.href = 'https://lightpanda.io/opensource-browser/15';
link.addEventListener('load', () => { fired = true; });
document.head.appendChild(link);
testing.eventually(() => testing.expectEqual(false, fired));
}
</script>
<script id="lazy-href-set">
{
let result = false;
const link = document.createElement("link");
link.rel = "stylesheet";
link.onload = () => result = true;
// Append to DOM,
document.head.appendChild(link);
// then set href.
link.href = 'https://lightpanda.io/opensource-browser/15';
testing.eventually(() => testing.expectEqual(true, result));
}
</script>

View File

@@ -106,3 +106,28 @@
testing.expectEqual(true, style.disabled);
}
</script>
<script id="style-load-event">
{
// A style element fires a load event when appended to the DOM.
const style = document.createElement("style");
let result = false;
testing.async(async () => {
await new Promise(resolve => {
style.addEventListener("load", ({ bubbles, cancelBubble, cancelable, composed, isTrusted, target }) => {
testing.expectEqual(false, bubbles);
testing.expectEqual(false, cancelBubble);
testing.expectEqual(false, cancelable);
testing.expectEqual(false, composed);
testing.expectEqual(true, isTrusted);
testing.expectEqual(style, target);
result = true;
return resolve();
});
document.head.appendChild(style);
});
});
testing.eventually(() => testing.expectEqual(true, result));
}
</script>

View File

@@ -722,6 +722,8 @@ const CloneError = error{
CloneError,
IFrameLoadError,
TooManyContexts,
LinkLoadError,
StyleLoadError,
};
pub fn cloneNode(self: *Node, deep_: ?bool, page: *Page) CloneError!*Node {
const deep = deep_ orelse false;

View File

@@ -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 self.imageAddedCallback(page);
}
pub fn getAlt(self: *const Image) []const u8 {
@@ -120,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);
@@ -145,13 +163,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 self.imageAddedCallback(page);
}
};

View File

@@ -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 self.linkAddedCallback(page);
}
}
pub fn getRel(self: *Link) []const u8 {
@@ -81,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);

View File

@@ -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);
@@ -113,13 +122,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", .{});