From c6de444d0bf0135a851595accf84fceb543dacd0 Mon Sep 17 00:00:00 2001 From: Muki Kiboigo Date: Tue, 17 Feb 2026 11:50:30 -0800 Subject: [PATCH 001/103] add support for Selection.modify --- src/browser/webapi/Selection.zig | 199 ++++++++++++++++++++++++++++--- 1 file changed, 180 insertions(+), 19 deletions(-) diff --git a/src/browser/webapi/Selection.zig b/src/browser/webapi/Selection.zig index 88da6d56..99b43464 100644 --- a/src/browser/webapi/Selection.zig +++ b/src/browser/webapi/Selection.zig @@ -291,16 +291,9 @@ const ModifyDirection = enum { const ModifyGranularity = enum { character, word, - line, - paragraph, - lineboundary, - // Firefox doesn't implement: - // - sentence - // - paragraph - // - sentenceboundary - // - paragraphboundary - // - documentboundary - // so we won't either for now. + // The rest are either: + // 1. Layout dependent. + // 2. Not widely supported across browsers. pub fn fromString(str: []const u8) ?ModifyGranularity { return std.meta.stringToEnum(ModifyGranularity, str); @@ -312,18 +305,186 @@ pub fn modify( alter_str: []const u8, direction_str: []const u8, granularity_str: []const u8, + page: *Page, ) !void { - const alter = ModifyAlter.fromString(alter_str) orelse return error.InvalidParams; - const direction = ModifyDirection.fromString(direction_str) orelse return error.InvalidParams; - const granularity = ModifyGranularity.fromString(granularity_str) orelse return error.InvalidParams; + const alter = ModifyAlter.fromString(alter_str) orelse return; + const direction = ModifyDirection.fromString(direction_str) orelse return; + const granularity = ModifyGranularity.fromString(granularity_str) orelse return; - _ = self._range orelse return; + const range = self._range orelse return; - log.warn(.not_implemented, "Selection.modify", .{ - .alter = alter, - .direction = direction, - .granularity = granularity, - }); + const is_forward = switch (direction) { + .forward, .right => true, + .backward, .left => false, + }; + + switch (granularity) { + .character => try self.modifyByCharacter(alter, is_forward, range, page), + .word => try self.modifyByWord(alter, is_forward, range, page), + } +} + +fn isTextNode(node: *const Node) bool { + return switch (node._type) { + .cdata => |cd| cd._type == .text, + else => false, + }; +} + +fn nextTextNode(node: *Node) ?*Node { + var current = node; + + while (true) { + if (current.firstChild()) |child| { + current = child; + } else if (current.nextSibling()) |sib| { + current = sib; + } else { + while (true) { + const parent = current.parentNode() orelse return null; + if (parent.nextSibling()) |uncle| { + current = uncle; + break; + } + current = parent; + } + } + + if (isTextNode(current)) return current; + } +} + +fn prevTextNode(node: *Node) ?*Node { + var current = node; + + while (true) { + if (current.previousSibling()) |sib| { + current = sib; + while (current.lastChild()) |child| { + current = child; + } + } else { + current = current.parentNode() orelse return null; + } + + if (isTextNode(current)) return current; + } +} + +fn modifyByCharacter(self: *Selection, alter: ModifyAlter, forward: bool, range: *Range, page: *Page) !void { + const abstract = range.asAbstractRange(); + + const focus_node = switch (self._direction) { + .forward, .none => abstract.getStartContainer(), + .backward => abstract.getEndContainer(), + }; + + const focus_offset = switch (self._direction) { + .forward, .none => abstract.getStartOffset(), + .backward => abstract.getEndOffset(), + }; + + var new_node = focus_node; + var new_offset = focus_offset; + + if (forward) { + const len = focus_node.getLength(); + if (focus_offset < len) { + new_offset += 1; + } else if (nextTextNode(focus_node)) |next| { + new_node = next; + new_offset = 0; + } + } else { + if (focus_offset > 0) { + new_offset -= 1; + } else if (prevTextNode(focus_node)) |prev| { + new_node = prev; + new_offset = prev.getLength() - 1; + } + } + + switch (alter) { + .move => { + const new_range = try Range.init(page); + try new_range.setStart(new_node, new_offset); + try new_range.setEnd(new_node, new_offset); + self._range = new_range; + self._direction = .none; + try dispatchSelectionChangeEvent(page); + }, + .extend => try self.extend(new_node, new_offset, page), + } +} + +fn isWordChar(c: u8) bool { + return std.ascii.isAlphanumeric(c) or c == '_'; +} + +fn nextWordEnd(text: []const u8, offset: u32) u32 { + var i = offset; + // consumes whitespace till next word + while (i < text.len and !isWordChar(text[i])) : (i += 1) {} + // consumes next word + while (i < text.len and isWordChar(text[i])) : (i += 1) {} + return i; +} + +fn prevWordStart(text: []const u8, offset: u32) u32 { + var i = offset; + if (i > 0) i -= 1; + // consumes the white space + while (i > 0 and !isWordChar(text[i])) : (i -= 1) {} + // consumes the last word + while (i > 0 and isWordChar(text[i - 1])) : (i -= 1) {} + return i; +} + +fn modifyByWord(self: *Selection, alter: ModifyAlter, forward: bool, range: *Range, page: *Page) !void { + const abstract = range.asAbstractRange(); + + const focus_node = switch (self._direction) { + .forward, .none => abstract.getStartContainer(), + .backward => abstract.getEndContainer(), + }; + + const focus_offset = switch (self._direction) { + .forward, .none => abstract.getStartOffset(), + .backward => abstract.getEndOffset(), + }; + + var new_node = focus_node; + var new_offset = focus_offset; + + if (forward) { + const i = nextWordEnd(new_node.getData(), new_offset); + if (i > new_offset) { + new_offset = i; + } else if (nextTextNode(focus_node)) |next| { + new_node = next; + new_offset = nextWordEnd(next.getData(), 0); + } + } else { + const i = prevWordStart(new_node.getData(), new_offset); + if (i < new_offset) { + new_offset = i; + } else if (prevTextNode(focus_node)) |prev| { + new_node = prev; + new_offset = prevWordStart(prev.getData(), @intCast(prev.getData().len)); + } + } + + switch (alter) { + .move => { + const new_range = try Range.init(page); + try new_range.setStart(new_node, new_offset); + try new_range.setEnd(new_node, new_offset); + self._range = new_range; + self._direction = .none; + try dispatchSelectionChangeEvent(page); + }, + .extend => try self.extend(new_node, new_offset, page), + } } pub fn selectAllChildren(self: *Selection, parent: *Node, page: *Page) !void { From e77e4acea964791a77e71ce608a507b127aa137b Mon Sep 17 00:00:00 2001 From: Muki Kiboigo Date: Tue, 17 Feb 2026 11:50:38 -0800 Subject: [PATCH 002/103] add tests for Selection.modify --- src/browser/tests/selection.html | 98 +++++++++++++++++++++++++++++--- 1 file changed, 90 insertions(+), 8 deletions(-) diff --git a/src/browser/tests/selection.html b/src/browser/tests/selection.html index 51319ba5..1adac45e 100644 --- a/src/browser/tests/selection.html +++ b/src/browser/tests/selection.html @@ -546,14 +546,14 @@ { const sel = window.getSelection(); sel.removeAllRanges(); - let eventCount = 0; let lastEvent = null; - document.addEventListener('selectionchange', (e) => { + const listener = (e) => { eventCount++; lastEvent = e; - }); + }; + document.addEventListener('selectionchange', listener); const p1 = document.getElementById("p1"); const textNode = p1.firstChild; @@ -563,27 +563,25 @@ sel.extend(textNode, 10); sel.collapseToStart(); sel.collapseToEnd(); - sel.removeAllRanges(); const range = document.createRange(); range.setStart(textNode, 4); range.setEnd(textNode, 15); sel.addRange(range); - sel.removeRange(range); - const newRange = document.createRange(); newRange.selectNodeContents(p1); sel.addRange(newRange); sel.removeAllRanges(); - sel.selectAllChildren(nested); sel.setBaseAndExtent(textNode, 4, textNode, 15); - sel.collapse(textNode, 5); sel.extend(textNode, 10); sel.deleteFromDocument(); + document.removeEventListener('selectionchange', listener); + textNode.textContent = "The quick brown fox"; + testing.eventually(() => { testing.expectEqual(14, eventCount); testing.expectEqual('selectionchange', lastEvent.type); @@ -593,3 +591,87 @@ }); } + + + + + + + + From f8f99f38783fee0d1c14afdba63c93b8183bc18d Mon Sep 17 00:00:00 2001 From: Muki Kiboigo Date: Tue, 17 Feb 2026 11:58:13 -0800 Subject: [PATCH 003/103] pass selection/modify.tentative.html --- src/browser/webapi/Selection.zig | 51 ++++++++++++++++++++++++-------- 1 file changed, 39 insertions(+), 12 deletions(-) diff --git a/src/browser/webapi/Selection.zig b/src/browser/webapi/Selection.zig index 99b43464..512e18fe 100644 --- a/src/browser/webapi/Selection.zig +++ b/src/browser/webapi/Selection.zig @@ -387,20 +387,47 @@ fn modifyByCharacter(self: *Selection, alter: ModifyAlter, forward: bool, range: var new_node = focus_node; var new_offset = focus_offset; - if (forward) { - const len = focus_node.getLength(); - if (focus_offset < len) { - new_offset += 1; - } else if (nextTextNode(focus_node)) |next| { - new_node = next; - new_offset = 0; + if (!isTextNode(focus_node)) { + if (forward) { + if (focus_node.getChildAt(focus_offset)) |child| { + if (isTextNode(child)) { + new_node = child; + new_offset = 0; + } else if (nextTextNode(child)) |t| { + new_node = t; + new_offset = 0; + } + } + } else { + var idx = focus_offset; + while (idx > 0) { + idx -= 1; + const child = focus_node.getChildAt(idx) orelse break; + var bottom = child; + while (bottom.lastChild()) |c| bottom = c; + if (isTextNode(bottom)) { + new_node = bottom; + new_offset = bottom.getLength(); + break; + } + } } } else { - if (focus_offset > 0) { - new_offset -= 1; - } else if (prevTextNode(focus_node)) |prev| { - new_node = prev; - new_offset = prev.getLength() - 1; + if (forward) { + const len = focus_node.getLength(); + if (focus_offset < len) { + new_offset += 1; + } else if (nextTextNode(focus_node)) |next| { + new_node = next; + new_offset = 0; + } + } else { + if (focus_offset > 0) { + new_offset -= 1; + } else if (prevTextNode(focus_node)) |prev| { + new_node = prev; + new_offset = prev.getLength(); + } } } From 3822e3f8d9f6319204198a38ca2400f0f4d0d3f8 Mon Sep 17 00:00:00 2001 From: Muki Kiboigo Date: Tue, 17 Feb 2026 12:02:34 -0800 Subject: [PATCH 004/103] pass selection/modify-extend-word-trailing-inline-block.tentative.html --- src/browser/webapi/Selection.zig | 78 ++++++++++++++++++++++---------- 1 file changed, 55 insertions(+), 23 deletions(-) diff --git a/src/browser/webapi/Selection.zig b/src/browser/webapi/Selection.zig index 512e18fe..e896ef19 100644 --- a/src/browser/webapi/Selection.zig +++ b/src/browser/webapi/Selection.zig @@ -375,13 +375,12 @@ fn modifyByCharacter(self: *Selection, alter: ModifyAlter, forward: bool, range: const abstract = range.asAbstractRange(); const focus_node = switch (self._direction) { - .forward, .none => abstract.getStartContainer(), - .backward => abstract.getEndContainer(), + .backward => abstract.getStartContainer(), + .forward, .none => abstract.getEndContainer(), }; - const focus_offset = switch (self._direction) { - .forward, .none => abstract.getStartOffset(), - .backward => abstract.getEndOffset(), + .backward => abstract.getStartOffset(), + .forward, .none => abstract.getEndOffset(), }; var new_node = focus_node; @@ -471,36 +470,69 @@ fn modifyByWord(self: *Selection, alter: ModifyAlter, forward: bool, range: *Ran const abstract = range.asAbstractRange(); const focus_node = switch (self._direction) { - .forward, .none => abstract.getStartContainer(), - .backward => abstract.getEndContainer(), + .backward => abstract.getStartContainer(), + .forward, .none => abstract.getEndContainer(), }; - const focus_offset = switch (self._direction) { - .forward, .none => abstract.getStartOffset(), - .backward => abstract.getEndOffset(), + .backward => abstract.getStartOffset(), + .forward, .none => abstract.getEndOffset(), }; var new_node = focus_node; var new_offset = focus_offset; - if (forward) { - const i = nextWordEnd(new_node.getData(), new_offset); - if (i > new_offset) { - new_offset = i; - } else if (nextTextNode(focus_node)) |next| { - new_node = next; - new_offset = nextWordEnd(next.getData(), 0); + if (!isTextNode(focus_node)) { + if (forward) { + const child = focus_node.getChildAt(focus_offset) orelse { + if (nextTextNode(focus_node)) |next| { + new_node = next; + new_offset = nextWordEnd(next.getData(), 0); + } + return self.applyWordModify(alter, new_node, new_offset, page); + }; + const t = if (isTextNode(child)) child else nextTextNode(child) orelse { + return self.applyWordModify(alter, new_node, new_offset, page); + }; + new_node = t; + new_offset = nextWordEnd(t.getData(), 0); + } else { + var idx = focus_offset; + while (idx > 0) { + idx -= 1; + const child = focus_node.getChildAt(idx) orelse break; + var bottom = child; + while (bottom.lastChild()) |c| bottom = c; + if (isTextNode(bottom)) { + new_node = bottom; + new_offset = prevWordStart(bottom.getData(), bottom.getLength()); + break; + } + } } } else { - const i = prevWordStart(new_node.getData(), new_offset); - if (i < new_offset) { - new_offset = i; - } else if (prevTextNode(focus_node)) |prev| { - new_node = prev; - new_offset = prevWordStart(prev.getData(), @intCast(prev.getData().len)); + if (forward) { + const i = nextWordEnd(new_node.getData(), new_offset); + if (i > new_offset) { + new_offset = i; + } else if (nextTextNode(focus_node)) |next| { + new_node = next; + new_offset = nextWordEnd(next.getData(), 0); + } + } else { + const i = prevWordStart(new_node.getData(), new_offset); + if (i < new_offset) { + new_offset = i; + } else if (prevTextNode(focus_node)) |prev| { + new_node = prev; + new_offset = prevWordStart(prev.getData(), @intCast(prev.getData().len)); + } } } + try self.applyWordModify(alter, new_node, new_offset, page); +} + +fn applyWordModify(self: *Selection, alter: ModifyAlter, new_node: *Node, new_offset: u32, page: *Page) !void { switch (alter) { .move => { const new_range = try Range.init(page); From c4391ff058e8341c6d4f77acdec60dcfe4d800f6 Mon Sep 17 00:00:00 2001 From: Muki Kiboigo Date: Tue, 17 Feb 2026 12:11:29 -0800 Subject: [PATCH 005/103] refactor modifyBy implementations --- src/browser/webapi/Selection.zig | 116 ++++++++++++++++--------------- 1 file changed, 61 insertions(+), 55 deletions(-) diff --git a/src/browser/webapi/Selection.zig b/src/browser/webapi/Selection.zig index e896ef19..7309d772 100644 --- a/src/browser/webapi/Selection.zig +++ b/src/browser/webapi/Selection.zig @@ -386,32 +386,7 @@ fn modifyByCharacter(self: *Selection, alter: ModifyAlter, forward: bool, range: var new_node = focus_node; var new_offset = focus_offset; - if (!isTextNode(focus_node)) { - if (forward) { - if (focus_node.getChildAt(focus_offset)) |child| { - if (isTextNode(child)) { - new_node = child; - new_offset = 0; - } else if (nextTextNode(child)) |t| { - new_node = t; - new_offset = 0; - } - } - } else { - var idx = focus_offset; - while (idx > 0) { - idx -= 1; - const child = focus_node.getChildAt(idx) orelse break; - var bottom = child; - while (bottom.lastChild()) |c| bottom = c; - if (isTextNode(bottom)) { - new_node = bottom; - new_offset = bottom.getLength(); - break; - } - } - } - } else { + if (isTextNode(focus_node)) { if (forward) { const len = focus_node.getLength(); if (focus_offset < len) { @@ -428,6 +403,32 @@ fn modifyByCharacter(self: *Selection, alter: ModifyAlter, forward: bool, range: new_offset = prev.getLength(); } } + } else { + if (forward) { + if (focus_node.getChildAt(focus_offset)) |child| { + if (isTextNode(child)) { + new_node = child; + new_offset = 0; + } else if (nextTextNode(child)) |t| { + new_node = t; + new_offset = 0; + } + } + } else { + var idx = focus_offset; + + while (idx > 0) { + idx -= 1; + const child = focus_node.getChildAt(idx) orelse break; + var bottom = child; + while (bottom.lastChild()) |c| bottom = c; + if (isTextNode(bottom)) { + new_node = bottom; + new_offset = bottom.getLength(); + break; + } + } + } } switch (alter) { @@ -481,35 +482,7 @@ fn modifyByWord(self: *Selection, alter: ModifyAlter, forward: bool, range: *Ran var new_node = focus_node; var new_offset = focus_offset; - if (!isTextNode(focus_node)) { - if (forward) { - const child = focus_node.getChildAt(focus_offset) orelse { - if (nextTextNode(focus_node)) |next| { - new_node = next; - new_offset = nextWordEnd(next.getData(), 0); - } - return self.applyWordModify(alter, new_node, new_offset, page); - }; - const t = if (isTextNode(child)) child else nextTextNode(child) orelse { - return self.applyWordModify(alter, new_node, new_offset, page); - }; - new_node = t; - new_offset = nextWordEnd(t.getData(), 0); - } else { - var idx = focus_offset; - while (idx > 0) { - idx -= 1; - const child = focus_node.getChildAt(idx) orelse break; - var bottom = child; - while (bottom.lastChild()) |c| bottom = c; - if (isTextNode(bottom)) { - new_node = bottom; - new_offset = prevWordStart(bottom.getData(), bottom.getLength()); - break; - } - } - } - } else { + if (isTextNode(focus_node)) { if (forward) { const i = nextWordEnd(new_node.getData(), new_offset); if (i > new_offset) { @@ -527,6 +500,39 @@ fn modifyByWord(self: *Selection, alter: ModifyAlter, forward: bool, range: *Ran new_offset = prevWordStart(prev.getData(), @intCast(prev.getData().len)); } } + } else { + // Search and apply rules on the next Text Node. + // This is either next (on forward) or previous (on backward). + + if (forward) { + const child = focus_node.getChildAt(focus_offset) orelse { + if (nextTextNode(focus_node)) |next| { + new_node = next; + new_offset = nextWordEnd(next.getData(), 0); + } + return self.applyWordModify(alter, new_node, new_offset, page); + }; + + const t = if (isTextNode(child)) child else nextTextNode(child) orelse { + return self.applyWordModify(alter, new_node, new_offset, page); + }; + + new_node = t; + new_offset = nextWordEnd(t.getData(), 0); + } else { + var idx = focus_offset; + while (idx > 0) { + idx -= 1; + const child = focus_node.getChildAt(idx) orelse break; + var bottom = child; + while (bottom.lastChild()) |c| bottom = c; + if (isTextNode(bottom)) { + new_node = bottom; + new_offset = prevWordStart(bottom.getData(), bottom.getLength()); + break; + } + } + } } try self.applyWordModify(alter, new_node, new_offset, page); From 90138ed57474c8728470c19a8def1b18d2fbd844 Mon Sep 17 00:00:00 2001 From: Muki Kiboigo Date: Wed, 18 Feb 2026 06:07:06 -0800 Subject: [PATCH 006/103] use applyModify generally --- src/browser/webapi/Selection.zig | 20 +++++--------------- 1 file changed, 5 insertions(+), 15 deletions(-) diff --git a/src/browser/webapi/Selection.zig b/src/browser/webapi/Selection.zig index 7309d772..e7012eda 100644 --- a/src/browser/webapi/Selection.zig +++ b/src/browser/webapi/Selection.zig @@ -431,17 +431,7 @@ fn modifyByCharacter(self: *Selection, alter: ModifyAlter, forward: bool, range: } } - switch (alter) { - .move => { - const new_range = try Range.init(page); - try new_range.setStart(new_node, new_offset); - try new_range.setEnd(new_node, new_offset); - self._range = new_range; - self._direction = .none; - try dispatchSelectionChangeEvent(page); - }, - .extend => try self.extend(new_node, new_offset, page), - } + try self.applyModify(alter, new_node, new_offset, page); } fn isWordChar(c: u8) bool { @@ -510,11 +500,11 @@ fn modifyByWord(self: *Selection, alter: ModifyAlter, forward: bool, range: *Ran new_node = next; new_offset = nextWordEnd(next.getData(), 0); } - return self.applyWordModify(alter, new_node, new_offset, page); + return self.applyModify(alter, new_node, new_offset, page); }; const t = if (isTextNode(child)) child else nextTextNode(child) orelse { - return self.applyWordModify(alter, new_node, new_offset, page); + return self.applyModify(alter, new_node, new_offset, page); }; new_node = t; @@ -535,10 +525,10 @@ fn modifyByWord(self: *Selection, alter: ModifyAlter, forward: bool, range: *Ran } } - try self.applyWordModify(alter, new_node, new_offset, page); + try self.applyModify(alter, new_node, new_offset, page); } -fn applyWordModify(self: *Selection, alter: ModifyAlter, new_node: *Node, new_offset: u32, page: *Page) !void { +fn applyModify(self: *Selection, alter: ModifyAlter, new_node: *Node, new_offset: u32, page: *Page) !void { switch (alter) { .move => { const new_range = try Range.init(page); From 861126f810104354008533cf98a9f73b6721085b Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Sat, 21 Feb 2026 12:58:35 +0800 Subject: [PATCH 007/103] Add dummy implementation of OffscreenCanvas --- src/browser/js/bridge.zig | 2 + .../tests/canvas/offscreen_canvas.html | 64 ++++++ src/browser/webapi/canvas/OffscreenCanvas.zig | 105 +++++++++ .../OffscreenCanvasRenderingContext2D.zig | 209 ++++++++++++++++++ src/browser/webapi/element/html/Canvas.zig | 10 + src/browser/webapi/storage/Cookie.zig | 2 +- 6 files changed, 391 insertions(+), 1 deletion(-) create mode 100644 src/browser/tests/canvas/offscreen_canvas.html create mode 100644 src/browser/webapi/canvas/OffscreenCanvas.zig create mode 100644 src/browser/webapi/canvas/OffscreenCanvasRenderingContext2D.zig diff --git a/src/browser/js/bridge.zig b/src/browser/js/bridge.zig index 3a991313..cbab600d 100644 --- a/src/browser/js/bridge.zig +++ b/src/browser/js/bridge.zig @@ -865,6 +865,8 @@ pub const JsApis = flattenTypes(&.{ @import("../webapi/navigation/NavigationActivation.zig"), @import("../webapi/canvas/CanvasRenderingContext2D.zig"), @import("../webapi/canvas/WebGLRenderingContext.zig"), + @import("../webapi/canvas/OffscreenCanvas.zig"), + @import("../webapi/canvas/OffscreenCanvasRenderingContext2D.zig"), @import("../webapi/SubtleCrypto.zig"), @import("../webapi/Selection.zig"), @import("../webapi/ImageData.zig"), diff --git a/src/browser/tests/canvas/offscreen_canvas.html b/src/browser/tests/canvas/offscreen_canvas.html new file mode 100644 index 00000000..f162213a --- /dev/null +++ b/src/browser/tests/canvas/offscreen_canvas.html @@ -0,0 +1,64 @@ + + + + + + + + + + + + + + diff --git a/src/browser/webapi/canvas/OffscreenCanvas.zig b/src/browser/webapi/canvas/OffscreenCanvas.zig new file mode 100644 index 00000000..be83c146 --- /dev/null +++ b/src/browser/webapi/canvas/OffscreenCanvas.zig @@ -0,0 +1,105 @@ +// 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 js = @import("../../js/js.zig"); +const Page = @import("../../Page.zig"); + +const Blob = @import("../Blob.zig"); +const OffscreenCanvasRenderingContext2D = @import("OffscreenCanvasRenderingContext2D.zig"); + +/// https://developer.mozilla.org/en-US/docs/Web/API/OffscreenCanvas +const OffscreenCanvas = @This(); + +pub const _prototype_root = true; + +_width: u32, +_height: u32, + +/// Since there's no base class rendering contextes inherit from, +/// we're using tagged union. +const DrawingContext = union(enum) { + @"2d": *OffscreenCanvasRenderingContext2D, +}; + +pub fn constructor(width: u32, height: u32, page: *Page) !*OffscreenCanvas { + return page._factory.create(OffscreenCanvas{ + ._width = width, + ._height = height, + }); +} + +pub fn getWidth(self: *const OffscreenCanvas) u32 { + return self._width; +} + +pub fn setWidth(self: *OffscreenCanvas, value: u32) void { + self._width = value; +} + +pub fn getHeight(self: *const OffscreenCanvas) u32 { + return self._height; +} + +pub fn setHeight(self: *OffscreenCanvas, value: u32) void { + self._height = value; +} + +pub fn getContext(_: *OffscreenCanvas, context_type: []const u8, page: *Page) !?DrawingContext { + if (std.mem.eql(u8, context_type, "2d")) { + const ctx = try page._factory.create(OffscreenCanvasRenderingContext2D{}); + return .{ .@"2d" = ctx }; + } + + return null; +} + +/// Returns a Promise that resolves to a Blob containing the image. +/// Since we have no actual rendering, this returns an empty blob. +pub fn convertToBlob(_: *OffscreenCanvas, page: *Page) !js.Promise { + const blob = try Blob.init(null, null, page); + return page.js.local.?.resolvePromise(blob); +} + +/// Returns an ImageBitmap with the rendered content (stub). +pub fn transferToImageBitmap(_: *OffscreenCanvas) ?void { + // ImageBitmap not implemented yet, return null + return null; +} + +pub const JsApi = struct { + pub const bridge = js.Bridge(OffscreenCanvas); + + pub const Meta = struct { + pub const name = "OffscreenCanvas"; + pub const prototype_chain = bridge.prototypeChain(); + pub var class_id: bridge.ClassId = undefined; + }; + + pub const constructor = bridge.constructor(OffscreenCanvas.constructor, .{}); + pub const width = bridge.accessor(OffscreenCanvas.getWidth, OffscreenCanvas.setWidth, .{}); + pub const height = bridge.accessor(OffscreenCanvas.getHeight, OffscreenCanvas.setHeight, .{}); + pub const getContext = bridge.function(OffscreenCanvas.getContext, .{}); + pub const convertToBlob = bridge.function(OffscreenCanvas.convertToBlob, .{}); + pub const transferToImageBitmap = bridge.function(OffscreenCanvas.transferToImageBitmap, .{}); +}; + +const testing = @import("../../../testing.zig"); +test "WebApi: OffscreenCanvas" { + try testing.htmlRunner("canvas/offscreen_canvas.html", .{}); +} diff --git a/src/browser/webapi/canvas/OffscreenCanvasRenderingContext2D.zig b/src/browser/webapi/canvas/OffscreenCanvasRenderingContext2D.zig new file mode 100644 index 00000000..26ebb8ec --- /dev/null +++ b/src/browser/webapi/canvas/OffscreenCanvasRenderingContext2D.zig @@ -0,0 +1,209 @@ +// Copyright (C) 2023-2025 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 js = @import("../../js/js.zig"); +const color = @import("../../color.zig"); +const Page = @import("../../Page.zig"); + +const ImageData = @import("../ImageData.zig"); + +/// This class doesn't implement a `constructor`. +/// It can be obtained with a call to `OffscreenCanvas#getContext`. +/// https://developer.mozilla.org/en-US/docs/Web/API/OffscreenCanvasRenderingContext2D +const OffscreenCanvasRenderingContext2D = @This(); +/// Fill color. +/// TODO: Add support for `CanvasGradient` and `CanvasPattern`. +_fill_style: color.RGBA = color.RGBA.Named.black, + +pub fn getFillStyle(self: *const OffscreenCanvasRenderingContext2D, page: *Page) ![]const u8 { + var w = std.Io.Writer.Allocating.init(page.call_arena); + try self._fill_style.format(&w.writer); + return w.written(); +} + +pub fn setFillStyle( + self: *OffscreenCanvasRenderingContext2D, + value: []const u8, +) !void { + // Prefer the same fill_style if fails. + self._fill_style = color.RGBA.parse(value) catch self._fill_style; +} + +pub fn getGlobalAlpha(_: *const OffscreenCanvasRenderingContext2D) f64 { + return 1.0; +} + +pub fn getGlobalCompositeOperation(_: *const OffscreenCanvasRenderingContext2D) []const u8 { + return "source-over"; +} + +pub fn getStrokeStyle(_: *const OffscreenCanvasRenderingContext2D) []const u8 { + return "#000000"; +} + +pub fn getLineWidth(_: *const OffscreenCanvasRenderingContext2D) f64 { + return 1.0; +} + +pub fn getLineCap(_: *const OffscreenCanvasRenderingContext2D) []const u8 { + return "butt"; +} + +pub fn getLineJoin(_: *const OffscreenCanvasRenderingContext2D) []const u8 { + return "miter"; +} + +pub fn getMiterLimit(_: *const OffscreenCanvasRenderingContext2D) f64 { + return 10.0; +} + +pub fn getFont(_: *const OffscreenCanvasRenderingContext2D) []const u8 { + return "10px sans-serif"; +} + +pub fn getTextAlign(_: *const OffscreenCanvasRenderingContext2D) []const u8 { + return "start"; +} + +pub fn getTextBaseline(_: *const OffscreenCanvasRenderingContext2D) []const u8 { + return "alphabetic"; +} + +const WidthOrImageData = union(enum) { + width: u32, + image_data: *ImageData, +}; + +pub fn createImageData( + _: *const OffscreenCanvasRenderingContext2D, + width_or_image_data: WidthOrImageData, + /// If `ImageData` variant preferred, this is null. + maybe_height: ?u32, + /// Can be used if width and height provided. + maybe_settings: ?ImageData.ConstructorSettings, + page: *Page, +) !*ImageData { + switch (width_or_image_data) { + .width => |width| { + const height = maybe_height orelse return error.TypeError; + return ImageData.constructor(width, height, maybe_settings, page); + }, + .image_data => |image_data| { + return ImageData.constructor(image_data._width, image_data._height, null, page); + }, + } +} + +pub fn putImageData(_: *const OffscreenCanvasRenderingContext2D, _: *ImageData, _: f64, _: f64, _: ?f64, _: ?f64, _: ?f64, _: ?f64) void {} + +pub fn save(_: *OffscreenCanvasRenderingContext2D) void {} +pub fn restore(_: *OffscreenCanvasRenderingContext2D) void {} +pub fn scale(_: *OffscreenCanvasRenderingContext2D, _: f64, _: f64) void {} +pub fn rotate(_: *OffscreenCanvasRenderingContext2D, _: f64) void {} +pub fn translate(_: *OffscreenCanvasRenderingContext2D, _: f64, _: f64) void {} +pub fn transform(_: *OffscreenCanvasRenderingContext2D, _: f64, _: f64, _: f64, _: f64, _: f64, _: f64) void {} +pub fn setTransform(_: *OffscreenCanvasRenderingContext2D, _: f64, _: f64, _: f64, _: f64, _: f64, _: f64) void {} +pub fn resetTransform(_: *OffscreenCanvasRenderingContext2D) void {} +pub fn setGlobalAlpha(_: *OffscreenCanvasRenderingContext2D, _: f64) void {} +pub fn setGlobalCompositeOperation(_: *OffscreenCanvasRenderingContext2D, _: []const u8) void {} +pub fn setStrokeStyle(_: *OffscreenCanvasRenderingContext2D, _: []const u8) void {} +pub fn setLineWidth(_: *OffscreenCanvasRenderingContext2D, _: f64) void {} +pub fn setLineCap(_: *OffscreenCanvasRenderingContext2D, _: []const u8) void {} +pub fn setLineJoin(_: *OffscreenCanvasRenderingContext2D, _: []const u8) void {} +pub fn setMiterLimit(_: *OffscreenCanvasRenderingContext2D, _: f64) void {} +pub fn clearRect(_: *OffscreenCanvasRenderingContext2D, _: f64, _: f64, _: f64, _: f64) void {} +pub fn fillRect(_: *OffscreenCanvasRenderingContext2D, _: f64, _: f64, _: f64, _: f64) void {} +pub fn strokeRect(_: *OffscreenCanvasRenderingContext2D, _: f64, _: f64, _: f64, _: f64) void {} +pub fn beginPath(_: *OffscreenCanvasRenderingContext2D) void {} +pub fn closePath(_: *OffscreenCanvasRenderingContext2D) void {} +pub fn moveTo(_: *OffscreenCanvasRenderingContext2D, _: f64, _: f64) void {} +pub fn lineTo(_: *OffscreenCanvasRenderingContext2D, _: f64, _: f64) void {} +pub fn quadraticCurveTo(_: *OffscreenCanvasRenderingContext2D, _: f64, _: f64, _: f64, _: f64) void {} +pub fn bezierCurveTo(_: *OffscreenCanvasRenderingContext2D, _: f64, _: f64, _: f64, _: f64, _: f64, _: f64) void {} +pub fn arc(_: *OffscreenCanvasRenderingContext2D, _: f64, _: f64, _: f64, _: f64, _: f64, _: ?bool) void {} +pub fn arcTo(_: *OffscreenCanvasRenderingContext2D, _: f64, _: f64, _: f64, _: f64, _: f64) void {} +pub fn rect(_: *OffscreenCanvasRenderingContext2D, _: f64, _: f64, _: f64, _: f64) void {} +pub fn fill(_: *OffscreenCanvasRenderingContext2D) void {} +pub fn stroke(_: *OffscreenCanvasRenderingContext2D) void {} +pub fn clip(_: *OffscreenCanvasRenderingContext2D) void {} +pub fn setFont(_: *OffscreenCanvasRenderingContext2D, _: []const u8) void {} +pub fn setTextAlign(_: *OffscreenCanvasRenderingContext2D, _: []const u8) void {} +pub fn setTextBaseline(_: *OffscreenCanvasRenderingContext2D, _: []const u8) void {} +pub fn fillText(_: *OffscreenCanvasRenderingContext2D, _: []const u8, _: f64, _: f64, _: ?f64) void {} +pub fn strokeText(_: *OffscreenCanvasRenderingContext2D, _: []const u8, _: f64, _: f64, _: ?f64) void {} + +pub const JsApi = struct { + pub const bridge = js.Bridge(OffscreenCanvasRenderingContext2D); + + pub const Meta = struct { + pub const name = "OffscreenCanvasRenderingContext2D"; + + pub const prototype_chain = bridge.prototypeChain(); + pub var class_id: bridge.ClassId = undefined; + }; + + pub const createImageData = bridge.function(OffscreenCanvasRenderingContext2D.createImageData, .{ .dom_exception = true }); + pub const putImageData = bridge.function(OffscreenCanvasRenderingContext2D.putImageData, .{}); + + pub const save = bridge.function(OffscreenCanvasRenderingContext2D.save, .{}); + pub const restore = bridge.function(OffscreenCanvasRenderingContext2D.restore, .{}); + + pub const scale = bridge.function(OffscreenCanvasRenderingContext2D.scale, .{}); + pub const rotate = bridge.function(OffscreenCanvasRenderingContext2D.rotate, .{}); + pub const translate = bridge.function(OffscreenCanvasRenderingContext2D.translate, .{}); + pub const transform = bridge.function(OffscreenCanvasRenderingContext2D.transform, .{}); + pub const setTransform = bridge.function(OffscreenCanvasRenderingContext2D.setTransform, .{}); + pub const resetTransform = bridge.function(OffscreenCanvasRenderingContext2D.resetTransform, .{}); + + pub const globalAlpha = bridge.accessor(OffscreenCanvasRenderingContext2D.getGlobalAlpha, OffscreenCanvasRenderingContext2D.setGlobalAlpha, .{}); + pub const globalCompositeOperation = bridge.accessor(OffscreenCanvasRenderingContext2D.getGlobalCompositeOperation, OffscreenCanvasRenderingContext2D.setGlobalCompositeOperation, .{}); + + pub const fillStyle = bridge.accessor(OffscreenCanvasRenderingContext2D.getFillStyle, OffscreenCanvasRenderingContext2D.setFillStyle, .{}); + pub const strokeStyle = bridge.accessor(OffscreenCanvasRenderingContext2D.getStrokeStyle, OffscreenCanvasRenderingContext2D.setStrokeStyle, .{}); + + pub const lineWidth = bridge.accessor(OffscreenCanvasRenderingContext2D.getLineWidth, OffscreenCanvasRenderingContext2D.setLineWidth, .{}); + pub const lineCap = bridge.accessor(OffscreenCanvasRenderingContext2D.getLineCap, OffscreenCanvasRenderingContext2D.setLineCap, .{}); + pub const lineJoin = bridge.accessor(OffscreenCanvasRenderingContext2D.getLineJoin, OffscreenCanvasRenderingContext2D.setLineJoin, .{}); + pub const miterLimit = bridge.accessor(OffscreenCanvasRenderingContext2D.getMiterLimit, OffscreenCanvasRenderingContext2D.setMiterLimit, .{}); + + pub const clearRect = bridge.function(OffscreenCanvasRenderingContext2D.clearRect, .{}); + pub const fillRect = bridge.function(OffscreenCanvasRenderingContext2D.fillRect, .{}); + pub const strokeRect = bridge.function(OffscreenCanvasRenderingContext2D.strokeRect, .{}); + + pub const beginPath = bridge.function(OffscreenCanvasRenderingContext2D.beginPath, .{}); + pub const closePath = bridge.function(OffscreenCanvasRenderingContext2D.closePath, .{}); + pub const moveTo = bridge.function(OffscreenCanvasRenderingContext2D.moveTo, .{}); + pub const lineTo = bridge.function(OffscreenCanvasRenderingContext2D.lineTo, .{}); + pub const quadraticCurveTo = bridge.function(OffscreenCanvasRenderingContext2D.quadraticCurveTo, .{}); + pub const bezierCurveTo = bridge.function(OffscreenCanvasRenderingContext2D.bezierCurveTo, .{}); + pub const arc = bridge.function(OffscreenCanvasRenderingContext2D.arc, .{}); + pub const arcTo = bridge.function(OffscreenCanvasRenderingContext2D.arcTo, .{}); + pub const rect = bridge.function(OffscreenCanvasRenderingContext2D.rect, .{}); + + pub const fill = bridge.function(OffscreenCanvasRenderingContext2D.fill, .{}); + pub const stroke = bridge.function(OffscreenCanvasRenderingContext2D.stroke, .{}); + pub const clip = bridge.function(OffscreenCanvasRenderingContext2D.clip, .{}); + + pub const font = bridge.accessor(OffscreenCanvasRenderingContext2D.getFont, OffscreenCanvasRenderingContext2D.setFont, .{}); + pub const textAlign = bridge.accessor(OffscreenCanvasRenderingContext2D.getTextAlign, OffscreenCanvasRenderingContext2D.setTextAlign, .{}); + pub const textBaseline = bridge.accessor(OffscreenCanvasRenderingContext2D.getTextBaseline, OffscreenCanvasRenderingContext2D.setTextBaseline, .{}); + pub const fillText = bridge.function(OffscreenCanvasRenderingContext2D.fillText, .{}); + pub const strokeText = bridge.function(OffscreenCanvasRenderingContext2D.strokeText, .{}); +}; diff --git a/src/browser/webapi/element/html/Canvas.zig b/src/browser/webapi/element/html/Canvas.zig index 122552b5..a29dce4a 100644 --- a/src/browser/webapi/element/html/Canvas.zig +++ b/src/browser/webapi/element/html/Canvas.zig @@ -25,6 +25,7 @@ const HtmlElement = @import("../Html.zig"); const CanvasRenderingContext2D = @import("../../canvas/CanvasRenderingContext2D.zig"); const WebGLRenderingContext = @import("../../canvas/WebGLRenderingContext.zig"); +const OffscreenCanvas = @import("../../canvas/OffscreenCanvas.zig"); const Canvas = @This(); _proto: *HtmlElement, @@ -80,6 +81,14 @@ pub fn getContext(_: *Canvas, context_type: []const u8, page: *Page) !?DrawingCo return null; } +/// Transfers control of the canvas to an OffscreenCanvas. +/// Returns an OffscreenCanvas with the same dimensions. +pub fn transferControlToOffscreen(self: *Canvas, page: *Page) !*OffscreenCanvas { + const width = self.getWidth(); + const height = self.getHeight(); + return OffscreenCanvas.constructor(width, height, page); +} + pub const JsApi = struct { pub const bridge = js.Bridge(Canvas); @@ -92,4 +101,5 @@ pub const JsApi = struct { pub const width = bridge.accessor(Canvas.getWidth, Canvas.setWidth, .{}); pub const height = bridge.accessor(Canvas.getHeight, Canvas.setHeight, .{}); pub const getContext = bridge.function(Canvas.getContext, .{}); + pub const transferControlToOffscreen = bridge.function(Canvas.transferControlToOffscreen, .{}); }; diff --git a/src/browser/webapi/storage/Cookie.zig b/src/browser/webapi/storage/Cookie.zig index 5e352bd4..da499efb 100644 --- a/src/browser/webapi/storage/Cookie.zig +++ b/src/browser/webapi/storage/Cookie.zig @@ -435,7 +435,7 @@ pub const Jar = struct { pub fn removeExpired(self: *Jar, request_time: ?i64) void { if (self.cookies.items.len == 0) return; const time = request_time orelse std.time.timestamp(); - var i: usize = self.cookies.items.len ; + var i: usize = self.cookies.items.len; while (i > 0) { i -= 1; const cookie = &self.cookies.items[i]; From 603e7d922e57d5fb2602c62c2c42d471942f210a Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Fri, 20 Feb 2026 16:24:25 +0800 Subject: [PATCH 008/103] Improve Context shutdown Under some conditions, a microtask would be executed for a context that was already deinit'd, resulting in various use-after-free. The culprit appears to be WASM compilation being placed in the microtask queue (by a user-script) and then resolved at some point in the future. We guard the microtask queue by a context.shutting_down boolean, but v8 doesn't know anything about this flag. The fact is that, microtasks are tied to an isolate, not a context. This commit introduces a number of changes: 1 - It follows https://github.com/lightpanda-io/browser/commit/309f254c2c8c590a2655972aa1b31d020777634c and stores the zig Context inside of an embedder field. This ensures v8 doesn't consider this when GC'ing, which _could_ extend the lifetime of the v8::Context beyond what we expect 2 - Most significantly, it introduces per-context microtasks queues. Each context gets its own queue. This makes cleanup much simpler and reduces the chance of microtasks outliving the context 3 - pumpMessageLoop is called on context.deinit, this helps to ensure that any tasks v8 has for our context are processed (e.g. wasm compilation) before shtudown 4 - The order of context shutdown is important, we notify the isolate of the context destruction first, then pump the message loop and finally destroy the context's message loop. Depends on https://github.com/lightpanda-io/zig-v8-fork/pull/151 --- .github/actions/install/action.yml | 2 +- Dockerfile | 2 +- build.zig.zon | 4 +-- src/browser/Browser.zig | 6 +--- src/browser/js/Context.zig | 46 ++++++++++++------------------ src/browser/js/Env.zig | 31 +++++++++++++++----- src/browser/js/Inspector.zig | 2 -- src/browser/js/Isolate.zig | 12 -------- src/browser/js/Local.zig | 2 +- src/cdp/cdp.zig | 1 + 10 files changed, 50 insertions(+), 58 deletions(-) diff --git a/.github/actions/install/action.yml b/.github/actions/install/action.yml index 88a672a0..9e47eac2 100644 --- a/.github/actions/install/action.yml +++ b/.github/actions/install/action.yml @@ -13,7 +13,7 @@ inputs: zig-v8: description: 'zig v8 version to install' required: false - default: 'v0.2.9' + default: 'v0.3.0' v8: description: 'v8 version to install' required: false diff --git a/Dockerfile b/Dockerfile index 79ae2627..1aa5d592 100644 --- a/Dockerfile +++ b/Dockerfile @@ -3,7 +3,7 @@ FROM debian:stable-slim ARG MINISIG=0.12 ARG ZIG_MINISIG=RWSGOq2NVecA2UPNdBUZykf1CCb147pkmdtYxgb3Ti+JO/wCYvhbAb/U ARG V8=14.0.365.4 -ARG ZIG_V8=v0.2.9 +ARG ZIG_V8=v0.3.0 ARG TARGETPLATFORM RUN apt-get update -yq && \ diff --git a/build.zig.zon b/build.zig.zon index 946210d1..0d115d53 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -6,8 +6,8 @@ .minimum_zig_version = "0.15.2", .dependencies = .{ .v8 = .{ - .url = "https://github.com/lightpanda-io/zig-v8-fork/archive/refs/tags/v0.2.9.tar.gz", - .hash = "v8-0.0.0-xddH689vBACgpqFVEhT2wxRin-qQQSOcKJoM37MVo0rU", + .url = "https://github.com/lightpanda-io/zig-v8-fork/archive/refs/tags/v0.3.0.tar.gz", + .hash = "v8-0.0.0-xddH69R6BADRXsnhjA8wNnfKfLQACF1I7CSTZvsMAvp8", }, //.v8 = .{ .path = "../zig-v8-fork" }, .@"boringssl-zig" = .{ diff --git a/src/browser/Browser.zig b/src/browser/Browser.zig index 09a78cab..09f7773d 100644 --- a/src/browser/Browser.zig +++ b/src/browser/Browser.zig @@ -96,10 +96,6 @@ pub fn runMacrotasks(self: *Browser) !?u64 { } pub fn runMessageLoop(self: *const Browser) void { - while (self.env.pumpMessageLoop()) { - if (comptime IS_DEBUG) { - log.debug(.browser, "pumpMessageLoop", .{}); - } - } + self.env.pumpMessageLoop(); self.env.runIdleTasks(); } diff --git a/src/browser/js/Context.zig b/src/browser/js/Context.zig index 115b6156..377ced77 100644 --- a/src/browser/js/Context.zig +++ b/src/browser/js/Context.zig @@ -43,6 +43,9 @@ env: *Env, page: *Page, isolate: js.Isolate, +// Per-context microtask queue for isolation between contexts +microtask_queue: *v8.MicrotaskQueue, + // The v8::Global. When necessary, we can create a v8::Local<> // from this, and we can free it when the context is done. handle: v8.Global, @@ -121,10 +124,6 @@ script_manager: ?*ScriptManager, // Our macrotasks scheduler: Scheduler, -// Prevents us from enqueuing a microtask for this context while we're shutting -// down. -shutting_down: bool = false, - unknown_properties: (if (IS_DEBUG) std.StringHashMapUnmanaged(UnknownPropertyStat) else void) = if (IS_DEBUG) .{} else {}, const ModuleEntry = struct { @@ -146,16 +145,11 @@ const ModuleEntry = struct { }; pub fn fromC(c_context: *const v8.Context) *Context { - const data = v8.v8__Context__GetEmbedderData(c_context, 1).?; - const big_int = js.BigInt{ .handle = @ptrCast(data) }; - return @ptrFromInt(big_int.getUint64()); + return @ptrCast(@alignCast(v8.v8__Context__GetAlignedPointerFromEmbedderData(c_context, 1))); } pub fn fromIsolate(isolate: js.Isolate) *Context { - const v8_context = v8.v8__Isolate__GetCurrentContext(isolate.handle).?; - const data = v8.v8__Context__GetEmbedderData(v8_context, 1).?; - const big_int = js.BigInt{ .handle = @ptrCast(data) }; - return @ptrFromInt(big_int.getUint64()); + return fromC(v8.v8__Isolate__GetCurrentContext(isolate.handle).?); } pub fn deinit(self: *Context) void { @@ -169,21 +163,15 @@ pub fn deinit(self: *Context) void { }); } } - defer self.env.app.arena_pool.release(self.arena); + + const env = self.env; + defer env.app.arena_pool.release(self.arena); var hs: js.HandleScope = undefined; const entered = self.enter(&hs); defer entered.exit(); - // We might have microtasks in the isolate that refence this context. The - // only option we have is to run them. But a microtask could queue another - // microtask, so we set the shutting_down flag, so that any such microtask - // will be a noop (this isn't automatic, when v8 calls our microtask callback - // the first thing we'll check is if self.shutting_down == true). - self.shutting_down = true; - self.env.runMicrotasks(); - - // can release objects + // this can release objects self.scheduler.deinit(); { @@ -244,7 +232,13 @@ pub fn deinit(self: *Context) void { v8.v8__Global__Reset(global); } } + v8.v8__Global__Reset(&self.handle); + env.isolate.notifyContextDisposed(); + // There can be other tasks associated with this context that we need to + // purge while the context is still alive. + env.pumpMessageLoop(); + v8.v8__MicrotaskQueue__DELETE(self.microtask_queue); } pub fn weakRef(self: *Context, obj: anytype) void { @@ -992,13 +986,10 @@ pub fn queueSlotchangeDelivery(self: *Context) !void { // But for these Context microtasks, we want to (a) make sure the context isn't // being shut down and (b) that it's entered. fn enqueueMicrotask(self: *Context, callback: anytype) void { - self.isolate.enqueueMicrotask(struct { + // Use context-specific microtask queue instead of isolate queue + v8.v8__MicrotaskQueue__EnqueueMicrotask(self.microtask_queue, self.isolate.handle, struct { fn run(data: ?*anyopaque) callconv(.c) void { const ctx: *Context = @ptrCast(@alignCast(data.?)); - if (ctx.shutting_down) { - return; - } - var hs: js.HandleScope = undefined; const entered = ctx.enter(&hs); defer entered.exit(); @@ -1008,7 +999,8 @@ fn enqueueMicrotask(self: *Context, callback: anytype) void { } pub fn queueMicrotaskFunc(self: *Context, cb: js.Function) void { - self.isolate.enqueueMicrotaskFunc(cb); + // Use context-specific microtask queue instead of isolate queue + v8.v8__MicrotaskQueue__EnqueueMicrotaskFunc(self.microtask_queue, self.isolate.handle, cb.handle); } pub fn createFinalizerCallback(self: *Context, global: v8.Global, ptr: *anyopaque, finalizerFn: *const fn (ptr: *anyopaque) void) !*FinalizerCallback { diff --git a/src/browser/js/Env.zig b/src/browser/js/Env.zig index 458ff099..309285e8 100644 --- a/src/browser/js/Env.zig +++ b/src/browser/js/Env.zig @@ -249,10 +249,19 @@ pub fn createContext(self: *Env, page: *Page) !*Context { hs.init(isolate); defer hs.deinit(); + // Create a per-context microtask queue for isolation + const microtask_queue = v8.v8__MicrotaskQueue__New(isolate.handle, v8.kExplicit).?; + errdefer v8.v8__MicrotaskQueue__DELETE(microtask_queue); + // Get the global template that was created once per isolate const global_template: *const v8.ObjectTemplate = @ptrCast(@alignCast(v8.v8__Eternal__Get(&self.global_template, isolate.handle).?)); v8.v8__ObjectTemplate__SetInternalFieldCount(global_template, comptime Snapshot.countInternalFields(Window.JsApi)); - const v8_context = v8.v8__Context__New(isolate.handle, global_template, null).?; + + const v8_context = v8.v8__Context__New__Config(isolate.handle, &.{ + .global_template = global_template, + .global_object = null, + .microtask_queue = microtask_queue, + }).?; // Create the v8::Context and wrap it in a v8::Global var context_global: v8.Global = undefined; @@ -292,6 +301,7 @@ pub fn createContext(self: *Env, page: *Page) !*Context { .handle = context_global, .templates = self.templates, .call_arena = page.call_arena, + .microtask_queue = microtask_queue, .script_manager = &page._script_manager, .scheduler = .init(context_arena), .finalizer_callback_pool = std.heap.MemoryPool(Context.FinalizerCallback).init(self.app.allocator), @@ -300,8 +310,7 @@ pub fn createContext(self: *Env, page: *Page) !*Context { // Store a pointer to our context inside the v8 context so that, given // a v8 context, we can get our context out - const data = isolate.initBigInt(@intFromPtr(context)); - v8.v8__Context__SetEmbedderData(v8_context, 1, @ptrCast(data.handle)); + v8.v8__Context__SetAlignedPointerInEmbedderData(v8_context, 1, @ptrCast(context)); try self.contexts.append(self.app.allocator, context); return context; @@ -328,11 +337,13 @@ pub fn destroyContext(self: *Env, context: *Context) void { } context.deinit(); - isolate.notifyContextDisposed(); } pub fn runMicrotasks(self: *const Env) void { - self.isolate.performMicrotasksCheckpoint(); + const v8_isolate = self.isolate.handle; + for (self.contexts.items) |ctx| { + v8.v8__MicrotaskQueue__PerformCheckpoint(ctx.microtask_queue, v8_isolate); + } } pub fn runMacrotasks(self: *Env) !?u64 { @@ -360,12 +371,18 @@ pub fn runMacrotasks(self: *Env) !?u64 { return ms_to_next_task; } -pub fn pumpMessageLoop(self: *const Env) bool { +pub fn pumpMessageLoop(self: *const Env) void { var hs: v8.HandleScope = undefined; v8.v8__HandleScope__CONSTRUCT(&hs, self.isolate.handle); defer v8.v8__HandleScope__DESTRUCT(&hs); - return v8.v8__Platform__PumpMessageLoop(self.platform.handle, self.isolate.handle, false); + const isolate = self.isolate.handle; + const platform = self.platform.handle; + while (v8.v8__Platform__PumpMessageLoop(platform, isolate, false)) { + if (comptime IS_DEBUG) { + log.debug(.browser, "pumpMessageLoop", .{}); + } + } } pub fn runIdleTasks(self: *const Env) void { diff --git a/src/browser/js/Inspector.zig b/src/browser/js/Inspector.zig index 245c5582..a9032e33 100644 --- a/src/browser/js/Inspector.zig +++ b/src/browser/js/Inspector.zig @@ -241,8 +241,6 @@ pub const Session = struct { msg.ptr, msg.len, ); - - v8.v8__Isolate__PerformMicrotaskCheckpoint(isolate); } // Gets a value by object ID regardless of which context it is in. diff --git a/src/browser/js/Isolate.zig b/src/browser/js/Isolate.zig index 74974cc0..32b0f7d6 100644 --- a/src/browser/js/Isolate.zig +++ b/src/browser/js/Isolate.zig @@ -41,18 +41,6 @@ pub fn exit(self: Isolate) void { v8.v8__Isolate__Exit(self.handle); } -pub fn performMicrotasksCheckpoint(self: Isolate) void { - v8.v8__Isolate__PerformMicrotaskCheckpoint(self.handle); -} - -pub fn enqueueMicrotask(self: Isolate, callback: anytype, data: anytype) void { - v8.v8__Isolate__EnqueueMicrotask(self.handle, callback, data); -} - -pub fn enqueueMicrotaskFunc(self: Isolate, function: js.Function) void { - v8.v8__Isolate__EnqueueMicrotaskFunc(self.handle, function.handle); -} - pub fn lowMemoryNotification(self: Isolate) void { v8.v8__Isolate__LowMemoryNotification(self.handle); } diff --git a/src/browser/js/Local.zig b/src/browser/js/Local.zig index b51453b5..5937c06c 100644 --- a/src/browser/js/Local.zig +++ b/src/browser/js/Local.zig @@ -82,7 +82,7 @@ pub fn createTypedArray(self: *const Local, comptime array_type: js.ArrayType, s } pub fn runMicrotasks(self: *const Local) void { - self.isolate.performMicrotasksCheckpoint(); + self.ctx.env.runMicrotasks(); } // == Executors == diff --git a/src/cdp/cdp.zig b/src/cdp/cdp.zig index d8ccfb2b..c486e794 100644 --- a/src/cdp/cdp.zig +++ b/src/cdp/cdp.zig @@ -662,6 +662,7 @@ pub fn BrowserContext(comptime CDP_T: type) type { pub fn callInspector(self: *const Self, msg: []const u8) void { self.inspector_session.send(msg); + self.session.browser.env.runMicrotasks(); } pub fn onInspectorResponse(ctx: *anyopaque, _: u32, msg: []const u8) void { From 5f459c090127a60f9d02132dd5d250c15e482b3e Mon Sep 17 00:00:00 2001 From: egrs Date: Sat, 21 Feb 2026 14:43:41 +0100 Subject: [PATCH 009/103] cache document.implementation for object identity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit getImplementation() now returns a cached *DOMImplementation pointer per Document, matching the getStyleSheets() pattern. This ensures document.implementation === document.implementation holds true. Flips dom/nodes/Document-implementation.html (1/2 → 2/2). --- src/browser/webapi/Document.zig | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/browser/webapi/Document.zig b/src/browser/webapi/Document.zig index 85f9c35f..4ee70ded 100644 --- a/src/browser/webapi/Document.zig +++ b/src/browser/webapi/Document.zig @@ -52,6 +52,7 @@ _elements_by_id: std.StringHashMapUnmanaged(*Element) = .empty, _removed_ids: std.StringHashMapUnmanaged(void) = .empty, _active_element: ?*Element = null, _style_sheets: ?*StyleSheetList = null, +_implementation: ?*DOMImplementation = null, _write_insertion_point: ?*Node = null, _script_created_parser: ?Parser.Streaming = null, _adopted_style_sheets: ?js.Object.Global = null, @@ -272,8 +273,11 @@ pub fn querySelectorAll(self: *Document, input: String, page: *Page) !*Selector. return Selector.querySelectorAll(self.asNode(), input.str(), page); } -pub fn getImplementation(_: *const Document) DOMImplementation { - return .{}; +pub fn getImplementation(self: *Document, page: *Page) !*DOMImplementation { + if (self._implementation) |impl| return impl; + const impl = try page._factory.create(DOMImplementation{}); + self._implementation = impl; + return impl; } pub fn createDocumentFragment(self: *Document, page: *Page) !*Node.DocumentFragment { @@ -726,6 +730,7 @@ pub fn open(self: *Document, page: *Page) !*Document { self._elements_by_id.clearAndFree(page.arena); self._active_element = null; self._style_sheets = null; + self._implementation = null; self._ready_state = .loading; self._script_created_parser = Parser.Streaming.init(page.arena, doc_node, page); From 028b728760adee22a51c24cbdc7e36408394f0bb Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Sun, 22 Feb 2026 20:35:05 +0800 Subject: [PATCH 010/103] Tweak Finalizer callbacks 1 - Finalizer callbacks are now give a *Page parameter. Various types no longer need to maintain a reference to *Page just to finalize 2 - EventManager now handles v8_handoff == false cleanup. This is largely because of the above change, which would require every: ``` defer if (!event._v8_handoff) event.deinit(false); ``` to be turned into: ``` defer if (!event._v8_handoff) event.deinit(false, page); ``` But the caller might not have a page. Besides this, it makes most uses of Event simpler. But, in some cases, it could leave a window where the event doesn't reach the EventManager to be properly managed (though, we have no such cases as of now). --- src/browser/EventManager.zig | 5 +- src/browser/Factory.zig | 15 ++-- src/browser/Page.zig | 15 +--- src/browser/ScriptManager.zig | 2 +- src/browser/js/Context.zig | 6 +- src/browser/js/Local.zig | 3 +- src/browser/js/bridge.zig | 10 +-- src/browser/webapi/AbortSignal.zig | 2 - src/browser/webapi/Element.zig | 6 -- src/browser/webapi/Event.zig | 12 ++- src/browser/webapi/History.zig | 2 - src/browser/webapi/IntersectionObserver.zig | 15 ++-- src/browser/webapi/MessagePort.zig | 1 - src/browser/webapi/MutationObserver.zig | 21 ++--- src/browser/webapi/Selection.zig | 1 - src/browser/webapi/Window.zig | 16 ++-- src/browser/webapi/animation/Animation.zig | 4 +- src/browser/webapi/element/Html.zig | 1 - src/browser/webapi/element/html/Input.zig | 2 - src/browser/webapi/element/html/Media.zig | 1 - src/browser/webapi/element/html/TextArea.zig | 2 - src/browser/webapi/encoding/TextDecoder.zig | 6 +- src/browser/webapi/event/CompositionEvent.zig | 4 +- src/browser/webapi/event/CustomEvent.zig | 7 +- src/browser/webapi/event/ErrorEvent.zig | 7 +- src/browser/webapi/event/FocusEvent.zig | 4 +- src/browser/webapi/event/KeyboardEvent.zig | 4 +- src/browser/webapi/event/MessageEvent.zig | 7 +- src/browser/webapi/event/MouseEvent.zig | 4 +- .../NavigationCurrentEntryChangeEvent.zig | 4 +- .../webapi/event/PageTransitionEvent.zig | 4 +- src/browser/webapi/event/PointerEvent.zig | 4 +- src/browser/webapi/event/PopStateEvent.zig | 4 +- src/browser/webapi/event/ProgressEvent.zig | 4 +- .../webapi/event/PromiseRejectionEvent.zig | 10 +-- src/browser/webapi/event/TextEvent.zig | 4 +- src/browser/webapi/event/UIEvent.zig | 4 +- src/browser/webapi/event/WheelEvent.zig | 4 +- src/browser/webapi/navigation/Navigation.zig | 89 +++++++++---------- src/browser/webapi/net/Fetch.zig | 6 +- src/browser/webapi/net/Response.zig | 6 +- src/browser/webapi/net/XMLHttpRequest.zig | 10 +-- .../webapi/net/XMLHttpRequestEventTarget.zig | 1 - src/browser/webapi/storage/Cookie.zig | 2 +- 44 files changed, 136 insertions(+), 205 deletions(-) diff --git a/src/browser/EventManager.zig b/src/browser/EventManager.zig index cfe5966e..ffc8de9c 100644 --- a/src/browser/EventManager.zig +++ b/src/browser/EventManager.zig @@ -170,6 +170,8 @@ const DispatchError = error{ JsException, }; pub fn dispatch(self: *EventManager, target: *EventTarget, event: *Event) DispatchError!void { + defer if (!event._v8_handoff) event.deinit(false, self.page); + if (comptime IS_DEBUG) { log.debug(.event, "eventManager.dispatch", .{ .type = event._type_string.str(), .bubbles = event._bubbles }); } @@ -218,6 +220,8 @@ const DispatchWithFunctionOptions = struct { inject_target: bool = true, }; pub fn dispatchWithFunction(self: *EventManager, target: *EventTarget, event: *Event, function_: ?js.Function, comptime opts: DispatchWithFunctionOptions) !void { + defer if (!event._v8_handoff) event.deinit(false, self.page); + if (comptime IS_DEBUG) { log.debug(.event, "dispatchWithFunction", .{ .type = event._type_string.str(), .context = opts.context, .has_function = function_ != null }); } @@ -757,7 +761,6 @@ const ActivationState = struct { .bubbles = true, .cancelable = false, }, page); - defer if (!event._v8_handoff) event.deinit(false); const target = input.asElement().asEventTarget(); try page._event_manager.dispatch(target, event); diff --git a/src/browser/Factory.zig b/src/browser/Factory.zig index a46c3dc2..44bfcb1e 100644 --- a/src/browser/Factory.zig +++ b/src/browser/Factory.zig @@ -183,41 +183,41 @@ pub fn standaloneEventTarget(self: *Factory, child: anytype) !*EventTarget { } // this is a root object -pub fn event(self: *Factory, arena: Allocator, typ: String, child: anytype) !*@TypeOf(child) { +pub fn event(_: *const Factory, arena: Allocator, typ: String, child: anytype) !*@TypeOf(child) { const chain = try PrototypeChain( &.{ Event, @TypeOf(child) }, ).allocate(arena); // Special case: Event has a _type_string field, so we need manual setup const event_ptr = chain.get(0); - event_ptr.* = try self.eventInit(arena, typ, chain.get(1)); + event_ptr.* = try eventInit(arena, typ, chain.get(1)); chain.setLeaf(1, child); return chain.get(1); } -pub fn uiEvent(self: *Factory, arena: Allocator, typ: String, child: anytype) !*@TypeOf(child) { +pub fn uiEvent(_: *const Factory, arena: Allocator, typ: String, child: anytype) !*@TypeOf(child) { const chain = try PrototypeChain( &.{ Event, UIEvent, @TypeOf(child) }, ).allocate(arena); // Special case: Event has a _type_string field, so we need manual setup const event_ptr = chain.get(0); - event_ptr.* = try self.eventInit(arena, typ, chain.get(1)); + event_ptr.* = try eventInit(arena, typ, chain.get(1)); chain.setMiddle(1, UIEvent.Type); chain.setLeaf(2, child); return chain.get(2); } -pub fn mouseEvent(self: *Factory, arena: Allocator, typ: String, mouse: MouseEvent, child: anytype) !*@TypeOf(child) { +pub fn mouseEvent(_: *const Factory, arena: Allocator, typ: String, mouse: MouseEvent, child: anytype) !*@TypeOf(child) { const chain = try PrototypeChain( &.{ Event, UIEvent, MouseEvent, @TypeOf(child) }, ).allocate(arena); // Special case: Event has a _type_string field, so we need manual setup const event_ptr = chain.get(0); - event_ptr.* = try self.eventInit(arena, typ, chain.get(1)); + event_ptr.* = try eventInit(arena, typ, chain.get(1)); chain.setMiddle(1, UIEvent.Type); // Set MouseEvent with all its fields @@ -231,14 +231,13 @@ pub fn mouseEvent(self: *Factory, arena: Allocator, typ: String, mouse: MouseEve return chain.get(3); } -fn eventInit(self: *const Factory, arena: Allocator, typ: String, value: anytype) !Event { +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; return .{ ._arena = arena, - ._page = self._page, ._type = unionInit(Event.Type, value), ._type_string = typ, ._time_stamp = time_stamp, diff --git a/src/browser/Page.zig b/src/browser/Page.zig index bf87fad7..c58d8086 100644 --- a/src/browser/Page.zig +++ b/src/browser/Page.zig @@ -643,7 +643,6 @@ pub fn documentIsLoaded(self: *Page) void { pub fn _documentIsLoaded(self: *Page) !void { const event = try Event.initTrusted(.wrap("DOMContentLoaded"), .{ .bubbles = true }, self); - defer if (!event._v8_handoff) event.deinit(false); try self._event_manager.dispatch( self.document.asEventTarget(), event, @@ -664,7 +663,6 @@ pub fn iframeCompletedLoading(self: *Page, iframe: *Element.Html.IFrame) void { log.err(.page, "iframe event init", .{ .err = err }); break :blk; }; - defer if (!event._v8_handoff) event.deinit(false); self._event_manager.dispatch(iframe.asNode().asEventTarget(), event) catch |err| { log.warn(.js, "iframe onload", .{ .err = err, .url = iframe._src }); }; @@ -727,7 +725,6 @@ fn _documentIsComplete(self: *Page) !void { // Dispatch `_to_load` events before window.load. for (self._to_load.items) |element| { const event = try Event.initTrusted(comptime .wrap("load"), .{}, self); - defer if (!event._v8_handoff) event.deinit(false); try self._event_manager.dispatch(element.asEventTarget(), event); } @@ -736,7 +733,6 @@ fn _documentIsComplete(self: *Page) !void { // Dispatch window.load event. const event = try Event.initTrusted(comptime .wrap("load"), .{}, self); - defer if (!event._v8_handoff) event.deinit(false); // This event is weird, it's dispatched directly on the window, but // with the document as the target. event._target = self.document.asEventTarget(); @@ -748,7 +744,6 @@ fn _documentIsComplete(self: *Page) !void { ); const pageshow_event = (try PageTransitionEvent.initTrusted(comptime .wrap("pageshow"), .{}, self)).asEvent(); - defer if (!pageshow_event._v8_handoff) pageshow_event.deinit(false); try self._event_manager.dispatchWithFunction( self.window.asEventTarget(), pageshow_event, @@ -1565,8 +1560,6 @@ pub fn deliverSlotchangeEvents(self: *Page) void { log.err(.page, "deliverSlotchange.init", .{ .err = err, .type = self._type }); continue; }; - defer if (!event._v8_handoff) event.deinit(false); - const target = slot.asNode().asEventTarget(); _ = target.dispatchEvent(event, self) catch |err| { log.err(.page, "deliverSlotchange.dispatch", .{ .err = err, .type = self._type }); @@ -3182,8 +3175,6 @@ pub fn triggerMouseClick(self: *Page, x: f64, y: f64) !void { .clientX = x, .clientY = y, }, self)).asEvent(); - - defer if (!event._v8_handoff) event.deinit(false); try self._event_manager.dispatch(target.asEventTarget(), event); } @@ -3241,8 +3232,6 @@ pub fn handleClick(self: *Page, target: *Node) !void { pub fn triggerKeyboard(self: *Page, keyboard_event: *KeyboardEvent) !void { const event = keyboard_event.asEvent(); - defer if (!event._v8_handoff) event.deinit(false); - const element = self.window._document._active_element orelse return; if (comptime IS_DEBUG) { log.debug(.page, "page keydown", .{ @@ -3312,10 +3301,8 @@ pub fn submitForm(self: *Page, submitter_: ?*Element, form_: ?*Element.Html.Form const form_element = form.asElement(); if (submit_opts.fire_event) { - const submit_event = try Event.initTrusted(comptime .wrap("submit"), .{ .bubbles = true, .cancelable = true }, self); - defer if (!submit_event._v8_handoff) submit_event.deinit(false); - const onsubmit_handler = try form.asHtmlElement().getOnSubmit(self); + const submit_event = try Event.initTrusted(comptime .wrap("submit"), .{ .bubbles = true, .cancelable = true }, self); var ls: JS.Local.Scope = undefined; self.js.localScope(&ls); diff --git a/src/browser/ScriptManager.zig b/src/browser/ScriptManager.zig index 19e20825..f4d95230 100644 --- a/src/browser/ScriptManager.zig +++ b/src/browser/ScriptManager.zig @@ -893,7 +893,7 @@ pub const Script = struct { }); return; }; - defer if (!event._v8_handoff) event.deinit(false); + defer if (!event._v8_handoff) event.deinit(false, self.manager.page); var caught: js.TryCatch.Caught = undefined; cb.tryCall(void, .{event}, &caught) catch { diff --git a/src/browser/js/Context.zig b/src/browser/js/Context.zig index 115b6156..24981c08 100644 --- a/src/browser/js/Context.zig +++ b/src/browser/js/Context.zig @@ -1011,7 +1011,7 @@ pub fn queueMicrotaskFunc(self: *Context, cb: js.Function) void { self.isolate.enqueueMicrotaskFunc(cb); } -pub fn createFinalizerCallback(self: *Context, global: v8.Global, ptr: *anyopaque, finalizerFn: *const fn (ptr: *anyopaque) void) !*FinalizerCallback { +pub fn createFinalizerCallback(self: *Context, global: v8.Global, ptr: *anyopaque, finalizerFn: *const fn (ptr: *anyopaque, page: *Page) void) !*FinalizerCallback { const fc = try self.finalizer_callback_pool.create(); fc.* = .{ .ctx = self, @@ -1031,10 +1031,10 @@ pub const FinalizerCallback = struct { ctx: *Context, ptr: *anyopaque, global: v8.Global, - finalizerFn: *const fn (ptr: *anyopaque) void, + finalizerFn: *const fn (ptr: *anyopaque, page: *Page) void, pub fn deinit(self: *FinalizerCallback) void { - self.finalizerFn(self.ptr); + self.finalizerFn(self.ptr, self.ctx.page); self.ctx.finalizer_callback_pool.destroy(self); } }; diff --git a/src/browser/js/Local.zig b/src/browser/js/Local.zig index b51453b5..c4362d4b 100644 --- a/src/browser/js/Local.zig +++ b/src/browser/js/Local.zig @@ -17,6 +17,7 @@ // along with this program. If not, see . const std = @import("std"); +const Page = @import("../Page.zig"); const log = @import("../../log.zig"); const string = @import("../../string.zig"); @@ -1061,7 +1062,7 @@ const Resolved = struct { class_id: u16, prototype_chain: []const @import("TaggedOpaque.zig").PrototypeChainEntry, finalizer_from_v8: ?*const fn (handle: ?*const v8.WeakCallbackInfo) callconv(.c) void = null, - finalizer_from_zig: ?*const fn (ptr: *anyopaque) void = null, + finalizer_from_zig: ?*const fn (ptr: *anyopaque, page: *Page) void = null, }; pub fn resolveValue(value: anytype) Resolved { const T = bridge.Struct(@TypeOf(value)); diff --git a/src/browser/js/bridge.zig b/src/browser/js/bridge.zig index 3a991313..a8558161 100644 --- a/src/browser/js/bridge.zig +++ b/src/browser/js/bridge.zig @@ -104,11 +104,11 @@ pub fn Builder(comptime T: type) type { return entries; } - pub fn finalizer(comptime func: *const fn (self: *T, shutdown: bool) void) Finalizer { + pub fn finalizer(comptime func: *const fn (self: *T, shutdown: bool, page: *Page) void) Finalizer { return .{ .from_zig = struct { - fn wrap(ptr: *anyopaque) void { - func(@ptrCast(@alignCast(ptr)), true); + fn wrap(ptr: *anyopaque, page: *Page) void { + func(@ptrCast(@alignCast(ptr)), true, page); } }.wrap, @@ -120,7 +120,7 @@ pub fn Builder(comptime T: type) type { const ctx = fc.ctx; const value_ptr = fc.ptr; if (ctx.finalizer_callbacks.contains(@intFromPtr(value_ptr))) { - func(@ptrCast(@alignCast(value_ptr)), false); + func(@ptrCast(@alignCast(value_ptr)), false, ctx.page); ctx.release(value_ptr); } else { // A bit weird, but v8 _requires_ that we release it @@ -398,7 +398,7 @@ pub const Property = struct { const Finalizer = struct { // The finalizer wrapper when called fro Zig. This is only called on // Context.deinit - from_zig: *const fn (ctx: *anyopaque) void, + from_zig: *const fn (ctx: *anyopaque, page: *Page) void, // The finalizer wrapper when called from V8. This may never be called // (hence why we fallback to calling in Context.denit). If it is called, diff --git a/src/browser/webapi/AbortSignal.zig b/src/browser/webapi/AbortSignal.zig index 8eb61062..186a6cad 100644 --- a/src/browser/webapi/AbortSignal.zig +++ b/src/browser/webapi/AbortSignal.zig @@ -77,8 +77,6 @@ pub fn abort(self: *AbortSignal, reason_: ?Reason, local: *const js.Local, page: // Dispatch abort event const event = try Event.initTrusted(comptime .wrap("abort"), .{}, page); - defer if (!event._v8_handoff) event.deinit(false); - try page._event_manager.dispatchWithFunction( self.asEventTarget(), event, diff --git a/src/browser/webapi/Element.zig b/src/browser/webapi/Element.zig index 753d50a7..3a47be9b 100644 --- a/src/browser/webapi/Element.zig +++ b/src/browser/webapi/Element.zig @@ -868,12 +868,10 @@ pub fn focus(self: *Element, page: *Page) !void { // Dispatch blur on old element (no bubble, composed) const blur_event = try FocusEvent.initTrusted(comptime .wrap("blur"), .{ .composed = true, .relatedTarget = new_target }, page); - defer if (!blur_event.asEvent()._v8_handoff) blur_event.deinit(false); try page._event_manager.dispatch(old_target, blur_event.asEvent()); // Dispatch focusout on old element (bubbles, composed) const focusout_event = try FocusEvent.initTrusted(comptime .wrap("focusout"), .{ .bubbles = true, .composed = true, .relatedTarget = new_target }, page); - defer if (!focusout_event.asEvent()._v8_handoff) focusout_event.deinit(false); try page._event_manager.dispatch(old_target, focusout_event.asEvent()); } @@ -881,12 +879,10 @@ pub fn focus(self: *Element, page: *Page) !void { // Dispatch focus on new element (no bubble, composed) const focus_event = try FocusEvent.initTrusted(comptime .wrap("focus"), .{ .composed = true, .relatedTarget = old_related }, page); - defer if (!focus_event.asEvent()._v8_handoff) focus_event.deinit(false); try page._event_manager.dispatch(new_target, focus_event.asEvent()); // Dispatch focusin on new element (bubbles, composed) const focusin_event = try FocusEvent.initTrusted(comptime .wrap("focusin"), .{ .bubbles = true, .composed = true, .relatedTarget = old_related }, page); - defer if (!focusin_event.asEvent()._v8_handoff) focusin_event.deinit(false); try page._event_manager.dispatch(new_target, focusin_event.asEvent()); } @@ -900,12 +896,10 @@ pub fn blur(self: *Element, page: *Page) !void { // Dispatch blur (no bubble, composed) const blur_event = try FocusEvent.initTrusted(comptime .wrap("blur"), .{ .composed = true }, page); - defer if (!blur_event.asEvent()._v8_handoff) blur_event.deinit(false); try page._event_manager.dispatch(old_target, blur_event.asEvent()); // Dispatch focusout (bubbles, composed) const focusout_event = try FocusEvent.initTrusted(comptime .wrap("focusout"), .{ .bubbles = true, .composed = true }, page); - defer if (!focusout_event.asEvent()._v8_handoff) focusout_event.deinit(false); try page._event_manager.dispatch(old_target, focusout_event.asEvent()); } diff --git a/src/browser/webapi/Event.zig b/src/browser/webapi/Event.zig index b8568e79..0082682f 100644 --- a/src/browser/webapi/Event.zig +++ b/src/browser/webapi/Event.zig @@ -30,7 +30,6 @@ pub const Event = @This(); pub const _prototype_root = true; _type: Type, -_page: *Page, _arena: Allocator, _bubbles: bool = false, _cancelable: bool = false, @@ -84,16 +83,16 @@ pub fn init(typ: []const u8, opts_: ?Options, page: *Page) !*Event { const arena = try page.getArena(.{ .debug = "Event" }); errdefer page.releaseArena(arena); const str = try String.init(arena, typ, .{}); - return initWithTrusted(arena, str, opts_, false, page); + return initWithTrusted(arena, str, opts_, false); } pub fn initTrusted(typ: String, opts_: ?Options, page: *Page) !*Event { const arena = try page.getArena(.{ .debug = "Event.trusted" }); errdefer page.releaseArena(arena); - return initWithTrusted(arena, typ, opts_, true, page); + return initWithTrusted(arena, typ, opts_, true); } -fn initWithTrusted(arena: Allocator, typ: String, opts_: ?Options, trusted: bool, page: *Page) !*Event { +fn initWithTrusted(arena: Allocator, typ: String, opts_: ?Options, trusted: bool) !*Event { const opts = opts_ orelse Options{}; // Round to 2ms for privacy (browsers do this) @@ -102,7 +101,6 @@ fn initWithTrusted(arena: Allocator, typ: String, opts_: ?Options, trusted: bool const event = try arena.create(Event); event.* = .{ - ._page = page, ._arena = arena, ._type = .generic, ._bubbles = opts.bubbles, @@ -133,9 +131,9 @@ pub fn initEvent( self._prevent_default = false; } -pub fn deinit(self: *Event, shutdown: bool) void { +pub fn deinit(self: *Event, shutdown: bool, page: *Page) void { _ = shutdown; - self._page.releaseArena(self._arena); + page.releaseArena(self._arena); } pub fn as(self: *Event, comptime T: type) *T { diff --git a/src/browser/webapi/History.zig b/src/browser/webapi/History.zig index cf665e05..4e6bb348 100644 --- a/src/browser/webapi/History.zig +++ b/src/browser/webapi/History.zig @@ -80,8 +80,6 @@ fn goInner(delta: i32, page: *Page) !void { if (entry._url) |url| { if (try page.isSameOrigin(url)) { const event = (try PopStateEvent.initTrusted(comptime .wrap("popstate"), .{ .state = entry._state.value }, page)).asEvent(); - defer if (!event._v8_handoff) event.deinit(false); - try page._event_manager.dispatchWithFunction( page.window.asEventTarget(), event, diff --git a/src/browser/webapi/IntersectionObserver.zig b/src/browser/webapi/IntersectionObserver.zig index 4431d224..9d64de3c 100644 --- a/src/browser/webapi/IntersectionObserver.zig +++ b/src/browser/webapi/IntersectionObserver.zig @@ -36,7 +36,6 @@ pub fn registerTypes() []const type { const IntersectionObserver = @This(); -_page: *Page, _arena: Allocator, _callback: js.Function.Temp, _observing: std.ArrayList(*Element) = .{}, @@ -83,7 +82,6 @@ pub fn init(callback: js.Function.Temp, options: ?ObserverInit, page: *Page) !*I const self = try arena.create(IntersectionObserver); self.* = .{ - ._page = page, ._arena = arena, ._callback = callback, ._root = opts.root, @@ -93,8 +91,7 @@ pub fn init(callback: js.Function.Temp, options: ?ObserverInit, page: *Page) !*I return self; } -pub fn deinit(self: *IntersectionObserver, shutdown: bool) void { - const page = self._page; +pub fn deinit(self: *IntersectionObserver, shutdown: bool, page: *Page) void { page.js.release(self._callback); if ((comptime IS_DEBUG) and !shutdown) { std.debug.assert(self._observing.items.len == 0); @@ -140,7 +137,7 @@ pub fn unobserve(self: *IntersectionObserver, target: *Element, page: *Page) voi while (j < self._pending_entries.items.len) { if (self._pending_entries.items[j]._target == target) { const entry = self._pending_entries.swapRemove(j); - entry.deinit(false); + entry.deinit(false, page); } else { j += 1; } @@ -160,7 +157,7 @@ pub fn disconnect(self: *IntersectionObserver, page: *Page) void { self._previous_states.clearRetainingCapacity(); for (self._pending_entries.items) |entry| { - entry.deinit(false); + entry.deinit(false, page); } self._pending_entries.clearRetainingCapacity(); page.js.safeWeakRef(self); @@ -245,7 +242,6 @@ fn checkIntersection(self: *IntersectionObserver, target: *Element, page: *Page) const entry = try arena.create(IntersectionObserverEntry); entry.* = .{ - ._page = page, ._arena = arena, ._target = target, ._time = page.window._performance.now(), @@ -297,7 +293,6 @@ pub fn deliverEntries(self: *IntersectionObserver, page: *Page) !void { } pub const IntersectionObserverEntry = struct { - _page: *Page, _arena: Allocator, _time: f64, _target: *Element, @@ -307,8 +302,8 @@ pub const IntersectionObserverEntry = struct { _intersection_ratio: f64, _is_intersecting: bool, - pub fn deinit(self: *const IntersectionObserverEntry, _: bool) void { - self._page.releaseArena(self._arena); + pub fn deinit(self: *const IntersectionObserverEntry, _: bool, page: *Page) void { + page.releaseArena(self._arena); } pub fn getTarget(self: *const IntersectionObserverEntry) *Element { diff --git a/src/browser/webapi/MessagePort.zig b/src/browser/webapi/MessagePort.zig index def38da0..ffddf30c 100644 --- a/src/browser/webapi/MessagePort.zig +++ b/src/browser/webapi/MessagePort.zig @@ -130,7 +130,6 @@ const PostMessageCallback = struct { log.err(.dom, "MessagePort.postMessage", .{ .err = err }); return null; }).asEvent(); - defer if (!event._v8_handoff) event.deinit(false); var ls: js.Local.Scope = undefined; page.js.localScope(&ls); diff --git a/src/browser/webapi/MutationObserver.zig b/src/browser/webapi/MutationObserver.zig index c84e4db0..68d85fb4 100644 --- a/src/browser/webapi/MutationObserver.zig +++ b/src/browser/webapi/MutationObserver.zig @@ -38,7 +38,6 @@ pub fn registerTypes() []const type { const MutationObserver = @This(); -_page: *Page, _arena: Allocator, _callback: js.Function.Temp, _observing: std.ArrayList(Observing) = .{}, @@ -79,15 +78,13 @@ pub fn init(callback: js.Function.Temp, page: *Page) !*MutationObserver { const self = try arena.create(MutationObserver); self.* = .{ - ._page = page, ._arena = arena, ._callback = callback, }; return self; } -pub fn deinit(self: *MutationObserver, shutdown: bool) void { - const page = self._page; +pub fn deinit(self: *MutationObserver, shutdown: bool, page: *Page) void { page.js.release(self._callback); if ((comptime IS_DEBUG) and !shutdown) { std.debug.assert(self._observing.items.len == 0); @@ -174,7 +171,7 @@ pub fn disconnect(self: *MutationObserver, page: *Page) void { page.unregisterMutationObserver(self); self._observing.clearRetainingCapacity(); for (self._pending_records.items) |record| { - record.deinit(false); + record.deinit(false, page); } self._pending_records.clearRetainingCapacity(); page.js.safeWeakRef(self); @@ -218,10 +215,9 @@ pub fn notifyAttributeChange( } } - const arena = try self._page.getArena(.{ .debug = "MutationRecord" }); + const arena = try page.getArena(.{ .debug = "MutationRecord" }); const record = try arena.create(MutationRecord); record.* = .{ - ._page = page, ._arena = arena, ._type = .attributes, ._target = target_node, @@ -263,10 +259,9 @@ pub fn notifyCharacterDataChange( continue; } - const arena = try self._page.getArena(.{ .debug = "MutationRecord" }); + const arena = try page.getArena(.{ .debug = "MutationRecord" }); const record = try arena.create(MutationRecord); record.* = .{ - ._page = page, ._arena = arena, ._type = .characterData, ._target = target, @@ -311,10 +306,9 @@ pub fn notifyChildListChange( continue; } - const arena = try self._page.getArena(.{ .debug = "MutationRecord" }); + const arena = try page.getArena(.{ .debug = "MutationRecord" }); const record = try arena.create(MutationRecord); record.* = .{ - ._page = page, ._arena = arena, ._type = .childList, ._target = target, @@ -354,7 +348,6 @@ pub fn deliverRecords(self: *MutationObserver, page: *Page) !void { pub const MutationRecord = struct { _type: Type, - _page: *Page, _target: *Node, _arena: Allocator, _attribute_name: ?[]const u8, @@ -370,8 +363,8 @@ pub const MutationRecord = struct { characterData, }; - pub fn deinit(self: *const MutationRecord, _: bool) void { - self._page.releaseArena(self._arena); + pub fn deinit(self: *const MutationRecord, _: bool, page: *Page) void { + page.releaseArena(self._arena); } pub fn getType(self: *const MutationRecord) []const u8 { diff --git a/src/browser/webapi/Selection.zig b/src/browser/webapi/Selection.zig index 88da6d56..ed7bb4fd 100644 --- a/src/browser/webapi/Selection.zig +++ b/src/browser/webapi/Selection.zig @@ -39,7 +39,6 @@ pub const init: Selection = .{}; fn dispatchSelectionChangeEvent(page: *Page) !void { const event = try Event.init("selectionchange", .{}, page); - defer if (!event._v8_handoff) event.deinit(false); try page._event_manager.dispatch(page.document.asEventTarget(), event); } diff --git a/src/browser/webapi/Window.zig b/src/browser/webapi/Window.zig index 80694dfa..d33a9308 100644 --- a/src/browser/webapi/Window.zig +++ b/src/browser/webapi/Window.zig @@ -308,12 +308,10 @@ pub fn reportError(self: *Window, err: js.Value, page: *Page) !void { .cancelable = true, }, page); - const event = error_event.asEvent(); - defer if (!event._v8_handoff) event.deinit(false); - // Invoke window.onerror callback if set (per WHATWG spec, this is called // with 5 arguments: message, source, lineno, colno, error) // If it returns true, the event is cancelled. + var prevent_default = false; if (self._on_error) |on_error| { var ls: js.Local.Scope = undefined; page.js.localScope(&ls); @@ -330,12 +328,12 @@ pub fn reportError(self: *Window, err: js.Value, page: *Page) !void { // Per spec: returning true from onerror cancels the event if (result) |r| { - if (r.isTrue()) { - event._prevent_default = true; - } + prevent_default = r.isTrue(); } } + const event = error_event.asEvent(); + event._prevent_default = prevent_default; try page._event_manager.dispatch(self.asEventTarget(), event); if (comptime builtin.is_test == false) { @@ -478,7 +476,6 @@ pub fn scrollTo(self: *Window, opts: ScrollToOpts, y: ?i32, page: *Page) !void { } const event = try Event.initTrusted(comptime .wrap("scroll"), .{ .bubbles = true }, p); - defer if (!event._v8_handoff) event.deinit(false); try p._event_manager.dispatch(p.document.asEventTarget(), event); pos.state = .end; @@ -506,7 +503,6 @@ pub fn scrollTo(self: *Window, opts: ScrollToOpts, y: ?i32, page: *Page) !void { .done => return null, } const event = try Event.initTrusted(comptime .wrap("scrollend"), .{ .bubbles = true }, p); - defer if (!event._v8_handoff) event.deinit(false); try p._event_manager.dispatch(p.document.asEventTarget(), event); pos.state = .done; @@ -527,11 +523,10 @@ pub fn unhandledPromiseRejection(self: *Window, rejection: js.PromiseRejection, }); } - var event = (try @import("event/PromiseRejectionEvent.zig").init("unhandledrejection", .{ + const event = (try @import("event/PromiseRejectionEvent.zig").init("unhandledrejection", .{ .reason = if (rejection.reason()) |r| try r.temp() else null, .promise = try rejection.promise().temp(), }, page)).asEvent(); - defer if (!event._v8_handoff) event.deinit(false); try page._event_manager.dispatchWithFunction( self.asEventTarget(), @@ -705,7 +700,6 @@ const PostMessageCallback = struct { .bubbles = false, .cancelable = false, }, page)).asEvent(); - defer if (!event._v8_handoff) event.deinit(false); try page._event_manager.dispatch(window.asEventTarget(), event); return null; diff --git a/src/browser/webapi/animation/Animation.zig b/src/browser/webapi/animation/Animation.zig index f69ca118..96143952 100644 --- a/src/browser/webapi/animation/Animation.zig +++ b/src/browser/webapi/animation/Animation.zig @@ -61,8 +61,8 @@ pub fn init(page: *Page) !*Animation { return self; } -pub fn deinit(self: *Animation, _: bool) void { - self._page.releaseArena(self._arena); +pub fn deinit(self: *Animation, _: bool, page: *Page) void { + page.releaseArena(self._arena); } pub fn play(self: *Animation, page: *Page) !void { diff --git a/src/browser/webapi/element/Html.zig b/src/browser/webapi/element/Html.zig index cba1306b..0e5fa3ad 100644 --- a/src/browser/webapi/element/Html.zig +++ b/src/browser/webapi/element/Html.zig @@ -338,7 +338,6 @@ pub fn click(self: *HtmlElement, page: *Page) !void { .clientX = 0, .clientY = 0, }, page)).asEvent(); - defer if (!event._v8_handoff) event.deinit(false); try page._event_manager.dispatch(self.asEventTarget(), event); } diff --git a/src/browser/webapi/element/html/Input.zig b/src/browser/webapi/element/html/Input.zig index 0928a9c9..c16af069 100644 --- a/src/browser/webapi/element/html/Input.zig +++ b/src/browser/webapi/element/html/Input.zig @@ -100,7 +100,6 @@ pub fn setOnSelectionChange(self: *Input, listener: ?js.Function) !void { fn dispatchSelectionChangeEvent(self: *Input, page: *Page) !void { const event = try Event.init("selectionchange", .{ .bubbles = true }, page); - defer if (!event._v8_handoff) event.deinit(false); try page._event_manager.dispatch(self.asElement().asEventTarget(), event); } @@ -344,7 +343,6 @@ pub fn select(self: *Input, page: *Page) !void { const len = if (self._value) |v| @as(u32, @intCast(v.len)) else 0; try self.setSelectionRange(0, len, null, page); const event = try Event.init("select", .{ .bubbles = true }, page); - defer if (!event._v8_handoff) event.deinit(false); try page._event_manager.dispatch(self.asElement().asEventTarget(), event); } diff --git a/src/browser/webapi/element/html/Media.zig b/src/browser/webapi/element/html/Media.zig index ca9f6088..f258ee74 100644 --- a/src/browser/webapi/element/html/Media.zig +++ b/src/browser/webapi/element/html/Media.zig @@ -166,7 +166,6 @@ pub fn load(self: *Media, page: *Page) !void { fn dispatchEvent(self: *Media, name: []const u8, page: *Page) !void { const event = try Event.init(name, .{ .bubbles = false, .cancelable = false }, page); - defer if (!event._v8_handoff) event.deinit(false); try page._event_manager.dispatch(self.asElement().asEventTarget(), event); } diff --git a/src/browser/webapi/element/html/TextArea.zig b/src/browser/webapi/element/html/TextArea.zig index 41ccce76..916f67d8 100644 --- a/src/browser/webapi/element/html/TextArea.zig +++ b/src/browser/webapi/element/html/TextArea.zig @@ -52,7 +52,6 @@ pub fn setOnSelectionChange(self: *TextArea, listener: ?js.Function) !void { fn dispatchSelectionChangeEvent(self: *TextArea, page: *Page) !void { const event = try Event.init("selectionchange", .{ .bubbles = true }, page); - defer if (!event._v8_handoff) event.deinit(false); try page._event_manager.dispatch(self.asElement().asEventTarget(), event); } @@ -140,7 +139,6 @@ pub fn select(self: *TextArea, page: *Page) !void { const len = if (self._value) |v| @as(u32, @intCast(v.len)) else 0; try self.setSelectionRange(0, len, null, page); const event = try Event.init("select", .{ .bubbles = true }, page); - defer if (!event._v8_handoff) event.deinit(false); try page._event_manager.dispatch(self.asElement().asEventTarget(), event); } diff --git a/src/browser/webapi/encoding/TextDecoder.zig b/src/browser/webapi/encoding/TextDecoder.zig index be719279..4f23cfcc 100644 --- a/src/browser/webapi/encoding/TextDecoder.zig +++ b/src/browser/webapi/encoding/TextDecoder.zig @@ -25,7 +25,6 @@ const Allocator = std.mem.Allocator; const TextDecoder = @This(); _fatal: bool, -_page: *Page, _arena: Allocator, _ignore_bom: bool, _stream: std.ArrayList(u8), @@ -52,7 +51,6 @@ pub fn init(label_: ?[]const u8, opts_: ?InitOpts, page: *Page) !*TextDecoder { const opts = opts_ orelse InitOpts{}; const self = try arena.create(TextDecoder); self.* = .{ - ._page = page, ._arena = arena, ._stream = .empty, ._fatal = opts.fatal, @@ -61,8 +59,8 @@ pub fn init(label_: ?[]const u8, opts_: ?InitOpts, page: *Page) !*TextDecoder { return self; } -pub fn deinit(self: *TextDecoder, _: bool) void { - self._page.releaseArena(self._arena); +pub fn deinit(self: *TextDecoder, _: bool, page: *Page) void { + page.releaseArena(self._arena); } pub fn getIgnoreBOM(self: *const TextDecoder) bool { diff --git a/src/browser/webapi/event/CompositionEvent.zig b/src/browser/webapi/event/CompositionEvent.zig index 0362cb83..b98fdd6f 100644 --- a/src/browser/webapi/event/CompositionEvent.zig +++ b/src/browser/webapi/event/CompositionEvent.zig @@ -53,8 +53,8 @@ pub fn init(typ: []const u8, opts_: ?Options, page: *Page) !*CompositionEvent { return event; } -pub fn deinit(self: *CompositionEvent, shutdown: bool) void { - self._proto.deinit(shutdown); +pub fn deinit(self: *CompositionEvent, shutdown: bool, page: *Page) void { + self._proto.deinit(shutdown, page); } pub fn asEvent(self: *CompositionEvent) *Event { diff --git a/src/browser/webapi/event/CustomEvent.zig b/src/browser/webapi/event/CustomEvent.zig index 808e6ed2..e303d901 100644 --- a/src/browser/webapi/event/CustomEvent.zig +++ b/src/browser/webapi/event/CustomEvent.zig @@ -72,12 +72,11 @@ pub fn initCustomEvent( self._detail = detail_; } -pub fn deinit(self: *CustomEvent, shutdown: bool) void { - const proto = self._proto; +pub fn deinit(self: *CustomEvent, shutdown: bool, page: *Page) void { if (self._detail) |d| { - proto._page.js.release(d); + page.js.release(d); } - proto.deinit(shutdown); + self._proto.deinit(shutdown, page); } pub fn asEvent(self: *CustomEvent) *Event { diff --git a/src/browser/webapi/event/ErrorEvent.zig b/src/browser/webapi/event/ErrorEvent.zig index 4983fe90..5dd12a26 100644 --- a/src/browser/webapi/event/ErrorEvent.zig +++ b/src/browser/webapi/event/ErrorEvent.zig @@ -79,12 +79,11 @@ fn initWithTrusted(arena: Allocator, typ: String, opts_: ?Options, trusted: bool return event; } -pub fn deinit(self: *ErrorEvent, shutdown: bool) void { - const proto = self._proto; +pub fn deinit(self: *ErrorEvent, shutdown: bool, page: *Page) void { if (self._error) |e| { - proto._page.js.release(e); + page.js.release(e); } - proto.deinit(shutdown); + self._proto.deinit(shutdown, page); } pub fn asEvent(self: *ErrorEvent) *Event { diff --git a/src/browser/webapi/event/FocusEvent.zig b/src/browser/webapi/event/FocusEvent.zig index 2da56b04..37065936 100644 --- a/src/browser/webapi/event/FocusEvent.zig +++ b/src/browser/webapi/event/FocusEvent.zig @@ -69,8 +69,8 @@ fn initWithTrusted(arena: Allocator, typ: String, _opts: ?Options, trusted: bool return event; } -pub fn deinit(self: *FocusEvent, shutdown: bool) void { - self._proto.deinit(shutdown); +pub fn deinit(self: *FocusEvent, shutdown: bool, page: *Page) void { + self._proto.deinit(shutdown, page); } pub fn asEvent(self: *FocusEvent) *Event { diff --git a/src/browser/webapi/event/KeyboardEvent.zig b/src/browser/webapi/event/KeyboardEvent.zig index 7f062840..6e391812 100644 --- a/src/browser/webapi/event/KeyboardEvent.zig +++ b/src/browser/webapi/event/KeyboardEvent.zig @@ -221,8 +221,8 @@ fn initWithTrusted(arena: Allocator, typ: String, _opts: ?Options, trusted: bool return event; } -pub fn deinit(self: *KeyboardEvent, shutdown: bool) void { - self._proto.deinit(shutdown); +pub fn deinit(self: *KeyboardEvent, shutdown: bool, page: *Page) void { + self._proto.deinit(shutdown, page); } pub fn asEvent(self: *KeyboardEvent) *Event { diff --git a/src/browser/webapi/event/MessageEvent.zig b/src/browser/webapi/event/MessageEvent.zig index d285e0e7..34e04518 100644 --- a/src/browser/webapi/event/MessageEvent.zig +++ b/src/browser/webapi/event/MessageEvent.zig @@ -72,12 +72,11 @@ fn initWithTrusted(arena: Allocator, typ: String, opts_: ?Options, trusted: bool return event; } -pub fn deinit(self: *MessageEvent, shutdown: bool) void { - const proto = self._proto; +pub fn deinit(self: *MessageEvent, shutdown: bool, page: *Page) void { if (self._data) |d| { - proto._page.js.release(d); + page.js.release(d); } - proto.deinit(shutdown); + self._proto.deinit(shutdown, page); } pub fn asEvent(self: *MessageEvent) *Event { diff --git a/src/browser/webapi/event/MouseEvent.zig b/src/browser/webapi/event/MouseEvent.zig index 3997140d..c94a37d1 100644 --- a/src/browser/webapi/event/MouseEvent.zig +++ b/src/browser/webapi/event/MouseEvent.zig @@ -109,8 +109,8 @@ pub fn init(typ: []const u8, _opts: ?Options, page: *Page) !*MouseEvent { return event; } -pub fn deinit(self: *MouseEvent, shutdown: bool) void { - self._proto.deinit(shutdown); +pub fn deinit(self: *MouseEvent, shutdown: bool, page: *Page) void { + self._proto.deinit(shutdown, page); } pub fn asEvent(self: *MouseEvent) *Event { diff --git a/src/browser/webapi/event/NavigationCurrentEntryChangeEvent.zig b/src/browser/webapi/event/NavigationCurrentEntryChangeEvent.zig index 47101f2a..59e32b06 100644 --- a/src/browser/webapi/event/NavigationCurrentEntryChangeEvent.zig +++ b/src/browser/webapi/event/NavigationCurrentEntryChangeEvent.zig @@ -82,8 +82,8 @@ fn initWithTrusted( return event; } -pub fn deinit(self: *NavigationCurrentEntryChangeEvent, shutdown: bool) void { - self._proto.deinit(shutdown); +pub fn deinit(self: *NavigationCurrentEntryChangeEvent, shutdown: bool, page: *Page) void { + self._proto.deinit(shutdown, page); } pub fn asEvent(self: *NavigationCurrentEntryChangeEvent) *Event { diff --git a/src/browser/webapi/event/PageTransitionEvent.zig b/src/browser/webapi/event/PageTransitionEvent.zig index 259c68db..eceab4f2 100644 --- a/src/browser/webapi/event/PageTransitionEvent.zig +++ b/src/browser/webapi/event/PageTransitionEvent.zig @@ -65,8 +65,8 @@ fn initWithTrusted(arena: Allocator, typ: String, _opts: ?Options, trusted: bool return event; } -pub fn deinit(self: *PageTransitionEvent, shutdown: bool) void { - self._proto.deinit(shutdown); +pub fn deinit(self: *PageTransitionEvent, shutdown: bool, page: *Page) void { + self._proto.deinit(shutdown, page); } pub fn asEvent(self: *PageTransitionEvent) *Event { diff --git a/src/browser/webapi/event/PointerEvent.zig b/src/browser/webapi/event/PointerEvent.zig index df388186..82f4874f 100644 --- a/src/browser/webapi/event/PointerEvent.zig +++ b/src/browser/webapi/event/PointerEvent.zig @@ -127,8 +127,8 @@ pub fn init(typ: []const u8, _opts: ?Options, page: *Page) !*PointerEvent { return event; } -pub fn deinit(self: *PointerEvent, shutdown: bool) void { - self._proto.deinit(shutdown); +pub fn deinit(self: *PointerEvent, shutdown: bool, page: *Page) void { + self._proto.deinit(shutdown, page); } pub fn asEvent(self: *PointerEvent) *Event { diff --git a/src/browser/webapi/event/PopStateEvent.zig b/src/browser/webapi/event/PopStateEvent.zig index d346d9cc..45774998 100644 --- a/src/browser/webapi/event/PopStateEvent.zig +++ b/src/browser/webapi/event/PopStateEvent.zig @@ -66,8 +66,8 @@ fn initWithTrusted(arena: Allocator, typ: String, _opts: ?Options, trusted: bool return event; } -pub fn deinit(self: *PopStateEvent, shutdown: bool) void { - self._proto.deinit(shutdown); +pub fn deinit(self: *PopStateEvent, shutdown: bool, page: *Page) void { + self._proto.deinit(shutdown, page); } pub fn asEvent(self: *PopStateEvent) *Event { diff --git a/src/browser/webapi/event/ProgressEvent.zig b/src/browser/webapi/event/ProgressEvent.zig index 8e061c71..a78982a1 100644 --- a/src/browser/webapi/event/ProgressEvent.zig +++ b/src/browser/webapi/event/ProgressEvent.zig @@ -67,8 +67,8 @@ fn initWithTrusted(arena: Allocator, typ: String, _opts: ?Options, trusted: bool return event; } -pub fn deinit(self: *ProgressEvent, shutdown: bool) void { - self._proto.deinit(shutdown); +pub fn deinit(self: *ProgressEvent, shutdown: bool, page: *Page) void { + self._proto.deinit(shutdown, page); } pub fn asEvent(self: *ProgressEvent) *Event { diff --git a/src/browser/webapi/event/PromiseRejectionEvent.zig b/src/browser/webapi/event/PromiseRejectionEvent.zig index db498a24..957228df 100644 --- a/src/browser/webapi/event/PromiseRejectionEvent.zig +++ b/src/browser/webapi/event/PromiseRejectionEvent.zig @@ -56,16 +56,14 @@ pub fn init(typ: []const u8, opts_: ?Options, page: *Page) !*PromiseRejectionEve return event; } -pub fn deinit(self: *PromiseRejectionEvent, shutdown: bool) void { - const proto = self._proto; - const js_ctx = proto._page.js; +pub fn deinit(self: *PromiseRejectionEvent, shutdown: bool, page: *Page) void { if (self._reason) |r| { - js_ctx.release(r); + page.js.release(r); } if (self._promise) |p| { - js_ctx.release(p); + page.js.release(p); } - proto.deinit(shutdown); + self._proto.deinit(shutdown, page); } pub fn asEvent(self: *PromiseRejectionEvent) *Event { diff --git a/src/browser/webapi/event/TextEvent.zig b/src/browser/webapi/event/TextEvent.zig index 65b16db8..54789c13 100644 --- a/src/browser/webapi/event/TextEvent.zig +++ b/src/browser/webapi/event/TextEvent.zig @@ -58,8 +58,8 @@ pub fn init(typ: []const u8, _opts: ?Options, page: *Page) !*TextEvent { return event; } -pub fn deinit(self: *TextEvent, shutdown: bool) void { - self._proto.deinit(shutdown); +pub fn deinit(self: *TextEvent, shutdown: bool, page: *Page) void { + self._proto.deinit(shutdown, page); } pub fn asEvent(self: *TextEvent) *Event { diff --git a/src/browser/webapi/event/UIEvent.zig b/src/browser/webapi/event/UIEvent.zig index b708dbd9..6d329221 100644 --- a/src/browser/webapi/event/UIEvent.zig +++ b/src/browser/webapi/event/UIEvent.zig @@ -69,8 +69,8 @@ pub fn init(typ: []const u8, _opts: ?Options, page: *Page) !*UIEvent { return event; } -pub fn deinit(self: *UIEvent, shutdown: bool) void { - self._proto.deinit(shutdown); +pub fn deinit(self: *UIEvent, shutdown: bool, page: *Page) void { + self._proto.deinit(shutdown, page); } pub fn as(self: *UIEvent, comptime T: type) *T { diff --git a/src/browser/webapi/event/WheelEvent.zig b/src/browser/webapi/event/WheelEvent.zig index f1aadba7..ee725941 100644 --- a/src/browser/webapi/event/WheelEvent.zig +++ b/src/browser/webapi/event/WheelEvent.zig @@ -86,8 +86,8 @@ pub fn init(typ: []const u8, _opts: ?Options, page: *Page) !*WheelEvent { return event; } -pub fn deinit(self: *WheelEvent, shutdown: bool) void { - self._proto.deinit(shutdown); +pub fn deinit(self: *WheelEvent, shutdown: bool, page: *Page) void { + self._proto.deinit(shutdown, page); } pub fn asEvent(self: *WheelEvent) *Event { diff --git a/src/browser/webapi/navigation/Navigation.zig b/src/browser/webapi/navigation/Navigation.zig index fb856aef..6facfee9 100644 --- a/src/browser/webapi/navigation/Navigation.zig +++ b/src/browser/webapi/navigation/Navigation.zig @@ -24,10 +24,11 @@ const URL = @import("../URL.zig"); const js = @import("../../js/js.zig"); const Page = @import("../../Page.zig"); -const IS_DEBUG = @import("builtin").mode == .Debug; - +const Event = @import("../Event.zig"); const EventTarget = @import("../EventTarget.zig"); +const IS_DEBUG = @import("builtin").mode == .Debug; + // https://developer.mozilla.org/en-US/docs/Web/API/Navigation const Navigation = @This(); @@ -203,15 +204,17 @@ pub fn pushEntry( try self._entries.append(arena, entry); self._index = index; - if (previous) |prev| { - if (should_dispatch) { - const event = try NavigationCurrentEntryChangeEvent.initTrusted( - .wrap("currententrychange"), - .{ .from = prev, .navigationType = @tagName(.push) }, - page, - ); - try self.dispatch(.{ .currententrychange = event }, page); - } + if (previous == null or should_dispatch == false) { + return entry; + } + + if (self._on_currententrychange) |cec| { + const event = (try NavigationCurrentEntryChangeEvent.initTrusted( + .wrap("currententrychange"), + .{ .from = previous.?, .navigationType = @tagName(.push) }, + page, + )).asEvent(); + try self.dispatch(cec, event, page); } return entry; @@ -243,13 +246,17 @@ pub fn replaceEntry( self._entries.items[self._index] = entry; - if (should_dispatch) { - const event = try NavigationCurrentEntryChangeEvent.initTrusted( + if (should_dispatch == false) { + return entry; + } + + if (self._on_currententrychange) |cec| { + const event = (try NavigationCurrentEntryChangeEvent.initTrusted( .wrap("currententrychange"), .{ .from = previous, .navigationType = @tagName(.replace) }, page, - ); - try self.dispatch(.{ .currententrychange = event }, page); + )).asEvent(); + try self.dispatch(cec, event, page); } return entry; @@ -335,13 +342,15 @@ pub fn navigateInner( }, } - // If we haven't navigated off, let us fire off an a currententrychange. - const event = try NavigationCurrentEntryChangeEvent.initTrusted( - .wrap("currententrychange"), - .{ .from = previous, .navigationType = @tagName(kind) }, - page, - ); - try self.dispatch(.{ .currententrychange = event }, page); + if (self._on_currententrychange) |cec| { + // If we haven't navigated off, let us fire off an a currententrychange. + const event = (try NavigationCurrentEntryChangeEvent.initTrusted( + .wrap("currententrychange"), + .{ .from = previous, .navigationType = @tagName(kind) }, + page, + )).asEvent(); + try self.dispatch(cec, event, page); + } _ = try committed.persist(); _ = try finished.persist(); @@ -420,35 +429,17 @@ pub fn updateCurrentEntry(self: *Navigation, options: UpdateCurrentEntryOptions, .value = options.state.toJson(arena) catch return error.DataClone, }; - const event = try NavigationCurrentEntryChangeEvent.initTrusted( - .wrap("currententrychange"), - .{ .from = previous, .navigationType = null }, - page, - ); - try self.dispatch(.{ .currententrychange = event }, page); + if (self._on_currententrychange) |cec| { + const event = (try NavigationCurrentEntryChangeEvent.initTrusted( + .wrap("currententrychange"), + .{ .from = previous, .navigationType = null }, + page, + )).asEvent(); + try self.dispatch(cec, event, page); + } } -const DispatchType = union(enum) { - currententrychange: *NavigationCurrentEntryChangeEvent, -}; - -pub fn dispatch(self: *Navigation, event_type: DispatchType, page: *Page) !void { - const event, const field = blk: { - break :blk switch (event_type) { - .currententrychange => |cec| .{ cec.asEvent(), "_on_currententrychange" }, - }; - }; - defer if (!event._v8_handoff) event.deinit(false); - - if (comptime IS_DEBUG) { - if (page.js.local == null) { - log.fatal(.bug, "null context scope", .{ .src = "Navigation.dispatch", .url = page.url }); - std.debug.assert(page.js.local != null); - } - } - - const func = @field(self, field) orelse return; - +pub fn dispatch(self: *Navigation, func: js.Function.Global, event: *Event, page: *Page) !void { var ls: js.Local.Scope = undefined; page.js.localScope(&ls); defer ls.deinit(); diff --git a/src/browser/webapi/net/Fetch.zig b/src/browser/webapi/net/Fetch.zig index 47fa8bc5..161e88c2 100644 --- a/src/browser/webapi/net/Fetch.zig +++ b/src/browser/webapi/net/Fetch.zig @@ -45,7 +45,7 @@ pub const InitOpts = Request.InitOpts; pub fn init(input: Input, options: ?InitOpts, page: *Page) !js.Promise { const request = try Request.init(input, options, page); const response = try Response.init(null, .{ .status = 0 }, page); - errdefer response.deinit(true); + errdefer response.deinit(true, page); const resolver = page.js.local.?.createPromiseResolver(); @@ -184,7 +184,7 @@ fn httpErrorCallback(ctx: *anyopaque, err: anyerror) void { // clear this. (defer since `self is in the response's arena). defer if (self._owns_response) { - response.deinit(err == error.Abort); + response.deinit(err == error.Abort, self._page); self._owns_response = false; }; @@ -205,7 +205,7 @@ fn httpShutdownCallback(ctx: *anyopaque) void { if (self._owns_response) { var response = self._response; response._transfer = null; - response.deinit(true); + response.deinit(true, self._page); // Do not access `self` after this point: the Fetch struct was // allocated from response._arena which has been released. } diff --git a/src/browser/webapi/net/Response.zig b/src/browser/webapi/net/Response.zig index d8c67e53..2884c825 100644 --- a/src/browser/webapi/net/Response.zig +++ b/src/browser/webapi/net/Response.zig @@ -36,7 +36,6 @@ pub const Type = enum { opaqueredirect, }; -_page: *Page, _status: u16, _arena: Allocator, _headers: *Headers, @@ -65,7 +64,6 @@ pub fn init(body_: ?[]const u8, opts_: ?InitOpts, page: *Page) !*Response { const self = try arena.create(Response); self.* = .{ - ._page = page, ._arena = arena, ._status = opts.status, ._status_text = status_text, @@ -78,7 +76,7 @@ pub fn init(body_: ?[]const u8, opts_: ?InitOpts, page: *Page) !*Response { return self; } -pub fn deinit(self: *Response, shutdown: bool) void { +pub fn deinit(self: *Response, shutdown: bool, page: *Page) void { if (self._transfer) |transfer| { if (shutdown) { transfer.terminate(); @@ -87,7 +85,7 @@ pub fn deinit(self: *Response, shutdown: bool) void { } self._transfer = null; } - self._page.releaseArena(self._arena); + page.releaseArena(self._arena); } pub fn getStatus(self: *const Response) u16 { diff --git a/src/browser/webapi/net/XMLHttpRequest.zig b/src/browser/webapi/net/XMLHttpRequest.zig index e39e8ce4..fa37ff42 100644 --- a/src/browser/webapi/net/XMLHttpRequest.zig +++ b/src/browser/webapi/net/XMLHttpRequest.zig @@ -92,7 +92,7 @@ pub fn init(page: *Page) !*XMLHttpRequest { }); } -pub fn deinit(self: *XMLHttpRequest, shutdown: bool) void { +pub fn deinit(self: *XMLHttpRequest, shutdown: bool, page: *Page) void { if (self._transfer) |transfer| { if (shutdown) { transfer.terminate(); @@ -102,7 +102,6 @@ pub fn deinit(self: *XMLHttpRequest, shutdown: bool) void { self._transfer = null; } - const page = self._page; const js_ctx = page.js; if (self._on_ready_state_change) |func| { js_ctx.release(func); @@ -182,9 +181,10 @@ pub fn open(self: *XMLHttpRequest, method_: []const u8, url: [:0]const u8) !void self._response_headers.clearRetainingCapacity(); self._request_body = null; + const page = self._page; self._method = try parseMethod(method_); - self._url = try URL.resolve(self._arena, self._page.base(), url, .{ .always_dupe = true }); - try self.stateChanged(.opened, self._page.js.local.?, self._page); + self._url = try URL.resolve(self._arena, page.base(), url, .{ .always_dupe = true }); + try self.stateChanged(.opened, page.js.local.?, page); } pub fn setRequestHeader(self: *XMLHttpRequest, name: []const u8, value: []const u8, page: *Page) !void { @@ -524,8 +524,6 @@ fn stateChanged(self: *XMLHttpRequest, state: ReadyState, local: *const js.Local self._ready_state = state; const event = try Event.initTrusted(.wrap("readystatechange"), .{}, page); - defer if (!event._v8_handoff) event.deinit(false); - try page._event_manager.dispatchWithFunction( self.asEventTarget(), event, diff --git a/src/browser/webapi/net/XMLHttpRequestEventTarget.zig b/src/browser/webapi/net/XMLHttpRequestEventTarget.zig index 355eddda..cb7be000 100644 --- a/src/browser/webapi/net/XMLHttpRequestEventTarget.zig +++ b/src/browser/webapi/net/XMLHttpRequestEventTarget.zig @@ -62,7 +62,6 @@ pub fn dispatch(self: *XMLHttpRequestEventTarget, comptime event_type: DispatchT .{ .total = progress.total, .loaded = progress.loaded }, page, )).asEvent(); - defer if (!event._v8_handoff) event.deinit(false); return page._event_manager.dispatchWithFunction( self.asEventTarget(), diff --git a/src/browser/webapi/storage/Cookie.zig b/src/browser/webapi/storage/Cookie.zig index 5e352bd4..da499efb 100644 --- a/src/browser/webapi/storage/Cookie.zig +++ b/src/browser/webapi/storage/Cookie.zig @@ -435,7 +435,7 @@ pub const Jar = struct { pub fn removeExpired(self: *Jar, request_time: ?i64) void { if (self.cookies.items.len == 0) return; const time = request_time orelse std.time.timestamp(); - var i: usize = self.cookies.items.len ; + var i: usize = self.cookies.items.len; while (i > 0) { i -= 1; const cookie = &self.cookies.items[i]; From 8b952110558e000aaaa1ae697705729dc5af4c71 Mon Sep 17 00:00:00 2001 From: Pierre Tachoire Date: Sun, 22 Feb 2026 14:59:41 +0100 Subject: [PATCH 011/103] add dummy implementation of font face set --- src/browser/js/bridge.zig | 1 + src/browser/tests/css/font_face_set.html | 58 +++++++++++++++++++++++ src/browser/webapi/Document.zig | 12 +++++ src/browser/webapi/css/FontFaceSet.zig | 59 ++++++++++++++++++++++++ 4 files changed, 130 insertions(+) create mode 100644 src/browser/tests/css/font_face_set.html create mode 100644 src/browser/webapi/css/FontFaceSet.zig diff --git a/src/browser/js/bridge.zig b/src/browser/js/bridge.zig index 3a991313..7640d0a1 100644 --- a/src/browser/js/bridge.zig +++ b/src/browser/js/bridge.zig @@ -713,6 +713,7 @@ pub const JsApis = flattenTypes(&.{ @import("../webapi/css/CSSStyleRule.zig"), @import("../webapi/css/CSSStyleSheet.zig"), @import("../webapi/css/CSSStyleProperties.zig"), + @import("../webapi/css/FontFaceSet.zig"), @import("../webapi/css/MediaQueryList.zig"), @import("../webapi/css/StyleSheetList.zig"), @import("../webapi/Document.zig"), diff --git a/src/browser/tests/css/font_face_set.html b/src/browser/tests/css/font_face_set.html new file mode 100644 index 00000000..a860669e --- /dev/null +++ b/src/browser/tests/css/font_face_set.html @@ -0,0 +1,58 @@ + + + + + + + + + + + + + + + + + + diff --git a/src/browser/webapi/Document.zig b/src/browser/webapi/Document.zig index e92f0dcf..7a7989ab 100644 --- a/src/browser/webapi/Document.zig +++ b/src/browser/webapi/Document.zig @@ -34,6 +34,7 @@ const DOMTreeWalker = @import("DOMTreeWalker.zig"); const DOMNodeIterator = @import("DOMNodeIterator.zig"); const DOMImplementation = @import("DOMImplementation.zig"); const StyleSheetList = @import("css/StyleSheetList.zig"); +const FontFaceSet = @import("css/FontFaceSet.zig"); const Selection = @import("Selection.zig"); pub const XMLDocument = @import("XMLDocument.zig"); @@ -52,6 +53,7 @@ _elements_by_id: std.StringHashMapUnmanaged(*Element) = .empty, _removed_ids: std.StringHashMapUnmanaged(void) = .empty, _active_element: ?*Element = null, _style_sheets: ?*StyleSheetList = null, +_fonts: ?*FontFaceSet = null, _write_insertion_point: ?*Node = null, _script_created_parser: ?Parser.Streaming = null, _adopted_style_sheets: ?js.Object.Global = null, @@ -422,6 +424,15 @@ pub fn getStyleSheets(self: *Document, page: *Page) !*StyleSheetList { return sheets; } +pub fn getFonts(self: *Document, page: *Page) !*FontFaceSet { + if (self._fonts) |fonts| { + return fonts; + } + const fonts = try FontFaceSet.init(page); + self._fonts = fonts; + return fonts; +} + pub fn adoptNode(_: *const Document, node: *Node, page: *Page) !*Node { if (node._type == .document) { return error.NotSupported; @@ -955,6 +966,7 @@ pub const JsApi = struct { pub const implementation = bridge.accessor(Document.getImplementation, null, .{}); pub const activeElement = bridge.accessor(Document.getActiveElement, null, .{}); pub const styleSheets = bridge.accessor(Document.getStyleSheets, null, .{}); + pub const fonts = bridge.accessor(Document.getFonts, null, .{}); pub const contentType = bridge.accessor(Document.getContentType, null, .{}); pub const domain = bridge.accessor(Document.getDomain, null, .{}); pub const createElement = bridge.function(Document.createElement, .{ .dom_exception = true }); diff --git a/src/browser/webapi/css/FontFaceSet.zig b/src/browser/webapi/css/FontFaceSet.zig new file mode 100644 index 00000000..cd3cf2b1 --- /dev/null +++ b/src/browser/webapi/css/FontFaceSet.zig @@ -0,0 +1,59 @@ +const std = @import("std"); +const js = @import("../../js/js.zig"); +const Page = @import("../../Page.zig"); + +const FontFaceSet = @This(); + +// Padding to avoid zero-size struct, which causes identity_map pointer collisions. +_pad: bool = false, + +pub fn init(page: *Page) !*FontFaceSet { + return page._factory.create(FontFaceSet{}); +} + +// FontFaceSet.ready - returns an already-resolved Promise. +// In a headless browser there is no font loading, so fonts are always ready. +pub fn getReady(_: *FontFaceSet, page: *Page) !js.Promise { + return page.js.local.?.resolvePromise({}); +} + +pub fn getStatus(_: *const FontFaceSet) []const u8 { + return "loaded"; +} + +pub fn getSize(_: *const FontFaceSet) u32 { + return 0; +} + +// check(font, text?) - always true; headless has no real fonts to check. +pub fn check(_: *const FontFaceSet, font: []const u8) bool { + _ = font; + return true; +} + +// load(font, text?) - resolves immediately with an empty array. +pub fn load(_: *FontFaceSet, font: []const u8, page: *Page) !js.Promise { + _ = font; + return page.js.local.?.resolvePromise({}); +} + +pub const JsApi = struct { + pub const bridge = js.Bridge(FontFaceSet); + + pub const Meta = struct { + pub const name = "FontFaceSet"; + pub const prototype_chain = bridge.prototypeChain(); + pub var class_id: bridge.ClassId = undefined; + }; + + pub const ready = bridge.accessor(FontFaceSet.getReady, null, .{}); + pub const status = bridge.accessor(FontFaceSet.getStatus, null, .{}); + pub const size = bridge.accessor(FontFaceSet.getSize, null, .{}); + pub const check = bridge.function(FontFaceSet.check, .{}); + pub const load = bridge.function(FontFaceSet.load, .{}); +}; + +const testing = @import("../../../testing.zig"); +test "WebApi: FontFaceSet" { + try testing.htmlRunner("css/font_face_set.html", .{}); +} From 0e5ec86ca97829d9237756fc174b81fd8e894f93 Mon Sep 17 00:00:00 2001 From: Pierre Tachoire Date: Sun, 22 Feb 2026 15:03:12 +0100 Subject: [PATCH 012/103] add Option.text setter --- src/browser/tests/element/html/option.html | 6 ++++++ src/browser/webapi/element/html/Option.zig | 6 +++++- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/src/browser/tests/element/html/option.html b/src/browser/tests/element/html/option.html index 6e7f72c8..45983370 100644 --- a/src/browser/tests/element/html/option.html +++ b/src/browser/tests/element/html/option.html @@ -29,6 +29,12 @@ testing.expectEqual('Text 3', $('#opt3').text) + + + + + diff --git a/src/browser/webapi/Console.zig b/src/browser/webapi/Console.zig index e972db3f..06a18331 100644 --- a/src/browser/webapi/Console.zig +++ b/src/browser/webapi/Console.zig @@ -178,7 +178,7 @@ pub const JsApi = struct { pub const info = bridge.function(Console.info, .{}); pub const log = bridge.function(Console.log, .{}); pub const warn = bridge.function(Console.warn, .{}); - pub const clear = bridge.function(Console.clear, .{}); + pub const clear = bridge.function(Console.clear, .{ .noop = true }); pub const assert = bridge.function(Console.assert, .{}); pub const @"error" = bridge.function(Console.@"error", .{}); pub const exception = bridge.function(Console.@"error", .{}); diff --git a/src/browser/webapi/Document.zig b/src/browser/webapi/Document.zig index a5e75bd7..ecb5edcc 100644 --- a/src/browser/webapi/Document.zig +++ b/src/browser/webapi/Document.zig @@ -821,17 +821,6 @@ pub fn setAdoptedStyleSheets(self: *Document, sheets: js.Object) !void { self._adopted_style_sheets = try sheets.persist(); } -pub fn getHidden(_: *const Document) bool { - // it's hidden when, for example, the decive is locked, or user is on a - // a different tab. - return false; -} - -pub fn getVisibilityState(_: *const Document) []const u8 { - // See getHidden above, possible options are "visible" or "hidden" - return "visible"; -} - // Validates that nodes can be inserted into a Document, respecting Document constraints: // - At most one Element child // - At most one DocumentType child @@ -1028,8 +1017,8 @@ pub const JsApi = struct { pub const lastElementChild = bridge.accessor(Document.getLastElementChild, null, .{}); pub const childElementCount = bridge.accessor(Document.getChildElementCount, null, .{}); pub const adoptedStyleSheets = bridge.accessor(Document.getAdoptedStyleSheets, Document.setAdoptedStyleSheets, .{}); - pub const hidden = bridge.accessor(Document.getHidden, null, .{}); - pub const visibilityState = bridge.accessor(Document.getVisibilityState, null, .{}); + pub const hidden = bridge.property(false, .{ .template = false, .readonly = true }); + pub const visibilityState = bridge.property("visible", .{ .template = false, .readonly = true }); pub const defaultView = bridge.accessor(struct { fn defaultView(_: *const Document, page: *Page) *@import("Window.zig") { return page.window; diff --git a/src/browser/webapi/ImageData.zig b/src/browser/webapi/ImageData.zig index c9ced5f1..abb5abd5 100644 --- a/src/browser/webapi/ImageData.zig +++ b/src/browser/webapi/ImageData.zig @@ -93,14 +93,6 @@ pub fn getHeight(self: *const ImageData) u32 { return self._height; } -pub fn getPixelFormat(_: *const ImageData) String { - return comptime .wrap("rgba-unorm8"); -} - -pub fn getColorSpace(_: *const ImageData) String { - return comptime .wrap("srgb"); -} - pub fn getData(self: *const ImageData) js.ArrayBufferRef(.uint8_clamped).Global { return self._data; } @@ -116,11 +108,12 @@ pub const JsApi = struct { pub const constructor = bridge.constructor(ImageData.constructor, .{ .dom_exception = true }); + pub const colorSpace = bridge.property("srgb", .{ .template = false, .readonly = true }); + pub const pixelFormat = bridge.property("rgba-unorm8", .{ .template = false, .readonly = true }); + + pub const data = bridge.accessor(ImageData.getData, null, .{}); pub const width = bridge.accessor(ImageData.getWidth, null, .{}); pub const height = bridge.accessor(ImageData.getHeight, null, .{}); - pub const pixelFormat = bridge.accessor(ImageData.getPixelFormat, null, .{}); - pub const colorSpace = bridge.accessor(ImageData.getColorSpace, null, .{}); - pub const data = bridge.accessor(ImageData.getData, null, .{}); }; const testing = @import("../../testing.zig"); diff --git a/src/browser/webapi/Window.zig b/src/browser/webapi/Window.zig index 80694dfa..149b6259 100644 --- a/src/browser/webapi/Window.zig +++ b/src/browser/webapi/Window.zig @@ -807,7 +807,7 @@ pub const JsApi = struct { pub const alert = bridge.function(struct { fn alert(_: *const Window, _: ?[]const u8) void {} - }.alert, .{}); + }.alert, .{ .noop = true }); pub const confirm = bridge.function(struct { fn confirm(_: *const Window, _: ?[]const u8) bool { return false; diff --git a/src/browser/webapi/canvas/CanvasRenderingContext2D.zig b/src/browser/webapi/canvas/CanvasRenderingContext2D.zig index f9aa0b18..056100ee 100644 --- a/src/browser/webapi/canvas/CanvasRenderingContext2D.zig +++ b/src/browser/webapi/canvas/CanvasRenderingContext2D.zig @@ -47,46 +47,6 @@ pub fn setFillStyle( self._fill_style = color.RGBA.parse(value) catch self._fill_style; } -pub fn getGlobalAlpha(_: *const CanvasRenderingContext2D) f64 { - return 1.0; -} - -pub fn getGlobalCompositeOperation(_: *const CanvasRenderingContext2D) []const u8 { - return "source-over"; -} - -pub fn getStrokeStyle(_: *const CanvasRenderingContext2D) []const u8 { - return "#000000"; -} - -pub fn getLineWidth(_: *const CanvasRenderingContext2D) f64 { - return 1.0; -} - -pub fn getLineCap(_: *const CanvasRenderingContext2D) []const u8 { - return "butt"; -} - -pub fn getLineJoin(_: *const CanvasRenderingContext2D) []const u8 { - return "miter"; -} - -pub fn getMiterLimit(_: *const CanvasRenderingContext2D) f64 { - return 10.0; -} - -pub fn getFont(_: *const CanvasRenderingContext2D) []const u8 { - return "10px sans-serif"; -} - -pub fn getTextAlign(_: *const CanvasRenderingContext2D) []const u8 { - return "start"; -} - -pub fn getTextBaseline(_: *const CanvasRenderingContext2D) []const u8 { - return "alphabetic"; -} - const WidthOrImageData = union(enum) { width: u32, image_data: *ImageData, @@ -113,7 +73,6 @@ pub fn createImageData( } pub fn putImageData(_: *const CanvasRenderingContext2D, _: *ImageData, _: f64, _: f64, _: ?f64, _: ?f64, _: ?f64, _: ?f64) void {} - pub fn save(_: *CanvasRenderingContext2D) void {} pub fn restore(_: *CanvasRenderingContext2D) void {} pub fn scale(_: *CanvasRenderingContext2D, _: f64, _: f64) void {} @@ -122,13 +81,7 @@ pub fn translate(_: *CanvasRenderingContext2D, _: f64, _: f64) void {} pub fn transform(_: *CanvasRenderingContext2D, _: f64, _: f64, _: f64, _: f64, _: f64, _: f64) void {} pub fn setTransform(_: *CanvasRenderingContext2D, _: f64, _: f64, _: f64, _: f64, _: f64, _: f64) void {} pub fn resetTransform(_: *CanvasRenderingContext2D) void {} -pub fn setGlobalAlpha(_: *CanvasRenderingContext2D, _: f64) void {} -pub fn setGlobalCompositeOperation(_: *CanvasRenderingContext2D, _: []const u8) void {} pub fn setStrokeStyle(_: *CanvasRenderingContext2D, _: []const u8) void {} -pub fn setLineWidth(_: *CanvasRenderingContext2D, _: f64) void {} -pub fn setLineCap(_: *CanvasRenderingContext2D, _: []const u8) void {} -pub fn setLineJoin(_: *CanvasRenderingContext2D, _: []const u8) void {} -pub fn setMiterLimit(_: *CanvasRenderingContext2D, _: f64) void {} pub fn clearRect(_: *CanvasRenderingContext2D, _: f64, _: f64, _: f64, _: f64) void {} pub fn fillRect(_: *CanvasRenderingContext2D, _: f64, _: f64, _: f64, _: f64) void {} pub fn strokeRect(_: *CanvasRenderingContext2D, _: f64, _: f64, _: f64, _: f64) void {} @@ -144,9 +97,6 @@ pub fn rect(_: *CanvasRenderingContext2D, _: f64, _: f64, _: f64, _: f64) void { pub fn fill(_: *CanvasRenderingContext2D) void {} pub fn stroke(_: *CanvasRenderingContext2D) void {} pub fn clip(_: *CanvasRenderingContext2D) void {} -pub fn setFont(_: *CanvasRenderingContext2D, _: []const u8) void {} -pub fn setTextAlign(_: *CanvasRenderingContext2D, _: []const u8) void {} -pub fn setTextBaseline(_: *CanvasRenderingContext2D, _: []const u8) void {} pub fn fillText(_: *CanvasRenderingContext2D, _: []const u8, _: f64, _: f64, _: ?f64) void {} pub fn strokeText(_: *CanvasRenderingContext2D, _: []const u8, _: f64, _: f64, _: ?f64) void {} @@ -160,53 +110,46 @@ pub const JsApi = struct { pub var class_id: bridge.ClassId = undefined; }; - pub const createImageData = bridge.function(CanvasRenderingContext2D.createImageData, .{ .dom_exception = true }); - pub const putImageData = bridge.function(CanvasRenderingContext2D.putImageData, .{}); - - pub const save = bridge.function(CanvasRenderingContext2D.save, .{}); - pub const restore = bridge.function(CanvasRenderingContext2D.restore, .{}); - - pub const scale = bridge.function(CanvasRenderingContext2D.scale, .{}); - pub const rotate = bridge.function(CanvasRenderingContext2D.rotate, .{}); - pub const translate = bridge.function(CanvasRenderingContext2D.translate, .{}); - pub const transform = bridge.function(CanvasRenderingContext2D.transform, .{}); - pub const setTransform = bridge.function(CanvasRenderingContext2D.setTransform, .{}); - pub const resetTransform = bridge.function(CanvasRenderingContext2D.resetTransform, .{}); - - pub const globalAlpha = bridge.accessor(CanvasRenderingContext2D.getGlobalAlpha, CanvasRenderingContext2D.setGlobalAlpha, .{}); - pub const globalCompositeOperation = bridge.accessor(CanvasRenderingContext2D.getGlobalCompositeOperation, CanvasRenderingContext2D.setGlobalCompositeOperation, .{}); + pub const font = bridge.property("10px sans-serif", .{ .template = false, .readonly = false }); + pub const globalAlpha = bridge.property(1.0, .{ .template = false, .readonly = false }); + pub const globalCompositeOperation = bridge.property("source-over", .{ .template = false, .readonly = false }); + pub const strokeStyle = bridge.property("#000000", .{ .template = false, .readonly = false }); + pub const lineWidth = bridge.property(1.0, .{ .template = false, .readonly = false }); + pub const lineCap = bridge.property("butt", .{ .template = false, .readonly = false }); + pub const lineJoin = bridge.property("miter", .{ .template = false, .readonly = false }); + pub const miterLimit = bridge.property(10.0, .{ .template = false, .readonly = false }); + pub const textAlign = bridge.property("start", .{ .template = false, .readonly = false }); + pub const textBaseline = bridge.property("alphabetic", .{ .template = false, .readonly = false }); pub const fillStyle = bridge.accessor(CanvasRenderingContext2D.getFillStyle, CanvasRenderingContext2D.setFillStyle, .{}); - pub const strokeStyle = bridge.accessor(CanvasRenderingContext2D.getStrokeStyle, CanvasRenderingContext2D.setStrokeStyle, .{}); + pub const createImageData = bridge.function(CanvasRenderingContext2D.createImageData, .{ .dom_exception = true }); - pub const lineWidth = bridge.accessor(CanvasRenderingContext2D.getLineWidth, CanvasRenderingContext2D.setLineWidth, .{}); - pub const lineCap = bridge.accessor(CanvasRenderingContext2D.getLineCap, CanvasRenderingContext2D.setLineCap, .{}); - pub const lineJoin = bridge.accessor(CanvasRenderingContext2D.getLineJoin, CanvasRenderingContext2D.setLineJoin, .{}); - pub const miterLimit = bridge.accessor(CanvasRenderingContext2D.getMiterLimit, CanvasRenderingContext2D.setMiterLimit, .{}); - - pub const clearRect = bridge.function(CanvasRenderingContext2D.clearRect, .{}); - pub const fillRect = bridge.function(CanvasRenderingContext2D.fillRect, .{}); - pub const strokeRect = bridge.function(CanvasRenderingContext2D.strokeRect, .{}); - - pub const beginPath = bridge.function(CanvasRenderingContext2D.beginPath, .{}); - pub const closePath = bridge.function(CanvasRenderingContext2D.closePath, .{}); - pub const moveTo = bridge.function(CanvasRenderingContext2D.moveTo, .{}); - pub const lineTo = bridge.function(CanvasRenderingContext2D.lineTo, .{}); - pub const quadraticCurveTo = bridge.function(CanvasRenderingContext2D.quadraticCurveTo, .{}); - pub const bezierCurveTo = bridge.function(CanvasRenderingContext2D.bezierCurveTo, .{}); - pub const arc = bridge.function(CanvasRenderingContext2D.arc, .{}); - pub const arcTo = bridge.function(CanvasRenderingContext2D.arcTo, .{}); - pub const rect = bridge.function(CanvasRenderingContext2D.rect, .{}); - - pub const fill = bridge.function(CanvasRenderingContext2D.fill, .{}); - pub const stroke = bridge.function(CanvasRenderingContext2D.stroke, .{}); - pub const clip = bridge.function(CanvasRenderingContext2D.clip, .{}); - - pub const font = bridge.accessor(CanvasRenderingContext2D.getFont, CanvasRenderingContext2D.setFont, .{}); - pub const textAlign = bridge.accessor(CanvasRenderingContext2D.getTextAlign, CanvasRenderingContext2D.setTextAlign, .{}); - pub const textBaseline = bridge.accessor(CanvasRenderingContext2D.getTextBaseline, CanvasRenderingContext2D.setTextBaseline, .{}); - pub const fillText = bridge.function(CanvasRenderingContext2D.fillText, .{}); - pub const strokeText = bridge.function(CanvasRenderingContext2D.strokeText, .{}); + pub const putImageData = bridge.function(CanvasRenderingContext2D.putImageData, .{ .noop = true }); + pub const save = bridge.function(CanvasRenderingContext2D.save, .{ .noop = true }); + pub const restore = bridge.function(CanvasRenderingContext2D.restore, .{ .noop = true }); + pub const scale = bridge.function(CanvasRenderingContext2D.scale, .{ .noop = true }); + pub const rotate = bridge.function(CanvasRenderingContext2D.rotate, .{ .noop = true }); + pub const translate = bridge.function(CanvasRenderingContext2D.translate, .{ .noop = true }); + pub const transform = bridge.function(CanvasRenderingContext2D.transform, .{ .noop = true }); + pub const setTransform = bridge.function(CanvasRenderingContext2D.setTransform, .{ .noop = true }); + pub const resetTransform = bridge.function(CanvasRenderingContext2D.resetTransform, .{ .noop = true }); + pub const clearRect = bridge.function(CanvasRenderingContext2D.clearRect, .{ .noop = true }); + pub const fillRect = bridge.function(CanvasRenderingContext2D.fillRect, .{ .noop = true }); + pub const strokeRect = bridge.function(CanvasRenderingContext2D.strokeRect, .{ .noop = true }); + pub const beginPath = bridge.function(CanvasRenderingContext2D.beginPath, .{ .noop = true }); + pub const closePath = bridge.function(CanvasRenderingContext2D.closePath, .{ .noop = true }); + pub const moveTo = bridge.function(CanvasRenderingContext2D.moveTo, .{ .noop = true }); + pub const lineTo = bridge.function(CanvasRenderingContext2D.lineTo, .{ .noop = true }); + pub const quadraticCurveTo = bridge.function(CanvasRenderingContext2D.quadraticCurveTo, .{ .noop = true }); + pub const bezierCurveTo = bridge.function(CanvasRenderingContext2D.bezierCurveTo, .{ .noop = true }); + pub const arc = bridge.function(CanvasRenderingContext2D.arc, .{ .noop = true }); + pub const arcTo = bridge.function(CanvasRenderingContext2D.arcTo, .{ .noop = true }); + pub const rect = bridge.function(CanvasRenderingContext2D.rect, .{ .noop = true }); + pub const fill = bridge.function(CanvasRenderingContext2D.fill, .{ .noop = true }); + pub const stroke = bridge.function(CanvasRenderingContext2D.stroke, .{ .noop = true }); + pub const clip = bridge.function(CanvasRenderingContext2D.clip, .{ .noop = true }); + pub const fillText = bridge.function(CanvasRenderingContext2D.fillText, .{ .noop = true }); + pub const strokeText = bridge.function(CanvasRenderingContext2D.strokeText, .{ .noop = true }); }; const testing = @import("../../../testing.zig"); diff --git a/src/browser/webapi/canvas/OffscreenCanvasRenderingContext2D.zig b/src/browser/webapi/canvas/OffscreenCanvasRenderingContext2D.zig index 26ebb8ec..1a11bd68 100644 --- a/src/browser/webapi/canvas/OffscreenCanvasRenderingContext2D.zig +++ b/src/browser/webapi/canvas/OffscreenCanvasRenderingContext2D.zig @@ -46,46 +46,6 @@ pub fn setFillStyle( self._fill_style = color.RGBA.parse(value) catch self._fill_style; } -pub fn getGlobalAlpha(_: *const OffscreenCanvasRenderingContext2D) f64 { - return 1.0; -} - -pub fn getGlobalCompositeOperation(_: *const OffscreenCanvasRenderingContext2D) []const u8 { - return "source-over"; -} - -pub fn getStrokeStyle(_: *const OffscreenCanvasRenderingContext2D) []const u8 { - return "#000000"; -} - -pub fn getLineWidth(_: *const OffscreenCanvasRenderingContext2D) f64 { - return 1.0; -} - -pub fn getLineCap(_: *const OffscreenCanvasRenderingContext2D) []const u8 { - return "butt"; -} - -pub fn getLineJoin(_: *const OffscreenCanvasRenderingContext2D) []const u8 { - return "miter"; -} - -pub fn getMiterLimit(_: *const OffscreenCanvasRenderingContext2D) f64 { - return 10.0; -} - -pub fn getFont(_: *const OffscreenCanvasRenderingContext2D) []const u8 { - return "10px sans-serif"; -} - -pub fn getTextAlign(_: *const OffscreenCanvasRenderingContext2D) []const u8 { - return "start"; -} - -pub fn getTextBaseline(_: *const OffscreenCanvasRenderingContext2D) []const u8 { - return "alphabetic"; -} - const WidthOrImageData = union(enum) { width: u32, image_data: *ImageData, @@ -112,7 +72,6 @@ pub fn createImageData( } pub fn putImageData(_: *const OffscreenCanvasRenderingContext2D, _: *ImageData, _: f64, _: f64, _: ?f64, _: ?f64, _: ?f64, _: ?f64) void {} - pub fn save(_: *OffscreenCanvasRenderingContext2D) void {} pub fn restore(_: *OffscreenCanvasRenderingContext2D) void {} pub fn scale(_: *OffscreenCanvasRenderingContext2D, _: f64, _: f64) void {} @@ -121,13 +80,7 @@ pub fn translate(_: *OffscreenCanvasRenderingContext2D, _: f64, _: f64) void {} pub fn transform(_: *OffscreenCanvasRenderingContext2D, _: f64, _: f64, _: f64, _: f64, _: f64, _: f64) void {} pub fn setTransform(_: *OffscreenCanvasRenderingContext2D, _: f64, _: f64, _: f64, _: f64, _: f64, _: f64) void {} pub fn resetTransform(_: *OffscreenCanvasRenderingContext2D) void {} -pub fn setGlobalAlpha(_: *OffscreenCanvasRenderingContext2D, _: f64) void {} -pub fn setGlobalCompositeOperation(_: *OffscreenCanvasRenderingContext2D, _: []const u8) void {} pub fn setStrokeStyle(_: *OffscreenCanvasRenderingContext2D, _: []const u8) void {} -pub fn setLineWidth(_: *OffscreenCanvasRenderingContext2D, _: f64) void {} -pub fn setLineCap(_: *OffscreenCanvasRenderingContext2D, _: []const u8) void {} -pub fn setLineJoin(_: *OffscreenCanvasRenderingContext2D, _: []const u8) void {} -pub fn setMiterLimit(_: *OffscreenCanvasRenderingContext2D, _: f64) void {} pub fn clearRect(_: *OffscreenCanvasRenderingContext2D, _: f64, _: f64, _: f64, _: f64) void {} pub fn fillRect(_: *OffscreenCanvasRenderingContext2D, _: f64, _: f64, _: f64, _: f64) void {} pub fn strokeRect(_: *OffscreenCanvasRenderingContext2D, _: f64, _: f64, _: f64, _: f64) void {} @@ -143,9 +96,6 @@ pub fn rect(_: *OffscreenCanvasRenderingContext2D, _: f64, _: f64, _: f64, _: f6 pub fn fill(_: *OffscreenCanvasRenderingContext2D) void {} pub fn stroke(_: *OffscreenCanvasRenderingContext2D) void {} pub fn clip(_: *OffscreenCanvasRenderingContext2D) void {} -pub fn setFont(_: *OffscreenCanvasRenderingContext2D, _: []const u8) void {} -pub fn setTextAlign(_: *OffscreenCanvasRenderingContext2D, _: []const u8) void {} -pub fn setTextBaseline(_: *OffscreenCanvasRenderingContext2D, _: []const u8) void {} pub fn fillText(_: *OffscreenCanvasRenderingContext2D, _: []const u8, _: f64, _: f64, _: ?f64) void {} pub fn strokeText(_: *OffscreenCanvasRenderingContext2D, _: []const u8, _: f64, _: f64, _: ?f64) void {} @@ -159,51 +109,44 @@ pub const JsApi = struct { pub var class_id: bridge.ClassId = undefined; }; - pub const createImageData = bridge.function(OffscreenCanvasRenderingContext2D.createImageData, .{ .dom_exception = true }); - pub const putImageData = bridge.function(OffscreenCanvasRenderingContext2D.putImageData, .{}); - - pub const save = bridge.function(OffscreenCanvasRenderingContext2D.save, .{}); - pub const restore = bridge.function(OffscreenCanvasRenderingContext2D.restore, .{}); - - pub const scale = bridge.function(OffscreenCanvasRenderingContext2D.scale, .{}); - pub const rotate = bridge.function(OffscreenCanvasRenderingContext2D.rotate, .{}); - pub const translate = bridge.function(OffscreenCanvasRenderingContext2D.translate, .{}); - pub const transform = bridge.function(OffscreenCanvasRenderingContext2D.transform, .{}); - pub const setTransform = bridge.function(OffscreenCanvasRenderingContext2D.setTransform, .{}); - pub const resetTransform = bridge.function(OffscreenCanvasRenderingContext2D.resetTransform, .{}); - - pub const globalAlpha = bridge.accessor(OffscreenCanvasRenderingContext2D.getGlobalAlpha, OffscreenCanvasRenderingContext2D.setGlobalAlpha, .{}); - pub const globalCompositeOperation = bridge.accessor(OffscreenCanvasRenderingContext2D.getGlobalCompositeOperation, OffscreenCanvasRenderingContext2D.setGlobalCompositeOperation, .{}); + pub const font = bridge.property("10px sans-serif", .{ .template = false, .readonly = false }); + pub const globalAlpha = bridge.property(1.0, .{ .template = false, .readonly = false }); + pub const globalCompositeOperation = bridge.property("source-over", .{ .template = false, .readonly = false }); + pub const strokeStyle = bridge.property("#000000", .{ .template = false, .readonly = false }); + pub const lineWidth = bridge.property(1.0, .{ .template = false, .readonly = false }); + pub const lineCap = bridge.property("butt", .{ .template = false, .readonly = false }); + pub const lineJoin = bridge.property("miter", .{ .template = false, .readonly = false }); + pub const miterLimit = bridge.property(10.0, .{ .template = false, .readonly = false }); + pub const textAlign = bridge.property("start", .{ .template = false, .readonly = false }); + pub const textBaseline = bridge.property("alphabetic", .{ .template = false, .readonly = false }); pub const fillStyle = bridge.accessor(OffscreenCanvasRenderingContext2D.getFillStyle, OffscreenCanvasRenderingContext2D.setFillStyle, .{}); - pub const strokeStyle = bridge.accessor(OffscreenCanvasRenderingContext2D.getStrokeStyle, OffscreenCanvasRenderingContext2D.setStrokeStyle, .{}); + pub const createImageData = bridge.function(OffscreenCanvasRenderingContext2D.createImageData, .{ .dom_exception = true }); - pub const lineWidth = bridge.accessor(OffscreenCanvasRenderingContext2D.getLineWidth, OffscreenCanvasRenderingContext2D.setLineWidth, .{}); - pub const lineCap = bridge.accessor(OffscreenCanvasRenderingContext2D.getLineCap, OffscreenCanvasRenderingContext2D.setLineCap, .{}); - pub const lineJoin = bridge.accessor(OffscreenCanvasRenderingContext2D.getLineJoin, OffscreenCanvasRenderingContext2D.setLineJoin, .{}); - pub const miterLimit = bridge.accessor(OffscreenCanvasRenderingContext2D.getMiterLimit, OffscreenCanvasRenderingContext2D.setMiterLimit, .{}); - - pub const clearRect = bridge.function(OffscreenCanvasRenderingContext2D.clearRect, .{}); - pub const fillRect = bridge.function(OffscreenCanvasRenderingContext2D.fillRect, .{}); - pub const strokeRect = bridge.function(OffscreenCanvasRenderingContext2D.strokeRect, .{}); - - pub const beginPath = bridge.function(OffscreenCanvasRenderingContext2D.beginPath, .{}); - pub const closePath = bridge.function(OffscreenCanvasRenderingContext2D.closePath, .{}); - pub const moveTo = bridge.function(OffscreenCanvasRenderingContext2D.moveTo, .{}); - pub const lineTo = bridge.function(OffscreenCanvasRenderingContext2D.lineTo, .{}); - pub const quadraticCurveTo = bridge.function(OffscreenCanvasRenderingContext2D.quadraticCurveTo, .{}); - pub const bezierCurveTo = bridge.function(OffscreenCanvasRenderingContext2D.bezierCurveTo, .{}); - pub const arc = bridge.function(OffscreenCanvasRenderingContext2D.arc, .{}); - pub const arcTo = bridge.function(OffscreenCanvasRenderingContext2D.arcTo, .{}); - pub const rect = bridge.function(OffscreenCanvasRenderingContext2D.rect, .{}); - - pub const fill = bridge.function(OffscreenCanvasRenderingContext2D.fill, .{}); - pub const stroke = bridge.function(OffscreenCanvasRenderingContext2D.stroke, .{}); - pub const clip = bridge.function(OffscreenCanvasRenderingContext2D.clip, .{}); - - pub const font = bridge.accessor(OffscreenCanvasRenderingContext2D.getFont, OffscreenCanvasRenderingContext2D.setFont, .{}); - pub const textAlign = bridge.accessor(OffscreenCanvasRenderingContext2D.getTextAlign, OffscreenCanvasRenderingContext2D.setTextAlign, .{}); - pub const textBaseline = bridge.accessor(OffscreenCanvasRenderingContext2D.getTextBaseline, OffscreenCanvasRenderingContext2D.setTextBaseline, .{}); - pub const fillText = bridge.function(OffscreenCanvasRenderingContext2D.fillText, .{}); - pub const strokeText = bridge.function(OffscreenCanvasRenderingContext2D.strokeText, .{}); + pub const putImageData = bridge.function(OffscreenCanvasRenderingContext2D.putImageData, .{ .noop = true }); + pub const save = bridge.function(OffscreenCanvasRenderingContext2D.save, .{ .noop = true }); + pub const restore = bridge.function(OffscreenCanvasRenderingContext2D.restore, .{ .noop = true }); + pub const scale = bridge.function(OffscreenCanvasRenderingContext2D.scale, .{ .noop = true }); + pub const rotate = bridge.function(OffscreenCanvasRenderingContext2D.rotate, .{ .noop = true }); + pub const translate = bridge.function(OffscreenCanvasRenderingContext2D.translate, .{ .noop = true }); + pub const transform = bridge.function(OffscreenCanvasRenderingContext2D.transform, .{ .noop = true }); + pub const setTransform = bridge.function(OffscreenCanvasRenderingContext2D.setTransform, .{ .noop = true }); + pub const resetTransform = bridge.function(OffscreenCanvasRenderingContext2D.resetTransform, .{ .noop = true }); + pub const clearRect = bridge.function(OffscreenCanvasRenderingContext2D.clearRect, .{ .noop = true }); + pub const fillRect = bridge.function(OffscreenCanvasRenderingContext2D.fillRect, .{ .noop = true }); + pub const strokeRect = bridge.function(OffscreenCanvasRenderingContext2D.strokeRect, .{ .noop = true }); + pub const beginPath = bridge.function(OffscreenCanvasRenderingContext2D.beginPath, .{ .noop = true }); + pub const closePath = bridge.function(OffscreenCanvasRenderingContext2D.closePath, .{ .noop = true }); + pub const moveTo = bridge.function(OffscreenCanvasRenderingContext2D.moveTo, .{ .noop = true }); + pub const lineTo = bridge.function(OffscreenCanvasRenderingContext2D.lineTo, .{ .noop = true }); + pub const quadraticCurveTo = bridge.function(OffscreenCanvasRenderingContext2D.quadraticCurveTo, .{ .noop = true }); + pub const bezierCurveTo = bridge.function(OffscreenCanvasRenderingContext2D.bezierCurveTo, .{ .noop = true }); + pub const arc = bridge.function(OffscreenCanvasRenderingContext2D.arc, .{ .noop = true }); + pub const arcTo = bridge.function(OffscreenCanvasRenderingContext2D.arcTo, .{ .noop = true }); + pub const rect = bridge.function(OffscreenCanvasRenderingContext2D.rect, .{ .noop = true }); + pub const fill = bridge.function(OffscreenCanvasRenderingContext2D.fill, .{ .noop = true }); + pub const stroke = bridge.function(OffscreenCanvasRenderingContext2D.stroke, .{ .noop = true }); + pub const clip = bridge.function(OffscreenCanvasRenderingContext2D.clip, .{ .noop = true }); + pub const fillText = bridge.function(OffscreenCanvasRenderingContext2D.fillText, .{ .noop = true }); + pub const strokeText = bridge.function(OffscreenCanvasRenderingContext2D.strokeText, .{ .noop = true }); }; diff --git a/src/browser/webapi/canvas/WebGLRenderingContext.zig b/src/browser/webapi/canvas/WebGLRenderingContext.zig index 2e3ac485..decd41c4 100644 --- a/src/browser/webapi/canvas/WebGLRenderingContext.zig +++ b/src/browser/webapi/canvas/WebGLRenderingContext.zig @@ -122,14 +122,6 @@ pub const Extension = union(enum) { pub const UNMASKED_VENDOR_WEBGL: u64 = 0x9245; pub const UNMASKED_RENDERER_WEBGL: u64 = 0x9246; - pub fn getUnmaskedVendorWebGL(_: *const WEBGL_debug_renderer_info) u64 { - return UNMASKED_VENDOR_WEBGL; - } - - pub fn getUnmaskedRendererWebGL(_: *const WEBGL_debug_renderer_info) u64 { - return UNMASKED_RENDERER_WEBGL; - } - pub const JsApi = struct { pub const bridge = js.Bridge(WEBGL_debug_renderer_info); @@ -140,8 +132,8 @@ pub const Extension = union(enum) { pub var class_id: bridge.ClassId = undefined; }; - pub const UNMASKED_VENDOR_WEBGL = bridge.accessor(WEBGL_debug_renderer_info.getUnmaskedVendorWebGL, null, .{}); - pub const UNMASKED_RENDERER_WEBGL = bridge.accessor(WEBGL_debug_renderer_info.getUnmaskedRendererWebGL, null, .{}); + pub const UNMASKED_VENDOR_WEBGL = bridge.property(WEBGL_debug_renderer_info.UNMASKED_VENDOR_WEBGL, .{ .template = false, .readonly = true }); + pub const UNMASKED_RENDERER_WEBGL = bridge.property(WEBGL_debug_renderer_info.UNMASKED_RENDERER_WEBGL, .{ .template = false, .readonly = true }); }; }; @@ -160,8 +152,8 @@ pub const Extension = union(enum) { pub var class_id: bridge.ClassId = undefined; }; - pub const loseContext = bridge.function(WEBGL_lose_context.loseContext, .{}); - pub const restoreContext = bridge.function(WEBGL_lose_context.restoreContext, .{}); + pub const loseContext = bridge.function(WEBGL_lose_context.loseContext, .{ .noop = true }); + pub const restoreContext = bridge.function(WEBGL_lose_context.restoreContext, .{ .noop = true }); }; }; }; diff --git a/src/browser/webapi/css/FontFaceSet.zig b/src/browser/webapi/css/FontFaceSet.zig index cd3cf2b1..28703080 100644 --- a/src/browser/webapi/css/FontFaceSet.zig +++ b/src/browser/webapi/css/FontFaceSet.zig @@ -17,14 +17,6 @@ pub fn getReady(_: *FontFaceSet, page: *Page) !js.Promise { return page.js.local.?.resolvePromise({}); } -pub fn getStatus(_: *const FontFaceSet) []const u8 { - return "loaded"; -} - -pub fn getSize(_: *const FontFaceSet) u32 { - return 0; -} - // check(font, text?) - always true; headless has no real fonts to check. pub fn check(_: *const FontFaceSet, font: []const u8) bool { _ = font; @@ -46,9 +38,9 @@ pub const JsApi = struct { pub var class_id: bridge.ClassId = undefined; }; + pub const size = bridge.property(0, .{ .template = false, .readonly = true }); + pub const status = bridge.property("loaded", .{ .template = false, .readonly = true }); pub const ready = bridge.accessor(FontFaceSet.getReady, null, .{}); - pub const status = bridge.accessor(FontFaceSet.getStatus, null, .{}); - pub const size = bridge.accessor(FontFaceSet.getSize, null, .{}); pub const check = bridge.function(FontFaceSet.check, .{}); pub const load = bridge.function(FontFaceSet.load, .{}); }; diff --git a/src/browser/webapi/css/MediaQueryList.zig b/src/browser/webapi/css/MediaQueryList.zig index 46304ccc..535c926d 100644 --- a/src/browser/webapi/css/MediaQueryList.zig +++ b/src/browser/webapi/css/MediaQueryList.zig @@ -38,11 +38,6 @@ pub fn getMedia(self: *const MediaQueryList) []const u8 { return self._media; } -/// Always returns false for dummy implementation -pub fn getMatches(_: *const MediaQueryList) bool { - return false; -} - pub fn addListener(_: *const MediaQueryList, _: js.Function) void {} pub fn removeListener(_: *const MediaQueryList, _: js.Function) void {} @@ -56,9 +51,9 @@ pub const JsApi = struct { }; pub const media = bridge.accessor(MediaQueryList.getMedia, null, .{}); - pub const matches = bridge.accessor(MediaQueryList.getMatches, null, .{}); - pub const addListener = bridge.function(MediaQueryList.addListener, .{}); - pub const removeListener = bridge.function(MediaQueryList.removeListener, .{}); + pub const matches = bridge.property(false, .{ .template = false, .readonly = true }); + pub const addListener = bridge.function(MediaQueryList.addListener, .{ .noop = true }); + pub const removeListener = bridge.function(MediaQueryList.removeListener, .{ .noop = true }); }; const testing = @import("../../../testing.zig"); From 955351b5bd8ea42a15cf9f8171aca359f6fdc401 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Mon, 23 Feb 2026 14:04:26 +0800 Subject: [PATCH 015/103] Don't bubble "load" event to Window As per spec: For legacy reasons, load events for resources inside the document (e.g., images) do not include the Window in the propagation path in HTML implementations. --- src/browser/EventManager.zig | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/src/browser/EventManager.zig b/src/browser/EventManager.zig index cfe5966e..e8f3c480 100644 --- a/src/browser/EventManager.zig +++ b/src/browser/EventManager.zig @@ -309,11 +309,14 @@ fn dispatchNode(self: *EventManager, target: *Node, event: *Event, was_handled: node = n._parent; } - // Even though the window isn't part of the DOM, events always propagate + // Even though the window isn't part of the DOM, most events propagate // through it in the capture phase (unless we stopped at a shadow boundary) - if (path_len < path_buffer.len) { - path_buffer[path_len] = page.window.asEventTarget(); - path_len += 1; + // The only explicit exception is "load" + if (event._type_string.eql(comptime .wrap("load")) == false) { + if (path_len < path_buffer.len) { + path_buffer[path_len] = page.window.asEventTarget(); + path_len += 1; + } } const path = path_buffer[0..path_len]; From 32c7399f26fcd341a43d717fbdc378d124bd98e3 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Mon, 23 Feb 2026 17:42:25 +0800 Subject: [PATCH 016/103] Log actual client address We're currently logging the server address. It should clearly be the client address. --- src/Server.zig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Server.zig b/src/Server.zig index 9f060a1c..0c677c85 100644 --- a/src/Server.zig +++ b/src/Server.zig @@ -291,7 +291,7 @@ pub const Client = struct { if (log.enabled(.app, .info)) { var client_address: std.net.Address = undefined; var socklen: posix.socklen_t = @sizeOf(net.Address); - try std.posix.getsockname(socket, &client_address.any, &socklen); + try std.posix.getpeername(socket, &client_address.any, &socklen); log.info(.app, "client connected", .{ .ip = client_address }); } From fb3eab1aa857994c5f2910ac3532e0ade4e7ac44 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Mon, 23 Feb 2026 18:20:15 +0800 Subject: [PATCH 017/103] Improve tests when running outside of our test runner We often verify the correctness of tests by loading them in an external browser, but some tests just don't run the same/correctly. For example, we used to hard- code the http://127.0.0.1:9582/ origin, but that would cause tests to fail if running from a different origin. This commit _begins_ the work of improving this. It introduces a testing.ORIGIN, testing.BASE_URL and testing.HOST which will work correctly in both our runner and an external browser. It also introduces `testing.IS_TEST_RUNNER` boolean flag so that tests which have no chance of working in an external browser (e.g. screen.width) can be skipped. The goal is to reduce/remove tests which fail in external browsers so that such failures aren't quickly written off as "just how it is". --- src/browser/tests/document/document.html | 6 ++--- src/browser/tests/element/html/anchor.html | 8 +++---- src/browser/tests/element/html/image.html | 4 ++-- src/browser/tests/element/html/input.html | 4 ++-- src/browser/tests/element/html/link.html | 2 +- src/browser/tests/element/html/media.html | 4 ++-- .../tests/element/html/script/script.html | 4 ++-- src/browser/tests/element/inner.html | 1 - src/browser/tests/net/request.html | 2 +- src/browser/tests/node/base_uri.html | 2 +- src/browser/tests/testing.js | 24 +++++++++++++++---- src/browser/tests/window/location.html | 10 ++++---- src/browser/tests/window/screen.html | 6 +++-- 13 files changed, 46 insertions(+), 31 deletions(-) diff --git a/src/browser/tests/document/document.html b/src/browser/tests/document/document.html index 322e6c74..74d8ff30 100644 --- a/src/browser/tests/document/document.html +++ b/src/browser/tests/document/document.html @@ -12,7 +12,7 @@ testing.expectEqual(10, document.childNodes[0].nodeType); testing.expectEqual(null, document.parentNode); testing.expectEqual(undefined, document.getCurrentScript); - testing.expectEqual("http://127.0.0.1:9582/src/browser/tests/document/document.html", document.URL); + testing.expectEqual(testing.BASE_URL + 'document/document.html', document.URL); testing.expectEqual(window, document.defaultView); testing.expectEqual(false, document.hidden); testing.expectEqual("visible", document.visibilityState); @@ -57,7 +57,7 @@ testing.expectEqual('CSS1Compat', document.compatMode); testing.expectEqual(document.URL, document.documentURI); testing.expectEqual('', document.referrer); - testing.expectEqual('127.0.0.1', document.domain); + testing.expectEqual(testing.HOST, document.domain); diff --git a/src/browser/tests/element/html/anchor.html b/src/browser/tests/element/html/anchor.html index 2eaa7935..3c248a7b 100644 --- a/src/browser/tests/element/html/anchor.html +++ b/src/browser/tests/element/html/anchor.html @@ -11,11 +11,11 @@ diff --git a/src/browser/tests/element/html/link.html b/src/browser/tests/element/html/link.html index 28dc1d52..2031e5fe 100644 --- a/src/browser/tests/element/html/link.html +++ b/src/browser/tests/element/html/link.html @@ -8,7 +8,7 @@ testing.expectEqual('https://lightpanda.io/opensource-browser/15', l2.href); l2.href = '/over/9000'; - testing.expectEqual('http://127.0.0.1:9582/over/9000', l2.href); + testing.expectEqual(testing.ORIGIN + 'over/9000', l2.href); l2.crossOrigin = 'nope'; testing.expectEqual('anonymous', l2.crossOrigin); diff --git a/src/browser/tests/element/html/media.html b/src/browser/tests/element/html/media.html index 4eba2e76..15cd9b33 100644 --- a/src/browser/tests/element/html/media.html +++ b/src/browser/tests/element/html/media.html @@ -238,7 +238,7 @@ testing.expectEqual('', audio.src); audio.src = 'test.mp3'; - testing.expectEqual('http://127.0.0.1:9582/src/browser/tests/element/html/test.mp3', audio.src); + testing.expectEqual(testing.BASE_URL + 'element/html/test.mp3', audio.src); } @@ -248,7 +248,7 @@ testing.expectEqual('', video.poster); video.poster = 'poster.jpg'; - testing.expectEqual('http://127.0.0.1:9582/src/browser/tests/element/html/poster.jpg', video.poster); + testing.expectEqual(testing.BASE_URL + 'element/html/poster.jpg', video.poster); } diff --git a/src/browser/tests/element/html/script/script.html b/src/browser/tests/element/html/script/script.html index 7db8b5fd..3629cf1a 100644 --- a/src/browser/tests/element/html/script/script.html +++ b/src/browser/tests/element/html/script/script.html @@ -6,7 +6,7 @@ let s = document.createElement('script'); testing.expectEqual('', s.src); - s.src = '/over.9000.js'; - testing.expectEqual('http://127.0.0.1:9582/over.9000.js', s.src); + s.src = 'over.9000.js'; + testing.expectEqual(testing.BASE_URL + 'element/html/script/over.9000.js', s.src); } diff --git a/src/browser/tests/element/inner.html b/src/browser/tests/element/inner.html index 1783020a..7c59ea14 100644 --- a/src/browser/tests/element/inner.html +++ b/src/browser/tests/element/inner.html @@ -45,7 +45,6 @@ testing.expectEqual('hi', $('#link').innerText); d1.innerHTML = ''; - testing.todo(null, $('#link')); diff --git a/src/browser/tests/window/screen.html b/src/browser/tests/window/screen.html index 5239ba43..61649033 100644 --- a/src/browser/tests/window/screen.html +++ b/src/browser/tests/window/screen.html @@ -3,8 +3,10 @@ diff --git a/src/browser/webapi/net/Response.zig b/src/browser/webapi/net/Response.zig index d8c67e53..a293d04d 100644 --- a/src/browser/webapi/net/Response.zig +++ b/src/browser/webapi/net/Response.zig @@ -145,6 +145,10 @@ pub fn getJson(self: *Response, page: *Page) !js.Promise { return local.resolvePromise(try value.persist()); } +pub fn arrayBuffer(self: *const Response, page: *Page) !js.Promise { + return page.js.local.?.resolvePromise(js.ArrayBuffer{ .values = self._body orelse "" }); +} + pub const JsApi = struct { pub const bridge = js.Bridge(Response); @@ -167,6 +171,7 @@ pub const JsApi = struct { pub const body = bridge.accessor(Response.getBody, null, .{}); pub const url = bridge.accessor(Response.getURL, null, .{}); pub const redirected = bridge.accessor(Response.isRedirected, null, .{}); + pub const arrayBuffer = bridge.function(Response.arrayBuffer, .{}); }; const testing = @import("../../../testing.zig"); From 0f1b8dd51a8223d40162b5d090be9e00912ff1fc Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Mon, 23 Feb 2026 15:01:13 +0800 Subject: [PATCH 019/103] Optimize Resource "load" event An amazon product page has 345 resources and not a single DOM "load" listener. This, I believe, is pretty common (reddit also has no "load" listener). So this is a simple optimization to skip dispatching the resource "load" event when there's no listener for it. The check could be more granular, i.e. checking the specific parents of the element. But I believe the global no "load" listener is common enough that this simpler approach is the best. The worst case is that we dispatch unnecessary "load" events, which is exactly what the code was doing before. --- src/browser/EventManager.zig | 9 + src/browser/Page.zig | 43 ++--- src/browser/webapi/element/Html.zig | 217 ++++++++++++---------- src/browser/webapi/element/html/Image.zig | 2 +- src/browser/webapi/element/html/Style.zig | 4 +- 5 files changed, 143 insertions(+), 132 deletions(-) diff --git a/src/browser/EventManager.zig b/src/browser/EventManager.zig index 8b1f82f5..42de7d55 100644 --- a/src/browser/EventManager.zig +++ b/src/browser/EventManager.zig @@ -56,6 +56,10 @@ pub const EventManager = @This(); page: *Page, arena: Allocator, +// Used as an optimization in Page._documentIsComplete. If we know there are no +// 'load' listeners in the document, we can skip dispatching the per-resource +// 'load' event (e.g. amazon product page has no listener and ~350 resources) +has_dom_load_listener: bool, listener_pool: std.heap.MemoryPool(Listener), list_pool: std.heap.MemoryPool(std.DoublyLinkedList), lookup: std.HashMapUnmanaged( @@ -76,6 +80,7 @@ pub fn init(arena: Allocator, page: *Page) EventManager { .listener_pool = .init(arena), .dispatch_depth = 0, .deferred_removals = .{}, + .has_dom_load_listener = false, }; } @@ -106,6 +111,10 @@ pub fn register(self: *EventManager, target: *EventTarget, typ: []const u8, call // Allocate the type string we'll use in both listener and key const type_string = try String.init(self.arena, typ, .{}); + if (type_string.eql(comptime .wrap("load")) and target._type == .node) { + self.has_dom_load_listener = true; + } + const gop = try self.lookup.getOrPut(self.arena, .{ .type_string = type_string, .event_target = @intFromPtr(target), diff --git a/src/browser/Page.zig b/src/browser/Page.zig index b053942c..699e5cb0 100644 --- a/src/browser/Page.zig +++ b/src/browser/Page.zig @@ -69,9 +69,7 @@ const timestamp = @import("../datetime.zig").timestamp; const milliTimestamp = @import("../datetime.zig").milliTimestamp; const WebApiURL = @import("webapi/URL.zig"); -const global_event_handlers = @import("webapi/global_event_handlers.zig"); -const GlobalEventHandlersLookup = global_event_handlers.Lookup; -const GlobalEventHandler = global_event_handlers.Handler; +const GlobalEventHandlersLookup = @import("webapi/global_event_handlers.zig").Lookup; var default_url = WebApiURL{ ._raw = "about:blank" }; pub var default_location: Location = Location{ ._url = &default_url }; @@ -139,7 +137,7 @@ _blob_urls: std.StringHashMapUnmanaged(*Blob) = .{}, /// `load` events that'll be fired before window's `load` event. /// A call to `documentIsComplete` (which calls `_documentIsComplete`) resets it. -_to_load: std.ArrayList(*Element) = .{}, +_to_load: std.ArrayList(*Element.Html) = .{}, _script_manager: ScriptManager, @@ -722,12 +720,16 @@ fn _documentIsComplete(self: *Page) !void { self.js.localScope(&ls); defer ls.deinit(); - // Dispatch `_to_load` events before window.load. - for (self._to_load.items) |element| { - const event = try Event.initTrusted(comptime .wrap("load"), .{}, self); - try self._event_manager.dispatch(element.asEventTarget(), event); + { + // 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); @@ -1346,29 +1348,6 @@ pub fn getElementByIdFromNode(self: *Page, node: *Node, id: []const u8) ?*Elemen return null; } -/// Sets an inline event listener (`onload`, `onclick`, `onwheel` etc.); -/// overrides the listener if there's already one. -pub fn setAttrListener( - self: *Page, - element: *Element, - listener_type: GlobalEventHandler, - listener_callback: JS.Function.Global, -) !void { - if (comptime IS_DEBUG) { - log.debug(.event, "Page.setAttrListener", .{ - .element = element, - .listener_type = listener_type, - .type = self._type, - }); - } - - const gop = try self._element_attr_listeners.getOrPut(self.arena, .{ - .target = element.asEventTarget(), - .handler = listener_type, - }); - gop.value_ptr.* = listener_callback; -} - pub fn registerPerformanceObserver(self: *Page, observer: *PerformanceObserver) !void { return self._performance_observers.append(self.arena, observer); } diff --git a/src/browser/webapi/element/Html.zig b/src/browser/webapi/element/Html.zig index 0e5fa3ad..6d2cf086 100644 --- a/src/browser/webapi/element/Html.zig +++ b/src/browser/webapi/element/Html.zig @@ -95,6 +95,8 @@ pub const Track = @import("html/Track.zig"); pub const UL = @import("html/UL.zig"); pub const Unknown = @import("html/Unknown.zig"); +const IS_DEBUG = @import("builtin").mode == .Debug; + const HtmlElement = @This(); _type: Type, @@ -396,13 +398,36 @@ pub fn getAttributeFunction( }, }; - try page.setAttrListener(element, listener_type, callback); + try self.setAttributeListener(listener_type, callback, page); return callback; } +pub fn hasAttributeFunction(self: *HtmlElement, listener_type: GlobalEventHandler, page: *const Page) bool { + return page._element_attr_listeners.contains(.{ .target = self.asEventTarget(), .handler = listener_type }); +} + +fn setAttributeListener( + self: *Element.Html, + listener_type: GlobalEventHandler, + listener_callback: js.Function.Global, + page: *Page, +) !void { + if (comptime IS_DEBUG) { + log.debug(.event, "Html.setAttributeListener", .{ + .type = self._type, + .listener_type = listener_type, + }); + } + + try page._element_attr_listeners.put(page.arena, .{ + .target = self.asEventTarget(), + .handler = listener_type, + }, listener_callback); +} + pub fn setOnAbort(self: *HtmlElement, callback: js.Function.Global, page: *Page) !void { - return page.setAttrListener(self.asElement(), .onabort, callback); + return self.setAttributeListener(.onabort, callback, page); } pub fn getOnAbort(self: *HtmlElement, page: *Page) !?js.Function.Global { @@ -410,7 +435,7 @@ pub fn getOnAbort(self: *HtmlElement, page: *Page) !?js.Function.Global { } pub fn setOnAnimationCancel(self: *HtmlElement, callback: js.Function.Global, page: *Page) !void { - return page.setAttrListener(self.asElement(), .onanimationcancel, callback); + return self.setAttributeListener(.onanimationcancel, callback, page); } pub fn getOnAnimationCancel(self: *HtmlElement, page: *Page) !?js.Function.Global { @@ -418,7 +443,7 @@ pub fn getOnAnimationCancel(self: *HtmlElement, page: *Page) !?js.Function.Globa } pub fn setOnAnimationEnd(self: *HtmlElement, callback: js.Function.Global, page: *Page) !void { - return page.setAttrListener(self.asElement(), .onanimationend, callback); + return self.setAttributeListener(.onanimationend, callback, page); } pub fn getOnAnimationEnd(self: *HtmlElement, page: *Page) !?js.Function.Global { @@ -426,7 +451,7 @@ pub fn getOnAnimationEnd(self: *HtmlElement, page: *Page) !?js.Function.Global { } pub fn setOnAnimationIteration(self: *HtmlElement, callback: js.Function.Global, page: *Page) !void { - return page.setAttrListener(self.asElement(), .onanimationiteration, callback); + return self.setAttributeListener(.onanimationiteration, callback, page); } pub fn getOnAnimationIteration(self: *HtmlElement, page: *Page) !?js.Function.Global { @@ -434,7 +459,7 @@ pub fn getOnAnimationIteration(self: *HtmlElement, page: *Page) !?js.Function.Gl } pub fn setOnAnimationStart(self: *HtmlElement, callback: js.Function.Global, page: *Page) !void { - return page.setAttrListener(self.asElement(), .onanimationstart, callback); + return self.setAttributeListener(.onanimationstart, callback, page); } pub fn getOnAnimationStart(self: *HtmlElement, page: *Page) !?js.Function.Global { @@ -442,7 +467,7 @@ pub fn getOnAnimationStart(self: *HtmlElement, page: *Page) !?js.Function.Global } pub fn setOnAuxClick(self: *HtmlElement, callback: js.Function.Global, page: *Page) !void { - return page.setAttrListener(self.asElement(), .onauxclick, callback); + return self.setAttributeListener(.onauxclick, callback, page); } pub fn getOnAuxClick(self: *HtmlElement, page: *Page) !?js.Function.Global { @@ -450,7 +475,7 @@ pub fn getOnAuxClick(self: *HtmlElement, page: *Page) !?js.Function.Global { } pub fn setOnBeforeInput(self: *HtmlElement, callback: js.Function.Global, page: *Page) !void { - return page.setAttrListener(self.asElement(), .onbeforeinput, callback); + return self.setAttributeListener(.onbeforeinput, callback, page); } pub fn getOnBeforeInput(self: *HtmlElement, page: *Page) !?js.Function.Global { @@ -458,7 +483,7 @@ pub fn getOnBeforeInput(self: *HtmlElement, page: *Page) !?js.Function.Global { } pub fn setOnBeforeMatch(self: *HtmlElement, callback: js.Function.Global, page: *Page) !void { - return page.setAttrListener(self.asElement(), .onbeforematch, callback); + return self.setAttributeListener(.onbeforematch, callback, page); } pub fn getOnBeforeMatch(self: *HtmlElement, page: *Page) !?js.Function.Global { @@ -466,7 +491,7 @@ pub fn getOnBeforeMatch(self: *HtmlElement, page: *Page) !?js.Function.Global { } pub fn setOnBeforeToggle(self: *HtmlElement, callback: js.Function.Global, page: *Page) !void { - return page.setAttrListener(self.asElement(), .onbeforetoggle, callback); + return self.setAttributeListener(.onbeforetoggle, callback, page); } pub fn getOnBeforeToggle(self: *HtmlElement, page: *Page) !?js.Function.Global { @@ -474,7 +499,7 @@ pub fn getOnBeforeToggle(self: *HtmlElement, page: *Page) !?js.Function.Global { } pub fn setOnBlur(self: *HtmlElement, callback: js.Function.Global, page: *Page) !void { - return page.setAttrListener(self.asElement(), .onblur, callback); + return self.setAttributeListener(.onblur, callback, page); } pub fn getOnBlur(self: *HtmlElement, page: *Page) !?js.Function.Global { @@ -482,7 +507,7 @@ pub fn getOnBlur(self: *HtmlElement, page: *Page) !?js.Function.Global { } pub fn setOnCancel(self: *HtmlElement, callback: js.Function.Global, page: *Page) !void { - return page.setAttrListener(self.asElement(), .oncancel, callback); + return self.setAttributeListener(.oncancel, callback, page); } pub fn getOnCancel(self: *HtmlElement, page: *Page) !?js.Function.Global { @@ -490,7 +515,7 @@ pub fn getOnCancel(self: *HtmlElement, page: *Page) !?js.Function.Global { } pub fn setOnCanPlay(self: *HtmlElement, callback: js.Function.Global, page: *Page) !void { - return page.setAttrListener(self.asElement(), .oncanplay, callback); + return self.setAttributeListener(.oncanplay, callback, page); } pub fn getOnCanPlay(self: *HtmlElement, page: *Page) !?js.Function.Global { @@ -498,7 +523,7 @@ pub fn getOnCanPlay(self: *HtmlElement, page: *Page) !?js.Function.Global { } pub fn setOnCanPlayThrough(self: *HtmlElement, callback: js.Function.Global, page: *Page) !void { - return page.setAttrListener(self.asElement(), .oncanplaythrough, callback); + return self.setAttributeListener(.oncanplaythrough, callback, page); } pub fn getOnCanPlayThrough(self: *HtmlElement, page: *Page) !?js.Function.Global { @@ -506,7 +531,7 @@ pub fn getOnCanPlayThrough(self: *HtmlElement, page: *Page) !?js.Function.Global } pub fn setOnChange(self: *HtmlElement, callback: js.Function.Global, page: *Page) !void { - return page.setAttrListener(self.asElement(), .onchange, callback); + return self.setAttributeListener(.onchange, callback, page); } pub fn getOnChange(self: *HtmlElement, page: *Page) !?js.Function.Global { @@ -514,7 +539,7 @@ pub fn getOnChange(self: *HtmlElement, page: *Page) !?js.Function.Global { } pub fn setOnClick(self: *HtmlElement, callback: js.Function.Global, page: *Page) !void { - return page.setAttrListener(self.asElement(), .onclick, callback); + return self.setAttributeListener(.onclick, callback, page); } pub fn getOnClick(self: *HtmlElement, page: *Page) !?js.Function.Global { @@ -522,7 +547,7 @@ pub fn getOnClick(self: *HtmlElement, page: *Page) !?js.Function.Global { } pub fn setOnClose(self: *HtmlElement, callback: js.Function.Global, page: *Page) !void { - return page.setAttrListener(self.asElement(), .onclose, callback); + return self.setAttributeListener(.onclose, callback, page); } pub fn getOnClose(self: *HtmlElement, page: *Page) !?js.Function.Global { @@ -530,7 +555,7 @@ pub fn getOnClose(self: *HtmlElement, page: *Page) !?js.Function.Global { } pub fn setOnCommand(self: *HtmlElement, callback: js.Function.Global, page: *Page) !void { - return page.setAttrListener(self.asElement(), .oncommand, callback); + return self.setAttributeListener(.oncommand, callback, page); } pub fn getOnCommand(self: *HtmlElement, page: *Page) !?js.Function.Global { @@ -538,7 +563,7 @@ pub fn getOnCommand(self: *HtmlElement, page: *Page) !?js.Function.Global { } pub fn setOnContentVisibilityAutoStateChange(self: *HtmlElement, callback: js.Function.Global, page: *Page) !void { - return page.setAttrListener(self.asElement(), .oncontentvisibilityautostatechange, callback); + return self.setAttributeListener(.oncontentvisibilityautostatechange, callback, page); } pub fn getOnContentVisibilityAutoStateChange(self: *HtmlElement, page: *Page) !?js.Function.Global { @@ -546,7 +571,7 @@ pub fn getOnContentVisibilityAutoStateChange(self: *HtmlElement, page: *Page) !? } pub fn setOnContextLost(self: *HtmlElement, callback: js.Function.Global, page: *Page) !void { - return page.setAttrListener(self.asElement(), .oncontextlost, callback); + return self.setAttributeListener(.oncontextlost, callback, page); } pub fn getOnContextLost(self: *HtmlElement, page: *Page) !?js.Function.Global { @@ -554,7 +579,7 @@ pub fn getOnContextLost(self: *HtmlElement, page: *Page) !?js.Function.Global { } pub fn setOnContextMenu(self: *HtmlElement, callback: js.Function.Global, page: *Page) !void { - return page.setAttrListener(self.asElement(), .oncontextmenu, callback); + return self.setAttributeListener(.oncontextmenu, callback, page); } pub fn getOnContextMenu(self: *HtmlElement, page: *Page) !?js.Function.Global { @@ -562,7 +587,7 @@ pub fn getOnContextMenu(self: *HtmlElement, page: *Page) !?js.Function.Global { } pub fn setOnContextRestored(self: *HtmlElement, callback: js.Function.Global, page: *Page) !void { - return page.setAttrListener(self.asElement(), .oncontextrestored, callback); + return self.setAttributeListener(.oncontextrestored, callback, page); } pub fn getOnContextRestored(self: *HtmlElement, page: *Page) !?js.Function.Global { @@ -570,7 +595,7 @@ pub fn getOnContextRestored(self: *HtmlElement, page: *Page) !?js.Function.Globa } pub fn setOnCopy(self: *HtmlElement, callback: js.Function.Global, page: *Page) !void { - return page.setAttrListener(self.asElement(), .oncopy, callback); + return self.setAttributeListener(.oncopy, callback, page); } pub fn getOnCopy(self: *HtmlElement, page: *Page) !?js.Function.Global { @@ -578,7 +603,7 @@ pub fn getOnCopy(self: *HtmlElement, page: *Page) !?js.Function.Global { } pub fn setOnCueChange(self: *HtmlElement, callback: js.Function.Global, page: *Page) !void { - return page.setAttrListener(self.asElement(), .oncuechange, callback); + return self.setAttributeListener(.oncuechange, callback, page); } pub fn getOnCueChange(self: *HtmlElement, page: *Page) !?js.Function.Global { @@ -586,7 +611,7 @@ pub fn getOnCueChange(self: *HtmlElement, page: *Page) !?js.Function.Global { } pub fn setOnCut(self: *HtmlElement, callback: js.Function.Global, page: *Page) !void { - return page.setAttrListener(self.asElement(), .oncut, callback); + return self.setAttributeListener(.oncut, callback, page); } pub fn getOnCut(self: *HtmlElement, page: *Page) !?js.Function.Global { @@ -594,7 +619,7 @@ pub fn getOnCut(self: *HtmlElement, page: *Page) !?js.Function.Global { } pub fn setOnDblClick(self: *HtmlElement, callback: js.Function.Global, page: *Page) !void { - return page.setAttrListener(self.asElement(), .ondblclick, callback); + return self.setAttributeListener(.ondblclick, callback, page); } pub fn getOnDblClick(self: *HtmlElement, page: *Page) !?js.Function.Global { @@ -602,7 +627,7 @@ pub fn getOnDblClick(self: *HtmlElement, page: *Page) !?js.Function.Global { } pub fn setOnDrag(self: *HtmlElement, callback: js.Function.Global, page: *Page) !void { - return page.setAttrListener(self.asElement(), .ondrag, callback); + return self.setAttributeListener(.ondrag, callback, page); } pub fn getOnDrag(self: *HtmlElement, page: *Page) !?js.Function.Global { @@ -610,7 +635,7 @@ pub fn getOnDrag(self: *HtmlElement, page: *Page) !?js.Function.Global { } pub fn setOnDragEnd(self: *HtmlElement, callback: js.Function.Global, page: *Page) !void { - return page.setAttrListener(self.asElement(), .ondragend, callback); + return self.setAttributeListener(.ondragend, callback, page); } pub fn getOnDragEnd(self: *HtmlElement, page: *Page) !?js.Function.Global { @@ -618,7 +643,7 @@ pub fn getOnDragEnd(self: *HtmlElement, page: *Page) !?js.Function.Global { } pub fn setOnDragEnter(self: *HtmlElement, callback: js.Function.Global, page: *Page) !void { - return page.setAttrListener(self.asElement(), .ondragenter, callback); + return self.setAttributeListener(.ondragenter, callback, page); } pub fn getOnDragEnter(self: *HtmlElement, page: *Page) !?js.Function.Global { @@ -626,7 +651,7 @@ pub fn getOnDragEnter(self: *HtmlElement, page: *Page) !?js.Function.Global { } pub fn setOnDragExit(self: *HtmlElement, callback: js.Function.Global, page: *Page) !void { - return page.setAttrListener(self.asElement(), .ondragexit, callback); + return self.setAttributeListener(.ondragexit, callback, page); } pub fn getOnDragExit(self: *HtmlElement, page: *Page) !?js.Function.Global { @@ -634,7 +659,7 @@ pub fn getOnDragExit(self: *HtmlElement, page: *Page) !?js.Function.Global { } pub fn setOnDragLeave(self: *HtmlElement, callback: js.Function.Global, page: *Page) !void { - return page.setAttrListener(self.asElement(), .ondragleave, callback); + return self.setAttributeListener(.ondragleave, callback, page); } pub fn getOnDragLeave(self: *HtmlElement, page: *Page) !?js.Function.Global { @@ -642,7 +667,7 @@ pub fn getOnDragLeave(self: *HtmlElement, page: *Page) !?js.Function.Global { } pub fn setOnDragOver(self: *HtmlElement, callback: js.Function.Global, page: *Page) !void { - return page.setAttrListener(self.asElement(), .ondragover, callback); + return self.setAttributeListener(.ondragover, callback, page); } pub fn getOnDragOver(self: *HtmlElement, page: *Page) !?js.Function.Global { @@ -650,7 +675,7 @@ pub fn getOnDragOver(self: *HtmlElement, page: *Page) !?js.Function.Global { } pub fn setOnDragStart(self: *HtmlElement, callback: js.Function.Global, page: *Page) !void { - return page.setAttrListener(self.asElement(), .ondragstart, callback); + return self.setAttributeListener(.ondragstart, callback, page); } pub fn getOnDragStart(self: *HtmlElement, page: *Page) !?js.Function.Global { @@ -658,7 +683,7 @@ pub fn getOnDragStart(self: *HtmlElement, page: *Page) !?js.Function.Global { } pub fn setOnDrop(self: *HtmlElement, callback: js.Function.Global, page: *Page) !void { - return page.setAttrListener(self.asElement(), .ondrop, callback); + return self.setAttributeListener(.ondrop, callback, page); } pub fn getOnDrop(self: *HtmlElement, page: *Page) !?js.Function.Global { @@ -666,7 +691,7 @@ pub fn getOnDrop(self: *HtmlElement, page: *Page) !?js.Function.Global { } pub fn setOnDurationChange(self: *HtmlElement, callback: js.Function.Global, page: *Page) !void { - return page.setAttrListener(self.asElement(), .ondurationchange, callback); + return self.setAttributeListener(.ondurationchange, callback, page); } pub fn getOnDurationChange(self: *HtmlElement, page: *Page) !?js.Function.Global { @@ -674,7 +699,7 @@ pub fn getOnDurationChange(self: *HtmlElement, page: *Page) !?js.Function.Global } pub fn setOnEmptied(self: *HtmlElement, callback: js.Function.Global, page: *Page) !void { - return page.setAttrListener(self.asElement(), .onemptied, callback); + return self.setAttributeListener(.onemptied, callback, page); } pub fn getOnEmptied(self: *HtmlElement, page: *Page) !?js.Function.Global { @@ -682,7 +707,7 @@ pub fn getOnEmptied(self: *HtmlElement, page: *Page) !?js.Function.Global { } pub fn setOnEnded(self: *HtmlElement, callback: js.Function.Global, page: *Page) !void { - return page.setAttrListener(self.asElement(), .onended, callback); + return self.setAttributeListener(.onended, callback, page); } pub fn getOnEnded(self: *HtmlElement, page: *Page) !?js.Function.Global { @@ -690,7 +715,7 @@ pub fn getOnEnded(self: *HtmlElement, page: *Page) !?js.Function.Global { } pub fn setOnError(self: *HtmlElement, callback: js.Function.Global, page: *Page) !void { - return page.setAttrListener(self.asElement(), .onerror, callback); + return self.setAttributeListener(.onerror, callback, page); } pub fn getOnError(self: *HtmlElement, page: *Page) !?js.Function.Global { @@ -698,7 +723,7 @@ pub fn getOnError(self: *HtmlElement, page: *Page) !?js.Function.Global { } pub fn setOnFocus(self: *HtmlElement, callback: js.Function.Global, page: *Page) !void { - return page.setAttrListener(self.asElement(), .onfocus, callback); + return self.setAttributeListener(.onfocus, callback, page); } pub fn getOnFocus(self: *HtmlElement, page: *Page) !?js.Function.Global { @@ -706,7 +731,7 @@ pub fn getOnFocus(self: *HtmlElement, page: *Page) !?js.Function.Global { } pub fn setOnFormData(self: *HtmlElement, callback: js.Function.Global, page: *Page) !void { - return page.setAttrListener(self.asElement(), .onformdata, callback); + return self.setAttributeListener(.onformdata, callback, page); } pub fn getOnFormData(self: *HtmlElement, page: *Page) !?js.Function.Global { @@ -714,7 +739,7 @@ pub fn getOnFormData(self: *HtmlElement, page: *Page) !?js.Function.Global { } pub fn setOnFullscreenChange(self: *HtmlElement, callback: js.Function.Global, page: *Page) !void { - return page.setAttrListener(self.asElement(), .onfullscreenchange, callback); + return self.setAttributeListener(.onfullscreenchange, callback, page); } pub fn getOnFullscreenChange(self: *HtmlElement, page: *Page) !?js.Function.Global { @@ -722,7 +747,7 @@ pub fn getOnFullscreenChange(self: *HtmlElement, page: *Page) !?js.Function.Glob } pub fn setOnFullscreenError(self: *HtmlElement, callback: js.Function.Global, page: *Page) !void { - return page.setAttrListener(self.asElement(), .onfullscreenerror, callback); + return self.setAttributeListener(.onfullscreenerror, callback, page); } pub fn getOnFullscreenError(self: *HtmlElement, page: *Page) !?js.Function.Global { @@ -730,7 +755,7 @@ pub fn getOnFullscreenError(self: *HtmlElement, page: *Page) !?js.Function.Globa } pub fn setOnGotPointerCapture(self: *HtmlElement, callback: js.Function.Global, page: *Page) !void { - return page.setAttrListener(self.asElement(), .ongotpointercapture, callback); + return self.setAttributeListener(.ongotpointercapture, callback, page); } pub fn getOnGotPointerCapture(self: *HtmlElement, page: *Page) !?js.Function.Global { @@ -738,7 +763,7 @@ pub fn getOnGotPointerCapture(self: *HtmlElement, page: *Page) !?js.Function.Glo } pub fn setOnInput(self: *HtmlElement, callback: js.Function.Global, page: *Page) !void { - return page.setAttrListener(self.asElement(), .oninput, callback); + return self.setAttributeListener(.oninput, callback, page); } pub fn getOnInput(self: *HtmlElement, page: *Page) !?js.Function.Global { @@ -746,7 +771,7 @@ pub fn getOnInput(self: *HtmlElement, page: *Page) !?js.Function.Global { } pub fn setOnInvalid(self: *HtmlElement, callback: js.Function.Global, page: *Page) !void { - return page.setAttrListener(self.asElement(), .oninvalid, callback); + return self.setAttributeListener(.oninvalid, callback, page); } pub fn getOnInvalid(self: *HtmlElement, page: *Page) !?js.Function.Global { @@ -754,7 +779,7 @@ pub fn getOnInvalid(self: *HtmlElement, page: *Page) !?js.Function.Global { } pub fn setOnKeyDown(self: *HtmlElement, callback: js.Function.Global, page: *Page) !void { - return page.setAttrListener(self.asElement(), .onkeydown, callback); + return self.setAttributeListener(.onkeydown, callback, page); } pub fn getOnKeyDown(self: *HtmlElement, page: *Page) !?js.Function.Global { @@ -762,7 +787,7 @@ pub fn getOnKeyDown(self: *HtmlElement, page: *Page) !?js.Function.Global { } pub fn setOnKeyPress(self: *HtmlElement, callback: js.Function.Global, page: *Page) !void { - return page.setAttrListener(self.asElement(), .onkeypress, callback); + return self.setAttributeListener(.onkeypress, callback, page); } pub fn getOnKeyPress(self: *HtmlElement, page: *Page) !?js.Function.Global { @@ -770,7 +795,7 @@ pub fn getOnKeyPress(self: *HtmlElement, page: *Page) !?js.Function.Global { } pub fn setOnKeyUp(self: *HtmlElement, callback: js.Function.Global, page: *Page) !void { - return page.setAttrListener(self.asElement(), .onkeyup, callback); + return self.setAttributeListener(.onkeyup, callback, page); } pub fn getOnKeyUp(self: *HtmlElement, page: *Page) !?js.Function.Global { @@ -778,7 +803,7 @@ pub fn getOnKeyUp(self: *HtmlElement, page: *Page) !?js.Function.Global { } pub fn setOnLoad(self: *HtmlElement, callback: js.Function.Global, page: *Page) !void { - return page.setAttrListener(self.asElement(), .onload, callback); + return self.setAttributeListener(.onload, callback, page); } pub fn getOnLoad(self: *HtmlElement, page: *Page) !?js.Function.Global { @@ -786,7 +811,7 @@ pub fn getOnLoad(self: *HtmlElement, page: *Page) !?js.Function.Global { } pub fn setOnLoadedData(self: *HtmlElement, callback: js.Function.Global, page: *Page) !void { - return page.setAttrListener(self.asElement(), .onloadeddata, callback); + return self.setAttributeListener(.onloadeddata, callback, page); } pub fn getOnLoadedData(self: *HtmlElement, page: *Page) !?js.Function.Global { @@ -794,7 +819,7 @@ pub fn getOnLoadedData(self: *HtmlElement, page: *Page) !?js.Function.Global { } pub fn setOnLoadedMetadata(self: *HtmlElement, callback: js.Function.Global, page: *Page) !void { - return page.setAttrListener(self.asElement(), .onloadedmetadata, callback); + return self.setAttributeListener(.onloadedmetadata, callback, page); } pub fn getOnLoadedMetadata(self: *HtmlElement, page: *Page) !?js.Function.Global { @@ -802,7 +827,7 @@ pub fn getOnLoadedMetadata(self: *HtmlElement, page: *Page) !?js.Function.Global } pub fn setOnLoadStart(self: *HtmlElement, callback: js.Function.Global, page: *Page) !void { - return page.setAttrListener(self.asElement(), .onloadstart, callback); + return self.setAttributeListener(.onloadstart, callback, page); } pub fn getOnLoadStart(self: *HtmlElement, page: *Page) !?js.Function.Global { @@ -810,7 +835,7 @@ pub fn getOnLoadStart(self: *HtmlElement, page: *Page) !?js.Function.Global { } pub fn setOnLostPointerCapture(self: *HtmlElement, callback: js.Function.Global, page: *Page) !void { - return page.setAttrListener(self.asElement(), .onlostpointercapture, callback); + return self.setAttributeListener(.onlostpointercapture, callback, page); } pub fn getOnLostPointerCapture(self: *HtmlElement, page: *Page) !?js.Function.Global { @@ -818,7 +843,7 @@ pub fn getOnLostPointerCapture(self: *HtmlElement, page: *Page) !?js.Function.Gl } pub fn setOnMouseDown(self: *HtmlElement, callback: js.Function.Global, page: *Page) !void { - return page.setAttrListener(self.asElement(), .onmousedown, callback); + return self.setAttributeListener(.onmousedown, callback, page); } pub fn getOnMouseDown(self: *HtmlElement, page: *Page) !?js.Function.Global { @@ -826,7 +851,7 @@ pub fn getOnMouseDown(self: *HtmlElement, page: *Page) !?js.Function.Global { } pub fn setOnMouseMove(self: *HtmlElement, callback: js.Function.Global, page: *Page) !void { - return page.setAttrListener(self.asElement(), .onmousemove, callback); + return self.setAttributeListener(.onmousemove, callback, page); } pub fn getOnMouseMove(self: *HtmlElement, page: *Page) !?js.Function.Global { @@ -834,7 +859,7 @@ pub fn getOnMouseMove(self: *HtmlElement, page: *Page) !?js.Function.Global { } pub fn setOnMouseOut(self: *HtmlElement, callback: js.Function.Global, page: *Page) !void { - return page.setAttrListener(self.asElement(), .onmouseout, callback); + return self.setAttributeListener(.onmouseout, callback, page); } pub fn getOnMouseOut(self: *HtmlElement, page: *Page) !?js.Function.Global { @@ -842,7 +867,7 @@ pub fn getOnMouseOut(self: *HtmlElement, page: *Page) !?js.Function.Global { } pub fn setOnMouseOver(self: *HtmlElement, callback: js.Function.Global, page: *Page) !void { - return page.setAttrListener(self.asElement(), .onmouseover, callback); + return self.setAttributeListener(.onmouseover, callback, page); } pub fn getOnMouseOver(self: *HtmlElement, page: *Page) !?js.Function.Global { @@ -850,7 +875,7 @@ pub fn getOnMouseOver(self: *HtmlElement, page: *Page) !?js.Function.Global { } pub fn setOnMouseUp(self: *HtmlElement, callback: js.Function.Global, page: *Page) !void { - return page.setAttrListener(self.asElement(), .onmouseup, callback); + return self.setAttributeListener(.onmouseup, callback, page); } pub fn getOnMouseUp(self: *HtmlElement, page: *Page) !?js.Function.Global { @@ -858,7 +883,7 @@ pub fn getOnMouseUp(self: *HtmlElement, page: *Page) !?js.Function.Global { } pub fn setOnPaste(self: *HtmlElement, callback: js.Function.Global, page: *Page) !void { - return page.setAttrListener(self.asElement(), .onpaste, callback); + return self.setAttributeListener(.onpaste, callback, page); } pub fn getOnPaste(self: *HtmlElement, page: *Page) !?js.Function.Global { @@ -866,7 +891,7 @@ pub fn getOnPaste(self: *HtmlElement, page: *Page) !?js.Function.Global { } pub fn setOnPause(self: *HtmlElement, callback: js.Function.Global, page: *Page) !void { - return page.setAttrListener(self.asElement(), .onpause, callback); + return self.setAttributeListener(.onpause, callback, page); } pub fn getOnPause(self: *HtmlElement, page: *Page) !?js.Function.Global { @@ -874,7 +899,7 @@ pub fn getOnPause(self: *HtmlElement, page: *Page) !?js.Function.Global { } pub fn setOnPlay(self: *HtmlElement, callback: js.Function.Global, page: *Page) !void { - return page.setAttrListener(self.asElement(), .onplay, callback); + return self.setAttributeListener(.onplay, callback, page); } pub fn getOnPlay(self: *HtmlElement, page: *Page) !?js.Function.Global { @@ -882,7 +907,7 @@ pub fn getOnPlay(self: *HtmlElement, page: *Page) !?js.Function.Global { } pub fn setOnPlaying(self: *HtmlElement, callback: js.Function.Global, page: *Page) !void { - return page.setAttrListener(self.asElement(), .onplaying, callback); + return self.setAttributeListener(.onplaying, callback, page); } pub fn getOnPlaying(self: *HtmlElement, page: *Page) !?js.Function.Global { @@ -890,7 +915,7 @@ pub fn getOnPlaying(self: *HtmlElement, page: *Page) !?js.Function.Global { } pub fn setOnPointerCancel(self: *HtmlElement, callback: js.Function.Global, page: *Page) !void { - return page.setAttrListener(self.asElement(), .onpointercancel, callback); + return self.setAttributeListener(.onpointercancel, callback, page); } pub fn getOnPointerCancel(self: *HtmlElement, page: *Page) !?js.Function.Global { @@ -898,7 +923,7 @@ pub fn getOnPointerCancel(self: *HtmlElement, page: *Page) !?js.Function.Global } pub fn setOnPointerDown(self: *HtmlElement, callback: js.Function.Global, page: *Page) !void { - return page.setAttrListener(self.asElement(), .onpointerdown, callback); + return self.setAttributeListener(.onpointerdown, callback, page); } pub fn getOnPointerDown(self: *HtmlElement, page: *Page) !?js.Function.Global { @@ -906,7 +931,7 @@ pub fn getOnPointerDown(self: *HtmlElement, page: *Page) !?js.Function.Global { } pub fn setOnPointerEnter(self: *HtmlElement, callback: js.Function.Global, page: *Page) !void { - return page.setAttrListener(self.asElement(), .onpointerenter, callback); + return self.setAttributeListener(.onpointerenter, callback, page); } pub fn getOnPointerEnter(self: *HtmlElement, page: *Page) !?js.Function.Global { @@ -914,7 +939,7 @@ pub fn getOnPointerEnter(self: *HtmlElement, page: *Page) !?js.Function.Global { } pub fn setOnPointerLeave(self: *HtmlElement, callback: js.Function.Global, page: *Page) !void { - return page.setAttrListener(self.asElement(), .onpointerleave, callback); + return self.setAttributeListener(.onpointerleave, callback, page); } pub fn getOnPointerLeave(self: *HtmlElement, page: *Page) !?js.Function.Global { @@ -922,7 +947,7 @@ pub fn getOnPointerLeave(self: *HtmlElement, page: *Page) !?js.Function.Global { } pub fn setOnPointerMove(self: *HtmlElement, callback: js.Function.Global, page: *Page) !void { - return page.setAttrListener(self.asElement(), .onpointermove, callback); + return self.setAttributeListener(.onpointermove, callback, page); } pub fn getOnPointerMove(self: *HtmlElement, page: *Page) !?js.Function.Global { @@ -930,7 +955,7 @@ pub fn getOnPointerMove(self: *HtmlElement, page: *Page) !?js.Function.Global { } pub fn setOnPointerOut(self: *HtmlElement, callback: js.Function.Global, page: *Page) !void { - return page.setAttrListener(self.asElement(), .onpointerout, callback); + return self.setAttributeListener(.onpointerout, callback, page); } pub fn getOnPointerOut(self: *HtmlElement, page: *Page) !?js.Function.Global { @@ -938,7 +963,7 @@ pub fn getOnPointerOut(self: *HtmlElement, page: *Page) !?js.Function.Global { } pub fn setOnPointerOver(self: *HtmlElement, callback: js.Function.Global, page: *Page) !void { - return page.setAttrListener(self.asElement(), .onpointerover, callback); + return self.setAttributeListener(.onpointerover, callback, page); } pub fn getOnPointerOver(self: *HtmlElement, page: *Page) !?js.Function.Global { @@ -946,7 +971,7 @@ pub fn getOnPointerOver(self: *HtmlElement, page: *Page) !?js.Function.Global { } pub fn setOnPointerRawUpdate(self: *HtmlElement, callback: js.Function.Global, page: *Page) !void { - return page.setAttrListener(self.asElement(), .onpointerrawupdate, callback); + return self.setAttributeListener(.onpointerrawupdate, callback, page); } pub fn getOnPointerRawUpdate(self: *HtmlElement, page: *Page) !?js.Function.Global { @@ -954,7 +979,7 @@ pub fn getOnPointerRawUpdate(self: *HtmlElement, page: *Page) !?js.Function.Glob } pub fn setOnPointerUp(self: *HtmlElement, callback: js.Function.Global, page: *Page) !void { - return page.setAttrListener(self.asElement(), .onpointerup, callback); + return self.setAttributeListener(.onpointerup, callback, page); } pub fn getOnPointerUp(self: *HtmlElement, page: *Page) !?js.Function.Global { @@ -962,7 +987,7 @@ pub fn getOnPointerUp(self: *HtmlElement, page: *Page) !?js.Function.Global { } pub fn setOnProgress(self: *HtmlElement, callback: js.Function.Global, page: *Page) !void { - return page.setAttrListener(self.asElement(), .onprogress, callback); + return self.setAttributeListener(.onprogress, callback, page); } pub fn getOnProgress(self: *HtmlElement, page: *Page) !?js.Function.Global { @@ -970,7 +995,7 @@ pub fn getOnProgress(self: *HtmlElement, page: *Page) !?js.Function.Global { } pub fn setOnRateChange(self: *HtmlElement, callback: js.Function.Global, page: *Page) !void { - return page.setAttrListener(self.asElement(), .onratechange, callback); + return self.setAttributeListener(.onratechange, callback, page); } pub fn getOnRateChange(self: *HtmlElement, page: *Page) !?js.Function.Global { @@ -978,7 +1003,7 @@ pub fn getOnRateChange(self: *HtmlElement, page: *Page) !?js.Function.Global { } pub fn setOnReset(self: *HtmlElement, callback: js.Function.Global, page: *Page) !void { - return page.setAttrListener(self.asElement(), .onreset, callback); + return self.setAttributeListener(.onreset, callback, page); } pub fn getOnReset(self: *HtmlElement, page: *Page) !?js.Function.Global { @@ -986,7 +1011,7 @@ pub fn getOnReset(self: *HtmlElement, page: *Page) !?js.Function.Global { } pub fn setOnResize(self: *HtmlElement, callback: js.Function.Global, page: *Page) !void { - return page.setAttrListener(self.asElement(), .onresize, callback); + return self.setAttributeListener(.onresize, callback, page); } pub fn getOnResize(self: *HtmlElement, page: *Page) !?js.Function.Global { @@ -994,7 +1019,7 @@ pub fn getOnResize(self: *HtmlElement, page: *Page) !?js.Function.Global { } pub fn setOnScroll(self: *HtmlElement, callback: js.Function.Global, page: *Page) !void { - return page.setAttrListener(self.asElement(), .onscroll, callback); + return self.setAttributeListener(.onscroll, callback, page); } pub fn getOnScroll(self: *HtmlElement, page: *Page) !?js.Function.Global { @@ -1002,7 +1027,7 @@ pub fn getOnScroll(self: *HtmlElement, page: *Page) !?js.Function.Global { } pub fn setOnScrollEnd(self: *HtmlElement, callback: js.Function.Global, page: *Page) !void { - return page.setAttrListener(self.asElement(), .onscrollend, callback); + return self.setAttributeListener(.onscrollend, callback, page); } pub fn getOnScrollEnd(self: *HtmlElement, page: *Page) !?js.Function.Global { @@ -1010,7 +1035,7 @@ pub fn getOnScrollEnd(self: *HtmlElement, page: *Page) !?js.Function.Global { } pub fn setOnSecurityPolicyViolation(self: *HtmlElement, callback: js.Function.Global, page: *Page) !void { - return page.setAttrListener(self.asElement(), .onsecuritypolicyviolation, callback); + return self.setAttributeListener(.onsecuritypolicyviolation, callback, page); } pub fn getOnSecurityPolicyViolation(self: *HtmlElement, page: *Page) !?js.Function.Global { @@ -1018,7 +1043,7 @@ pub fn getOnSecurityPolicyViolation(self: *HtmlElement, page: *Page) !?js.Functi } pub fn setOnSeeked(self: *HtmlElement, callback: js.Function.Global, page: *Page) !void { - return page.setAttrListener(self.asElement(), .onseeked, callback); + return self.setAttributeListener(.onseeked, callback, page); } pub fn getOnSeeked(self: *HtmlElement, page: *Page) !?js.Function.Global { @@ -1026,7 +1051,7 @@ pub fn getOnSeeked(self: *HtmlElement, page: *Page) !?js.Function.Global { } pub fn setOnSeeking(self: *HtmlElement, callback: js.Function.Global, page: *Page) !void { - return page.setAttrListener(self.asElement(), .onseeking, callback); + return self.setAttributeListener(.onseeking, callback, page); } pub fn getOnSeeking(self: *HtmlElement, page: *Page) !?js.Function.Global { @@ -1034,7 +1059,7 @@ pub fn getOnSeeking(self: *HtmlElement, page: *Page) !?js.Function.Global { } pub fn setOnSelect(self: *HtmlElement, callback: js.Function.Global, page: *Page) !void { - return page.setAttrListener(self.asElement(), .onselect, callback); + return self.setAttributeListener(.onselect, callback, page); } pub fn getOnSelect(self: *HtmlElement, page: *Page) !?js.Function.Global { @@ -1042,7 +1067,7 @@ pub fn getOnSelect(self: *HtmlElement, page: *Page) !?js.Function.Global { } pub fn setOnSelectionChange(self: *HtmlElement, callback: js.Function.Global, page: *Page) !void { - return page.setAttrListener(self.asElement(), .onselectionchange, callback); + return self.setAttributeListener(.onselectionchange, callback, page); } pub fn getOnSelectionChange(self: *HtmlElement, page: *Page) !?js.Function.Global { @@ -1050,7 +1075,7 @@ pub fn getOnSelectionChange(self: *HtmlElement, page: *Page) !?js.Function.Globa } pub fn setOnSelectStart(self: *HtmlElement, callback: js.Function.Global, page: *Page) !void { - return page.setAttrListener(self.asElement(), .onselectstart, callback); + return self.setAttributeListener(.onselectstart, callback, page); } pub fn getOnSelectStart(self: *HtmlElement, page: *Page) !?js.Function.Global { @@ -1058,7 +1083,7 @@ pub fn getOnSelectStart(self: *HtmlElement, page: *Page) !?js.Function.Global { } pub fn setOnSlotChange(self: *HtmlElement, callback: js.Function.Global, page: *Page) !void { - return page.setAttrListener(self.asElement(), .onslotchange, callback); + return self.setAttributeListener(.onslotchange, callback, page); } pub fn getOnSlotChange(self: *HtmlElement, page: *Page) !?js.Function.Global { @@ -1066,7 +1091,7 @@ pub fn getOnSlotChange(self: *HtmlElement, page: *Page) !?js.Function.Global { } pub fn setOnStalled(self: *HtmlElement, callback: js.Function.Global, page: *Page) !void { - return page.setAttrListener(self.asElement(), .onstalled, callback); + return self.setAttributeListener(.onstalled, callback, page); } pub fn getOnStalled(self: *HtmlElement, page: *Page) !?js.Function.Global { @@ -1074,7 +1099,7 @@ pub fn getOnStalled(self: *HtmlElement, page: *Page) !?js.Function.Global { } pub fn setOnSubmit(self: *HtmlElement, callback: js.Function.Global, page: *Page) !void { - return page.setAttrListener(self.asElement(), .onsubmit, callback); + return self.setAttributeListener(.onsubmit, callback, page); } pub fn getOnSubmit(self: *HtmlElement, page: *Page) !?js.Function.Global { @@ -1082,7 +1107,7 @@ pub fn getOnSubmit(self: *HtmlElement, page: *Page) !?js.Function.Global { } pub fn setOnSuspend(self: *HtmlElement, callback: js.Function.Global, page: *Page) !void { - return page.setAttrListener(self.asElement(), .onsuspend, callback); + return self.setAttributeListener(.onsuspend, callback, page); } pub fn getOnSuspend(self: *HtmlElement, page: *Page) !?js.Function.Global { @@ -1090,7 +1115,7 @@ pub fn getOnSuspend(self: *HtmlElement, page: *Page) !?js.Function.Global { } pub fn setOnTimeUpdate(self: *HtmlElement, callback: js.Function.Global, page: *Page) !void { - return page.setAttrListener(self.asElement(), .ontimeupdate, callback); + return self.setAttributeListener(.ontimeupdate, callback, page); } pub fn getOnTimeUpdate(self: *HtmlElement, page: *Page) !?js.Function.Global { @@ -1098,7 +1123,7 @@ pub fn getOnTimeUpdate(self: *HtmlElement, page: *Page) !?js.Function.Global { } pub fn setOnToggle(self: *HtmlElement, callback: js.Function.Global, page: *Page) !void { - return page.setAttrListener(self.asElement(), .ontoggle, callback); + return self.setAttributeListener(.ontoggle, callback, page); } pub fn getOnToggle(self: *HtmlElement, page: *Page) !?js.Function.Global { @@ -1106,7 +1131,7 @@ pub fn getOnToggle(self: *HtmlElement, page: *Page) !?js.Function.Global { } pub fn setOnTransitionCancel(self: *HtmlElement, callback: js.Function.Global, page: *Page) !void { - return page.setAttrListener(self.asElement(), .ontransitioncancel, callback); + return self.setAttributeListener(.ontransitioncancel, callback, page); } pub fn getOnTransitionCancel(self: *HtmlElement, page: *Page) !?js.Function.Global { @@ -1114,7 +1139,7 @@ pub fn getOnTransitionCancel(self: *HtmlElement, page: *Page) !?js.Function.Glob } pub fn setOnTransitionEnd(self: *HtmlElement, callback: js.Function.Global, page: *Page) !void { - return page.setAttrListener(self.asElement(), .ontransitionend, callback); + return self.setAttributeListener(.ontransitionend, callback, page); } pub fn getOnTransitionEnd(self: *HtmlElement, page: *Page) !?js.Function.Global { @@ -1122,7 +1147,7 @@ pub fn getOnTransitionEnd(self: *HtmlElement, page: *Page) !?js.Function.Global } pub fn setOnTransitionRun(self: *HtmlElement, callback: js.Function.Global, page: *Page) !void { - return page.setAttrListener(self.asElement(), .ontransitionrun, callback); + return self.setAttributeListener(.ontransitionrun, callback, page); } pub fn getOnTransitionRun(self: *HtmlElement, page: *Page) !?js.Function.Global { @@ -1130,7 +1155,7 @@ pub fn getOnTransitionRun(self: *HtmlElement, page: *Page) !?js.Function.Global } pub fn setOnTransitionStart(self: *HtmlElement, callback: js.Function.Global, page: *Page) !void { - return page.setAttrListener(self.asElement(), .ontransitionstart, callback); + return self.setAttributeListener(.ontransitionstart, callback, page); } pub fn getOnTransitionStart(self: *HtmlElement, page: *Page) !?js.Function.Global { @@ -1138,7 +1163,7 @@ pub fn getOnTransitionStart(self: *HtmlElement, page: *Page) !?js.Function.Globa } pub fn setOnVolumeChange(self: *HtmlElement, callback: js.Function.Global, page: *Page) !void { - return page.setAttrListener(self.asElement(), .onvolumechange, callback); + return self.setAttributeListener(.onvolumechange, callback, page); } pub fn getOnVolumeChange(self: *HtmlElement, page: *Page) !?js.Function.Global { @@ -1146,7 +1171,7 @@ pub fn getOnVolumeChange(self: *HtmlElement, page: *Page) !?js.Function.Global { } pub fn setOnWaiting(self: *HtmlElement, callback: js.Function.Global, page: *Page) !void { - return page.setAttrListener(self.asElement(), .onwaiting, callback); + return self.setAttributeListener(.onwaiting, callback, page); } pub fn getOnWaiting(self: *HtmlElement, page: *Page) !?js.Function.Global { @@ -1154,7 +1179,7 @@ pub fn getOnWaiting(self: *HtmlElement, page: *Page) !?js.Function.Global { } pub fn setOnWheel(self: *HtmlElement, callback: js.Function.Global, page: *Page) !void { - return page.setAttrListener(self.asElement(), .onwheel, callback); + return self.setAttributeListener(.onwheel, callback, page); } pub fn getOnWheel(self: *HtmlElement, page: *Page) !?js.Function.Global { diff --git a/src/browser/webapi/element/html/Image.zig b/src/browser/webapi/element/html/Image.zig index d982cdbf..76a56ac8 100644 --- a/src/browser/webapi/element/html/Image.zig +++ b/src/browser/webapi/element/html/Image.zig @@ -151,7 +151,7 @@ pub const Build = struct { _ = 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, image); + return page._to_load.append(page.arena, self._proto); } }; diff --git a/src/browser/webapi/element/html/Style.zig b/src/browser/webapi/element/html/Style.zig index a46f1dde..3dbb288b 100644 --- a/src/browser/webapi/element/html/Style.zig +++ b/src/browser/webapi/element/html/Style.zig @@ -115,10 +115,8 @@ pub const JsApi = struct { pub const Build = struct { pub fn created(node: *Node, page: *Page) !void { - const self = node.as(Style); - const style = self.asElement(); // Push to `_to_load` to dispatch load event just before window load event. - return page._to_load.append(page.arena, style); + return page._to_load.append(page.arena, node.as(Element.Html)); } }; From ea5d7c0dee25ae4dbfcca82fce660d5236a5f80b Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Mon, 23 Feb 2026 17:28:14 +0800 Subject: [PATCH 020/103] Use EventManager.dispatch for Script events Should be updated and merged after: https://github.com/lightpanda-io/browser/pull/1623 else we'll have a double-free. The ScriptManager used to directly call the "onload" and "onerror" attributes. The implementation predates EventManager.dispatch support attribute-based callbacks. But now the EventManager is attribute-aware and correctly times the attribute dispatch AND details such as cancellation. So this commit moves the old attribute-only ScriptManager-specific callback to the EventManager. With one little wrinkle: 'load' listeners added during a script's execution should NOT receive a 'load' event when the script finishes. This makes no sense to me. The EventManager now maintains an ignore_list for "load" events which is reset after each script execution. A comptime flag is passed to dispatch to indicate whether the ignore list should be checked. This is only ever set when the ScriptManager dispatches the 'load' event, so there's no overhead to dispatch for most events. --- src/browser/EventManager.zig | 69 +++++++++++++++---- src/browser/ScriptManager.zig | 30 ++++---- .../tests/element/html/script/empty.js | 0 .../tests/element/html/script/script.html | 27 ++++++-- src/browser/webapi/element/html/Script.zig | 38 +--------- 5 files changed, 94 insertions(+), 70 deletions(-) create mode 100644 src/browser/tests/element/html/script/empty.js diff --git a/src/browser/EventManager.zig b/src/browser/EventManager.zig index 42de7d55..53e2c6ad 100644 --- a/src/browser/EventManager.zig +++ b/src/browser/EventManager.zig @@ -61,6 +61,7 @@ arena: Allocator, // 'load' event (e.g. amazon product page has no listener and ~350 resources) has_dom_load_listener: bool, listener_pool: std.heap.MemoryPool(Listener), +ignore_list: std.ArrayList(*Listener), list_pool: std.heap.MemoryPool(std.DoublyLinkedList), lookup: std.HashMapUnmanaged( EventKey, @@ -76,6 +77,7 @@ pub fn init(arena: Allocator, page: *Page) EventManager { .page = page, .lookup = .{}, .arena = arena, + .ignore_list = .{}, .list_pool = .init(arena), .listener_pool = .init(arena), .dispatch_depth = 0, @@ -155,6 +157,11 @@ pub fn register(self: *EventManager, target: *EventTarget, typ: []const u8, call }; // append the listener to the list of listeners for this target gop.value_ptr.*.append(&listener.node); + + // Track load listeners for script execution ignore list + if (type_string.eql(comptime .wrap("load"))) { + try self.ignore_list.append(self.arena, listener); + } } pub fn remove(self: *EventManager, target: *EventTarget, typ: []const u8, callback: Callback, use_capture: bool) void { @@ -167,6 +174,10 @@ pub fn remove(self: *EventManager, target: *EventTarget, typ: []const u8, callba } } +pub fn clearIgnoreList(self: *EventManager) void { + self.ignore_list.clearRetainingCapacity(); +} + // Dispatching can be recursive from the compiler's point of view, so we need to // give it an explicit error set so that other parts of the code can use and // inferred error. @@ -178,7 +189,21 @@ const DispatchError = error{ ExecutionError, JsException, }; + +pub const DispatchOpts = struct { + // A "load" event triggered by a script (in ScriptManager) should not trigger + // a "load" listener added within that script. Therefore, any "load" listener + // that we add go into an ignore list until after the script finishes executing. + // The ignore list is only checked when apply_ignore == true, which is only + // set by the ScriptManager when raising the script's "load" event. + apply_ignore: bool = false, +}; + pub fn dispatch(self: *EventManager, target: *EventTarget, event: *Event) DispatchError!void { + return self.dispatchOpts(target, event, .{}); +} + +pub fn dispatchOpts(self: *EventManager, target: *EventTarget, event: *Event, comptime opts: DispatchOpts) DispatchError!void { defer if (!event._v8_handoff) event.deinit(false, self.page); if (comptime IS_DEBUG) { @@ -197,7 +222,7 @@ pub fn dispatch(self: *EventManager, target: *EventTarget, event: *Event) Dispat }; switch (target._type) { - .node => |node| try self.dispatchNode(node, event, &was_handled), + .node => |node| try self.dispatchNode(node, event, &was_handled, opts), .xhr, .window, .abort_signal, @@ -214,7 +239,7 @@ pub fn dispatch(self: *EventManager, target: *EventTarget, event: *Event) Dispat .event_target = @intFromPtr(target), .type_string = event._type_string, }) orelse return; - try self.dispatchAll(list, target, event, &was_handled); + try self.dispatchAll(list, target, event, &was_handled, opts); }, } } @@ -262,10 +287,10 @@ pub fn dispatchWithFunction(self: *EventManager, target: *EventTarget, event: *E .event_target = @intFromPtr(target), .type_string = event._type_string, }) orelse return; - try self.dispatchAll(list, target, event, &was_dispatched); + try self.dispatchAll(list, target, event, &was_dispatched, .{}); } -fn dispatchNode(self: *EventManager, target: *Node, event: *Event, was_handled: *bool) !void { +fn dispatchNode(self: *EventManager, target: *Node, event: *Event, was_handled: *bool, comptime opts: DispatchOpts) !void { const ShadowRoot = @import("webapi/ShadowRoot.zig"); const page = self.page; @@ -346,7 +371,7 @@ fn dispatchNode(self: *EventManager, target: *Node, event: *Event, was_handled: .event_target = @intFromPtr(current_target), .type_string = event._type_string, })) |list| { - try self.dispatchPhase(list, current_target, event, was_handled, true); + try self.dispatchPhase(list, current_target, event, was_handled, comptime .init(true, opts)); } } @@ -380,7 +405,7 @@ fn dispatchNode(self: *EventManager, target: *Node, event: *Event, was_handled: .type_string = event._type_string, .event_target = @intFromPtr(target_et), })) |list| { - try self.dispatchPhase(list, target_et, event, was_handled, null); + try self.dispatchPhase(list, target_et, event, was_handled, comptime .init(null, opts)); if (event._stop_propagation) { return; } @@ -397,13 +422,25 @@ fn dispatchNode(self: *EventManager, target: *Node, event: *Event, was_handled: .type_string = event._type_string, .event_target = @intFromPtr(current_target), })) |list| { - try self.dispatchPhase(list, current_target, event, was_handled, false); + try self.dispatchPhase(list, current_target, event, was_handled, comptime .init(false, opts)); } } } } -fn dispatchPhase(self: *EventManager, list: *std.DoublyLinkedList, current_target: *EventTarget, event: *Event, was_handled: *bool, comptime capture_only: ?bool) !void { +const DispatchPhaseOpts = struct { + capture_only: ?bool = null, + apply_ignore: bool = false, + + fn init(capture_only: ?bool, opts: DispatchOpts) DispatchPhaseOpts { + return .{ + .capture_only = capture_only, + .apply_ignore = opts.apply_ignore, + }; + } +}; + +fn dispatchPhase(self: *EventManager, list: *std.DoublyLinkedList, current_target: *EventTarget, event: *Event, was_handled: *bool, comptime opts: DispatchPhaseOpts) !void { const page = self.page; // Track dispatch depth for deferred removal @@ -429,7 +466,7 @@ fn dispatchPhase(self: *EventManager, list: *std.DoublyLinkedList, current_targe // Iterate through the list, stopping after we've encountered the last_listener var node = list.first; var is_done = false; - while (node) |n| { + node_loop: while (node) |n| { if (is_done) { break; } @@ -439,7 +476,7 @@ fn dispatchPhase(self: *EventManager, list: *std.DoublyLinkedList, current_targe node = n.next; // Skip non-matching listeners - if (comptime capture_only) |capture| { + if (comptime opts.capture_only) |capture| { if (listener.capture != capture) { continue; } @@ -458,6 +495,14 @@ fn dispatchPhase(self: *EventManager, list: *std.DoublyLinkedList, current_targe } } + if (comptime opts.apply_ignore) { + for (self.ignore_list.items) |ignored| { + if (ignored == listener) { + continue :node_loop; + } + } + } + // Remove "once" listeners BEFORE calling them so nested dispatches don't see them if (listener.once) { self.removeListener(list, listener); @@ -502,8 +547,8 @@ fn dispatchPhase(self: *EventManager, list: *std.DoublyLinkedList, current_targe } // Non-Node dispatching (XHR, Window without propagation) -fn dispatchAll(self: *EventManager, list: *std.DoublyLinkedList, current_target: *EventTarget, event: *Event, was_handled: *bool) !void { - return self.dispatchPhase(list, current_target, event, was_handled, null); +fn dispatchAll(self: *EventManager, list: *std.DoublyLinkedList, current_target: *EventTarget, event: *Event, was_handled: *bool, comptime opts: DispatchOpts) !void { + return self.dispatchPhase(list, current_target, event, was_handled, comptime .init(null, opts)); } fn getInlineHandler(self: *EventManager, target: *EventTarget, event: *Event) ?js.Function.Global { diff --git a/src/browser/ScriptManager.zig b/src/browser/ScriptManager.zig index f4d95230..b536e2db 100644 --- a/src/browser/ScriptManager.zig +++ b/src/browser/ScriptManager.zig @@ -20,13 +20,14 @@ const std = @import("std"); const lp = @import("lightpanda"); const builtin = @import("builtin"); -const js = @import("js/js.zig"); const log = @import("../log.zig"); +const Http = @import("../http/Http.zig"); +const String = @import("../string.zig").String; +const js = @import("js/js.zig"); const URL = @import("URL.zig"); const Page = @import("Page.zig"); const Browser = @import("Browser.zig"); -const Http = @import("../http/Http.zig"); const Element = @import("webapi/Element.zig"); @@ -830,13 +831,15 @@ pub const Script = struct { .kind = self.kind, .cacheable = cacheable, }); - self.executeCallback("error", local.toLocal(script_element._on_error), page); + self.executeCallback(comptime .wrap("error"), page); return; }; - self.executeCallback("load", local.toLocal(script_element._on_load), page); + self.executeCallback(comptime .wrap("load"), page); return; } + defer page._event_manager.clearIgnoreList(); + var try_catch: js.TryCatch = undefined; try_catch.init(local); defer try_catch.deinit(); @@ -855,7 +858,7 @@ pub const Script = struct { }; if (comptime IS_DEBUG) { - log.debug(.browser, "executed script", .{ .src = url, .success = success, .on_load = script_element._on_load != null }); + log.debug(.browser, "executed script", .{ .src = url, .success = success }); } defer { @@ -867,7 +870,7 @@ pub const Script = struct { } if (success) { - self.executeCallback("load", local.toLocal(script_element._on_load), page); + self.executeCallback(comptime .wrap("load"), page); return; } @@ -878,14 +881,12 @@ pub const Script = struct { .cacheable = cacheable, }); - self.executeCallback("error", local.toLocal(script_element._on_error), page); + self.executeCallback(comptime .wrap("error"), page); } - fn executeCallback(self: *const Script, comptime typ: []const u8, cb_: ?js.Function, page: *Page) void { - const cb = cb_ orelse return; - + fn executeCallback(self: *const Script, typ: String, page: *Page) void { const Event = @import("webapi/Event.zig"); - const event = Event.initTrusted(comptime .wrap(typ), .{}, page) catch |err| { + const event = Event.initTrusted(typ, .{}, page) catch |err| { log.warn(.js, "script internal callback", .{ .url = self.url, .type = typ, @@ -893,14 +894,11 @@ pub const Script = struct { }); return; }; - defer if (!event._v8_handoff) event.deinit(false, self.manager.page); - - var caught: js.TryCatch.Caught = undefined; - cb.tryCall(void, .{event}, &caught) catch { + page._event_manager.dispatchOpts(self.script_element.?.asNode().asEventTarget(), event, .{ .apply_ignore = true }) catch |err| { log.warn(.js, "script callback", .{ .url = self.url, .type = typ, - .caught = caught, + .err = err, }); }; } diff --git a/src/browser/tests/element/html/script/empty.js b/src/browser/tests/element/html/script/empty.js new file mode 100644 index 00000000..e69de29b diff --git a/src/browser/tests/element/html/script/script.html b/src/browser/tests/element/html/script/script.html index 3629cf1a..75547d40 100644 --- a/src/browser/tests/element/html/script/script.html +++ b/src/browser/tests/element/html/script/script.html @@ -2,11 +2,28 @@ diff --git a/src/browser/webapi/element/html/Script.zig b/src/browser/webapi/element/html/Script.zig index 92b37199..41899bb4 100644 --- a/src/browser/webapi/element/html/Script.zig +++ b/src/browser/webapi/element/html/Script.zig @@ -30,8 +30,6 @@ const Script = @This(); _proto: *HtmlElement, _src: []const u8 = "", -_on_load: ?js.Function.Global = null, -_on_error: ?js.Function.Global = null, _executed: bool = false, pub fn asElement(self: *Script) *Element { @@ -108,22 +106,6 @@ pub fn setDefer(self: *Script, value: bool, page: *Page) !void { } } -pub fn getOnLoad(self: *const Script) ?js.Function.Global { - return self._on_load; -} - -pub fn setOnLoad(self: *Script, cb: ?js.Function.Global) void { - self._on_load = cb; -} - -pub fn getOnError(self: *const Script) ?js.Function.Global { - return self._on_error; -} - -pub fn setOnError(self: *Script, cb: ?js.Function.Global) void { - self._on_error = cb; -} - pub fn getNoModule(self: *const Script) bool { return self.asConstElement().getAttributeSafe(comptime .wrap("nomodule")) != null; } @@ -147,8 +129,6 @@ pub const JsApi = struct { pub const @"type" = bridge.accessor(Script.getType, Script.setType, .{}); pub const nonce = bridge.accessor(Script.getNonce, Script.setNonce, .{}); pub const charset = bridge.accessor(Script.getCharset, Script.setCharset, .{}); - pub const onload = bridge.accessor(Script.getOnLoad, Script.setOnLoad, .{}); - pub const onerror = bridge.accessor(Script.getOnError, Script.setOnError, .{}); pub const noModule = bridge.accessor(Script.getNoModule, null, .{}); pub const innerText = bridge.accessor(_innerText, Script.setInnerText, .{}); fn _innerText(self: *Script, page: *const Page) ![]const u8 { @@ -160,26 +140,10 @@ pub const JsApi = struct { }; pub const Build = struct { - pub fn complete(node: *Node, page: *Page) !void { + pub fn complete(node: *Node, _: *Page) !void { const self = node.as(Script); const element = self.asElement(); self._src = element.getAttributeSafe(comptime .wrap("src")) orelse ""; - - if (element.getAttributeSafe(comptime .wrap("onload"))) |on_load| { - if (page.js.stringToPersistedFunction(on_load)) |func| { - self._on_load = func; - } else |err| { - log.err(.js, "script.onload", .{ .err = err, .str = on_load }); - } - } - - if (element.getAttributeSafe(comptime .wrap("onerror"))) |on_error| { - if (page.js.stringToPersistedFunction(on_error)) |func| { - self._on_error = func; - } else |err| { - log.err(.js, "script.onerror", .{ .err = err, .str = on_error }); - } - } } }; From 238de489c19c1fbbac2ac1fe5109becac4def674 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Tue, 24 Feb 2026 10:42:50 +0800 Subject: [PATCH 021/103] Add [basic] reference counting to events Previously, we used a boolean, `_v8_handoff` to detect whether or not an event was handed off to v8. When it _was_ handed off, then we relied on the Global finalizer (or context shutdown) to cleanup the instance. When it wasn't handed off, we could immediately free the instance. The issue is that, under pressure, v8 might finalize the event _before_ we've checked the handoff flag. This was the old code: ```zig const event = try Event.initTrusted(.wrap("DOMContentLoaded"), .{ .bubbles = true }, self); defer if (!event._v8_handoff) event.deinit(false); try self._event_manager.dispatch( self.document.asEventTarget(), event, ); ``` But what happens if, during the call to dispatch, v8 finalizes the event? The defer statement will access event after its been freed. Rather than a boolean, we now track a basic reference count. deinit decreases the reference count, and only frees the object when it reaches 0. Any handoff to v8 automatically increases the reference count by 1. The above code becomes a simpler: ```zig const event = try Event.initTrusted(.wrap("DOMContentLoaded"), .{ .bubbles = true }, self); defer event.deinit(false); try self._event_manager.dispatch( self.document.asEventTarget(), event, ); ``` The deinit is un-conditional. The dispatch function itself increases the RC by 1, and then the v8 handoff increases it to 2. On v8 finalization the RC is decreased to 1. The defer deinit decreases it to 0, at which point it is freed. Fixes WPT /css/css-transitions/properties-value-003.html --- src/browser/EventManager.zig | 6 ++-- src/browser/Factory.zig | 1 + src/browser/js/Local.zig | 10 +++--- src/browser/webapi/Event.zig | 49 +++++++++++++++++++++--------- src/browser/webapi/EventTarget.zig | 3 +- 5 files changed, 47 insertions(+), 22 deletions(-) diff --git a/src/browser/EventManager.zig b/src/browser/EventManager.zig index 53e2c6ad..a1552440 100644 --- a/src/browser/EventManager.zig +++ b/src/browser/EventManager.zig @@ -204,7 +204,8 @@ pub fn dispatch(self: *EventManager, target: *EventTarget, event: *Event) Dispat } pub fn dispatchOpts(self: *EventManager, target: *EventTarget, event: *Event, comptime opts: DispatchOpts) DispatchError!void { - defer if (!event._v8_handoff) event.deinit(false, self.page); + event.acquireRef(); + defer event.deinit(false, self.page); if (comptime IS_DEBUG) { log.debug(.event, "eventManager.dispatch", .{ .type = event._type_string.str(), .bubbles = event._bubbles }); @@ -254,7 +255,8 @@ const DispatchWithFunctionOptions = struct { inject_target: bool = true, }; pub fn dispatchWithFunction(self: *EventManager, target: *EventTarget, event: *Event, function_: ?js.Function, comptime opts: DispatchWithFunctionOptions) !void { - defer if (!event._v8_handoff) event.deinit(false, self.page); + event.acquireRef(); + defer event.deinit(false, self.page); if (comptime IS_DEBUG) { log.debug(.event, "dispatchWithFunction", .{ .type = event._type_string.str(), .context = opts.context, .has_function = function_ != null }); diff --git a/src/browser/Factory.zig b/src/browser/Factory.zig index 44bfcb1e..2e3699f5 100644 --- a/src/browser/Factory.zig +++ b/src/browser/Factory.zig @@ -237,6 +237,7 @@ fn eventInit(arena: Allocator, typ: String, value: anytype) !Event { const time_stamp = (raw_timestamp / 2) * 2; return .{ + ._rc = 0, ._arena = arena, ._type = unionInit(Event.Type, value), ._type_string = typ, diff --git a/src/browser/js/Local.zig b/src/browser/js/Local.zig index 019f22d1..e667be62 100644 --- a/src/browser/js/Local.zig +++ b/src/browser/js/Local.zig @@ -217,7 +217,7 @@ pub fn mapZigInstanceToJs(self: *const Local, js_obj_handle: ?*const v8.Object, try ctx.finalizer_callbacks.put(ctx.arena, @intFromPtr(resolved.ptr), fc); } - conditionallyFlagHandoff(value); + conditionallyReference(value); if (@hasDecl(JsApi.Meta, "weak")) { if (comptime IS_DEBUG) { std.debug.assert(JsApi.Meta.weak == true); @@ -1101,14 +1101,14 @@ fn resolveT(comptime T: type, value: *anyopaque) Resolved { }; } -fn conditionallyFlagHandoff(value: anytype) void { +fn conditionallyReference(value: anytype) void { const T = bridge.Struct(@TypeOf(value)); - if (@hasField(T, "_v8_handoff")) { - value._v8_handoff = true; + if (@hasDecl(T, "acquireRef")) { + value.acquireRef(); return; } if (@hasField(T, "_proto")) { - conditionallyFlagHandoff(value._proto); + conditionallyReference(value._proto); } } diff --git a/src/browser/webapi/Event.zig b/src/browser/webapi/Event.zig index 0082682f..2821e9f1 100644 --- a/src/browser/webapi/Event.zig +++ b/src/browser/webapi/Event.zig @@ -25,6 +25,7 @@ const Node = @import("Node.zig"); const String = @import("../../string.zig").String; const Allocator = std.mem.Allocator; +const IS_DEBUG = @import("builtin").mode == .Debug; pub const Event = @This(); @@ -44,13 +45,16 @@ _stop_immediate_propagation: bool = false, _event_phase: EventPhase = .none, _time_stamp: u64, _needs_retargeting: bool = false, -_isTrusted: bool = false, +_is_trusted: bool = false, // 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. This flag -// when true, tells us the event is registered in the js.Contxt and thus, at -// the very least, will be finalized on context shutdown. -_v8_handoff: bool = false, +// where things can fail. If it does fail, we need to deinit the event. The timing +// window can be difficult to capture, so we use a reference count. +// should be 0, 1, or 2. 0 +// - 0: no reference, always a transient state going to either 1 or about to be deinit'd +// - 1: either zig or v8 have a reference +// - 2: both zig and v8 have a reference +_rc: u8 = 0, pub const EventPhase = enum(u8) { none = 0, @@ -92,7 +96,7 @@ pub fn initTrusted(typ: String, opts_: ?Options, page: *Page) !*Event { return initWithTrusted(arena, typ, opts_, true); } -fn initWithTrusted(arena: Allocator, typ: String, opts_: ?Options, trusted: bool) !*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) @@ -108,7 +112,7 @@ fn initWithTrusted(arena: Allocator, typ: String, opts_: ?Options, trusted: bool ._cancelable = opts.cancelable, ._composed = opts.composed, ._type_string = typ, - ._isTrusted = trusted, + ._is_trusted = trusted, }; return event; } @@ -131,9 +135,26 @@ pub fn initEvent( self._prevent_default = false; } +pub fn acquireRef(self: *Event) void { + self._rc += 1; +} + pub fn deinit(self: *Event, shutdown: bool, page: *Page) void { - _ = shutdown; - page.releaseArena(self._arena); + if (shutdown) { + page.releaseArena(self._arena); + return; + } + + const rc = self._rc; + if (comptime IS_DEBUG) { + std.debug.assert(rc != 0); + } + + if (rc == 1) { + page.releaseArena(self._arena); + } else { + self._rc = rc - 1; + } } pub fn as(self: *Event, comptime T: type) *T { @@ -235,15 +256,15 @@ pub fn getTimeStamp(self: *const Event) u64 { } pub fn setTrusted(self: *Event) void { - self._isTrusted = true; + self._is_trusted = true; } pub fn setUntrusted(self: *Event) void { - self._isTrusted = false; + self._is_trusted = false; } pub fn getIsTrusted(self: *const Event) bool { - return self._isTrusted; + return self._is_trusted; } pub fn composedPath(self: *Event, page: *Page) ![]const *EventTarget { @@ -401,8 +422,8 @@ pub fn populatePrototypes(self: anytype, opts: anytype, trusted: bool) void { } // Set isTrusted at the Event level (base of prototype chain) - if (T == Event or @hasField(T, "_isTrusted")) { - self._isTrusted = trusted; + if (T == Event or @hasField(T, "is_trusted")) { + self._is_trusted = trusted; } } diff --git a/src/browser/webapi/EventTarget.zig b/src/browser/webapi/EventTarget.zig index 28b8ee93..d322b68d 100644 --- a/src/browser/webapi/EventTarget.zig +++ b/src/browser/webapi/EventTarget.zig @@ -55,7 +55,8 @@ pub fn dispatchEvent(self: *EventTarget, event: *Event, page: *Page) !bool { if (event._event_phase != .none) { return error.InvalidStateError; } - event._isTrusted = false; + event._is_trusted = false; + event.acquireRef(); try page._event_manager.dispatch(self, event); return !event._cancelable or !event._prevent_default; } From bb773c6c13a6da8c611fcf78ebafc163bd82ee0c Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Tue, 24 Feb 2026 12:57:05 +0800 Subject: [PATCH 022/103] All frames must share the same Arena/Factory This is a bit sad, but at least for now, all frames must share the same page.arena and page.factory (they still get their own v8::Context and call_arena). Consider this case (from WPT /css/cssom-view/scrollingElement.html): ``` let nonQuirksFrame = document.createElement("iframe"); nonQuirksFrame.onload = this.step_func_done(function() { var nonQuirksDoc = nonQuirksFrame.contentDocument; nonQuirksDoc.removeChild(nonQuirksDoc.documentElement); }); nonQuirksFrame.src = URL.createObjectURL(new Blob([``], { type: "text/html" })); document.body.append(nonQuirksFrame); ``` We have the root page (p0) and the frame page (p1). When the frame (p1) is created, it's [currently] given its own arena/factory and parses the page. Those nodes are created with p1's factory. The onload callback executes on p0, so when we call removeChild that's executing with p0's arena/factory and tries to release memory using that factory - which is NOT the factory that created them. A better approach might be that _memory_ operations aren't tied to the current calling context, but rather the owning document's page. But: 1 - That would mean we have 2 execution contexts: the v8 context where the code is running, and the memory context that owns the code 2 - Nodes can be disconnected, what page should we use? 3 - Some operations can behave across frames, p0 could adoptNode on p1, so we'd have to carefully use p1's factory when cleaning up and re-create the node in p0's factory. So much hassle. Using a shared factory/arena solves these problems at the cost of bloat - when a frame goes away or navigates, we can't free its old memory. At some point, we should fix that. But I don't have a quick and easy solution, and sharing the arena/factory is _really_ quick and easy. --- src/browser/Factory.zig | 183 ++++++++++++++++++---------------------- src/browser/Page.zig | 16 ++-- 2 files changed, 94 insertions(+), 105 deletions(-) diff --git a/src/browser/Factory.zig b/src/browser/Factory.zig index 44bfcb1e..21d4d839 100644 --- a/src/browser/Factory.zig +++ b/src/browser/Factory.zig @@ -42,12 +42,93 @@ const Allocator = std.mem.Allocator; const IS_DEBUG = builtin.mode == .Debug; const assert = std.debug.assert; +// Shared across all frames of a Page. const Factory = @This(); -_page: *Page, _arena: Allocator, _slab: SlabAllocator, +pub fn init(arena: Allocator) !*Factory { + const self = try arena.create(Factory); + self.* = .{ + ._arena = arena, + ._slab = SlabAllocator.init(arena, 128), + }; + return self; +} + +// this is a root object +pub fn eventTarget(self: *Factory, child: anytype) !*@TypeOf(child) { + const allocator = self._slab.allocator(); + const chain = try PrototypeChain( + &.{ EventTarget, @TypeOf(child) }, + ).allocate(allocator); + + const event_ptr = chain.get(0); + event_ptr.* = .{ + ._type = unionInit(EventTarget.Type, chain.get(1)), + }; + chain.setLeaf(1, child); + + return chain.get(1); +} + +pub fn standaloneEventTarget(self: *Factory, child: anytype) !*EventTarget { + const allocator = self._slab.allocator(); + const et = try allocator.create(EventTarget); + et.* = .{ ._type = unionInit(EventTarget.Type, child) }; + return et; +} + +// this is a root object +pub fn event(_: *const Factory, arena: Allocator, typ: String, child: anytype) !*@TypeOf(child) { + const chain = try PrototypeChain( + &.{ Event, @TypeOf(child) }, + ).allocate(arena); + + // Special case: Event has a _type_string field, so we need manual setup + const event_ptr = chain.get(0); + event_ptr.* = try eventInit(arena, typ, chain.get(1)); + chain.setLeaf(1, child); + + return chain.get(1); +} + +pub fn uiEvent(_: *const Factory, arena: Allocator, typ: String, child: anytype) !*@TypeOf(child) { + const chain = try PrototypeChain( + &.{ Event, UIEvent, @TypeOf(child) }, + ).allocate(arena); + + // Special case: Event has a _type_string field, so we need manual setup + const event_ptr = chain.get(0); + event_ptr.* = try eventInit(arena, typ, chain.get(1)); + chain.setMiddle(1, UIEvent.Type); + chain.setLeaf(2, child); + + return chain.get(2); +} + +pub fn mouseEvent(_: *const Factory, arena: Allocator, typ: String, mouse: MouseEvent, child: anytype) !*@TypeOf(child) { + const chain = try PrototypeChain( + &.{ Event, UIEvent, MouseEvent, @TypeOf(child) }, + ).allocate(arena); + + // Special case: Event has a _type_string field, so we need manual setup + const event_ptr = chain.get(0); + event_ptr.* = try eventInit(arena, typ, chain.get(1)); + chain.setMiddle(1, UIEvent.Type); + + // Set MouseEvent with all its fields + const mouse_ptr = chain.get(2); + mouse_ptr.* = mouse; + mouse_ptr._proto = chain.get(1); + mouse_ptr._type = unionInit(MouseEvent.Type, chain.get(3)); + + chain.setLeaf(3, child); + + return chain.get(3); +} + fn PrototypeChain(comptime types: []const type) type { return struct { const Self = @This(); @@ -151,86 +232,6 @@ fn AutoPrototypeChain(comptime types: []const type) type { }; } -pub fn init(arena: Allocator, page: *Page) Factory { - return .{ - ._page = page, - ._arena = arena, - ._slab = SlabAllocator.init(arena, 128), - }; -} - -// this is a root object -pub fn eventTarget(self: *Factory, child: anytype) !*@TypeOf(child) { - const allocator = self._slab.allocator(); - const chain = try PrototypeChain( - &.{ EventTarget, @TypeOf(child) }, - ).allocate(allocator); - - const event_ptr = chain.get(0); - event_ptr.* = .{ - ._type = unionInit(EventTarget.Type, chain.get(1)), - }; - chain.setLeaf(1, child); - - return chain.get(1); -} - -pub fn standaloneEventTarget(self: *Factory, child: anytype) !*EventTarget { - const allocator = self._slab.allocator(); - const et = try allocator.create(EventTarget); - et.* = .{ ._type = unionInit(EventTarget.Type, child) }; - return et; -} - -// this is a root object -pub fn event(_: *const Factory, arena: Allocator, typ: String, child: anytype) !*@TypeOf(child) { - const chain = try PrototypeChain( - &.{ Event, @TypeOf(child) }, - ).allocate(arena); - - // Special case: Event has a _type_string field, so we need manual setup - const event_ptr = chain.get(0); - event_ptr.* = try eventInit(arena, typ, chain.get(1)); - chain.setLeaf(1, child); - - return chain.get(1); -} - -pub fn uiEvent(_: *const Factory, arena: Allocator, typ: String, child: anytype) !*@TypeOf(child) { - const chain = try PrototypeChain( - &.{ Event, UIEvent, @TypeOf(child) }, - ).allocate(arena); - - // Special case: Event has a _type_string field, so we need manual setup - const event_ptr = chain.get(0); - event_ptr.* = try eventInit(arena, typ, chain.get(1)); - chain.setMiddle(1, UIEvent.Type); - chain.setLeaf(2, child); - - return chain.get(2); -} - -pub fn mouseEvent(_: *const Factory, arena: Allocator, typ: String, mouse: MouseEvent, child: anytype) !*@TypeOf(child) { - const chain = try PrototypeChain( - &.{ Event, UIEvent, MouseEvent, @TypeOf(child) }, - ).allocate(arena); - - // Special case: Event has a _type_string field, so we need manual setup - const event_ptr = chain.get(0); - event_ptr.* = try eventInit(arena, typ, chain.get(1)); - chain.setMiddle(1, UIEvent.Type); - - // Set MouseEvent with all its fields - const mouse_ptr = chain.get(2); - mouse_ptr.* = mouse; - mouse_ptr._proto = chain.get(1); - mouse_ptr._type = unionInit(MouseEvent.Type, chain.get(3)); - - chain.setLeaf(3, child); - - return chain.get(3); -} - 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); @@ -383,7 +384,7 @@ pub fn destroy(self: *Factory, value: anytype) void { } if (comptime @hasField(S, "_proto")) { - self.destroyChain(value, true, 0, std.mem.Alignment.@"1"); + self.destroyChain(value, 0, std.mem.Alignment.@"1"); } else { self.destroyStandalone(value); } @@ -397,7 +398,6 @@ pub fn destroyStandalone(self: *Factory, value: anytype) void { fn destroyChain( self: *Factory, value: anytype, - comptime first: bool, old_size: usize, old_align: std.mem.Alignment, ) void { @@ -409,23 +409,8 @@ fn destroyChain( const new_size = current_size + @sizeOf(S); const new_align = std.mem.Alignment.max(old_align, std.mem.Alignment.of(S)); - // This is initially called from a deinit. We don't want to call that - // same deinit. So when this is the first time destroyChain is called - // we don't call deinit (because we're in that deinit) - if (!comptime first) { - // But if it isn't the first time - if (@hasDecl(S, "deinit")) { - // And it has a deinit, we'll call it - switch (@typeInfo(@TypeOf(S.deinit)).@"fn".params.len) { - 1 => value.deinit(), - 2 => value.deinit(self._page), - else => @compileLog(@typeName(S) ++ " has an invalid deinit function"), - } - } - } - if (@hasField(S, "_proto")) { - self.destroyChain(value._proto, false, new_size, new_align); + self.destroyChain(value._proto, new_size, new_align); } else { // no proto so this is the head of the chain. // we use this as the ptr to the start of the chain. diff --git a/src/browser/Page.zig b/src/browser/Page.zig index 699e5cb0..08ef2e7d 100644 --- a/src/browser/Page.zig +++ b/src/browser/Page.zig @@ -173,7 +173,7 @@ _upgrading_element: ?*Node = null, _undefined_custom_elements: std.ArrayList(*Element.Html.Custom) = .{}, // for heap allocations and managing WebAPI objects -_factory: Factory, +_factory: *Factory, _load_state: LoadState = .waiting, @@ -247,14 +247,15 @@ pub fn init(self: *Page, id: u32, session: *Session, parent: ?*Page) !void { } const browser = session.browser; const arena_pool = browser.arena_pool; - const page_arena = try arena_pool.acquire(); - errdefer arena_pool.release(page_arena); + + const page_arena = if (parent) |p| p.arena else try arena_pool.acquire(); + errdefer if (parent == null) arena_pool.release(page_arena); + + var factory = if (parent) |p| p._factory else try Factory.init(page_arena); const call_arena = try arena_pool.acquire(); errdefer arena_pool.release(call_arena); - var factory = Factory.init(page_arena, self); - const document = (try factory.document(Node.Document.HTMLDocument{ ._proto = undefined, })).asDocument(); @@ -355,7 +356,10 @@ pub fn deinit(self: *Page) void { } self.arena_pool.release(self.call_arena); - self.arena_pool.release(self.arena); + + if (self.parent == null) { + self.arena_pool.release(self.arena); + } } pub fn base(self: *const Page) [:0]const u8 { From 2a332c0883bb84644f41323f6fe8487e8274b8d5 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Tue, 24 Feb 2026 13:31:34 +0800 Subject: [PATCH 023/103] Handle v8:Object::GetOwnPropertyNames returning null This seems to only happen in error cases, most notably someone changes the object to return an invalid ownKeys, as we see in WPT /fetch/api/headers/headers-record.any.html --- src/browser/js/Local.zig | 15 +++++++++++---- src/browser/js/Object.zig | 17 +++++++++++++---- src/browser/webapi/KeyValueList.zig | 2 +- 3 files changed, 25 insertions(+), 9 deletions(-) diff --git a/src/browser/js/Local.zig b/src/browser/js/Local.zig index 019f22d1..8dd08ab9 100644 --- a/src/browser/js/Local.zig +++ b/src/browser/js/Local.zig @@ -1209,13 +1209,20 @@ fn _debugValue(self: *const Local, js_val: js.Value, seen: *std.AutoHashMapUnman gop.value_ptr.* = {}; } - const names_arr = js_obj.getOwnPropertyNames(); - const len = names_arr.len(); - if (depth > 20) { return writer.writeAll("...deeply nested object..."); } - const own_len = js_obj.getOwnPropertyNames().len(); + + const names_arr = js_obj.getOwnPropertyNames() catch { + return writer.writeAll("...invalid object..."); + }; + const len = names_arr.len(); + + const own_len = blk: { + const own_names = js_obj.getOwnPropertyNames() catch break :blk 0; + break :blk own_names.len(); + }; + if (own_len == 0) { const js_val_str = try js_val.toStringSlice(); if (js_val_str.len > 2000) { diff --git a/src/browser/js/Object.zig b/src/browser/js/Object.zig index ce814837..981f4a2b 100644 --- a/src/browser/js/Object.zig +++ b/src/browser/js/Object.zig @@ -129,8 +129,14 @@ pub fn isNullOrUndefined(self: Object) bool { return v8.v8__Value__IsNullOrUndefined(@ptrCast(self.handle)); } -pub fn getOwnPropertyNames(self: Object) js.Array { - const handle = v8.v8__Object__GetOwnPropertyNames(self.handle, self.local.handle).?; +pub fn getOwnPropertyNames(self: Object) !js.Array { + const handle = v8.v8__Object__GetOwnPropertyNames(self.handle, self.local.handle) orelse { + // This is almost always a fatal error case. Either we're in some exception + // and things are messy, or we're shutting down, or someone has messed up + // the object (like some WPT tests do). + return error.TypeError; + }; + return .{ .local = self.local, .handle = handle, @@ -145,8 +151,11 @@ pub fn getPropertyNames(self: Object) js.Array { }; } -pub fn nameIterator(self: Object) NameIterator { - const handle = v8.v8__Object__GetPropertyNames(self.handle, self.local.handle).?; +pub fn nameIterator(self: Object) !NameIterator { + const handle = v8.v8__Object__GetPropertyNames(self.handle, self.local.handle) orelse { + // see getOwnPropertyNames above + return error.TypeError; + }; const count = v8.v8__Array__Length(handle); return .{ diff --git a/src/browser/webapi/KeyValueList.zig b/src/browser/webapi/KeyValueList.zig index f161ea7e..b068c2b9 100644 --- a/src/browser/webapi/KeyValueList.zig +++ b/src/browser/webapi/KeyValueList.zig @@ -62,7 +62,7 @@ pub fn copy(arena: Allocator, original: KeyValueList) !KeyValueList { } pub fn fromJsObject(arena: Allocator, js_obj: js.Object, comptime normalizer: ?Normalizer, page: *Page) !KeyValueList { - var it = js_obj.nameIterator(); + var it = try js_obj.nameIterator(); var list = KeyValueList.init(); try list.ensureTotalCapacity(arena, it.count); From 19ecb87b07423b6bbd502a7f800d4c131c34a646 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Tue, 24 Feb 2026 15:11:20 +0800 Subject: [PATCH 024/103] Include url in page logs Wasn't really needed before frames and multithreading,but it's pretty essential now to figure out what's going on. Probably needs to be applied more broadly. --- src/browser/Page.zig | 71 ++++++++++++++++++++++---------------------- 1 file changed, 36 insertions(+), 35 deletions(-) diff --git a/src/browser/Page.zig b/src/browser/Page.zig index 699e5cb0..70694540 100644 --- a/src/browser/Page.zig +++ b/src/browser/Page.zig @@ -349,7 +349,7 @@ pub fn deinit(self: *Page) void { var it = self._arena_pool_leak_track.valueIterator(); while (it.next()) |value_ptr| { if (value_ptr.count > 0) { - log.err(.bug, "ArenaPool Leak", .{ .owner = value_ptr.owner, .type = self._type }); + log.err(.bug, "ArenaPool Leak", .{ .owner = value_ptr.owner, .type = self._type, .url = self.url }); } } } @@ -418,7 +418,7 @@ pub fn releaseArena(self: *Page, allocator: Allocator) void { if (comptime IS_DEBUG) { const found = self._arena_pool_leak_track.getPtr(@intFromPtr(allocator.ptr)).?; if (found.count != 1) { - log.err(.bug, "ArenaPool Double Free", .{ .owner = found.owner, .count = found.count, .type = self._type }); + log.err(.bug, "ArenaPool Double Free", .{ .owner = found.owner, .count = found.count, .type = self._type, .url = self.url }); return; } found.count = 0; @@ -635,7 +635,7 @@ pub fn documentIsLoaded(self: *Page) void { self._load_state = .load; self.document._ready_state = .interactive; self._documentIsLoaded() catch |err| { - log.err(.page, "document is loaded", .{ .err = err, .type = self._type }); + log.err(.page, "document is loaded", .{ .err = err, .type = self._type, .url = self.url }); }; } @@ -658,7 +658,7 @@ pub fn iframeCompletedLoading(self: *Page, iframe: *Element.Html.IFrame) void { defer ls.deinit(); const event = Event.initTrusted(comptime .wrap("load"), .{}, self) catch |err| { - log.err(.page, "iframe event init", .{ .err = err }); + log.err(.page, "iframe event init", .{ .err = err, .url = iframe._src }); break :blk; }; self._event_manager.dispatch(iframe.asNode().asEventTarget(), event) catch |err| { @@ -697,7 +697,7 @@ pub fn documentIsComplete(self: *Page) void { self._load_state = .complete; self._documentIsComplete() catch |err| { - log.err(.page, "document is complete", .{ .err = err, .type = self._type }); + log.err(.page, "document is complete", .{ .err = err, .type = self._type, .url = self.url }); }; if (IS_DEBUG) { @@ -803,7 +803,7 @@ fn pageDataCallback(transfer: *Http.Transfer, data: []const u8) !void { } orelse .unknown; if (comptime IS_DEBUG) { - log.debug(.page, "navigate first chunk", .{ .content_type = mime.content_type, .len = data.len, .type = self._type }); + log.debug(.page, "navigate first chunk", .{ .content_type = mime.content_type, .len = data.len, .type = self._type, .url = self.url }); } switch (mime.content_type) { @@ -850,7 +850,7 @@ fn pageDoneCallback(ctx: *anyopaque) !void { var self: *Page = @ptrCast(@alignCast(ctx)); if (comptime IS_DEBUG) { - log.debug(.page, "navigate done", .{ .type = self._type }); + log.debug(.page, "navigate done", .{ .type = self._type, .url = self.url }); } //We need to handle different navigation types differently. @@ -928,13 +928,13 @@ fn pageDoneCallback(ctx: *anyopaque) !void { fn pageErrorCallback(ctx: *anyopaque, err: anyerror) void { var self: *Page = @ptrCast(@alignCast(ctx)); - log.err(.page, "navigate failed", .{ .err = err, .type = self._type }); + log.err(.page, "navigate failed", .{ .err = err, .type = self._type, .url = self.url }); self._parse_state = .{ .err = err }; // In case of error, we want to complete the page with a custom HTML // containing the error. pageDoneCallback(ctx) catch |e| { - log.err(.browser, "pageErrorCallback", .{ .err = e, .type = self._type }); + log.err(.browser, "pageErrorCallback", .{ .err = e, .type = self._type, .url = self.url }); return; }; } @@ -949,7 +949,7 @@ pub fn wait(self: *Page, wait_ms: u32) Session.WaitResult { // to run this through more real-world sites and see if we need // to expand the switch (err) to have more customized logs for // specific messages. - log.err(.browser, "page wait", .{ .err = err, .type = self._type }); + log.err(.browser, "page wait", .{ .err = err, .type = self._type, .url = self.url }); }, } return .done; @@ -1183,6 +1183,7 @@ pub fn scriptAddedCallback(self: *Page, comptime from_parser: bool, script: *Ele self._script_manager.addFromElement(from_parser, script, "parsing") catch |err| { log.err(.page, "page.scriptAddedCallback", .{ .err = err, + .url = self.url, .src = script.asElement().getAttributeSafe(comptime .wrap("src")), .type = self._type, }); @@ -1269,7 +1270,7 @@ pub fn domChanged(self: *Page) void { self._intersection_check_scheduled = true; self.js.queueIntersectionChecks() catch |err| { - log.err(.page, "page.schedIntersectChecks", .{ .err = err, .type = self._type }); + log.err(.page, "page.schedIntersectChecks", .{ .err = err, .type = self._type, .url = self.url }); }; } @@ -1367,7 +1368,7 @@ pub fn notifyPerformanceObservers(self: *Page, entry: *Performance.Entry) !void for (self._performance_observers.items) |observer| { if (observer.interested(entry)) { observer._entries.append(self.arena, entry) catch |err| { - log.err(.page, "notifyPerformanceObservers", .{ .err = err, .type = self._type }); + log.err(.page, "notifyPerformanceObservers", .{ .err = err, .type = self._type, .url = self.url }); }; } } @@ -1462,7 +1463,7 @@ pub fn performScheduledIntersectionChecks(self: *Page) void { } self._intersection_check_scheduled = false; self.checkIntersections() catch |err| { - log.err(.page, "page.schedIntersectChecks", .{ .err = err, .type = self._type }); + log.err(.page, "page.schedIntersectChecks", .{ .err = err, .type = self._type, .url = self.url }); }; } @@ -1478,7 +1479,7 @@ pub fn deliverIntersections(self: *Page) void { i -= 1; const observer = self._intersection_observers.items[i]; observer.deliverEntries(self) catch |err| { - log.err(.page, "page.deliverIntersections", .{ .err = err, .type = self._type }); + log.err(.page, "page.deliverIntersections", .{ .err = err, .type = self._type, .url = self.url }); }; } } @@ -1496,7 +1497,7 @@ pub fn deliverMutations(self: *Page) void { }; if (self._mutation_delivery_depth > 100) { - log.err(.page, "page.MutationLimit", .{ .type = self._type }); + log.err(.page, "page.MutationLimit", .{ .type = self._type, .url = self.url }); self._mutation_delivery_depth = 0; return; } @@ -1505,7 +1506,7 @@ pub fn deliverMutations(self: *Page) void { while (it) |node| : (it = node.next) { const observer: *MutationObserver = @fieldParentPtr("node", node); observer.deliverRecords(self) catch |err| { - log.err(.page, "page.deliverMutations", .{ .err = err, .type = self._type }); + log.err(.page, "page.deliverMutations", .{ .err = err, .type = self._type, .url = self.url }); }; } } @@ -1523,7 +1524,7 @@ pub fn deliverSlotchangeEvents(self: *Page) void { var i: usize = 0; var slots = self.call_arena.alloc(*Element.Html.Slot, pending) catch |err| { - log.err(.page, "deliverSlotchange.append", .{ .err = err, .type = self._type }); + log.err(.page, "deliverSlotchange.append", .{ .err = err, .type = self._type, .url = self.url }); return; }; @@ -1536,12 +1537,12 @@ pub fn deliverSlotchangeEvents(self: *Page) void { for (slots) |slot| { const event = Event.initTrusted(comptime .wrap("slotchange"), .{ .bubbles = true }, self) catch |err| { - log.err(.page, "deliverSlotchange.init", .{ .err = err, .type = self._type }); + log.err(.page, "deliverSlotchange.init", .{ .err = err, .type = self._type, .url = self.url }); continue; }; const target = slot.asNode().asEventTarget(); _ = target.dispatchEvent(event, self) catch |err| { - log.err(.page, "deliverSlotchange.dispatch", .{ .err = err, .type = self._type }); + log.err(.page, "deliverSlotchange.dispatch", .{ .err = err, .type = self._type, .url = self.url }); }; } } @@ -1596,7 +1597,7 @@ pub fn appendNew(self: *Page, parent: *Node, child: Node.NodeOrText) !void { // called from the parser when the node and all its children have been added pub fn nodeComplete(self: *Page, node: *Node) !void { Node.Build.call(node, "complete", .{ node, self }) catch |err| { - log.err(.bug, "build.complete", .{ .tag = node.getNodeName(&self.buf), .err = err, .type = self._type }); + log.err(.bug, "build.complete", .{ .tag = node.getNodeName(&self.buf), .err = err, .type = self._type, .url = self.url }); return err; }; return self.nodeIsReady(true, node); @@ -2295,7 +2296,7 @@ pub fn createElementNS(self: *Page, namespace: Element.Namespace, name: []const var caught: JS.TryCatch.Caught = undefined; _ = ls.toLocal(def.constructor).newInstance(&caught) catch |err| { - log.warn(.js, "custom element constructor", .{ .name = name, .err = err, .caught = caught, .type = self._type }); + log.warn(.js, "custom element constructor", .{ .name = name, .err = err, .caught = caught, .type = self._type, .url = self.url }); return node; }; @@ -2353,7 +2354,7 @@ fn createHtmlElementT(self: *Page, comptime E: type, namespace: Element.Namespac const node = element.asNode(); if (@hasDecl(E, "Build") and @hasDecl(E.Build, "created")) { @call(.auto, @field(E.Build, "created"), .{ node, self }) catch |err| { - log.err(.page, "build.created", .{ .tag = node.getNodeName(&self.buf), .err = err, .type = self._type }); + log.err(.page, "build.created", .{ .tag = node.getNodeName(&self.buf), .err = err, .type = self._type, .url = self.url }); return err; }; } @@ -2823,7 +2824,7 @@ pub fn _insertNodeRelative(self: *Page, comptime from_parser: bool, parent: *Nod pub fn attributeChange(self: *Page, element: *Element, name: String, value: String, old_value: ?String) void { _ = Element.Build.call(element, "attributeChange", .{ element, name, value, self }) catch |err| { - log.err(.bug, "build.attributeChange", .{ .tag = element.getTag(), .name = name, .value = value, .err = err, .type = self._type }); + log.err(.bug, "build.attributeChange", .{ .tag = element.getTag(), .name = name, .value = value, .err = err, .type = self._type, .url = self.url }); }; Element.Html.Custom.invokeAttributeChangedCallbackOnElement(element, name, old_value, value, self); @@ -2832,7 +2833,7 @@ pub fn attributeChange(self: *Page, element: *Element, name: String, value: Stri while (it) |node| : (it = node.next) { const observer: *MutationObserver = @fieldParentPtr("node", node); observer.notifyAttributeChange(element, name, old_value, self) catch |err| { - log.err(.page, "attributeChange.notifyObserver", .{ .err = err, .type = self._type }); + log.err(.page, "attributeChange.notifyObserver", .{ .err = err, .type = self._type, .url = self.url }); }; } @@ -2849,7 +2850,7 @@ pub fn attributeChange(self: *Page, element: *Element, name: String, value: Stri pub fn attributeRemove(self: *Page, element: *Element, name: String, old_value: String) void { _ = Element.Build.call(element, "attributeRemove", .{ element, name, self }) catch |err| { - log.err(.bug, "build.attributeRemove", .{ .tag = element.getTag(), .name = name, .err = err, .type = self._type }); + log.err(.bug, "build.attributeRemove", .{ .tag = element.getTag(), .name = name, .err = err, .type = self._type, .url = self.url }); }; Element.Html.Custom.invokeAttributeChangedCallbackOnElement(element, name, old_value, null, self); @@ -2858,7 +2859,7 @@ pub fn attributeRemove(self: *Page, element: *Element, name: String, old_value: while (it) |node| : (it = node.next) { const observer: *MutationObserver = @fieldParentPtr("node", node); observer.notifyAttributeChange(element, name, old_value, self) catch |err| { - log.err(.page, "attributeRemove.notifyObserver", .{ .err = err, .type = self._type }); + log.err(.page, "attributeRemove.notifyObserver", .{ .err = err, .type = self._type, .url = self.url }); }; } @@ -2875,11 +2876,11 @@ pub fn attributeRemove(self: *Page, element: *Element, name: String, old_value: fn signalSlotChange(self: *Page, slot: *Element.Html.Slot) void { self._slots_pending_slotchange.put(self.arena, slot, {}) catch |err| { - log.err(.page, "signalSlotChange.put", .{ .err = err, .type = self._type }); + log.err(.page, "signalSlotChange.put", .{ .err = err, .type = self._type, .url = self.url }); return; }; self.scheduleSlotchangeDelivery() catch |err| { - log.err(.page, "signalSlotChange.schedule", .{ .err = err, .type = self._type }); + log.err(.page, "signalSlotChange.schedule", .{ .err = err, .type = self._type, .url = self.url }); }; } @@ -2919,7 +2920,7 @@ fn updateElementAssignedSlot(self: *Page, element: *Element) void { // Recursively search through the shadow root for a matching slot if (findMatchingSlot(shadow_root.asNode(), slot_name)) |slot| { self._element_assigned_slots.put(self.arena, element, slot) catch |err| { - log.err(.page, "updateElementAssignedSlot.put", .{ .err = err, .type = self._type }); + log.err(.page, "updateElementAssignedSlot.put", .{ .err = err, .type = self._type, .url = self.url }); }; } } @@ -2966,7 +2967,7 @@ pub fn characterDataChange( while (it) |node| : (it = node.next) { const observer: *MutationObserver = @fieldParentPtr("node", node); observer.notifyCharacterDataChange(target, old_value, self) catch |err| { - log.err(.page, "cdataChange.notifyObserver", .{ .err = err, .type = self._type }); + log.err(.page, "cdataChange.notifyObserver", .{ .err = err, .type = self._type, .url = self.url }); }; } } @@ -2993,7 +2994,7 @@ pub fn childListChange( while (it) |node| : (it = node.next) { const observer: *MutationObserver = @fieldParentPtr("node", node); observer.notifyChildListChange(target, added_nodes, removed_nodes, previous_sibling, next_sibling, self) catch |err| { - log.err(.page, "childListChange.notifyObserver", .{ .err = err, .type = self._type }); + log.err(.page, "childListChange.notifyObserver", .{ .err = err, .type = self._type, .url = self.url }); }; } } @@ -3044,7 +3045,7 @@ fn nodeIsReady(self: *Page, comptime from_parser: bool, node: *Node) !void { } self.scriptAddedCallback(from_parser, script) catch |err| { - log.err(.page, "page.nodeIsReady", .{ .err = err, .element = "script", .type = self._type }); + log.err(.page, "page.nodeIsReady", .{ .err = err, .element = "script", .type = self._type, .url = self.url }); return err; }; } else if (node.is(Element.Html.IFrame)) |iframe| { @@ -3054,7 +3055,7 @@ fn nodeIsReady(self: *Page, comptime from_parser: bool, node: *Node) !void { } self.iframeAddedCallback(iframe) catch |err| { - log.err(.page, "page.nodeIsReady", .{ .err = err, .element = "iframe", .type = self._type }); + log.err(.page, "page.nodeIsReady", .{ .err = err, .element = "iframe", .type = self._type, .url = self.url }); return err; }; } @@ -3237,12 +3238,12 @@ pub fn handleClick(self: *Page, target: *Node) !void { // Check target attribute - don't navigate if opening in new window/tab const target_val = anchor.getTarget(); if (target_val.len > 0 and !std.mem.eql(u8, target_val, "_self")) { - log.warn(.not_implemented, "a.target", .{ .type = self._type }); + log.warn(.not_implemented, "a.target", .{ .type = self._type, .url = self.url }); return; } if (try element.hasAttribute(comptime .wrap("download"), self)) { - log.warn(.browser, "a.download", .{ .type = self._type }); + log.warn(.browser, "a.download", .{ .type = self._type, .url = self.url }); return; } From 71707b5aa702aa9b1ce08ebd8bc7564c3e7da0cb Mon Sep 17 00:00:00 2001 From: Pierre Tachoire Date: Tue, 24 Feb 2026 09:12:18 +0100 Subject: [PATCH 025/103] accept more performance mark name and return dummy 0 --- src/browser/tests/performance.html | 22 +++++++++++++++++ src/browser/webapi/Performance.zig | 38 ++++++++++++++++++++++++++---- 2 files changed, 56 insertions(+), 4 deletions(-) diff --git a/src/browser/tests/performance.html b/src/browser/tests/performance.html index 5928bba9..01d23ab4 100644 --- a/src/browser/tests/performance.html +++ b/src/browser/tests/performance.html @@ -252,6 +252,28 @@ } + + + + + + + + + + + + From 6ba0ba7126d332816902bcb1df46fc62242b3798 Mon Sep 17 00:00:00 2001 From: Halil Durak Date: Tue, 24 Feb 2026 18:25:26 +0300 Subject: [PATCH 030/103] add a test for invalid timer removal We cover such cases; yet its better to have a test. --- src/browser/tests/window/timers.html | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/browser/tests/window/timers.html b/src/browser/tests/window/timers.html index 83aa21df..0e8a09e1 100644 --- a/src/browser/tests/window/timers.html +++ b/src/browser/tests/window/timers.html @@ -23,7 +23,6 @@ }); - + + From 8291e4ba738836f8cbeb931149e96aaf349b5347 Mon Sep 17 00:00:00 2001 From: Pierre Tachoire Date: Tue, 24 Feb 2026 21:31:41 +0100 Subject: [PATCH 031/103] fix: add _pad to IdleDeadline to avoid identity_map pointer aliasing --- src/browser/webapi/IdleDeadline.zig | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/browser/webapi/IdleDeadline.zig b/src/browser/webapi/IdleDeadline.zig index c871702e..b193f561 100644 --- a/src/browser/webapi/IdleDeadline.zig +++ b/src/browser/webapi/IdleDeadline.zig @@ -20,6 +20,9 @@ const std = @import("std"); const IdleDeadline = @This(); +// Padding to avoid zero-size struct, which causes identity_map pointer collisions. +_pad: bool = false, + pub fn init() IdleDeadline { return .{}; } From a0e5c9d570662d76f8549a94201325686ab79018 Mon Sep 17 00:00:00 2001 From: Pierre Tachoire Date: Tue, 24 Feb 2026 21:36:38 +0100 Subject: [PATCH 032/103] add padding field for some other webapi --- src/browser/webapi/DOMParser.zig | 3 +++ src/browser/webapi/ResizeObserver.zig | 3 +++ src/browser/webapi/XMLSerializer.zig | 3 +++ 3 files changed, 9 insertions(+) diff --git a/src/browser/webapi/DOMParser.zig b/src/browser/webapi/DOMParser.zig index d386794a..e2a0a438 100644 --- a/src/browser/webapi/DOMParser.zig +++ b/src/browser/webapi/DOMParser.zig @@ -29,6 +29,9 @@ const Document = @import("Document.zig"); const DOMParser = @This(); +// Padding to avoid zero-size struct, which causes identity_map pointer collisions. +_pad: bool = false, + pub fn init() DOMParser { return .{}; } diff --git a/src/browser/webapi/ResizeObserver.zig b/src/browser/webapi/ResizeObserver.zig index 04e396ab..778bcb3b 100644 --- a/src/browser/webapi/ResizeObserver.zig +++ b/src/browser/webapi/ResizeObserver.zig @@ -22,6 +22,9 @@ const Element = @import("Element.zig"); pub const ResizeObserver = @This(); +// Padding to avoid zero-size struct, which causes identity_map pointer collisions. +_pad: bool = false, + fn init(cbk: js.Function) ResizeObserver { _ = cbk; return .{}; diff --git a/src/browser/webapi/XMLSerializer.zig b/src/browser/webapi/XMLSerializer.zig index 7aee1d20..11ee49cd 100644 --- a/src/browser/webapi/XMLSerializer.zig +++ b/src/browser/webapi/XMLSerializer.zig @@ -25,6 +25,9 @@ const dump = @import("../dump.zig"); const XMLSerializer = @This(); +// Padding to avoid zero-size struct, which causes identity_map pointer collisions. +_pad: bool = false, + pub fn init() XMLSerializer { return .{}; } From fcb3f08bcbd45abfb284380546d1e177550308d3 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Wed, 25 Feb 2026 08:17:05 +0800 Subject: [PATCH 033/103] Add url encoding option to URL.resolve Given: a.href = "over 9000!" Then: a.href === BASE_URL + '/over%209000!'; This commits adds an escape: bool option to URL.resolve which will escape the path, query and fragment when true. Also changes the Anchor, Image, Link and IFrame getSrc to escape. Escaping is also used when navigating a frame. --- src/browser/Page.zig | 16 +- src/browser/URL.zig | 308 +++++++++++++++++- src/browser/tests/element/html/anchor.html | 8 + src/browser/tests/element/html/image.html | 9 + src/browser/tests/frames/frames.html | 3 +- .../frames/support/{sub1.html => sub 1.html} | 0 src/browser/webapi/element/html/Anchor.zig | 2 +- src/browser/webapi/element/html/IFrame.zig | 2 +- src/browser/webapi/element/html/Image.zig | 2 +- src/browser/webapi/element/html/Link.zig | 2 +- src/browser/webapi/element/html/Media.zig | 2 +- src/browser/webapi/element/html/Script.zig | 2 +- src/browser/webapi/element/html/Video.zig | 2 +- 13 files changed, 338 insertions(+), 20 deletions(-) rename src/browser/tests/frames/support/{sub1.html => sub 1.html} (100%) diff --git a/src/browser/Page.zig b/src/browser/Page.zig index 11ff9ebc..66a3e083 100644 --- a/src/browser/Page.zig +++ b/src/browser/Page.zig @@ -566,7 +566,7 @@ fn scheduleNavigationWithArena(self: *Page, arena: Allocator, request_url: []con arena, self.base(), request_url, - .{ .always_dupe = true }, + .{ .always_dupe = true, .encode = true }, ); const session = self._session; @@ -1203,7 +1203,7 @@ pub fn iframeAddedCallback(self: *Page, iframe: *Element.Html.IFrame) !void { return; } - const src = try iframe.getSrc(self); + const src = iframe.asElement().getAttributeSafe(comptime .wrap("src")) orelse return; if (src.len == 0) { return; } @@ -1225,8 +1225,16 @@ pub fn iframeAddedCallback(self: *Page, iframe: *Element.Html.IFrame) !void { .timestamp = timestamp(.monotonic), }); - page_frame.navigate(src, .{ .reason = .initialFrameNavigation }) catch |err| { - log.warn(.page, "iframe navigate failure", .{ .url = src, .err = err }); + // navigate will dupe the url + const url = try URL.resolve( + self.call_arena, + self.base(), + src, + .{ .encode = true }, + ); + + page_frame.navigate(url, .{ .reason = .initialFrameNavigation }) catch |err| { + log.warn(.page, "iframe navigate failure", .{ .url = url, .err = err }); self._pending_loads -= 1; iframe._content_window = null; page_frame.deinit(); diff --git a/src/browser/URL.zig b/src/browser/URL.zig index 716480b1..3a2a0514 100644 --- a/src/browser/URL.zig +++ b/src/browser/URL.zig @@ -20,44 +20,61 @@ const std = @import("std"); const Allocator = std.mem.Allocator; const ResolveOpts = struct { + encode: bool = false, always_dupe: bool = false, }; + // path is anytype, so that it can be used with both []const u8 and [:0]const u8 pub fn resolve(allocator: Allocator, base: [:0]const u8, path: anytype, comptime opts: ResolveOpts) ![:0]const u8 { const PT = @TypeOf(path); if (base.len == 0 or isCompleteHTTPUrl(path)) { if (comptime opts.always_dupe or !isNullTerminated(PT)) { - return allocator.dupeZ(u8, path); + const duped = try allocator.dupeZ(u8, path); + return encodeURL(allocator, duped, opts); + } + if (comptime opts.encode) { + return encodeURL(allocator, path, opts); } return path; } if (path.len == 0) { if (comptime opts.always_dupe) { - return allocator.dupeZ(u8, base); + const duped = try allocator.dupeZ(u8, base); + return encodeURL(allocator, duped, opts); + } + if (comptime opts.encode) { + return encodeURL(allocator, base, opts); } return base; } if (path[0] == '?') { const base_path_end = std.mem.indexOfAny(u8, base, "?#") orelse base.len; - return std.mem.joinZ(allocator, "", &.{ base[0..base_path_end], path }); + const result = try std.mem.joinZ(allocator, "", &.{ base[0..base_path_end], path }); + return encodeURL(allocator, result, opts); } if (path[0] == '#') { const base_fragment_start = std.mem.indexOfScalar(u8, base, '#') orelse base.len; - return std.mem.joinZ(allocator, "", &.{ base[0..base_fragment_start], path }); + const result = try std.mem.joinZ(allocator, "", &.{ base[0..base_fragment_start], path }); + return encodeURL(allocator, result, opts); } if (std.mem.startsWith(u8, path, "//")) { // network-path reference const index = std.mem.indexOfScalar(u8, base, ':') orelse { if (comptime isNullTerminated(PT)) { + if (comptime opts.encode) { + return encodeURL(allocator, path, opts); + } return path; } - return allocator.dupeZ(u8, path); + const duped = try allocator.dupeZ(u8, path); + return encodeURL(allocator, duped, opts); }; const protocol = base[0 .. index + 1]; - return std.mem.joinZ(allocator, "", &.{ protocol, path }); + const result = try std.mem.joinZ(allocator, "", &.{ protocol, path }); + return encodeURL(allocator, result, opts); } const scheme_end = std.mem.indexOf(u8, base, "://"); @@ -65,7 +82,8 @@ pub fn resolve(allocator: Allocator, base: [:0]const u8, path: anytype, comptime const path_start = std.mem.indexOfScalarPos(u8, base, authority_start, '/') orelse base.len; if (path[0] == '/') { - return std.mem.joinZ(allocator, "", &.{ base[0..path_start], path }); + const result = try std.mem.joinZ(allocator, "", &.{ base[0..path_start], path }); + return encodeURL(allocator, result, opts); } var normalized_base: []const u8 = base[0..path_start]; @@ -127,7 +145,115 @@ pub fn resolve(allocator: Allocator, base: [:0]const u8, path: anytype, comptime // we always have an extra space out[out_i] = 0; - return out[0..out_i :0]; + return encodeURL(allocator, out[0..out_i :0], opts); +} + +fn encodeURL(allocator: Allocator, url: [:0]const u8, comptime opts: ResolveOpts) ![:0]const u8 { + if (!comptime opts.encode) { + return url; + } + + const scheme_end = std.mem.indexOf(u8, url, "://"); + const authority_start = if (scheme_end) |end| end + 3 else 0; + const path_start = std.mem.indexOfScalarPos(u8, url, authority_start, '/') orelse return url; + + const query_start = std.mem.indexOfScalarPos(u8, url, path_start, '?'); + const fragment_start = std.mem.indexOfScalarPos(u8, url, query_start orelse path_start, '#'); + + const path_end = query_start orelse fragment_start orelse url.len; + const query_end = if (query_start) |_| (fragment_start orelse url.len) else path_end; + + const path_to_encode = url[path_start..path_end]; + const encoded_path = try percentEncodeSegment(allocator, path_to_encode, true); + + const encoded_query = if (query_start) |qs| blk: { + const query_to_encode = url[qs + 1 .. query_end]; + const encoded = try percentEncodeSegment(allocator, query_to_encode, false); + break :blk encoded; + } else null; + + const encoded_fragment = if (fragment_start) |fs| blk: { + const fragment_to_encode = url[fs + 1 ..]; + const encoded = try percentEncodeSegment(allocator, fragment_to_encode, false); + break :blk encoded; + } else null; + + if (encoded_path.ptr == path_to_encode.ptr and + (encoded_query == null or encoded_query.?.ptr == url[query_start.? + 1 .. query_end].ptr) and + (encoded_fragment == null or encoded_fragment.?.ptr == url[fragment_start.? + 1 ..].ptr)) { + // nothing has changed + return url; + } + + var buf = try std.ArrayList(u8).initCapacity(allocator, url.len + 20); + try buf.appendSlice(allocator, url[0..path_start]); + try buf.appendSlice(allocator, encoded_path); + if (encoded_query) |eq| { + try buf.append(allocator, '?'); + try buf.appendSlice(allocator, eq); + } + if (encoded_fragment) |ef| { + try buf.append(allocator, '#'); + try buf.appendSlice(allocator, ef); + } + try buf.append(allocator, 0); + return buf.items[0 .. buf.items.len - 1 :0]; +} + +fn percentEncodeSegment(allocator: Allocator, segment: []const u8, comptime is_path: bool) ![]const u8 { + // Check if encoding is needed + var needs_encoding = false; + for (segment) |c| { + if (shouldPercentEncode(c, is_path)) { + needs_encoding = true; + break; + } + } + if (!needs_encoding) { + return segment; + } + + var buf = try std.ArrayList(u8).initCapacity(allocator, segment.len + 10); + + var i: usize = 0; + while (i < segment.len) : (i += 1) { + const c = segment[i]; + + // Check if this is an already-encoded sequence (%XX) + if (c == '%' and i + 2 < segment.len) { + const end = i + 2; + const h1 = segment[i + 1]; + const h2 = segment[end]; + if (std.ascii.isHex(h1) and std.ascii.isHex(h2)) { + try buf.appendSlice(allocator, segment[i .. end + 1]); + i = end; + continue; + } + } + + if (shouldPercentEncode(c, is_path)) { + try buf.writer(allocator).print("%{X:0>2}", .{c}); + } else { + try buf.append(allocator, c); + } + } + + return buf.items; +} + +fn shouldPercentEncode(c: u8, comptime is_path: bool) bool { + return switch (c) { + // Unreserved characters (RFC 3986) + 'A'...'Z', 'a'...'z', '0'...'9', '-', '.', '_', '~' => false, + // sub-delims allowed in both path and query + '!', '$', '&', '\'', '(', ')', '*', '+', ',', ';', '=' => false, + // Separators allowed in both path and query + '/', ':', '@' => false, + // Query-specific: '?' is allowed in queries but not in paths + '?' => comptime is_path, + // Everything else needs encoding (including space) + else => true, + }; } fn isNullTerminated(comptime value: type) bool { @@ -691,6 +817,172 @@ test "URL: resolve" { } } +test "URL: resolve with encoding" { + defer testing.reset(); + + const Case = struct { + base: [:0]const u8, + path: [:0]const u8, + expected: [:0]const u8, + }; + + const cases = [_]Case{ + // Spaces should be encoded as %20, but ! is allowed + .{ + .base = "https://example.com/dir/", + .path = "over 9000!", + .expected = "https://example.com/dir/over%209000!", + }, + .{ + .base = "https://example.com/", + .path = "hello world.html", + .expected = "https://example.com/hello%20world.html", + }, + // Multiple spaces + .{ + .base = "https://example.com/", + .path = "path with multiple spaces", + .expected = "https://example.com/path%20with%20%20multiple%20%20%20spaces", + }, + // Special characters that need encoding + .{ + .base = "https://example.com/", + .path = "file[1].html", + .expected = "https://example.com/file%5B1%5D.html", + }, + .{ + .base = "https://example.com/", + .path = "file{name}.html", + .expected = "https://example.com/file%7Bname%7D.html", + }, + .{ + .base = "https://example.com/", + .path = "file.html", + .expected = "https://example.com/file%3Ctest%3E.html", + }, + .{ + .base = "https://example.com/", + .path = "file\"quote\".html", + .expected = "https://example.com/file%22quote%22.html", + }, + .{ + .base = "https://example.com/", + .path = "file|pipe.html", + .expected = "https://example.com/file%7Cpipe.html", + }, + .{ + .base = "https://example.com/", + .path = "file\\backslash.html", + .expected = "https://example.com/file%5Cbackslash.html", + }, + .{ + .base = "https://example.com/", + .path = "file^caret.html", + .expected = "https://example.com/file%5Ecaret.html", + }, + .{ + .base = "https://example.com/", + .path = "file`backtick`.html", + .expected = "https://example.com/file%60backtick%60.html", + }, + // Characters that should NOT be encoded + .{ + .base = "https://example.com/", + .path = "path-with_under~tilde.html", + .expected = "https://example.com/path-with_under~tilde.html", + }, + .{ + .base = "https://example.com/", + .path = "path/with/slashes", + .expected = "https://example.com/path/with/slashes", + }, + .{ + .base = "https://example.com/", + .path = "sub-delims!$&'()*+,;=.html", + .expected = "https://example.com/sub-delims!$&'()*+,;=.html", + }, + // Already encoded characters should not be double-encoded + .{ + .base = "https://example.com/", + .path = "already%20encoded", + .expected = "https://example.com/already%20encoded", + }, + .{ + .base = "https://example.com/", + .path = "file%5B1%5D.html", + .expected = "https://example.com/file%5B1%5D.html", + }, + // Mix of encoded and unencoded + .{ + .base = "https://example.com/", + .path = "part%20encoded and not", + .expected = "https://example.com/part%20encoded%20and%20not", + }, + // Query strings and fragments ARE encoded + .{ + .base = "https://example.com/", + .path = "file name.html?query=value with spaces", + .expected = "https://example.com/file%20name.html?query=value%20with%20spaces", + }, + .{ + .base = "https://example.com/", + .path = "file name.html#anchor with spaces", + .expected = "https://example.com/file%20name.html#anchor%20with%20spaces", + }, + .{ + .base = "https://example.com/", + .path = "file.html?hello=world !", + .expected = "https://example.com/file.html?hello=world%20!", + }, + // Query structural characters should NOT be encoded + .{ + .base = "https://example.com/", + .path = "file.html?a=1&b=2", + .expected = "https://example.com/file.html?a=1&b=2", + }, + // Relative paths with encoding + .{ + .base = "https://example.com/dir/page.html", + .path = "../other dir/file.html", + .expected = "https://example.com/other%20dir/file.html", + }, + .{ + .base = "https://example.com/dir/", + .path = "./sub dir/file.html", + .expected = "https://example.com/dir/sub%20dir/file.html", + }, + // Absolute paths with encoding + .{ + .base = "https://example.com/some/path", + .path = "/absolute path/file.html", + .expected = "https://example.com/absolute%20path/file.html", + }, + // Unicode/high bytes (though ideally these should be UTF-8 encoded first) + .{ + .base = "https://example.com/", + .path = "café", + .expected = "https://example.com/caf%C3%A9", + }, + // Empty path + .{ + .base = "https://example.com/", + .path = "", + .expected = "https://example.com/", + }, + // Complete URL as path (should not be encoded) + .{ + .base = "https://example.com/", + .path = "https://other.com/path with spaces", + .expected = "https://other.com/path%20with%20spaces", + }, + }; + + for (cases) |case| { + const result = try resolve(testing.arena_allocator, case.base, case.path, .{ .encode = true }); + try testing.expectString(case.expected, result); + } +} + test "URL: eqlDocument" { defer testing.reset(); { diff --git a/src/browser/tests/element/html/anchor.html b/src/browser/tests/element/html/anchor.html index 3c248a7b..0522163f 100644 --- a/src/browser/tests/element/html/anchor.html +++ b/src/browser/tests/element/html/anchor.html @@ -245,3 +245,11 @@ testing.expectEqual('', b.toString()); } + + diff --git a/src/browser/tests/element/html/image.html b/src/browser/tests/element/html/image.html index 1fda424a..e7868229 100644 --- a/src/browser/tests/element/html/image.html +++ b/src/browser/tests/element/html/image.html @@ -172,3 +172,12 @@ }); } + + diff --git a/src/browser/tests/frames/frames.html b/src/browser/tests/frames/frames.html index 00403f3a..1aa81b21 100644 --- a/src/browser/tests/frames/frames.html +++ b/src/browser/tests/frames/frames.html @@ -7,7 +7,7 @@ } - + + + + + + + + + + + + + + + + + diff --git a/src/browser/webapi/Performance.zig b/src/browser/webapi/Performance.zig index 7004f63c..33404e4e 100644 --- a/src/browser/webapi/Performance.zig +++ b/src/browser/webapi/Performance.zig @@ -3,7 +3,7 @@ const Page = @import("../Page.zig"); const datetime = @import("../../datetime.zig"); pub fn registerTypes() []const type { - return &.{ Performance, Entry, Mark, Measure, PerformanceTiming }; + return &.{ Performance, Entry, Mark, Measure, PerformanceTiming, PerformanceNavigation }; } const std = @import("std"); @@ -13,6 +13,7 @@ const Performance = @This(); _time_origin: u64, _entries: std.ArrayList(*Entry) = .{}, _timing: PerformanceTiming = .{}, +_navigation: PerformanceNavigation = .{}, /// Get high-resolution timestamp in microseconds, rounded to 5μs increments /// to match browser behavior (prevents fingerprinting) @@ -29,6 +30,7 @@ pub fn init() Performance { ._time_origin = highResTimestamp(), ._entries = .{}, ._timing = .{}, + ._navigation = .{}, }; } @@ -48,6 +50,10 @@ pub fn getTimeOrigin(self: *const Performance) f64 { return @as(f64, @floatFromInt(self._time_origin)) / 1000.0; } +pub fn getNavigation(self: *Performance) *PerformanceNavigation { + return &self._navigation; +} + pub fn mark( self: *Performance, name: []const u8, @@ -270,6 +276,7 @@ pub const JsApi = struct { pub const getEntriesByName = bridge.function(Performance.getEntriesByName, .{}); pub const timeOrigin = bridge.accessor(Performance.getTimeOrigin, null, .{}); pub const timing = bridge.accessor(Performance.getTiming, null, .{}); + pub const navigation = bridge.accessor(Performance.getNavigation, null, .{}); }; pub const Entry = struct { @@ -519,6 +526,30 @@ pub const PerformanceTiming = struct { }; }; +// PerformanceNavigation implements the Navigation Timing Level 1 API. +// https://www.w3.org/TR/navigation-timing/#sec-navigation-navigation-timing-interface +// Stub implementation — returns 0 for type (TYPE_NAVIGATE) and 0 for redirectCount. +pub const PerformanceNavigation = struct { + // Padding to avoid zero-size struct, which causes identity_map pointer collisions. + _pad: bool = false, + + pub fn getType(_: *const PerformanceNavigation) f64 { return 0; } + pub fn getRedirectCount(_: *const PerformanceNavigation) f64 { return 0; } + + pub const JsApi = struct { + pub const bridge = js.Bridge(PerformanceNavigation); + + pub const Meta = struct { + pub const name = "PerformanceNavigation"; + pub const prototype_chain = bridge.prototypeChain(); + pub var class_id: bridge.ClassId = undefined; + }; + + pub const @"type" = bridge.accessor(PerformanceNavigation.getType, null, .{}); + pub const redirectCount = bridge.accessor(PerformanceNavigation.getRedirectCount, null, .{}); + }; +}; + const testing = @import("../../testing.zig"); test "WebApi: Performance" { try testing.htmlRunner("performance.html", .{}); From ba28bf01b761f7b0509b3a1289b700c1718be772 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Wed, 25 Feb 2026 18:05:33 +0800 Subject: [PATCH 048/103] Include assertion args in crash report Add more parameters to mysterious/persistent/infrequent ScriptManager.Header recall failed assertion. --- src/browser/ScriptManager.zig | 13 ++++++++++++- src/crash_handler.zig | 24 +++++++++++++++--------- 2 files changed, 27 insertions(+), 10 deletions(-) diff --git a/src/browser/ScriptManager.zig b/src/browser/ScriptManager.zig index b536e2db..8a7f40a4 100644 --- a/src/browser/ScriptManager.zig +++ b/src/browser/ScriptManager.zig @@ -628,8 +628,13 @@ pub const Script = struct { node: std.DoublyLinkedList.Node, script_element: ?*Element.Html.Script, manager: *ScriptManager, + + // for debugging a rare production issue header_callback_called: bool = false, + // for debugging a rare production issue + debug_transfer_id: u32 = 0, + const Kind = enum { module, javascript, @@ -697,8 +702,14 @@ pub const Script = struct { // temp debug, trying to figure out why the next assert sometimes // fails. Is the buffer just corrupt or is headerCallback really // being called twice? - lp.assert(self.header_callback_called == false, "ScriptManager.Header recall", .{}); + lp.assert(self.header_callback_called == false, "ScriptManager.Header recall", .{ + .thd = transfer._header_done_called, + .t1 = self.debug_transfer_id, + .t2 = transfer.id, + .tries = transfer._tries, + }); self.header_callback_called = true; + self.debug_transfer_id = transfer.id; } lp.assert(self.source.remote.capacity == 0, "ScriptManager.Header buffer", .{ .capacity = self.source.remote.capacity }); diff --git a/src/crash_handler.zig b/src/crash_handler.zig index 260ec71a..d9c77299 100644 --- a/src/crash_handler.zig +++ b/src/crash_handler.zig @@ -57,7 +57,7 @@ pub noinline fn crash( std.debug.dumpCurrentStackTraceToWriter(begin_addr, writer) catch abort(); } - report(reason, begin_addr) catch {}; + report(reason, begin_addr, args) catch {}; }, 1 => { panic_level = 2; @@ -71,7 +71,7 @@ pub noinline fn crash( abort(); } -fn report(reason: []const u8, begin_addr: usize) !void { +fn report(reason: []const u8, begin_addr: usize, args: anytype) !void { if (comptime IS_DEBUG) { return; } @@ -99,18 +99,24 @@ fn report(reason: []const u8, begin_addr: usize) !void { break :blk writer.buffered(); }; - var stack_buffer: [4096]u8 = undefined; - const stack = blk: { - var writer: std.Io.Writer = .fixed(stack_buffer[0..4095]); // reserve 1 space + var body_buffer: [8192]u8 = undefined; + const body = blk: { + var writer: std.Io.Writer = .fixed(body_buffer[0..8191]); // reserve 1 space + inline for (@typeInfo(@TypeOf(args)).@"struct".fields) |f| { + writer.writeAll(f.name ++ ": ") catch break; + @import("log.zig").writeValue(.pretty, @field(args, f.name), &writer) catch {}; + writer.writeByte('\n') catch {}; + } + std.debug.dumpCurrentStackTraceToWriter(begin_addr, &writer) catch {}; const written = writer.buffered(); if (written.len == 0) { break :blk "???"; } // Overwrite the last character with our null terminator - // stack_buffer always has to be > written - stack_buffer[written.len] = 0; - break :blk stack_buffer[0 .. written.len + 1]; + // body_buffer always has to be > written + body_buffer[written.len] = 0; + break :blk body_buffer[0 .. written.len + 1]; }; var argv = [_:null]?[*:0]const u8{ @@ -119,7 +125,7 @@ fn report(reason: []const u8, begin_addr: usize) !void { "-H", "Content-Type: application/octet-stream", "--data-binary", - stack[0 .. stack.len - 1 :0], + body[0 .. body.len - 1 :0], url[0 .. url.len - 1 :0], }; From 25298a32fa40129b079acf2a0d22a205aa75fafe Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Wed, 25 Feb 2026 18:16:13 +0800 Subject: [PATCH 049/103] Dump Attribute to empty string This is normally not called in "normal" dump-usage, but with XMLSerializer.serializeToString an Attr node _can_ be provided. The spec says, and FF agrees, this should return an empty string. --- src/browser/dump.zig | 6 +++++- src/browser/tests/xmlserializer.html | 11 +++++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/src/browser/dump.zig b/src/browser/dump.zig index e4d0fa05..0f20b06b 100644 --- a/src/browser/dump.zig +++ b/src/browser/dump.zig @@ -172,7 +172,11 @@ fn _deep(node: *Node, opts: Opts, comptime force_slot: bool, writer: *std.Io.Wri try writer.writeAll(">\n"); }, .document_fragment => try children(node, opts, writer, page), - .attribute => unreachable, + .attribute => { + // Not called normally, but can be called via XMLSerializer.serializeToString + // in which case it should return an empty string + try writer.writeAll(""); + }, } } diff --git a/src/browser/tests/xmlserializer.html b/src/browser/tests/xmlserializer.html index edbc60c8..0b8b7272 100644 --- a/src/browser/tests/xmlserializer.html +++ b/src/browser/tests/xmlserializer.html @@ -129,3 +129,14 @@ testing.expectEqual(original, serialized); } + + From 59535c112e369027215758e2196dc63d2a2db7a4 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Wed, 25 Feb 2026 19:03:24 +0800 Subject: [PATCH 050/103] Add FileReader --- src/browser/EventManager.zig | 1 + src/browser/Factory.zig | 5 +- src/browser/js/bridge.zig | 1 + src/browser/tests/file_reader.html | 197 ++++++++++ src/browser/webapi/EventTarget.zig | 2 + src/browser/webapi/FileReader.zig | 358 ++++++++++++++++++ .../webapi/net/XMLHttpRequestEventTarget.zig | 48 +-- 7 files changed, 575 insertions(+), 37 deletions(-) create mode 100644 src/browser/tests/file_reader.html create mode 100644 src/browser/webapi/FileReader.zig diff --git a/src/browser/EventManager.zig b/src/browser/EventManager.zig index a1552440..1cf82bfc 100644 --- a/src/browser/EventManager.zig +++ b/src/browser/EventManager.zig @@ -234,6 +234,7 @@ pub fn dispatchOpts(self: *EventManager, target: *EventTarget, event: *Event, co .screen, .screen_orientation, .visual_viewport, + .file_reader, .generic, => { const list = self.lookup.get(.{ diff --git a/src/browser/Factory.zig b/src/browser/Factory.zig index 402ce240..cbc2170d 100644 --- a/src/browser/Factory.zig +++ b/src/browser/Factory.zig @@ -59,7 +59,10 @@ pub fn init(arena: Allocator) !*Factory { // this is a root object pub fn eventTarget(self: *Factory, child: anytype) !*@TypeOf(child) { - const allocator = self._slab.allocator(); + return self.eventTargetWithAllocator(self._slab.allocator(), child); +} + +pub fn eventTargetWithAllocator(_: *const Factory, allocator: Allocator, child: anytype) !*@TypeOf(child) { const chain = try PrototypeChain( &.{ EventTarget, @TypeOf(child) }, ).allocate(allocator); diff --git a/src/browser/js/bridge.zig b/src/browser/js/bridge.zig index 30aae516..aaad5cc5 100644 --- a/src/browser/js/bridge.zig +++ b/src/browser/js/bridge.zig @@ -857,6 +857,7 @@ pub const JsApis = flattenTypes(&.{ @import("../webapi/IdleDeadline.zig"), @import("../webapi/Blob.zig"), @import("../webapi/File.zig"), + @import("../webapi/FileReader.zig"), @import("../webapi/Screen.zig"), @import("../webapi/VisualViewport.zig"), @import("../webapi/PerformanceObserver.zig"), diff --git a/src/browser/tests/file_reader.html b/src/browser/tests/file_reader.html new file mode 100644 index 00000000..ca0d87c4 --- /dev/null +++ b/src/browser/tests/file_reader.html @@ -0,0 +1,197 @@ + + + + + + + + + + + + + + + + + + + diff --git a/src/browser/webapi/EventTarget.zig b/src/browser/webapi/EventTarget.zig index d322b68d..6da06768 100644 --- a/src/browser/webapi/EventTarget.zig +++ b/src/browser/webapi/EventTarget.zig @@ -43,6 +43,7 @@ pub const Type = union(enum) { screen: *@import("Screen.zig"), screen_orientation: *@import("Screen.zig").Orientation, visual_viewport: *@import("VisualViewport.zig"), + file_reader: *@import("FileReader.zig"), }; pub fn init(page: *Page) !*EventTarget { @@ -152,6 +153,7 @@ pub fn toString(self: *EventTarget) []const u8 { .screen => return "[object Screen]", .screen_orientation => return "[object ScreenOrientation]", .visual_viewport => return "[object VisualViewport]", + .file_reader => return "[object FileReader]", }; } diff --git a/src/browser/webapi/FileReader.zig b/src/browser/webapi/FileReader.zig new file mode 100644 index 00000000..3d189089 --- /dev/null +++ b/src/browser/webapi/FileReader.zig @@ -0,0 +1,358 @@ +// 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 js = @import("../js/js.zig"); + +const Page = @import("../Page.zig"); +const EventTarget = @import("EventTarget.zig"); +const ProgressEvent = @import("event/ProgressEvent.zig"); +const Blob = @import("Blob.zig"); + +const Allocator = std.mem.Allocator; + +/// https://w3c.github.io/FileAPI/#dfn-filereader +/// https://developer.mozilla.org/en-US/docs/Web/API/FileReader +const FileReader = @This(); + +_page: *Page, +_proto: *EventTarget, +_arena: Allocator, + +_ready_state: ReadyState = .empty, +_result: ?Result = null, +_error: ?[]const u8 = null, + +_on_abort: ?js.Function.Temp = null, +_on_error: ?js.Function.Temp = null, +_on_load: ?js.Function.Temp = null, +_on_load_end: ?js.Function.Temp = null, +_on_load_start: ?js.Function.Temp = null, +_on_progress: ?js.Function.Temp = null, + +_aborted: bool = false, + +const ReadyState = enum(u8) { + empty = 0, + loading = 1, + done = 2, +}; + +const Result = union(enum) { + string: []const u8, + arraybuffer: js.ArrayBuffer, +}; + +pub fn init(page: *Page) !*FileReader { + const arena = try page.getArena(.{ .debug = "FileReader" }); + errdefer page.releaseArena(arena); + + return page._factory.eventTargetWithAllocator(arena, FileReader{ + ._page = page, + ._arena = arena, + ._proto = undefined, + }); +} + +pub fn deinit(self: *FileReader, _: bool, page: *Page) void { + const js_ctx = page.js; + + if (self._on_abort) |func| js_ctx.release(func); + if (self._on_error) |func| js_ctx.release(func); + if (self._on_load) |func| js_ctx.release(func); + if (self._on_load_end) |func| js_ctx.release(func); + if (self._on_load_start) |func| js_ctx.release(func); + if (self._on_progress) |func| js_ctx.release(func); + + page.releaseArena(self._arena); +} + +fn asEventTarget(self: *FileReader) *EventTarget { + return self._proto; +} + +pub fn getOnAbort(self: *const FileReader) ?js.Function.Temp { + return self._on_abort; +} + +pub fn setOnAbort(self: *FileReader, cb: ?js.Function.Temp) !void { + self._on_abort = cb; +} + +pub fn getOnError(self: *const FileReader) ?js.Function.Temp { + return self._on_error; +} + +pub fn setOnError(self: *FileReader, cb: ?js.Function.Temp) !void { + self._on_error = cb; +} + +pub fn getOnLoad(self: *const FileReader) ?js.Function.Temp { + return self._on_load; +} + +pub fn setOnLoad(self: *FileReader, cb: ?js.Function.Temp) !void { + self._on_load = cb; +} + +pub fn getOnLoadEnd(self: *const FileReader) ?js.Function.Temp { + return self._on_load_end; +} + +pub fn setOnLoadEnd(self: *FileReader, cb: ?js.Function.Temp) !void { + self._on_load_end = cb; +} + +pub fn getOnLoadStart(self: *const FileReader) ?js.Function.Temp { + return self._on_load_start; +} + +pub fn setOnLoadStart(self: *FileReader, cb: ?js.Function.Temp) !void { + self._on_load_start = cb; +} + +pub fn getOnProgress(self: *const FileReader) ?js.Function.Temp { + return self._on_progress; +} + +pub fn setOnProgress(self: *FileReader, cb: ?js.Function.Temp) !void { + self._on_progress = cb; +} + +pub fn getReadyState(self: *const FileReader) u8 { + return @intFromEnum(self._ready_state); +} + +pub fn getResult(self: *const FileReader) ?Result { + return self._result; +} + +pub fn getError(self: *const FileReader) ?[]const u8 { + return self._error; +} + +pub fn readAsArrayBuffer(self: *FileReader, blob: *Blob) !void { + try self.readInternal(blob, .arraybuffer); +} + +pub fn readAsBinaryString(self: *FileReader, blob: *Blob) !void { + try self.readInternal(blob, .binary_string); +} + +pub fn readAsText(self: *FileReader, blob: *Blob, encoding_: ?[]const u8) !void { + _ = encoding_; // TODO: Handle encoding properly + try self.readInternal(blob, .text); +} + +pub fn readAsDataURL(self: *FileReader, blob: *Blob) !void { + try self.readInternal(blob, .data_url); +} + +const ReadType = enum { + arraybuffer, + binary_string, + text, + data_url, +}; + +fn readInternal(self: *FileReader, blob: *Blob, read_type: ReadType) !void { + if (self._ready_state == .loading) { + return error.InvalidStateError; + } + + // Reset state + self._ready_state = .loading; + self._result = null; + self._error = null; + self._aborted = false; + + const page = self._page; + + var ls: js.Local.Scope = undefined; + page.js.localScope(&ls); + defer ls.deinit(); + const local = &ls.local; + + try self.dispatch(.load_start, .{ .loaded = 0, .total = blob.getSize() }, local, page); + if (self._aborted) { + return; + } + + // Perform the read (synchronous since data is in memory) + const data = blob._slice; + const size = data.len; + try self.dispatch(.progress, .{ .loaded = size, .total = size }, local, page); + if (self._aborted) { + return; + } + + // Process the data based on read type + self._result = switch (read_type) { + .arraybuffer => .{ .arraybuffer = .{ .values = data } }, + .binary_string => .{ .string = data }, + .text => .{ .string = data }, + .data_url => blk: { + // Create data URL with base64 encoding + const mime = if (blob._mime.len > 0) blob._mime else "application/octet-stream"; + const data_url = try encodeDataURL(self._arena, mime, data); + break :blk .{ .string = data_url }; + }, + }; + + self._ready_state = .done; + + try self.dispatch(.load, .{ .loaded = size, .total = size }, local, page); + try self.dispatch(.load_end, .{ .loaded = size, .total = size }, local, page); +} + +pub fn abort(self: *FileReader) !void { + if (self._ready_state != .loading) { + return; + } + + self._aborted = true; + self._ready_state = .done; + self._result = null; + + const page = self._page; + + var ls: js.Local.Scope = undefined; + page.js.localScope(&ls); + defer ls.deinit(); + const local = &ls.local; + + try self.dispatch(.abort, null, local, page); + + try self.dispatch(.load_end, null, local, page); +} + +fn dispatch(self: *FileReader, comptime event_type: DispatchType, progress_: ?Progress, local: *const js.Local, page: *Page) !void { + const field, const typ = comptime blk: { + break :blk switch (event_type) { + .abort => .{ "_on_abort", "abort" }, + .err => .{ "_on_error", "error" }, + .load => .{ "_on_load", "load" }, + .load_end => .{ "_on_load_end", "loadend" }, + .load_start => .{ "_on_load_start", "loadstart" }, + .progress => .{ "_on_progress", "progress" }, + }; + }; + + const progress = progress_ orelse Progress{}; + const event = (try ProgressEvent.initTrusted( + comptime .wrap(typ), + .{ .total = progress.total, .loaded = progress.loaded }, + page, + )).asEvent(); + + return page._event_manager.dispatchWithFunction( + self.asEventTarget(), + event, + local.toLocal(@field(self, field)), + .{ .context = "FileReader " ++ typ }, + ); +} + +const DispatchType = enum { + abort, + err, + load, + load_end, + load_start, + progress, +}; + +const Progress = struct { + loaded: usize = 0, + total: usize = 0, +}; + +/// Encodes binary data as a data URL with base64 encoding. +/// Format: data:[][;base64], +fn encodeDataURL(arena: Allocator, mime: []const u8, data: []const u8) ![]const u8 { + const base64 = std.base64.standard.Encoder; + + // Calculate size needed for base64 encoding + const encoded_size = base64.calcSize(data.len); + + // Allocate buffer for the full data URL + // Format: "data:" + mime + ";base64," + encoded_data + const prefix = "data:"; + const suffix = ";base64,"; + const total_size = prefix.len + mime.len + suffix.len + encoded_size; + + var pos: usize = 0; + const buf = try arena.alloc(u8, total_size); + + @memcpy(buf[pos..][0..prefix.len], prefix); + pos += prefix.len; + + @memcpy(buf[pos..][0..mime.len], mime); + pos += mime.len; + + @memcpy(buf[pos..][0..suffix.len], suffix); + pos += suffix.len; + + _ = base64.encode(buf[pos..], data); + + return buf; +} + +pub const JsApi = struct { + pub const bridge = js.Bridge(FileReader); + + pub const Meta = struct { + pub const name = "FileReader"; + pub const prototype_chain = bridge.prototypeChain(); + pub var class_id: bridge.ClassId = undefined; + pub const weak = true; + pub const finalizer = bridge.finalizer(FileReader.deinit); + }; + + pub const constructor = bridge.constructor(FileReader.init, .{}); + + // State constants + pub const EMPTY = bridge.property(@intFromEnum(FileReader.ReadyState.empty), .{ .template = true }); + pub const LOADING = bridge.property(@intFromEnum(FileReader.ReadyState.loading), .{ .template = true }); + pub const DONE = bridge.property(@intFromEnum(FileReader.ReadyState.done), .{ .template = true }); + + // Properties + pub const readyState = bridge.accessor(FileReader.getReadyState, null, .{}); + pub const result = bridge.accessor(FileReader.getResult, null, .{}); + pub const @"error" = bridge.accessor(FileReader.getError, null, .{}); + + // Event handlers + pub const onabort = bridge.accessor(FileReader.getOnAbort, FileReader.setOnAbort, .{}); + pub const onerror = bridge.accessor(FileReader.getOnError, FileReader.setOnError, .{}); + pub const onload = bridge.accessor(FileReader.getOnLoad, FileReader.setOnLoad, .{}); + pub const onloadend = bridge.accessor(FileReader.getOnLoadEnd, FileReader.setOnLoadEnd, .{}); + pub const onloadstart = bridge.accessor(FileReader.getOnLoadStart, FileReader.setOnLoadStart, .{}); + pub const onprogress = bridge.accessor(FileReader.getOnProgress, FileReader.setOnProgress, .{}); + + // Methods + pub const readAsArrayBuffer = bridge.function(FileReader.readAsArrayBuffer, .{ .dom_exception = true }); + pub const readAsBinaryString = bridge.function(FileReader.readAsBinaryString, .{ .dom_exception = true }); + pub const readAsText = bridge.function(FileReader.readAsText, .{ .dom_exception = true }); + pub const readAsDataURL = bridge.function(FileReader.readAsDataURL, .{ .dom_exception = true }); + pub const abort = bridge.function(FileReader.abort, .{}); +}; + +const testing = @import("../../testing.zig"); +test "WebApi: FileReader" { + try testing.htmlRunner("file_reader.html", .{}); +} diff --git a/src/browser/webapi/net/XMLHttpRequestEventTarget.zig b/src/browser/webapi/net/XMLHttpRequestEventTarget.zig index cb7be000..ae268a59 100644 --- a/src/browser/webapi/net/XMLHttpRequestEventTarget.zig +++ b/src/browser/webapi/net/XMLHttpRequestEventTarget.zig @@ -75,72 +75,48 @@ pub fn getOnAbort(self: *const XMLHttpRequestEventTarget) ?js.Function.Temp { return self._on_abort; } -pub fn setOnAbort(self: *XMLHttpRequestEventTarget, cb_: ?js.Function) !void { - if (cb_) |cb| { - self._on_abort = try cb.tempWithThis(self); - } else { - self._on_abort = null; - } +pub fn setOnAbort(self: *XMLHttpRequestEventTarget, cb: ?js.Function.Temp) !void { + self._on_abort = cb; } pub fn getOnError(self: *const XMLHttpRequestEventTarget) ?js.Function.Temp { return self._on_error; } -pub fn setOnError(self: *XMLHttpRequestEventTarget, cb_: ?js.Function) !void { - if (cb_) |cb| { - self._on_error = try cb.tempWithThis(self); - } else { - self._on_error = null; - } +pub fn setOnError(self: *XMLHttpRequestEventTarget, cb: ?js.Function.Temp) !void { + self._on_error = cb; } pub fn getOnLoad(self: *const XMLHttpRequestEventTarget) ?js.Function.Temp { return self._on_load; } -pub fn setOnLoad(self: *XMLHttpRequestEventTarget, cb_: ?js.Function) !void { - if (cb_) |cb| { - self._on_load = try cb.tempWithThis(self); - } else { - self._on_load = null; - } +pub fn setOnLoad(self: *XMLHttpRequestEventTarget, cb: ?js.Function.Temp) !void { + self._on_load = cb; } pub fn getOnLoadEnd(self: *const XMLHttpRequestEventTarget) ?js.Function.Temp { return self._on_load_end; } -pub fn setOnLoadEnd(self: *XMLHttpRequestEventTarget, cb_: ?js.Function) !void { - if (cb_) |cb| { - self._on_load_end = try cb.tempWithThis(self); - } else { - self._on_load_end = null; - } +pub fn setOnLoadEnd(self: *XMLHttpRequestEventTarget, cb: ?js.Function.Temp) !void { + self._on_load_end = cb; } pub fn getOnLoadStart(self: *const XMLHttpRequestEventTarget) ?js.Function.Temp { return self._on_load_start; } -pub fn setOnLoadStart(self: *XMLHttpRequestEventTarget, cb_: ?js.Function) !void { - if (cb_) |cb| { - self._on_load_start = try cb.tempWithThis(self); - } else { - self._on_load_start = null; - } +pub fn setOnLoadStart(self: *XMLHttpRequestEventTarget, cb: ?js.Function.Temp) !void { + self._on_load_start = cb; } pub fn getOnProgress(self: *const XMLHttpRequestEventTarget) ?js.Function.Temp { return self._on_progress; } -pub fn setOnProgress(self: *XMLHttpRequestEventTarget, cb_: ?js.Function) !void { - if (cb_) |cb| { - self._on_progress = try cb.tempWithThis(self); - } else { - self._on_progress = null; - } +pub fn setOnProgress(self: *XMLHttpRequestEventTarget, cb: ?js.Function.Temp) !void { + self._on_progress = cb; } pub fn getOnTimeout(self: *const XMLHttpRequestEventTarget) ?js.Function.Temp { From f348d85b11b18097c8374984fe1078c536215b76 Mon Sep 17 00:00:00 2001 From: Muki Kiboigo Date: Wed, 25 Feb 2026 07:21:16 -0800 Subject: [PATCH 051/103] add tests for walking past element on selection modify --- src/browser/tests/selection.html | 62 ++++++++++++++++++++++++++++++++ 1 file changed, 62 insertions(+) diff --git a/src/browser/tests/selection.html b/src/browser/tests/selection.html index 1adac45e..a1e73bff 100644 --- a/src/browser/tests/selection.html +++ b/src/browser/tests/selection.html @@ -675,3 +675,65 @@ testing.expectEqual(0, sel.anchorOffset); } + + + + + + + + From dd15f5e052a6159755f4929bfdf3d173a0cd9033 Mon Sep 17 00:00:00 2001 From: Muki Kiboigo Date: Wed, 25 Feb 2026 07:21:30 -0800 Subject: [PATCH 052/103] fix selection modify on nextTextNodeAfter --- src/browser/webapi/Selection.zig | 31 +++++++++++++++++++++++++++++-- 1 file changed, 29 insertions(+), 2 deletions(-) diff --git a/src/browser/webapi/Selection.zig b/src/browser/webapi/Selection.zig index e7012eda..16f54026 100644 --- a/src/browser/webapi/Selection.zig +++ b/src/browser/webapi/Selection.zig @@ -354,6 +354,30 @@ fn nextTextNode(node: *Node) ?*Node { } } +fn nextTextNodeAfter(node: *Node) ?*Node { + var current = node; + while (true) { + if (current.nextSibling()) |sib| { + current = sib; + } else { + while (true) { + const parent = current.parentNode() orelse return null; + if (parent.nextSibling()) |uncle| { + current = uncle; + break; + } + current = parent; + } + } + + var descend = current; + while (true) { + if (isTextNode(descend)) return descend; + descend = descend.firstChild() orelse break; + } + } +} + fn prevTextNode(node: *Node) ?*Node { var current = node; @@ -413,10 +437,13 @@ fn modifyByCharacter(self: *Selection, alter: ModifyAlter, forward: bool, range: new_node = t; new_offset = 0; } + } else if (nextTextNodeAfter(focus_node)) |next| { + new_node = next; + new_offset = 1; } } else { + // backward element-node case var idx = focus_offset; - while (idx > 0) { idx -= 1; const child = focus_node.getChildAt(idx) orelse break; @@ -496,7 +523,7 @@ fn modifyByWord(self: *Selection, alter: ModifyAlter, forward: bool, range: *Ran if (forward) { const child = focus_node.getChildAt(focus_offset) orelse { - if (nextTextNode(focus_node)) |next| { + if (nextTextNodeAfter(focus_node)) |next| { new_node = next; new_offset = nextWordEnd(next.getData(), 0); } From df7888d6fb7d4790bdeab4448a992152deeef0a7 Mon Sep 17 00:00:00 2001 From: Pierre Tachoire Date: Wed, 25 Feb 2026 21:10:27 +0100 Subject: [PATCH 053/103] use bridge.property for performance timing and navigation --- src/browser/webapi/Performance.zig | 71 ++++++++++-------------------- 1 file changed, 23 insertions(+), 48 deletions(-) diff --git a/src/browser/webapi/Performance.zig b/src/browser/webapi/Performance.zig index 33404e4e..66d7e520 100644 --- a/src/browser/webapi/Performance.zig +++ b/src/browser/webapi/Performance.zig @@ -471,28 +471,6 @@ pub const PerformanceTiming = struct { // Padding to avoid zero-size struct, which causes identity_map pointer collisions. _pad: bool = false, - pub fn getNavigationStart(_: *const PerformanceTiming) f64 { return 0; } - pub fn getUnloadEventStart(_: *const PerformanceTiming) f64 { return 0; } - pub fn getUnloadEventEnd(_: *const PerformanceTiming) f64 { return 0; } - pub fn getRedirectStart(_: *const PerformanceTiming) f64 { return 0; } - pub fn getRedirectEnd(_: *const PerformanceTiming) f64 { return 0; } - pub fn getFetchStart(_: *const PerformanceTiming) f64 { return 0; } - pub fn getDomainLookupStart(_: *const PerformanceTiming) f64 { return 0; } - pub fn getDomainLookupEnd(_: *const PerformanceTiming) f64 { return 0; } - pub fn getConnectStart(_: *const PerformanceTiming) f64 { return 0; } - pub fn getConnectEnd(_: *const PerformanceTiming) f64 { return 0; } - pub fn getSecureConnectionStart(_: *const PerformanceTiming) f64 { return 0; } - pub fn getRequestStart(_: *const PerformanceTiming) f64 { return 0; } - pub fn getResponseStart(_: *const PerformanceTiming) f64 { return 0; } - pub fn getResponseEnd(_: *const PerformanceTiming) f64 { return 0; } - pub fn getDomLoading(_: *const PerformanceTiming) f64 { return 0; } - pub fn getDomInteractive(_: *const PerformanceTiming) f64 { return 0; } - pub fn getDomContentLoadedEventStart(_: *const PerformanceTiming) f64 { return 0; } - pub fn getDomContentLoadedEventEnd(_: *const PerformanceTiming) f64 { return 0; } - pub fn getDomComplete(_: *const PerformanceTiming) f64 { return 0; } - pub fn getLoadEventStart(_: *const PerformanceTiming) f64 { return 0; } - pub fn getLoadEventEnd(_: *const PerformanceTiming) f64 { return 0; } - pub const JsApi = struct { pub const bridge = js.Bridge(PerformanceTiming); @@ -502,27 +480,27 @@ pub const PerformanceTiming = struct { pub var class_id: bridge.ClassId = undefined; }; - pub const navigationStart = bridge.accessor(PerformanceTiming.getNavigationStart, null, .{}); - pub const unloadEventStart = bridge.accessor(PerformanceTiming.getUnloadEventStart, null, .{}); - pub const unloadEventEnd = bridge.accessor(PerformanceTiming.getUnloadEventEnd, null, .{}); - pub const redirectStart = bridge.accessor(PerformanceTiming.getRedirectStart, null, .{}); - pub const redirectEnd = bridge.accessor(PerformanceTiming.getRedirectEnd, null, .{}); - pub const fetchStart = bridge.accessor(PerformanceTiming.getFetchStart, null, .{}); - pub const domainLookupStart = bridge.accessor(PerformanceTiming.getDomainLookupStart, null, .{}); - pub const domainLookupEnd = bridge.accessor(PerformanceTiming.getDomainLookupEnd, null, .{}); - pub const connectStart = bridge.accessor(PerformanceTiming.getConnectStart, null, .{}); - pub const connectEnd = bridge.accessor(PerformanceTiming.getConnectEnd, null, .{}); - pub const secureConnectionStart = bridge.accessor(PerformanceTiming.getSecureConnectionStart, null, .{}); - pub const requestStart = bridge.accessor(PerformanceTiming.getRequestStart, null, .{}); - pub const responseStart = bridge.accessor(PerformanceTiming.getResponseStart, null, .{}); - pub const responseEnd = bridge.accessor(PerformanceTiming.getResponseEnd, null, .{}); - pub const domLoading = bridge.accessor(PerformanceTiming.getDomLoading, null, .{}); - pub const domInteractive = bridge.accessor(PerformanceTiming.getDomInteractive, null, .{}); - pub const domContentLoadedEventStart = bridge.accessor(PerformanceTiming.getDomContentLoadedEventStart, null, .{}); - pub const domContentLoadedEventEnd = bridge.accessor(PerformanceTiming.getDomContentLoadedEventEnd, null, .{}); - pub const domComplete = bridge.accessor(PerformanceTiming.getDomComplete, null, .{}); - pub const loadEventStart = bridge.accessor(PerformanceTiming.getLoadEventStart, null, .{}); - pub const loadEventEnd = bridge.accessor(PerformanceTiming.getLoadEventEnd, null, .{}); + pub const navigationStart = bridge.property(0.0, .{ .template = false, .readonly = true }); + pub const unloadEventStart = bridge.property(0.0, .{ .template = false, .readonly = true }); + pub const unloadEventEnd = bridge.property(0.0, .{ .template = false, .readonly = true }); + pub const redirectStart = bridge.property(0.0, .{ .template = false, .readonly = true }); + pub const redirectEnd = bridge.property(0.0, .{ .template = false, .readonly = true }); + pub const fetchStart = bridge.property(0.0, .{ .template = false, .readonly = true }); + pub const domainLookupStart = bridge.property(0.0, .{ .template = false, .readonly = true }); + pub const domainLookupEnd = bridge.property(0.0, .{ .template = false, .readonly = true }); + pub const connectStart = bridge.property(0.0, .{ .template = false, .readonly = true }); + pub const connectEnd = bridge.property(0.0, .{ .template = false, .readonly = true }); + pub const secureConnectionStart = bridge.property(0.0, .{ .template = false, .readonly = true }); + pub const requestStart = bridge.property(0.0, .{ .template = false, .readonly = true }); + pub const responseStart = bridge.property(0.0, .{ .template = false, .readonly = true }); + pub const responseEnd = bridge.property(0.0, .{ .template = false, .readonly = true }); + pub const domLoading = bridge.property(0.0, .{ .template = false, .readonly = true }); + pub const domInteractive = bridge.property(0.0, .{ .template = false, .readonly = true }); + pub const domContentLoadedEventStart = bridge.property(0.0, .{ .template = false, .readonly = true }); + pub const domContentLoadedEventEnd = bridge.property(0.0, .{ .template = false, .readonly = true }); + pub const domComplete = bridge.property(0.0, .{ .template = false, .readonly = true }); + pub const loadEventStart = bridge.property(0.0, .{ .template = false, .readonly = true }); + pub const loadEventEnd = bridge.property(0.0, .{ .template = false, .readonly = true }); }; }; @@ -533,9 +511,6 @@ pub const PerformanceNavigation = struct { // Padding to avoid zero-size struct, which causes identity_map pointer collisions. _pad: bool = false, - pub fn getType(_: *const PerformanceNavigation) f64 { return 0; } - pub fn getRedirectCount(_: *const PerformanceNavigation) f64 { return 0; } - pub const JsApi = struct { pub const bridge = js.Bridge(PerformanceNavigation); @@ -545,8 +520,8 @@ pub const PerformanceNavigation = struct { pub var class_id: bridge.ClassId = undefined; }; - pub const @"type" = bridge.accessor(PerformanceNavigation.getType, null, .{}); - pub const redirectCount = bridge.accessor(PerformanceNavigation.getRedirectCount, null, .{}); + pub const @"type" = bridge.property(0.0, .{ .template = false, .readonly = true }); + pub const redirectCount = bridge.property(0.0, .{ .template = false, .readonly = true }); }; }; From 181178296fd1d3d8fabd23d09818bcee7c2e083a Mon Sep 17 00:00:00 2001 From: Pierre Tachoire Date: Wed, 25 Feb 2026 21:14:09 +0100 Subject: [PATCH 054/103] set empty_with_no_proto for performance timing and navigation --- src/browser/webapi/Performance.zig | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/browser/webapi/Performance.zig b/src/browser/webapi/Performance.zig index 66d7e520..e9ca0ebf 100644 --- a/src/browser/webapi/Performance.zig +++ b/src/browser/webapi/Performance.zig @@ -478,6 +478,7 @@ pub const PerformanceTiming = struct { pub const name = "PerformanceTiming"; pub const prototype_chain = bridge.prototypeChain(); pub var class_id: bridge.ClassId = undefined; + pub const empty_with_no_proto = true; }; pub const navigationStart = bridge.property(0.0, .{ .template = false, .readonly = true }); @@ -518,6 +519,7 @@ pub const PerformanceNavigation = struct { pub const name = "PerformanceNavigation"; pub const prototype_chain = bridge.prototypeChain(); pub var class_id: bridge.ClassId = undefined; + pub const empty_with_no_proto = true; }; pub const @"type" = bridge.property(0.0, .{ .template = false, .readonly = true }); From bec7e141dcdc38973e3cd6a4c15498b280ffa2cc Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Wed, 25 Feb 2026 11:10:50 +0800 Subject: [PATCH 055/103] Remove unused Page.wait and Page._wait These exist in Session since iframes. The code isn't currently being used, I must have pulled it back in during a rebase. --- src/browser/Page.zig | 231 ---------------------------------------- src/browser/Session.zig | 1 + 2 files changed, 1 insertion(+), 231 deletions(-) diff --git a/src/browser/Page.zig b/src/browser/Page.zig index 390ba84d..ba5f7dc1 100644 --- a/src/browser/Page.zig +++ b/src/browser/Page.zig @@ -946,237 +946,6 @@ fn pageErrorCallback(ctx: *anyopaque, err: anyerror) void { }; } -pub fn wait(self: *Page, wait_ms: u32) Session.WaitResult { - return self._wait(wait_ms) catch |err| { - switch (err) { - error.JsError => {}, // already logged (with hopefully more context) - else => { - // There may be errors from the http/client or ScriptManager - // that we should not treat as an error like this. Will need - // to run this through more real-world sites and see if we need - // to expand the switch (err) to have more customized logs for - // specific messages. - log.err(.browser, "page wait", .{ .err = err, .type = self._type, .url = self.url }); - }, - } - return .done; - }; -} - -fn _wait(self: *Page, wait_ms: u32) !Session.WaitResult { - if (comptime IS_DEBUG) { - std.debug.assert(self._type == .root); - } - - var timer = try std.time.Timer.start(); - var ms_remaining = wait_ms; - - const browser = self._session.browser; - var http_client = browser.http_client; - - // I'd like the page to know NOTHING about cdp_socket / CDP, but the - // fact is that the behavior of wait changes depending on whether or - // not we're using CDP. - // If we aren't using CDP, as soon as we think there's nothing left - // to do, we can exit - we'de done. - // But if we are using CDP, we should wait for the whole `wait_ms` - // because the http_click.tick() also monitors the CDP socket. And while - // we could let CDP poll http (like it does for HTTP requests), the fact - // is that we know more about the timing of stuff (e.g. how long to - // poll/sleep) in the page. - const exit_when_done = http_client.cdp_client == null; - - // for debugging - // defer self.printWaitAnalysis(); - - while (true) { - switch (self._parse_state) { - .pre, .raw, .text, .image => { - // The main page hasn't started/finished navigating. - // There's no JS to run, and no reason to run the scheduler. - if (http_client.active == 0 and exit_when_done) { - // haven't started navigating, I guess. - return .done; - } - // Either we have active http connections, or we're in CDP - // mode with an extra socket. Either way, we're waiting - // for http traffic - if (try http_client.tick(@intCast(ms_remaining)) == .cdp_socket) { - // exit_when_done is explicitly set when there isn't - // an extra socket, so it should not be possibl to - // get an cdp_socket message when exit_when_done - // is true. - if (IS_DEBUG) { - std.debug.assert(exit_when_done == false); - } - - // data on a socket we aren't handling, return to caller - return .cdp_socket; - } - }, - .html, .complete => { - if (self._queued_navigation != null) { - return .done; - } - - // The HTML page was parsed. We now either have JS scripts to - // download, or scheduled tasks to execute, or both. - - // scheduler.run could trigger new http transfers, so do not - // store http_client.active BEFORE this call and then use - // it AFTER. - const ms_to_next_task = try browser.runMacrotasks(); - - const http_active = http_client.active; - const total_network_activity = http_active + http_client.intercepted; - if (self._notified_network_almost_idle.check(total_network_activity <= 2)) { - self.notifyNetworkAlmostIdle(); - } - if (self._notified_network_idle.check(total_network_activity == 0)) { - self.notifyNetworkIdle(); - } - - if (http_active == 0 and exit_when_done) { - // we don't need to consider http_client.intercepted here - // because exit_when_done is true, and that can only be - // the case when interception isn't possible. - if (comptime IS_DEBUG) { - std.debug.assert(http_client.intercepted == 0); - } - - const ms = ms_to_next_task orelse blk: { - if (wait_ms - ms_remaining < 100) { - if (comptime builtin.is_test) { - return .done; - } - // Look, we want to exit ASAP, but we don't want - // to exit so fast that we've run none of the - // background jobs. - break :blk 50; - } - // No http transfers, no cdp extra socket, no - // scheduled tasks, we're done. - return .done; - }; - - if (ms > ms_remaining) { - // Same as above, except we have a scheduled task, - // it just happens to be too far into the future - // compared to how long we were told to wait. - return .done; - } - - // We have a task to run in the not-so-distant future. - // You might think we can just sleep until that task is - // ready, but we should continue to run lowPriority tasks - // in the meantime, and that could unblock things. So - // we'll just sleep for a bit, and then restart our wait - // loop to see if anything new can be processed. - std.Thread.sleep(std.time.ns_per_ms * @as(u64, @intCast(@min(ms, 20)))); - } else { - // We're here because we either have active HTTP - // connections, or exit_when_done == false (aka, there's - // an cdp_socket registered with the http client). - // We should continue to run lowPriority tasks, so we - // minimize how long we'll poll for network I/O. - const ms_to_wait = @min(200, @min(ms_remaining, ms_to_next_task orelse 200)); - if (try http_client.tick(ms_to_wait) == .cdp_socket) { - // data on a socket we aren't handling, return to caller - return .cdp_socket; - } - } - }, - .err => |err| { - self._parse_state = .{ .raw_done = @errorName(err) }; - return err; - }, - .raw_done => { - if (exit_when_done) { - return .done; - } - // we _could_ http_client.tick(ms_to_wait), but this has - // the same result, and I feel is more correct. - return .no_page; - }, - } - - const ms_elapsed = timer.lap() / 1_000_000; - if (ms_elapsed >= ms_remaining) { - return .done; - } - ms_remaining -= @intCast(ms_elapsed); - } -} - -fn printWaitAnalysis(self: *Page) void { - std.debug.print("load_state: {s}\n", .{@tagName(self._load_state)}); - std.debug.print("parse_state: {s}\n", .{@tagName(std.meta.activeTag(self._parse_state))}); - { - std.debug.print("\nactive requests: {d}\n", .{self._session.browser.http_client.active}); - var n_ = self._session.browser.http_client.handles.in_use.first; - while (n_) |n| { - const conn: *Net.Connection = @fieldParentPtr("node", n); - const transfer = Http.Transfer.fromConnection(conn) catch |err| { - std.debug.print(" - failed to load transfer: {any}\n", .{err}); - break; - }; - std.debug.print(" - {f}\n", .{transfer}); - n_ = n.next; - } - } - - { - std.debug.print("\nqueued requests: {d}\n", .{self._session.browser.http_client.queue.len()}); - var n_ = self._session.browser.http_client.queue.first; - while (n_) |n| { - const transfer: *Http.Transfer = @fieldParentPtr("_node", n); - std.debug.print(" - {f}\n", .{transfer}); - n_ = n.next; - } - } - - { - std.debug.print("\ndeferreds: {d}\n", .{self._script_manager.defer_scripts.len()}); - var n_ = self._script_manager.defer_scripts.first; - while (n_) |n| { - const script: *ScriptManager.Script = @fieldParentPtr("node", n); - std.debug.print(" - {s} complete: {any}\n", .{ script.url, script.complete }); - n_ = n.next; - } - } - - { - std.debug.print("\nasyncs: {d}\n", .{self._script_manager.async_scripts.len()}); - } - - { - std.debug.print("\nasyncs ready: {d}\n", .{self._script_manager.ready_scripts.len()}); - var n_ = self._script_manager.ready_scripts.first; - while (n_) |n| { - const script: *ScriptManager.Script = @fieldParentPtr("node", n); - std.debug.print(" - {s} complete: {any}\n", .{ script.url, script.complete }); - n_ = n.next; - } - } - - const now = milliTimestamp(.monotonic); - { - std.debug.print("\nhigh_priority schedule: {d}\n", .{self.js.scheduler.high_priority.count()}); - var it = self.js.scheduler.high_priority.iterator(); - while (it.next()) |task| { - std.debug.print(" - {s} schedule: {d}ms\n", .{ task.name, task.run_at - now }); - } - } - - { - std.debug.print("\nlow_priority schedule: {d}\n", .{self.js.scheduler.low_priority.count()}); - var it = self.js.scheduler.low_priority.iterator(); - while (it.next()) |task| { - std.debug.print(" - {s} schedule: {d}ms\n", .{ task.name, task.run_at - now }); - } - } -} - pub fn isGoingAway(self: *const Page) bool { return self._queued_navigation != null; } diff --git a/src/browser/Session.zig b/src/browser/Session.zig index c9b4db48..bd7bdff8 100644 --- a/src/browser/Session.zig +++ b/src/browser/Session.zig @@ -166,6 +166,7 @@ pub fn wait(self: *Session, wait_ms: u32) WaitResult { error.JsError => {}, // already logged (with hopefully more context) else => log.err(.browser, "session wait", .{ .err = err, + .url = page.url, }), } return .done; From 4c26161728307f80bd3510fb4eead45a4484e9bc Mon Sep 17 00:00:00 2001 From: Nikolay Govorov Date: Wed, 25 Feb 2026 23:10:11 +0000 Subject: [PATCH 056/103] Move curl C API to type-safe wrapper --- src/Net.zig | 441 +++++++------------------- src/http/Client.zig | 28 +- src/http/Http.zig | 8 +- src/sys/libcurl.zig | 736 ++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 858 insertions(+), 355 deletions(-) create mode 100644 src/sys/libcurl.zig diff --git a/src/Net.zig b/src/Net.zig index 8aa11668..2c27dabd 100644 --- a/src/Net.zig +++ b/src/Net.zig @@ -21,10 +21,7 @@ const builtin = @import("builtin"); const posix = std.posix; const Allocator = std.mem.Allocator; const ArenaAllocator = std.heap.ArenaAllocator; - -pub const c = @cImport({ - @cInclude("curl/curl.h"); -}); +const libcurl = @import("sys/libcurl.zig"); const log = @import("log.zig"); const Config = @import("Config.zig"); @@ -33,243 +30,19 @@ const assert = @import("lightpanda").assert; pub const ENABLE_DEBUG = false; const IS_DEBUG = builtin.mode == .Debug; -const Error = error{ - UnsupportedProtocol, - FailedInit, - UrlMalformat, - NotBuiltIn, - CouldntResolveProxy, - CouldntResolveHost, - CouldntConnect, - WeirdServerReply, - RemoteAccessDenied, - FtpAcceptFailed, - FtpWeirdPassReply, - FtpAcceptTimeout, - FtpWeirdPasvReply, - FtpWeird227Format, - FtpCantGetHost, - Http2, - FtpCouldntSetType, - PartialFile, - FtpCouldntRetrFile, - QuoteError, - HttpReturnedError, - WriteError, - UploadFailed, - ReadError, - OutOfMemory, - OperationTimedout, - FtpPortFailed, - FtpCouldntUseRest, - RangeError, - SslConnectError, - BadDownloadResume, - FileCouldntReadFile, - LdapCannotBind, - LdapSearchFailed, - AbortedByCallback, - BadFunctionArgument, - InterfaceFailed, - TooManyRedirects, - UnknownOption, - SetoptOptionSyntax, - GotNothing, - SslEngineNotfound, - SslEngineSetfailed, - SendError, - RecvError, - SslCertproblem, - SslCipher, - PeerFailedVerification, - BadContentEncoding, - FilesizeExceeded, - UseSslFailed, - SendFailRewind, - SslEngineInitfailed, - LoginDenied, - TftpNotfound, - TftpPerm, - RemoteDiskFull, - TftpIllegal, - TftpUnknownid, - RemoteFileExists, - TftpNosuchuser, - SslCacertBadfile, - RemoteFileNotFound, - Ssh, - SslShutdownFailed, - Again, - SslCrlBadfile, - SslIssuerError, - FtpPretFailed, - RtspCseqError, - RtspSessionError, - FtpBadFileList, - ChunkFailed, - NoConnectionAvailable, - SslPinnedpubkeynotmatch, - SslInvalidcertstatus, - Http2Stream, - RecursiveApiCall, - AuthError, - Http3, - QuicConnectError, - Proxy, - SslClientcert, - UnrecoverablePoll, - TooLarge, - Unknown, -}; +pub const Blob = libcurl.CurlBlob; +pub const WaitFd = libcurl.CurlWaitFd; +pub const writefunc_error = libcurl.curl_writefunc_error; -fn errorFromCode(code: c.CURLcode) Error { - if (comptime IS_DEBUG) { - std.debug.assert(code != c.CURLE_OK); - } +const Error = libcurl.Error; +const ErrorMulti = libcurl.ErrorMulti; +const errorFromCode = libcurl.errorFromCode; +const errorMFromCode = libcurl.errorMFromCode; +const errorCheck = libcurl.errorCheck; +const errorMCheck = libcurl.errorMCheck; - return switch (code) { - c.CURLE_UNSUPPORTED_PROTOCOL => Error.UnsupportedProtocol, - c.CURLE_FAILED_INIT => Error.FailedInit, - c.CURLE_URL_MALFORMAT => Error.UrlMalformat, - c.CURLE_NOT_BUILT_IN => Error.NotBuiltIn, - c.CURLE_COULDNT_RESOLVE_PROXY => Error.CouldntResolveProxy, - c.CURLE_COULDNT_RESOLVE_HOST => Error.CouldntResolveHost, - c.CURLE_COULDNT_CONNECT => Error.CouldntConnect, - c.CURLE_WEIRD_SERVER_REPLY => Error.WeirdServerReply, - c.CURLE_REMOTE_ACCESS_DENIED => Error.RemoteAccessDenied, - c.CURLE_FTP_ACCEPT_FAILED => Error.FtpAcceptFailed, - c.CURLE_FTP_WEIRD_PASS_REPLY => Error.FtpWeirdPassReply, - c.CURLE_FTP_ACCEPT_TIMEOUT => Error.FtpAcceptTimeout, - c.CURLE_FTP_WEIRD_PASV_REPLY => Error.FtpWeirdPasvReply, - c.CURLE_FTP_WEIRD_227_FORMAT => Error.FtpWeird227Format, - c.CURLE_FTP_CANT_GET_HOST => Error.FtpCantGetHost, - c.CURLE_HTTP2 => Error.Http2, - c.CURLE_FTP_COULDNT_SET_TYPE => Error.FtpCouldntSetType, - c.CURLE_PARTIAL_FILE => Error.PartialFile, - c.CURLE_FTP_COULDNT_RETR_FILE => Error.FtpCouldntRetrFile, - c.CURLE_QUOTE_ERROR => Error.QuoteError, - c.CURLE_HTTP_RETURNED_ERROR => Error.HttpReturnedError, - c.CURLE_WRITE_ERROR => Error.WriteError, - c.CURLE_UPLOAD_FAILED => Error.UploadFailed, - c.CURLE_READ_ERROR => Error.ReadError, - c.CURLE_OUT_OF_MEMORY => Error.OutOfMemory, - c.CURLE_OPERATION_TIMEDOUT => Error.OperationTimedout, - c.CURLE_FTP_PORT_FAILED => Error.FtpPortFailed, - c.CURLE_FTP_COULDNT_USE_REST => Error.FtpCouldntUseRest, - c.CURLE_RANGE_ERROR => Error.RangeError, - c.CURLE_SSL_CONNECT_ERROR => Error.SslConnectError, - c.CURLE_BAD_DOWNLOAD_RESUME => Error.BadDownloadResume, - c.CURLE_FILE_COULDNT_READ_FILE => Error.FileCouldntReadFile, - c.CURLE_LDAP_CANNOT_BIND => Error.LdapCannotBind, - c.CURLE_LDAP_SEARCH_FAILED => Error.LdapSearchFailed, - c.CURLE_ABORTED_BY_CALLBACK => Error.AbortedByCallback, - c.CURLE_BAD_FUNCTION_ARGUMENT => Error.BadFunctionArgument, - c.CURLE_INTERFACE_FAILED => Error.InterfaceFailed, - c.CURLE_TOO_MANY_REDIRECTS => Error.TooManyRedirects, - c.CURLE_UNKNOWN_OPTION => Error.UnknownOption, - c.CURLE_SETOPT_OPTION_SYNTAX => Error.SetoptOptionSyntax, - c.CURLE_GOT_NOTHING => Error.GotNothing, - c.CURLE_SSL_ENGINE_NOTFOUND => Error.SslEngineNotfound, - c.CURLE_SSL_ENGINE_SETFAILED => Error.SslEngineSetfailed, - c.CURLE_SEND_ERROR => Error.SendError, - c.CURLE_RECV_ERROR => Error.RecvError, - c.CURLE_SSL_CERTPROBLEM => Error.SslCertproblem, - c.CURLE_SSL_CIPHER => Error.SslCipher, - c.CURLE_PEER_FAILED_VERIFICATION => Error.PeerFailedVerification, - c.CURLE_BAD_CONTENT_ENCODING => Error.BadContentEncoding, - c.CURLE_FILESIZE_EXCEEDED => Error.FilesizeExceeded, - c.CURLE_USE_SSL_FAILED => Error.UseSslFailed, - c.CURLE_SEND_FAIL_REWIND => Error.SendFailRewind, - c.CURLE_SSL_ENGINE_INITFAILED => Error.SslEngineInitfailed, - c.CURLE_LOGIN_DENIED => Error.LoginDenied, - c.CURLE_TFTP_NOTFOUND => Error.TftpNotfound, - c.CURLE_TFTP_PERM => Error.TftpPerm, - c.CURLE_REMOTE_DISK_FULL => Error.RemoteDiskFull, - c.CURLE_TFTP_ILLEGAL => Error.TftpIllegal, - c.CURLE_TFTP_UNKNOWNID => Error.TftpUnknownid, - c.CURLE_REMOTE_FILE_EXISTS => Error.RemoteFileExists, - c.CURLE_TFTP_NOSUCHUSER => Error.TftpNosuchuser, - c.CURLE_SSL_CACERT_BADFILE => Error.SslCacertBadfile, - c.CURLE_REMOTE_FILE_NOT_FOUND => Error.RemoteFileNotFound, - c.CURLE_SSH => Error.Ssh, - c.CURLE_SSL_SHUTDOWN_FAILED => Error.SslShutdownFailed, - c.CURLE_AGAIN => Error.Again, - c.CURLE_SSL_CRL_BADFILE => Error.SslCrlBadfile, - c.CURLE_SSL_ISSUER_ERROR => Error.SslIssuerError, - c.CURLE_FTP_PRET_FAILED => Error.FtpPretFailed, - c.CURLE_RTSP_CSEQ_ERROR => Error.RtspCseqError, - c.CURLE_RTSP_SESSION_ERROR => Error.RtspSessionError, - c.CURLE_FTP_BAD_FILE_LIST => Error.FtpBadFileList, - c.CURLE_CHUNK_FAILED => Error.ChunkFailed, - c.CURLE_NO_CONNECTION_AVAILABLE => Error.NoConnectionAvailable, - c.CURLE_SSL_PINNEDPUBKEYNOTMATCH => Error.SslPinnedpubkeynotmatch, - c.CURLE_SSL_INVALIDCERTSTATUS => Error.SslInvalidcertstatus, - c.CURLE_HTTP2_STREAM => Error.Http2Stream, - c.CURLE_RECURSIVE_API_CALL => Error.RecursiveApiCall, - c.CURLE_AUTH_ERROR => Error.AuthError, - c.CURLE_HTTP3 => Error.Http3, - c.CURLE_QUIC_CONNECT_ERROR => Error.QuicConnectError, - c.CURLE_PROXY => Error.Proxy, - c.CURLE_SSL_CLIENTCERT => Error.SslClientcert, - c.CURLE_UNRECOVERABLE_POLL => Error.UnrecoverablePoll, - c.CURLE_TOO_LARGE => Error.TooLarge, - else => Error.Unknown, - }; -} - -const ErrorMulti = error{ - BadHandle, - BadEasyHandle, - OutOfMemory, - InternalError, - BadSocket, - UnknownOption, - AddedAlready, - RecursiveApiCall, - WakeupFailure, - BadFunctionArgument, - AbortedByCallback, - UnrecoverablePoll, - Unknown, -}; - -fn errorMFromCode(code: c.CURLMcode) ErrorMulti { - if (comptime IS_DEBUG) { - std.debug.assert(code != c.CURLM_OK); - } - - return switch (code) { - c.CURLM_BAD_HANDLE => ErrorMulti.BadHandle, - c.CURLM_BAD_EASY_HANDLE => ErrorMulti.BadEasyHandle, - c.CURLM_OUT_OF_MEMORY => ErrorMulti.OutOfMemory, - c.CURLM_INTERNAL_ERROR => ErrorMulti.InternalError, - c.CURLM_BAD_SOCKET => ErrorMulti.BadSocket, - c.CURLM_UNKNOWN_OPTION => ErrorMulti.UnknownOption, - c.CURLM_ADDED_ALREADY => ErrorMulti.AddedAlready, - c.CURLM_RECURSIVE_API_CALL => ErrorMulti.RecursiveApiCall, - c.CURLM_WAKEUP_FAILURE => ErrorMulti.WakeupFailure, - c.CURLM_BAD_FUNCTION_ARGUMENT => ErrorMulti.BadFunctionArgument, - c.CURLM_ABORTED_BY_CALLBACK => ErrorMulti.AbortedByCallback, - c.CURLM_UNRECOVERABLE_POLL => ErrorMulti.UnrecoverablePoll, - else => ErrorMulti.Unknown, - }; -} - -fn errorCheck(code: c.CURLcode) Error!void { - if (code == c.CURLE_OK) { - return; - } - return errorFromCode(code); -} - -fn errorMCheck(code: c.CURLMcode) ErrorMulti!void { - if (code == c.CURLM_OK) { - return; - } - if (code == c.CURLM_CALL_MULTI_PERFORM) { - return; - } - return errorMFromCode(code); +pub fn curl_version() [*c]const u8 { + return libcurl.curl_version(); } pub const Method = enum(u8) { @@ -289,11 +62,11 @@ pub const Header = struct { }; pub const Headers = struct { - headers: ?*c.curl_slist, + headers: ?*libcurl.CurlSList, cookies: ?[*c]const u8, pub fn init(user_agent: [:0]const u8) !Headers { - const header_list = c.curl_slist_append(null, user_agent); + const header_list = libcurl.curl_slist_append(null, user_agent); if (header_list == null) { return error.OutOfMemory; } @@ -302,13 +75,13 @@ pub const Headers = struct { pub fn deinit(self: *const Headers) void { if (self.headers) |hdr| { - c.curl_slist_free_all(hdr); + libcurl.curl_slist_free_all(hdr); } } pub fn add(self: *Headers, header: [*c]const u8) !void { // Copies the value - const updated_headers = c.curl_slist_append(self.headers, header); + const updated_headers = libcurl.curl_slist_append(self.headers, header); if (updated_headers == null) { return error.OutOfMemory; } @@ -333,7 +106,7 @@ pub const Headers = struct { } const Iterator = struct { - header: [*c]c.curl_slist, + header: [*c]libcurl.CurlSList, cookies: ?[*c]const u8, pub fn next(self: *Iterator) ?Header { @@ -365,10 +138,10 @@ pub const HeaderIterator = union(enum) { const CurlHeaderIterator = struct { conn: *const Connection, - prev: ?*c.curl_header = null, + prev: ?*libcurl.CurlHeader = null, pub fn next(self: *CurlHeaderIterator) ?Header { - const h = c.curl_easy_nextheader(self.conn.easy, c.CURLH_HEADER, -1, self.prev) orelse return null; + const h = libcurl.curl_easy_nextheader(self.conn.easy, .header, -1, self.prev) orelse return null; self.prev = h; const header = h.*; @@ -462,72 +235,72 @@ pub const ResponseHead = struct { }; pub fn globalInit() Error!void { - try errorCheck(c.curl_global_init(c.CURL_GLOBAL_SSL)); + try libcurl.curl_global_init(.{ .ssl = true }); } pub fn globalDeinit() void { - c.curl_global_cleanup(); + libcurl.curl_global_cleanup(); } pub const Connection = struct { - easy: *c.CURL, + easy: *libcurl.Curl, node: Handles.HandleList.Node = .{}, pub fn init( - ca_blob_: ?c.curl_blob, + ca_blob_: ?libcurl.CurlBlob, config: *const Config, ) !Connection { - const easy = c.curl_easy_init() orelse return error.FailedToInitializeEasy; - errdefer _ = c.curl_easy_cleanup(easy); + const easy = libcurl.curl_easy_init() orelse return error.FailedToInitializeEasy; + errdefer libcurl.curl_easy_cleanup(easy); // timeouts - try errorCheck(c.curl_easy_setopt(easy, c.CURLOPT_TIMEOUT_MS, @as(c_long, @intCast(config.httpTimeout())))); - try errorCheck(c.curl_easy_setopt(easy, c.CURLOPT_CONNECTTIMEOUT_MS, @as(c_long, @intCast(config.httpConnectTimeout())))); + try libcurl.curl_easy_setopt(easy, .timeout_ms, config.httpTimeout()); + try libcurl.curl_easy_setopt(easy, .connect_timeout_ms, config.httpConnectTimeout()); // redirect behavior - try errorCheck(c.curl_easy_setopt(easy, c.CURLOPT_MAXREDIRS, @as(c_long, @intCast(config.httpMaxRedirects())))); - try errorCheck(c.curl_easy_setopt(easy, c.CURLOPT_FOLLOWLOCATION, @as(c_long, 2))); - try errorCheck(c.curl_easy_setopt(easy, c.CURLOPT_REDIR_PROTOCOLS_STR, "HTTP,HTTPS")); // remove FTP and FTPS from the default + try libcurl.curl_easy_setopt(easy, .max_redirs, config.httpMaxRedirects()); + try libcurl.curl_easy_setopt(easy, .follow_location, 2); + try libcurl.curl_easy_setopt(easy, .redir_protocols_str, "HTTP,HTTPS"); // remove FTP and FTPS from the default // proxy const http_proxy = config.httpProxy(); if (http_proxy) |proxy| { - try errorCheck(c.curl_easy_setopt(easy, c.CURLOPT_PROXY, proxy.ptr)); + try libcurl.curl_easy_setopt(easy, .proxy, proxy.ptr); } // tls if (ca_blob_) |ca_blob| { - try errorCheck(c.curl_easy_setopt(easy, c.CURLOPT_CAINFO_BLOB, ca_blob)); + try libcurl.curl_easy_setopt(easy, .ca_info_blob, ca_blob); if (http_proxy != null) { - try errorCheck(c.curl_easy_setopt(easy, c.CURLOPT_PROXY_CAINFO_BLOB, ca_blob)); + try libcurl.curl_easy_setopt(easy, .proxy_ca_info_blob, ca_blob); } } else { assert(config.tlsVerifyHost() == false, "Http.init tls_verify_host", .{}); - try errorCheck(c.curl_easy_setopt(easy, c.CURLOPT_SSL_VERIFYHOST, @as(c_long, 0))); - try errorCheck(c.curl_easy_setopt(easy, c.CURLOPT_SSL_VERIFYPEER, @as(c_long, 0))); + try libcurl.curl_easy_setopt(easy, .ssl_verify_host, false); + try libcurl.curl_easy_setopt(easy, .ssl_verify_peer, false); if (http_proxy != null) { - try errorCheck(c.curl_easy_setopt(easy, c.CURLOPT_PROXY_SSL_VERIFYHOST, @as(c_long, 0))); - try errorCheck(c.curl_easy_setopt(easy, c.CURLOPT_PROXY_SSL_VERIFYPEER, @as(c_long, 0))); + try libcurl.curl_easy_setopt(easy, .proxy_ssl_verify_host, false); + try libcurl.curl_easy_setopt(easy, .proxy_ssl_verify_peer, false); } } // compression, don't remove this. CloudFront will send gzip content // even if we don't support it, and then it won't be decompressed. // empty string means: use whatever's available - try errorCheck(c.curl_easy_setopt(easy, c.CURLOPT_ACCEPT_ENCODING, "")); + try libcurl.curl_easy_setopt(easy, .accept_encoding, ""); // debug if (comptime ENABLE_DEBUG) { - try errorCheck(c.curl_easy_setopt(easy, c.CURLOPT_VERBOSE, @as(c_long, 1))); + try libcurl.curl_easy_setopt(easy, .verbose, true); // Sometimes the default debug output hides some useful data. You can // uncomment the following line (BUT KEEP THE LIVE ABOVE AS-IS), to // get more control over the data (specifically, the `CURLINFO_TEXT` // can include useful data). - // try errorCheck(c.curl_easy_setopt(easy, c.CURLOPT_DEBUGFUNCTION, debugCallback)); + // try libcurl.curl_easy_setopt(easy, .debug_function, debugCallback); } return .{ @@ -536,11 +309,11 @@ pub const Connection = struct { } pub fn deinit(self: *const Connection) void { - c.curl_easy_cleanup(self.easy); + libcurl.curl_easy_cleanup(self.easy); } pub fn setURL(self: *const Connection, url: [:0]const u8) !void { - try errorCheck(c.curl_easy_setopt(self.easy, c.CURLOPT_URL, url.ptr)); + try libcurl.curl_easy_setopt(self.easy, .url, url.ptr); } // a libcurl request has 2 methods. The first is the method that @@ -574,71 +347,69 @@ pub const Connection = struct { .PATCH => "PATCH", .PROPFIND => "PROPFIND", }; - try errorCheck(c.curl_easy_setopt(easy, c.CURLOPT_CUSTOMREQUEST, m.ptr)); + try libcurl.curl_easy_setopt(easy, .custom_request, m.ptr); } pub fn setBody(self: *const Connection, body: []const u8) !void { const easy = self.easy; - try errorCheck(c.curl_easy_setopt(easy, c.CURLOPT_HTTPPOST, @as(c_long, 1))); - try errorCheck(c.curl_easy_setopt(easy, c.CURLOPT_POSTFIELDSIZE, @as(c_long, @intCast(body.len)))); - try errorCheck(c.curl_easy_setopt(easy, c.CURLOPT_COPYPOSTFIELDS, body.ptr)); + try libcurl.curl_easy_setopt(easy, .post, true); + try libcurl.curl_easy_setopt(easy, .post_field_size, body.len); + try libcurl.curl_easy_setopt(easy, .copy_post_fields, body.ptr); } pub fn setGetMode(self: *const Connection) !void { - try errorCheck(c.curl_easy_setopt(self.easy, c.CURLOPT_HTTPGET, @as(c_long, 1))); + try libcurl.curl_easy_setopt(self.easy, .http_get, true); } pub fn setHeaders(self: *const Connection, headers: *Headers) !void { - try errorCheck(c.curl_easy_setopt(self.easy, c.CURLOPT_HTTPHEADER, headers.headers)); + try libcurl.curl_easy_setopt(self.easy, .http_header, headers.headers); } pub fn setCookies(self: *const Connection, cookies: [*c]const u8) !void { - try errorCheck(c.curl_easy_setopt(self.easy, c.CURLOPT_COOKIE, cookies)); + try libcurl.curl_easy_setopt(self.easy, .cookie, cookies); } pub fn setPrivate(self: *const Connection, ptr: *anyopaque) !void { - try errorCheck(c.curl_easy_setopt(self.easy, c.CURLOPT_PRIVATE, ptr)); + try libcurl.curl_easy_setopt(self.easy, .private, ptr); } pub fn setProxyCredentials(self: *const Connection, creds: [:0]const u8) !void { - try errorCheck(c.curl_easy_setopt(self.easy, c.CURLOPT_PROXYUSERPWD, creds.ptr)); + try libcurl.curl_easy_setopt(self.easy, .proxy_user_pwd, creds.ptr); } pub fn setCallbacks( self: *const Connection, - header_cb: *const fn ([*]const u8, usize, usize, *anyopaque) callconv(.c) usize, - data_cb: *const fn ([*]const u8, usize, usize, *anyopaque) callconv(.c) isize, + comptime header_cb: libcurl.CurlHeaderFunction, + comptime data_cb: libcurl.CurlWriteFunction, ) !void { - try errorCheck(c.curl_easy_setopt(self.easy, c.CURLOPT_HEADERDATA, self.easy)); - try errorCheck(c.curl_easy_setopt(self.easy, c.CURLOPT_HEADERFUNCTION, header_cb)); - try errorCheck(c.curl_easy_setopt(self.easy, c.CURLOPT_WRITEDATA, self.easy)); - try errorCheck(c.curl_easy_setopt(self.easy, c.CURLOPT_WRITEFUNCTION, data_cb)); + try libcurl.curl_easy_setopt(self.easy, .header_data, self.easy); + try libcurl.curl_easy_setopt(self.easy, .header_function, header_cb); + try libcurl.curl_easy_setopt(self.easy, .write_data, self.easy); + try libcurl.curl_easy_setopt(self.easy, .write_function, data_cb); } pub fn setProxy(self: *const Connection, proxy: ?[*:0]const u8) !void { - try errorCheck(c.curl_easy_setopt(self.easy, c.CURLOPT_PROXY, proxy)); + try libcurl.curl_easy_setopt(self.easy, .proxy, proxy); } pub fn setTlsVerify(self: *const Connection, verify: bool, use_proxy: bool) !void { - const host_val: c_long = if (verify) 2 else 0; - const peer_val: c_long = if (verify) 1 else 0; - try errorCheck(c.curl_easy_setopt(self.easy, c.CURLOPT_SSL_VERIFYHOST, host_val)); - try errorCheck(c.curl_easy_setopt(self.easy, c.CURLOPT_SSL_VERIFYPEER, peer_val)); + try libcurl.curl_easy_setopt(self.easy, .ssl_verify_host, verify); + try libcurl.curl_easy_setopt(self.easy, .ssl_verify_peer, verify); if (use_proxy) { - try errorCheck(c.curl_easy_setopt(self.easy, c.CURLOPT_PROXY_SSL_VERIFYHOST, host_val)); - try errorCheck(c.curl_easy_setopt(self.easy, c.CURLOPT_PROXY_SSL_VERIFYPEER, peer_val)); + try libcurl.curl_easy_setopt(self.easy, .proxy_ssl_verify_host, verify); + try libcurl.curl_easy_setopt(self.easy, .proxy_ssl_verify_peer, verify); } } pub fn getEffectiveUrl(self: *const Connection) ![*c]const u8 { var url: [*c]u8 = undefined; - try errorCheck(c.curl_easy_getinfo(self.easy, c.CURLINFO_EFFECTIVE_URL, &url)); + try libcurl.curl_easy_getinfo(self.easy, .effective_url, &url); return url; } pub fn getResponseCode(self: *const Connection) !u16 { var status: c_long = undefined; - try errorCheck(c.curl_easy_getinfo(self.easy, c.CURLINFO_RESPONSE_CODE, &status)); + try libcurl.curl_easy_getinfo(self.easy, .response_code, &status); if (status < 0 or status > std.math.maxInt(u16)) { return 0; } @@ -647,34 +418,31 @@ pub const Connection = struct { pub fn getRedirectCount(self: *const Connection) !u32 { var count: c_long = undefined; - try errorCheck(c.curl_easy_getinfo(self.easy, c.CURLINFO_REDIRECT_COUNT, &count)); + try libcurl.curl_easy_getinfo(self.easy, .redirect_count, &count); return @intCast(count); } pub fn getResponseHeader(self: *const Connection, name: [:0]const u8, index: usize) ?HeaderValue { - var hdr: [*c]c.curl_header = null; - const result = c.curl_easy_header(self.easy, name, index, c.CURLH_HEADER, -1, &hdr); - if (result == c.CURLE_OK) { - return .{ - .amount = hdr.*.amount, - .value = std.mem.span(hdr.*.value), - }; - } - - if (result == c.CURLE_FAILED_INIT) { - // seems to be what it returns if the header isn't found + var hdr: ?*libcurl.CurlHeader = null; + libcurl.curl_easy_header(self.easy, name, index, .header, -1, &hdr) catch |err| { + // ErrorHeader includes OutOfMemory — rare but real errors from curl internals. + // Logged and returned as null since callers don't expect errors. + log.err(.http, "get response header", .{ + .name = name, + .err = err, + }); return null; - } - log.err(.http, "get response header", .{ - .name = name, - .err = errorFromCode(result), - }); - return null; + }; + const h = hdr orelse return null; + return .{ + .amount = h.amount, + .value = std.mem.span(h.value), + }; } pub fn getPrivate(self: *const Connection) !*anyopaque { var private: *anyopaque = undefined; - try errorCheck(c.curl_easy_getinfo(self.easy, c.CURLINFO_PRIVATE, &private)); + try libcurl.curl_easy_getinfo(self.easy, .private, &private); return private; } @@ -696,7 +464,7 @@ pub const Connection = struct { try self.setCookies(cookies); } - try errorCheck(c.curl_easy_perform(self.easy)); + try libcurl.curl_easy_perform(self.easy); return self.getResponseCode(); } }; @@ -705,23 +473,23 @@ pub const Handles = struct { connections: []Connection, in_use: HandleList, available: HandleList, - multi: *c.CURLM, + multi: *libcurl.CurlM, performing: bool = false, pub const HandleList = std.DoublyLinkedList; pub fn init( allocator: Allocator, - ca_blob: ?c.curl_blob, + ca_blob: ?libcurl.CurlBlob, config: *const Config, ) !Handles { const count: usize = config.httpMaxConcurrent(); if (count == 0) return error.InvalidMaxConcurrent; - const multi = c.curl_multi_init() orelse return error.FailedToInitializeMulti; - errdefer _ = c.curl_multi_cleanup(multi); + const multi = libcurl.curl_multi_init() orelse return error.FailedToInitializeMulti; + errdefer libcurl.curl_multi_cleanup(multi) catch {}; - try errorMCheck(c.curl_multi_setopt(multi, c.CURLMOPT_MAX_HOST_CONNECTIONS, @as(c_long, config.httpMaxHostOpen()))); + try libcurl.curl_multi_setopt(multi, .max_host_connections, config.httpMaxHostOpen()); const connections = try allocator.alloc(Connection, count); errdefer allocator.free(connections); @@ -745,7 +513,7 @@ pub const Handles = struct { conn.deinit(); } allocator.free(self.connections); - _ = c.curl_multi_cleanup(self.multi); + libcurl.curl_multi_cleanup(self.multi) catch {}; } pub fn hasAvailable(self: *const Handles) bool { @@ -763,11 +531,11 @@ pub const Handles = struct { } pub fn add(self: *Handles, conn: *const Connection) !void { - try errorMCheck(c.curl_multi_add_handle(self.multi, conn.easy)); + try libcurl.curl_multi_add_handle(self.multi, conn.easy); } pub fn remove(self: *Handles, conn: *Connection) void { - errorMCheck(c.curl_multi_remove_handle(self.multi, conn.easy)) catch |err| { + libcurl.curl_multi_remove_handle(self.multi, conn.easy) catch |err| { log.fatal(.http, "multi remove handle", .{ .err = err }); }; var node = &conn.node; @@ -781,12 +549,12 @@ pub const Handles = struct { var running: c_int = undefined; self.performing = true; defer self.performing = false; - try errorMCheck(c.curl_multi_perform(self.multi, &running)); + try libcurl.curl_multi_perform(self.multi, &running); return running; } - pub fn poll(self: *Handles, extra_fds: []c.curl_waitfd, timeout_ms: c_int) !void { - try errorMCheck(c.curl_multi_poll(self.multi, extra_fds.ptr, @intCast(extra_fds.len), timeout_ms, null)); + pub fn poll(self: *Handles, extra_fds: []libcurl.CurlWaitFd, timeout_ms: c_int) !void { + try libcurl.curl_multi_poll(self.multi, extra_fds, timeout_ms, null); } pub const MultiMessage = struct { @@ -796,11 +564,13 @@ pub const Handles = struct { pub fn readMessage(self: *Handles) ?MultiMessage { var messages_count: c_int = 0; - const msg_ = c.curl_multi_info_read(self.multi, &messages_count) orelse return null; - const msg: *c.CURLMsg = @ptrCast(msg_); - return .{ - .conn = .{ .easy = msg.easy_handle.? }, - .err = if (errorCheck(msg.data.result)) |_| null else |err| err, + const msg = libcurl.curl_multi_info_read(self.multi, &messages_count) orelse return null; + return switch (msg.data) { + .done => |err| .{ + .conn = .{ .easy = msg.easy_handle }, + .err = err, + }, + else => unreachable, }; } }; @@ -809,7 +579,7 @@ pub const Handles = struct { // This whole rescan + decode is really just needed for MacOS. On Linux // bundle.rescan does find the .pem file(s) which could be in a few different // places, so it's still useful, just not efficient. -pub fn loadCerts(allocator: Allocator) !c.curl_blob { +pub fn loadCerts(allocator: Allocator) !libcurl.CurlBlob { var bundle: std.crypto.Certificate.Bundle = .{}; try bundle.rescan(allocator); defer bundle.deinit(allocator); @@ -892,15 +662,16 @@ const LineWriter = struct { } }; -fn debugCallback(_: *c.CURL, msg_type: c.curl_infotype, raw: [*c]u8, len: usize, _: *anyopaque) callconv(.c) void { +fn debugCallback(_: *libcurl.Curl, msg_type: libcurl.CurlInfoType, raw: [*c]u8, len: usize, _: *anyopaque) c_int { const data = raw[0..len]; switch (msg_type) { - c.CURLINFO_TEXT => std.debug.print("libcurl [text]: {s}\n", .{data}), - c.CURLINFO_HEADER_OUT => std.debug.print("libcurl [req-h]: {s}\n", .{data}), - c.CURLINFO_HEADER_IN => std.debug.print("libcurl [res-h]: {s}\n", .{data}), - // c.CURLINFO_DATA_IN => std.debug.print("libcurl [res-b]: {s}\n", .{data}), + .text => std.debug.print("libcurl [text]: {s}\n", .{data}), + .header_out => std.debug.print("libcurl [req-h]: {s}\n", .{data}), + .header_in => std.debug.print("libcurl [res-h]: {s}\n", .{data}), + // .data_in => std.debug.print("libcurl [res-b]: {s}\n", .{data}), else => std.debug.print("libcurl ?? {d}\n", .{msg_type}), } + return 0; } // Zig is in a weird backend transition right now. Need to determine if diff --git a/src/http/Client.zig b/src/http/Client.zig index 2cacf962..1670883c 100644 --- a/src/http/Client.zig +++ b/src/http/Client.zig @@ -29,8 +29,6 @@ const Notification = @import("../Notification.zig"); const CookieJar = @import("../browser/webapi/storage/Cookie.zig").Jar; const Robots = @import("../browser/Robots.zig"); const RobotStore = Robots.RobotStore; - -const c = Net.c; const posix = std.posix; const Allocator = std.mem.Allocator; @@ -123,7 +121,7 @@ pub const CDPClient = struct { const TransferQueue = std.DoublyLinkedList; -pub fn init(allocator: Allocator, ca_blob: ?c.curl_blob, robot_store: *RobotStore, config: *const Config) !*Client { +pub fn init(allocator: Allocator, ca_blob: ?Net.Blob, robot_store: *RobotStore, config: *const Config) !*Client { var transfer_pool = std.heap.MemoryPool(Transfer).init(allocator); errdefer transfer_pool.deinit(); @@ -718,13 +716,13 @@ fn perform(self: *Client, timeout_ms: c_int) !PerformStatus { var status = PerformStatus.normal; if (self.cdp_client) |cdp_client| { - var wait_fds = [_]c.curl_waitfd{.{ + var wait_fds = [_]Net.WaitFd{.{ .fd = cdp_client.socket, - .events = c.CURL_WAIT_POLLIN, - .revents = 0, + .events = .{ .pollin = true }, + .revents = .{}, }}; try self.handles.poll(&wait_fds, timeout_ms); - if (wait_fds[0].revents != 0) { + if (wait_fds[0].revents.pollin or wait_fds[0].revents.pollpri or wait_fds[0].revents.pollout) { status = .cdp_socket; } } else if (running > 0) { @@ -1195,7 +1193,7 @@ pub const Transfer = struct { } // headerCallback is called by curl on each request's header line read. - fn headerCallback(buffer: [*]const u8, header_count: usize, buf_len: usize, data: *anyopaque) callconv(.c) usize { + fn headerCallback(buffer: [*]const u8, header_count: usize, buf_len: usize, data: *anyopaque) usize { // libcurl should only ever emit 1 header at a time if (comptime IS_DEBUG) { std.debug.assert(header_count == 1); @@ -1317,7 +1315,7 @@ pub const Transfer = struct { return buf_len; } - fn dataCallback(buffer: [*]const u8, chunk_count: usize, chunk_len: usize, data: *anyopaque) callconv(.c) isize { + fn dataCallback(buffer: [*]const u8, chunk_count: usize, chunk_len: usize, data: *anyopaque) usize { // libcurl should only ever emit 1 chunk at a time if (comptime IS_DEBUG) { std.debug.assert(chunk_count == 1); @@ -1326,7 +1324,7 @@ pub const Transfer = struct { const conn: Net.Connection = .{ .easy = @ptrCast(@alignCast(data)) }; var transfer = fromConnection(&conn) catch |err| { log.err(.http, "get private info", .{ .err = err, .source = "body callback" }); - return c.CURL_WRITEFUNC_ERROR; + return Net.writefunc_error; }; if (transfer._redirecting or transfer._auth_challenge != null) { @@ -1336,11 +1334,11 @@ pub const Transfer = struct { if (!transfer._header_done_called) { const proceed = transfer.headerDoneCallback(&conn) catch |err| { log.err(.http, "header_done_callback", .{ .err = err, .req = transfer }); - return c.CURL_WRITEFUNC_ERROR; + return Net.writefunc_error; }; if (!proceed) { // signal abort to libcurl - return -1; + return Net.writefunc_error; } } @@ -1348,14 +1346,14 @@ pub const Transfer = struct { if (transfer.max_response_size) |max_size| { if (transfer.bytes_received > max_size) { requestFailed(transfer, error.ResponseTooLarge, true); - return -1; + return Net.writefunc_error; } } const chunk = buffer[0..chunk_len]; transfer.req.data_callback(transfer, chunk) catch |err| { log.err(.http, "data_callback", .{ .err = err, .req = transfer }); - return c.CURL_WRITEFUNC_ERROR; + return Net.writefunc_error; }; transfer.req.notification.dispatch(.http_response_data, &.{ @@ -1364,7 +1362,7 @@ pub const Transfer = struct { }); if (transfer.aborted) { - return -1; + return Net.writefunc_error; } return @intCast(chunk_len); diff --git a/src/http/Http.zig b/src/http/Http.zig index 01ac6dd7..778a1be4 100644 --- a/src/http/Http.zig +++ b/src/http/Http.zig @@ -19,8 +19,6 @@ const std = @import("std"); const Net = @import("../Net.zig"); -const c = Net.c; - const ENABLE_DEBUG = Net.ENABLE_DEBUG; pub const Client = @import("Client.zig"); pub const Transfer = Client.Transfer; @@ -45,7 +43,7 @@ const Http = @This(); arena: ArenaAllocator, allocator: Allocator, config: *const Config, -ca_blob: ?c.curl_blob, +ca_blob: ?Net.Blob, robot_store: *RobotStore, pub fn init(allocator: Allocator, robot_store: *RobotStore, config: *const Config) !Http { @@ -53,13 +51,13 @@ pub fn init(allocator: Allocator, robot_store: *RobotStore, config: *const Confi errdefer Net.globalDeinit(); if (comptime ENABLE_DEBUG) { - std.debug.print("curl version: {s}\n\n", .{c.curl_version()}); + std.debug.print("curl version: {s}\n\n", .{Net.curl_version()}); } var arena = ArenaAllocator.init(allocator); errdefer arena.deinit(); - var ca_blob: ?c.curl_blob = null; + var ca_blob: ?Net.Blob = null; if (config.tlsVerifyHost()) { ca_blob = try Net.loadCerts(allocator); } diff --git a/src/sys/libcurl.zig b/src/sys/libcurl.zig new file mode 100644 index 00000000..ca718ebb --- /dev/null +++ b/src/sys/libcurl.zig @@ -0,0 +1,736 @@ +// 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 builtin = @import("builtin"); + +const c = @cImport({ + @cInclude("curl/curl.h"); +}); + +const IS_DEBUG = builtin.mode == .Debug; + +pub const Curl = c.CURL; +pub const CurlM = c.CURLM; +pub const CurlCode = c.CURLcode; +pub const CurlMCode = c.CURLMcode; +pub const CurlSList = c.curl_slist; +pub const CurlHeader = c.curl_header; +pub const CurlHttpPost = c.curl_httppost; +pub const CurlSocket = c.curl_socket_t; +pub const CurlBlob = c.curl_blob; +pub const CurlOffT = c.curl_off_t; + +pub const CurlDebugFunction = fn (*Curl, CurlInfoType, [*c]u8, usize, *anyopaque) c_int; +pub const CurlHeaderFunction = fn ([*]const u8, usize, usize, *anyopaque) usize; +pub const CurlWriteFunction = fn ([*]const u8, usize, usize, *anyopaque) usize; +pub const curl_writefunc_error: usize = c.CURL_WRITEFUNC_ERROR; + +pub const CurlGlobalFlags = packed struct(u8) { + ssl: bool = false, + _reserved: u7 = 0, + + pub fn to_c(self: @This()) c_long { + var flags: c_long = 0; + if (self.ssl) flags |= c.CURL_GLOBAL_SSL; + return flags; + } +}; + +pub const CurlHeaderOrigin = enum(c_uint) { + header = c.CURLH_HEADER, + trailer = c.CURLH_TRAILER, + connect = c.CURLH_CONNECT, + @"1xx" = c.CURLH_1XX, + pseudo = c.CURLH_PSEUDO, +}; + +pub const CurlWaitEvents = packed struct(c_short) { + pollin: bool = false, + pollpri: bool = false, + pollout: bool = false, + _reserved: u13 = 0, +}; + +pub const CurlInfoType = enum(c.curl_infotype) { + text = c.CURLINFO_TEXT, + header_in = c.CURLINFO_HEADER_IN, + header_out = c.CURLINFO_HEADER_OUT, + data_in = c.CURLINFO_DATA_IN, + data_out = c.CURLINFO_DATA_OUT, + ssl_data_in = c.CURLINFO_SSL_DATA_IN, + ssl_data_out = c.CURLINFO_SSL_DATA_OUT, + end = c.CURLINFO_END, +}; + +pub const CurlWaitFd = extern struct { + fd: CurlSocket, + events: CurlWaitEvents, + revents: CurlWaitEvents, +}; + +comptime { + const debug_cb_check: c.curl_debug_callback = struct { + fn cb(handle: ?*Curl, msg_type: c.curl_infotype, raw: [*c]u8, len: usize, user: ?*anyopaque) callconv(.c) c_int { + _ = handle; + _ = msg_type; + _ = raw; + _ = len; + _ = user; + return 0; + } + }.cb; + const write_cb_check: c.curl_write_callback = struct { + fn cb(buffer: [*c]u8, count: usize, len: usize, user: ?*anyopaque) callconv(.c) usize { + _ = buffer; + _ = count; + _ = len; + _ = user; + return 0; + } + }.cb; + _ = debug_cb_check; + _ = write_cb_check; + + if (@sizeOf(CurlWaitFd) != @sizeOf(c.curl_waitfd)) { + @compileError("CurlWaitFd size mismatch"); + } + if (@offsetOf(CurlWaitFd, "fd") != @offsetOf(c.curl_waitfd, "fd") or + @offsetOf(CurlWaitFd, "events") != @offsetOf(c.curl_waitfd, "events") or + @offsetOf(CurlWaitFd, "revents") != @offsetOf(c.curl_waitfd, "revents")) + { + @compileError("CurlWaitFd layout mismatch"); + } + if (c.CURL_WAIT_POLLIN != 1 or c.CURL_WAIT_POLLPRI != 2 or c.CURL_WAIT_POLLOUT != 4) { + @compileError("CURL_WAIT_* flag values don't match CurlWaitEvents packed struct bit layout"); + } +} + +pub const CurlOption = enum(c.CURLoption) { + url = c.CURLOPT_URL, + timeout_ms = c.CURLOPT_TIMEOUT_MS, + connect_timeout_ms = c.CURLOPT_CONNECTTIMEOUT_MS, + max_redirs = c.CURLOPT_MAXREDIRS, + follow_location = c.CURLOPT_FOLLOWLOCATION, + redir_protocols_str = c.CURLOPT_REDIR_PROTOCOLS_STR, + proxy = c.CURLOPT_PROXY, + ca_info_blob = c.CURLOPT_CAINFO_BLOB, + proxy_ca_info_blob = c.CURLOPT_PROXY_CAINFO_BLOB, + ssl_verify_host = c.CURLOPT_SSL_VERIFYHOST, + ssl_verify_peer = c.CURLOPT_SSL_VERIFYPEER, + proxy_ssl_verify_host = c.CURLOPT_PROXY_SSL_VERIFYHOST, + proxy_ssl_verify_peer = c.CURLOPT_PROXY_SSL_VERIFYPEER, + accept_encoding = c.CURLOPT_ACCEPT_ENCODING, + verbose = c.CURLOPT_VERBOSE, + debug_function = c.CURLOPT_DEBUGFUNCTION, + custom_request = c.CURLOPT_CUSTOMREQUEST, + post = c.CURLOPT_POST, + http_post = c.CURLOPT_HTTPPOST, + post_field_size = c.CURLOPT_POSTFIELDSIZE, + copy_post_fields = c.CURLOPT_COPYPOSTFIELDS, + http_get = c.CURLOPT_HTTPGET, + http_header = c.CURLOPT_HTTPHEADER, + cookie = c.CURLOPT_COOKIE, + private = c.CURLOPT_PRIVATE, + proxy_user_pwd = c.CURLOPT_PROXYUSERPWD, + header_data = c.CURLOPT_HEADERDATA, + header_function = c.CURLOPT_HEADERFUNCTION, + write_data = c.CURLOPT_WRITEDATA, + write_function = c.CURLOPT_WRITEFUNCTION, +}; + +pub const CurlMOption = enum(c.CURLMoption) { + max_host_connections = c.CURLMOPT_MAX_HOST_CONNECTIONS, +}; + +pub const CurlInfo = enum(c.CURLINFO) { + effective_url = c.CURLINFO_EFFECTIVE_URL, + private = c.CURLINFO_PRIVATE, + redirect_count = c.CURLINFO_REDIRECT_COUNT, + response_code = c.CURLINFO_RESPONSE_CODE, +}; + +pub const Error = error{ + UnsupportedProtocol, + FailedInit, + UrlMalformat, + NotBuiltIn, + CouldntResolveProxy, + CouldntResolveHost, + CouldntConnect, + WeirdServerReply, + RemoteAccessDenied, + FtpAcceptFailed, + FtpWeirdPassReply, + FtpAcceptTimeout, + FtpWeirdPasvReply, + FtpWeird227Format, + FtpCantGetHost, + Http2, + FtpCouldntSetType, + PartialFile, + FtpCouldntRetrFile, + QuoteError, + HttpReturnedError, + WriteError, + UploadFailed, + ReadError, + OutOfMemory, + OperationTimedout, + FtpPortFailed, + FtpCouldntUseRest, + RangeError, + SslConnectError, + BadDownloadResume, + FileCouldntReadFile, + LdapCannotBind, + LdapSearchFailed, + AbortedByCallback, + BadFunctionArgument, + InterfaceFailed, + TooManyRedirects, + UnknownOption, + SetoptOptionSyntax, + GotNothing, + SslEngineNotfound, + SslEngineSetfailed, + SendError, + RecvError, + SslCertproblem, + SslCipher, + PeerFailedVerification, + BadContentEncoding, + FilesizeExceeded, + UseSslFailed, + SendFailRewind, + SslEngineInitfailed, + LoginDenied, + TftpNotfound, + TftpPerm, + RemoteDiskFull, + TftpIllegal, + TftpUnknownid, + RemoteFileExists, + TftpNosuchuser, + SslCacertBadfile, + RemoteFileNotFound, + Ssh, + SslShutdownFailed, + Again, + SslCrlBadfile, + SslIssuerError, + FtpPretFailed, + RtspCseqError, + RtspSessionError, + FtpBadFileList, + ChunkFailed, + NoConnectionAvailable, + SslPinnedpubkeynotmatch, + SslInvalidcertstatus, + Http2Stream, + RecursiveApiCall, + AuthError, + Http3, + QuicConnectError, + Proxy, + SslClientcert, + UnrecoverablePoll, + TooLarge, + Unknown, +}; + +pub fn errorFromCode(code: c.CURLcode) Error { + if (comptime IS_DEBUG) { + std.debug.assert(code != c.CURLE_OK); + } + + return switch (code) { + c.CURLE_UNSUPPORTED_PROTOCOL => Error.UnsupportedProtocol, + c.CURLE_FAILED_INIT => Error.FailedInit, + c.CURLE_URL_MALFORMAT => Error.UrlMalformat, + c.CURLE_NOT_BUILT_IN => Error.NotBuiltIn, + c.CURLE_COULDNT_RESOLVE_PROXY => Error.CouldntResolveProxy, + c.CURLE_COULDNT_RESOLVE_HOST => Error.CouldntResolveHost, + c.CURLE_COULDNT_CONNECT => Error.CouldntConnect, + c.CURLE_WEIRD_SERVER_REPLY => Error.WeirdServerReply, + c.CURLE_REMOTE_ACCESS_DENIED => Error.RemoteAccessDenied, + c.CURLE_FTP_ACCEPT_FAILED => Error.FtpAcceptFailed, + c.CURLE_FTP_WEIRD_PASS_REPLY => Error.FtpWeirdPassReply, + c.CURLE_FTP_ACCEPT_TIMEOUT => Error.FtpAcceptTimeout, + c.CURLE_FTP_WEIRD_PASV_REPLY => Error.FtpWeirdPasvReply, + c.CURLE_FTP_WEIRD_227_FORMAT => Error.FtpWeird227Format, + c.CURLE_FTP_CANT_GET_HOST => Error.FtpCantGetHost, + c.CURLE_HTTP2 => Error.Http2, + c.CURLE_FTP_COULDNT_SET_TYPE => Error.FtpCouldntSetType, + c.CURLE_PARTIAL_FILE => Error.PartialFile, + c.CURLE_FTP_COULDNT_RETR_FILE => Error.FtpCouldntRetrFile, + c.CURLE_QUOTE_ERROR => Error.QuoteError, + c.CURLE_HTTP_RETURNED_ERROR => Error.HttpReturnedError, + c.CURLE_WRITE_ERROR => Error.WriteError, + c.CURLE_UPLOAD_FAILED => Error.UploadFailed, + c.CURLE_READ_ERROR => Error.ReadError, + c.CURLE_OUT_OF_MEMORY => Error.OutOfMemory, + c.CURLE_OPERATION_TIMEDOUT => Error.OperationTimedout, + c.CURLE_FTP_PORT_FAILED => Error.FtpPortFailed, + c.CURLE_FTP_COULDNT_USE_REST => Error.FtpCouldntUseRest, + c.CURLE_RANGE_ERROR => Error.RangeError, + c.CURLE_SSL_CONNECT_ERROR => Error.SslConnectError, + c.CURLE_BAD_DOWNLOAD_RESUME => Error.BadDownloadResume, + c.CURLE_FILE_COULDNT_READ_FILE => Error.FileCouldntReadFile, + c.CURLE_LDAP_CANNOT_BIND => Error.LdapCannotBind, + c.CURLE_LDAP_SEARCH_FAILED => Error.LdapSearchFailed, + c.CURLE_ABORTED_BY_CALLBACK => Error.AbortedByCallback, + c.CURLE_BAD_FUNCTION_ARGUMENT => Error.BadFunctionArgument, + c.CURLE_INTERFACE_FAILED => Error.InterfaceFailed, + c.CURLE_TOO_MANY_REDIRECTS => Error.TooManyRedirects, + c.CURLE_UNKNOWN_OPTION => Error.UnknownOption, + c.CURLE_SETOPT_OPTION_SYNTAX => Error.SetoptOptionSyntax, + c.CURLE_GOT_NOTHING => Error.GotNothing, + c.CURLE_SSL_ENGINE_NOTFOUND => Error.SslEngineNotfound, + c.CURLE_SSL_ENGINE_SETFAILED => Error.SslEngineSetfailed, + c.CURLE_SEND_ERROR => Error.SendError, + c.CURLE_RECV_ERROR => Error.RecvError, + c.CURLE_SSL_CERTPROBLEM => Error.SslCertproblem, + c.CURLE_SSL_CIPHER => Error.SslCipher, + c.CURLE_PEER_FAILED_VERIFICATION => Error.PeerFailedVerification, + c.CURLE_BAD_CONTENT_ENCODING => Error.BadContentEncoding, + c.CURLE_FILESIZE_EXCEEDED => Error.FilesizeExceeded, + c.CURLE_USE_SSL_FAILED => Error.UseSslFailed, + c.CURLE_SEND_FAIL_REWIND => Error.SendFailRewind, + c.CURLE_SSL_ENGINE_INITFAILED => Error.SslEngineInitfailed, + c.CURLE_LOGIN_DENIED => Error.LoginDenied, + c.CURLE_TFTP_NOTFOUND => Error.TftpNotfound, + c.CURLE_TFTP_PERM => Error.TftpPerm, + c.CURLE_REMOTE_DISK_FULL => Error.RemoteDiskFull, + c.CURLE_TFTP_ILLEGAL => Error.TftpIllegal, + c.CURLE_TFTP_UNKNOWNID => Error.TftpUnknownid, + c.CURLE_REMOTE_FILE_EXISTS => Error.RemoteFileExists, + c.CURLE_TFTP_NOSUCHUSER => Error.TftpNosuchuser, + c.CURLE_SSL_CACERT_BADFILE => Error.SslCacertBadfile, + c.CURLE_REMOTE_FILE_NOT_FOUND => Error.RemoteFileNotFound, + c.CURLE_SSH => Error.Ssh, + c.CURLE_SSL_SHUTDOWN_FAILED => Error.SslShutdownFailed, + c.CURLE_AGAIN => Error.Again, + c.CURLE_SSL_CRL_BADFILE => Error.SslCrlBadfile, + c.CURLE_SSL_ISSUER_ERROR => Error.SslIssuerError, + c.CURLE_FTP_PRET_FAILED => Error.FtpPretFailed, + c.CURLE_RTSP_CSEQ_ERROR => Error.RtspCseqError, + c.CURLE_RTSP_SESSION_ERROR => Error.RtspSessionError, + c.CURLE_FTP_BAD_FILE_LIST => Error.FtpBadFileList, + c.CURLE_CHUNK_FAILED => Error.ChunkFailed, + c.CURLE_NO_CONNECTION_AVAILABLE => Error.NoConnectionAvailable, + c.CURLE_SSL_PINNEDPUBKEYNOTMATCH => Error.SslPinnedpubkeynotmatch, + c.CURLE_SSL_INVALIDCERTSTATUS => Error.SslInvalidcertstatus, + c.CURLE_HTTP2_STREAM => Error.Http2Stream, + c.CURLE_RECURSIVE_API_CALL => Error.RecursiveApiCall, + c.CURLE_AUTH_ERROR => Error.AuthError, + c.CURLE_HTTP3 => Error.Http3, + c.CURLE_QUIC_CONNECT_ERROR => Error.QuicConnectError, + c.CURLE_PROXY => Error.Proxy, + c.CURLE_SSL_CLIENTCERT => Error.SslClientcert, + c.CURLE_UNRECOVERABLE_POLL => Error.UnrecoverablePoll, + c.CURLE_TOO_LARGE => Error.TooLarge, + else => Error.Unknown, + }; +} + +pub const ErrorMulti = error{ + BadHandle, + BadEasyHandle, + OutOfMemory, + InternalError, + BadSocket, + UnknownOption, + AddedAlready, + RecursiveApiCall, + WakeupFailure, + BadFunctionArgument, + AbortedByCallback, + UnrecoverablePoll, + Unknown, +}; + +pub const ErrorHeader = error{ + OutOfMemory, + BadArgument, + NotBuiltIn, + Unknown, +}; + +pub fn errorMFromCode(code: c.CURLMcode) ErrorMulti { + if (comptime IS_DEBUG) { + std.debug.assert(code != c.CURLM_OK); + } + + return switch (code) { + c.CURLM_BAD_HANDLE => ErrorMulti.BadHandle, + c.CURLM_BAD_EASY_HANDLE => ErrorMulti.BadEasyHandle, + c.CURLM_OUT_OF_MEMORY => ErrorMulti.OutOfMemory, + c.CURLM_INTERNAL_ERROR => ErrorMulti.InternalError, + c.CURLM_BAD_SOCKET => ErrorMulti.BadSocket, + c.CURLM_UNKNOWN_OPTION => ErrorMulti.UnknownOption, + c.CURLM_ADDED_ALREADY => ErrorMulti.AddedAlready, + c.CURLM_RECURSIVE_API_CALL => ErrorMulti.RecursiveApiCall, + c.CURLM_WAKEUP_FAILURE => ErrorMulti.WakeupFailure, + c.CURLM_BAD_FUNCTION_ARGUMENT => ErrorMulti.BadFunctionArgument, + c.CURLM_ABORTED_BY_CALLBACK => ErrorMulti.AbortedByCallback, + c.CURLM_UNRECOVERABLE_POLL => ErrorMulti.UnrecoverablePoll, + else => ErrorMulti.Unknown, + }; +} + +pub fn errorHFromCode(code: c.CURLHcode) ErrorHeader { + if (comptime IS_DEBUG) { + std.debug.assert(code != c.CURLHE_OK); + } + + return switch (code) { + c.CURLHE_OUT_OF_MEMORY => ErrorHeader.OutOfMemory, + c.CURLHE_BAD_ARGUMENT => ErrorHeader.BadArgument, + c.CURLHE_NOT_BUILT_IN => ErrorHeader.NotBuiltIn, + else => ErrorHeader.Unknown, + }; +} + +pub fn errorCheck(code: c.CURLcode) Error!void { + if (code == c.CURLE_OK) { + return; + } + return errorFromCode(code); +} + +pub fn errorMCheck(code: c.CURLMcode) ErrorMulti!void { + if (code == c.CURLM_OK) { + return; + } + if (code == c.CURLM_CALL_MULTI_PERFORM) { + return; + } + return errorMFromCode(code); +} + +pub fn errorHCheck(code: c.CURLHcode) ErrorHeader!void { + if (code == c.CURLHE_OK) { + return; + } + return errorHFromCode(code); +} + +pub const CurlMsgType = enum(c.CURLMSG) { + none = c.CURLMSG_NONE, + done = c.CURLMSG_DONE, + last = c.CURLMSG_LAST, +}; + +pub const CurlMsgData = union(CurlMsgType) { + none: ?*anyopaque, + done: ?Error, + last: ?*anyopaque, +}; + +pub const CurlMsg = struct { + easy_handle: *Curl, + data: CurlMsgData, +}; + +pub fn curl_global_init(flags: CurlGlobalFlags) Error!void { + try errorCheck(c.curl_global_init(flags.to_c())); +} + +pub fn curl_global_cleanup() void { + c.curl_global_cleanup(); +} + +pub fn curl_version() [*c]const u8 { + return c.curl_version(); +} + +pub fn curl_easy_init() ?*Curl { + return c.curl_easy_init(); +} + +pub fn curl_easy_cleanup(easy: *Curl) void { + c.curl_easy_cleanup(easy); +} + +pub fn curl_easy_perform(easy: *Curl) Error!void { + try errorCheck(c.curl_easy_perform(easy)); +} + +pub fn curl_easy_setopt(easy: *Curl, comptime option: CurlOption, value: anytype) Error!void { + const opt: c.CURLoption = @intFromEnum(option); + const code = switch (option) { + .verbose, + .post, + .http_get, + .ssl_verify_host, + .ssl_verify_peer, + .proxy_ssl_verify_host, + .proxy_ssl_verify_peer, + => blk: { + const n: c_long = switch (@typeInfo(@TypeOf(value))) { + .bool => switch (option) { + .ssl_verify_host, .proxy_ssl_verify_host => if (value) 2 else 0, + else => if (value) 1 else 0, + }, + else => @compileError("expected bool|integer for " ++ @tagName(option) ++ ", got " ++ @typeName(@TypeOf(value))), + }; + break :blk c.curl_easy_setopt(easy, opt, n); + }, + + .timeout_ms, + .connect_timeout_ms, + .max_redirs, + .follow_location, + .post_field_size, + => blk: { + const n: c_long = switch (@typeInfo(@TypeOf(value))) { + .comptime_int, .int => @intCast(value), + else => @compileError("expected integer for " ++ @tagName(option) ++ ", got " ++ @typeName(@TypeOf(value))), + }; + break :blk c.curl_easy_setopt(easy, opt, n); + }, + + .url, + .redir_protocols_str, + .proxy, + .accept_encoding, + .custom_request, + .cookie, + .proxy_user_pwd, + .copy_post_fields, + => blk: { + const s: ?[*]const u8 = value; + break :blk c.curl_easy_setopt(easy, opt, s); + }, + + .ca_info_blob, + .proxy_ca_info_blob, + => blk: { + const blob: CurlBlob = value; + break :blk c.curl_easy_setopt(easy, opt, blob); + }, + + .http_post => blk: { + // CURLOPT_HTTPPOST expects ?*curl_httppost (multipart formdata) + const ptr: ?*CurlHttpPost = value; + break :blk c.curl_easy_setopt(easy, opt, ptr); + }, + + .http_header => blk: { + const list: ?*CurlSList = value; + break :blk c.curl_easy_setopt(easy, opt, list); + }, + + .private, + .header_data, + .write_data, + => blk: { + const ptr: *anyopaque = @ptrCast(value); + break :blk c.curl_easy_setopt(easy, opt, ptr); + }, + + .debug_function => blk: { + const cb: c.curl_debug_callback = switch (@typeInfo(@TypeOf(value))) { + .null => null, + .@"fn" => struct { + fn cb(handle: ?*Curl, msg_type: c.curl_infotype, raw: [*c]u8, len: usize, user: ?*anyopaque) callconv(.c) c_int { + const h = handle orelse unreachable; + const u = user orelse unreachable; + return value(h, @enumFromInt(@intFromEnum(msg_type)), raw, len, u); + } + }.cb, + else => @compileError("expected Zig function or null for " ++ @tagName(option) ++ ", got " ++ @typeName(@TypeOf(value))), + }; + break :blk c.curl_easy_setopt(easy, opt, cb); + }, + + .header_function => blk: { + const cb: c.curl_write_callback = switch (@typeInfo(@TypeOf(value))) { + .null => null, + .@"fn" => struct { + fn cb(buffer: [*c]u8, count: usize, len: usize, user: ?*anyopaque) callconv(.c) usize { + const u = user orelse unreachable; + return value(@ptrCast(buffer), count, len, u); + } + }.cb, + else => @compileError("expected Zig function or null for " ++ @tagName(option) ++ ", got " ++ @typeName(@TypeOf(value))), + }; + break :blk c.curl_easy_setopt(easy, opt, cb); + }, + + .write_function => blk: { + const cb: c.curl_write_callback = switch (@typeInfo(@TypeOf(value))) { + .null => null, + .@"fn" => struct { + fn cb(buffer: [*c]u8, count: usize, len: usize, user: ?*anyopaque) callconv(.c) usize { + const u = user orelse unreachable; + return value(@ptrCast(buffer), count, len, u); + } + }.cb, + else => @compileError("expected Zig function or null for " ++ @tagName(option) ++ ", got " ++ @typeName(@TypeOf(value))), + }; + break :blk c.curl_easy_setopt(easy, opt, cb); + }, + }; + try errorCheck(code); +} + +pub fn curl_easy_getinfo(easy: *Curl, comptime info: CurlInfo, out: anytype) Error!void { + if (@typeInfo(@TypeOf(out)) != .pointer) { + @compileError("curl_easy_getinfo out must be a pointer, got " ++ @typeName(@TypeOf(out))); + } + + const inf: c.CURLINFO = @intFromEnum(info); + const code = switch (info) { + .effective_url => blk: { + const p: *[*c]u8 = out; + break :blk c.curl_easy_getinfo(easy, inf, p); + }, + .response_code, + .redirect_count, + => blk: { + const p: *c_long = out; + break :blk c.curl_easy_getinfo(easy, inf, p); + }, + .private => blk: { + const p: **anyopaque = out; + break :blk c.curl_easy_getinfo(easy, inf, p); + }, + }; + try errorCheck(code); +} + +pub fn curl_easy_header( + easy: *Curl, + name: [*:0]const u8, + index: usize, + comptime origin: CurlHeaderOrigin, + request: c_int, + hout: *?*CurlHeader, +) ErrorHeader!void { + var c_hout: [*c]CurlHeader = null; + const code = c.curl_easy_header(easy, name, index, @intFromEnum(origin), request, &c_hout); + switch (code) { + c.CURLHE_OK => { + hout.* = @ptrCast(c_hout); + return; + }, + c.CURLHE_BADINDEX, + c.CURLHE_MISSING, + c.CURLHE_NOHEADERS, + c.CURLHE_NOREQUEST, + => { + hout.* = null; + return; + }, + else => { + hout.* = null; + return errorHFromCode(code); + }, + } +} + +pub fn curl_easy_nextheader( + easy: *Curl, + comptime origin: CurlHeaderOrigin, + request: c_int, + prev: ?*CurlHeader, +) ?*CurlHeader { + const ptr = c.curl_easy_nextheader(easy, @intFromEnum(origin), request, prev); + if (ptr == null) return null; + return @ptrCast(ptr); +} + +pub fn curl_multi_init() ?*CurlM { + return c.curl_multi_init(); +} + +pub fn curl_multi_cleanup(multi: *CurlM) ErrorMulti!void { + try errorMCheck(c.curl_multi_cleanup(multi)); +} + +pub fn curl_multi_setopt(multi: *CurlM, comptime option: CurlMOption, value: anytype) ErrorMulti!void { + const opt: c.CURLMoption = @intFromEnum(option); + const code = switch (option) { + .max_host_connections => blk: { + const n: c_long = switch (@typeInfo(@TypeOf(value))) { + .comptime_int, .int => @intCast(value), + else => @compileError("expected integer for " ++ @tagName(option) ++ ", got " ++ @typeName(@TypeOf(value))), + }; + break :blk c.curl_multi_setopt(multi, opt, n); + }, + }; + try errorMCheck(code); +} + +pub fn curl_multi_add_handle(multi: *CurlM, easy: *Curl) ErrorMulti!void { + try errorMCheck(c.curl_multi_add_handle(multi, easy)); +} + +pub fn curl_multi_remove_handle(multi: *CurlM, easy: *Curl) ErrorMulti!void { + try errorMCheck(c.curl_multi_remove_handle(multi, easy)); +} + +pub fn curl_multi_perform(multi: *CurlM, running_handles: *c_int) ErrorMulti!void { + try errorMCheck(c.curl_multi_perform(multi, running_handles)); +} + +pub fn curl_multi_poll( + multi: *CurlM, + extra_fds: []CurlWaitFd, + timeout_ms: c_int, + numfds: ?*c_int, +) ErrorMulti!void { + const raw_fds: [*c]c.curl_waitfd = if (extra_fds.len == 0) null else @ptrCast(extra_fds.ptr); + try errorMCheck(c.curl_multi_poll(multi, raw_fds, @intCast(extra_fds.len), timeout_ms, numfds)); +} + +pub fn curl_multi_info_read(multi: *CurlM, msgs_in_queue: *c_int) ?CurlMsg { + const ptr = c.curl_multi_info_read(multi, msgs_in_queue); + if (ptr == null) return null; + + const msg: *const c.CURLMsg = @ptrCast(ptr); + const easy_handle = msg.easy_handle orelse unreachable; + + return switch (msg.msg) { + c.CURLMSG_NONE => .{ + .easy_handle = easy_handle, + .data = .{ .none = msg.data.whatever }, + }, + c.CURLMSG_DONE => .{ + .easy_handle = easy_handle, + .data = .{ .done = if (errorCheck(msg.data.result)) |_| null else |err| err }, + }, + c.CURLMSG_LAST => .{ + .easy_handle = easy_handle, + .data = .{ .last = msg.data.whatever }, + }, + else => unreachable, + }; +} + +pub fn curl_slist_append(list: ?*CurlSList, header: [*:0]const u8) ?*CurlSList { + return c.curl_slist_append(list, header); +} + +pub fn curl_slist_free_all(list: ?*CurlSList) void { + if (list) |ptr| { + c.curl_slist_free_all(ptr); + } +} From ae6ab34e72edf80ed69a3c55dd6a7bb9dd3f6c31 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Thu, 26 Feb 2026 08:57:42 +0800 Subject: [PATCH 057/103] Make NodeList enumerable This probably needs to be done for more types. Foundation is now in bridge, so it should be easy to add. --- src/browser/URL.zig | 3 +- src/browser/js/Caller.zig | 30 +++++++++++- src/browser/js/Local.zig | 1 + src/browser/js/Snapshot.zig | 13 ++++- src/browser/js/bridge.zig | 48 +++++++++++++------ .../tests/document/query_selector_all.html | 2 + src/browser/webapi/PluginArray.zig | 2 +- src/browser/webapi/Window.zig | 2 +- .../webapi/collections/DOMTokenList.zig | 2 +- .../webapi/collections/HTMLAllCollection.zig | 2 +- .../webapi/collections/HTMLCollection.zig | 2 +- .../HTMLFormControlsCollection.zig | 2 +- .../collections/HTMLOptionsCollection.zig | 2 +- src/browser/webapi/collections/NodeList.zig | 11 ++++- .../webapi/collections/RadioNodeList.zig | 2 +- src/browser/webapi/css/CSSRuleList.zig | 2 +- src/browser/webapi/css/StyleSheetList.zig | 2 +- src/browser/webapi/element/Attribute.zig | 2 +- 18 files changed, 98 insertions(+), 32 deletions(-) diff --git a/src/browser/URL.zig b/src/browser/URL.zig index 3a2a0514..489d4b3c 100644 --- a/src/browser/URL.zig +++ b/src/browser/URL.zig @@ -180,7 +180,8 @@ fn encodeURL(allocator: Allocator, url: [:0]const u8, comptime opts: ResolveOpts if (encoded_path.ptr == path_to_encode.ptr and (encoded_query == null or encoded_query.?.ptr == url[query_start.? + 1 .. query_end].ptr) and - (encoded_fragment == null or encoded_fragment.?.ptr == url[fragment_start.? + 1 ..].ptr)) { + (encoded_fragment == null or encoded_fragment.?.ptr == url[fragment_start.? + 1 ..].ptr)) + { // nothing has changed return url; } diff --git a/src/browser/js/Caller.zig b/src/browser/js/Caller.zig index 4777b932..6ab014c4 100644 --- a/src/browser/js/Caller.zig +++ b/src/browser/js/Caller.zig @@ -251,7 +251,33 @@ fn _deleteNamedIndex(comptime T: type, local: *const Local, func: anytype, name: return handleIndexedReturn(T, F, false, local, ret, info, opts); } -fn handleIndexedReturn(comptime T: type, comptime F: type, comptime getter: bool, local: *const Local, ret: anytype, info: PropertyCallbackInfo, comptime opts: CallOpts) !u8 { +pub fn getEnumerator(self: *Caller, comptime T: type, func: anytype, handle: *const v8.PropertyCallbackInfo, comptime opts: CallOpts) u8 { + const local = &self.local; + + var hs: js.HandleScope = undefined; + hs.init(local.isolate); + defer hs.deinit(); + + const info = PropertyCallbackInfo{ .handle = handle }; + return _getEnumerator(T, local, func, info, opts) catch |err| { + handleError(T, @TypeOf(func), local, err, info, opts); + // not intercepted + return 0; + }; +} + +fn _getEnumerator(comptime T: type, local: *const Local, func: anytype, info: PropertyCallbackInfo, comptime opts: CallOpts) !u8 { + const F = @TypeOf(func); + var args: ParameterTypes(F) = undefined; + @field(args, "0") = try TaggedOpaque.fromJS(*T, info.getThis()); + if (@typeInfo(F).@"fn".params.len == 2) { + @field(args, "1") = local.ctx.page; + } + const ret = @call(.auto, func, args); + return handleIndexedReturn(T, F, true, local, ret, info, opts); +} + +fn handleIndexedReturn(comptime T: type, comptime F: type, comptime with_value: bool, local: *const Local, ret: anytype, info: PropertyCallbackInfo, comptime opts: CallOpts) !u8 { // need to unwrap this error immediately for when opts.null_as_undefined == true // and we need to compare it to null; const non_error_ret = switch (@typeInfo(@TypeOf(ret))) { @@ -274,7 +300,7 @@ fn handleIndexedReturn(comptime T: type, comptime F: type, comptime getter: bool else => ret, }; - if (comptime getter) { + if (comptime with_value) { info.getReturnValue().set(try local.zigValueToJs(non_error_ret, opts)); } // intercepted diff --git a/src/browser/js/Local.zig b/src/browser/js/Local.zig index af9febf4..a62080a0 100644 --- a/src/browser/js/Local.zig +++ b/src/browser/js/Local.zig @@ -323,6 +323,7 @@ pub fn zigValueToJs(self: *const Local, value: anytype, comptime opts: CallOpts) }, inline + js.Array, js.Function, js.Object, js.Promise, diff --git a/src/browser/js/Snapshot.zig b/src/browser/js/Snapshot.zig index 220fed4d..7aa2125c 100644 --- a/src/browser/js/Snapshot.zig +++ b/src/browser/js/Snapshot.zig @@ -308,13 +308,18 @@ fn countExternalReferences() comptime_int { const T = @TypeOf(value); if (T == bridge.Accessor) { count += 1; // getter - if (value.setter != null) count += 1; // setter + if (value.setter != null) { + count += 1; + } } else if (T == bridge.Function) { count += 1; } else if (T == bridge.Iterator) { count += 1; } else if (T == bridge.Indexed) { count += 1; + if (value.enumerator != null) { + count += 1; + } } else if (T == bridge.NamedIndexed) { count += 1; // getter if (value.setter != null) count += 1; @@ -376,6 +381,10 @@ fn collectExternalReferences() [countExternalReferences()]isize { } else if (T == bridge.Indexed) { references[idx] = @bitCast(@intFromPtr(value.getter)); idx += 1; + if (value.enumerator) |enumerator| { + references[idx] = @bitCast(@intFromPtr(enumerator)); + idx += 1; + } } else if (T == bridge.NamedIndexed) { references[idx] = @bitCast(@intFromPtr(value.getter)); idx += 1; @@ -515,10 +524,10 @@ fn attachClass(comptime JsApi: type, isolate: *v8.Isolate, template: *v8.Functio bridge.Indexed => { var configuration: v8.IndexedPropertyHandlerConfiguration = .{ .getter = value.getter, + .enumerator = value.enumerator, .setter = null, .query = null, .deleter = null, - .enumerator = null, .definer = null, .descriptor = null, .data = null, diff --git a/src/browser/js/bridge.zig b/src/browser/js/bridge.zig index 30aae516..4c70ece6 100644 --- a/src/browser/js/bridge.zig +++ b/src/browser/js/bridge.zig @@ -46,8 +46,8 @@ pub fn Builder(comptime T: type) type { return Function.init(T, func, opts); } - pub fn indexed(comptime getter_func: anytype, comptime opts: Indexed.Opts) Indexed { - return Indexed.init(T, getter_func, opts); + pub fn indexed(comptime getter_func: anytype, comptime enumerator_func: anytype, comptime opts: Indexed.Opts) Indexed { + return Indexed.init(T, getter_func, enumerator_func, opts); } pub fn namedIndexed(comptime getter_func: anytype, setter_func: anytype, deleter_func: anytype, comptime opts: NamedIndexed.Opts) NamedIndexed { @@ -230,26 +230,44 @@ pub const Accessor = struct { pub const Indexed = struct { getter: *const fn (idx: u32, handle: ?*const v8.PropertyCallbackInfo) callconv(.c) u8, + enumerator: ?*const fn (handle: ?*const v8.PropertyCallbackInfo) callconv(.c) u8, const Opts = struct { as_typed_array: bool = false, null_as_undefined: bool = false, }; - fn init(comptime T: type, comptime getter: anytype, comptime opts: Opts) Indexed { - return .{ .getter = struct { - fn wrap(idx: u32, handle: ?*const v8.PropertyCallbackInfo) callconv(.c) u8 { - const v8_isolate = v8.v8__PropertyCallbackInfo__GetIsolate(handle).?; - var caller: Caller = undefined; - caller.init(v8_isolate); - defer caller.deinit(); + fn init(comptime T: type, comptime getter: anytype, comptime enumerator: anytype, comptime opts: Opts) Indexed { + var indexed = Indexed{ + .enumerator = null, + .getter = struct { + fn wrap(idx: u32, handle: ?*const v8.PropertyCallbackInfo) callconv(.c) u8 { + const v8_isolate = v8.v8__PropertyCallbackInfo__GetIsolate(handle).?; + var caller: Caller = undefined; + caller.init(v8_isolate); + defer caller.deinit(); - return caller.getIndex(T, getter, idx, handle.?, .{ - .as_typed_array = opts.as_typed_array, - .null_as_undefined = opts.null_as_undefined, - }); - } - }.wrap }; + return caller.getIndex(T, getter, idx, handle.?, .{ + .as_typed_array = opts.as_typed_array, + .null_as_undefined = opts.null_as_undefined, + }); + } + }.wrap, + }; + + if (@typeInfo(@TypeOf(enumerator)) != .null) { + indexed.enumerator = struct { + fn wrap(handle: ?*const v8.PropertyCallbackInfo) callconv(.c) u8 { + const v8_isolate = v8.v8__PropertyCallbackInfo__GetIsolate(handle).?; + var caller: Caller = undefined; + caller.init(v8_isolate); + defer caller.deinit(); + return caller.getEnumerator(T, enumerator, handle.?, .{}); + } + }.wrap; + } + + return indexed; } }; diff --git a/src/browser/tests/document/query_selector_all.html b/src/browser/tests/document/query_selector_all.html index c98de73c..4e46f7e3 100644 --- a/src/browser/tests/document/query_selector_all.html +++ b/src/browser/tests/document/query_selector_all.html @@ -27,7 +27,9 @@ testing.expectEqual(expected.length, result.length); testing.expectEqual(expected, Array.from(result).map((e) => e.textContent)); testing.expectEqual(expected, Array.from(result.values()).map((e) => e.textContent)); + testing.expectEqual(expected.map((e, i) => i), Array.from(result.keys())); + testing.expectEqual(expected.map((e, i) => i.toString()), Object.keys(result)); } diff --git a/src/browser/webapi/PluginArray.zig b/src/browser/webapi/PluginArray.zig index a256e01f..cc9a5968 100644 --- a/src/browser/webapi/PluginArray.zig +++ b/src/browser/webapi/PluginArray.zig @@ -63,7 +63,7 @@ pub const JsApi = struct { pub const length = bridge.property(0, .{ .template = false }); pub const refresh = bridge.function(PluginArray.refresh, .{}); - pub const @"[int]" = bridge.indexed(PluginArray.getAtIndex, .{ .null_as_undefined = true }); + pub const @"[int]" = bridge.indexed(PluginArray.getAtIndex, null, .{ .null_as_undefined = true }); pub const @"[str]" = bridge.namedIndexed(PluginArray.getByName, null, null, .{ .null_as_undefined = true }); pub const item = bridge.function(_item, .{}); fn _item(self: *const PluginArray, index: i32) ?*Plugin { diff --git a/src/browser/webapi/Window.zig b/src/browser/webapi/Window.zig index 194ce397..f6ec5176 100644 --- a/src/browser/webapi/Window.zig +++ b/src/browser/webapi/Window.zig @@ -776,7 +776,7 @@ pub const JsApi = struct { pub const getSelection = bridge.function(Window.getSelection, .{}); pub const frames = bridge.accessor(Window.getWindow, null, .{}); - pub const index = bridge.indexed(Window.getFrame, .{ .null_as_undefined = true }); + pub const index = bridge.indexed(Window.getFrame, null, .{ .null_as_undefined = true }); pub const length = bridge.accessor(Window.getFramesLength, null, .{}); pub const scrollX = bridge.accessor(Window.getScrollX, null, .{}); pub const scrollY = bridge.accessor(Window.getScrollY, null, .{}); diff --git a/src/browser/webapi/collections/DOMTokenList.zig b/src/browser/webapi/collections/DOMTokenList.zig index 42bcb2ab..c9843895 100644 --- a/src/browser/webapi/collections/DOMTokenList.zig +++ b/src/browser/webapi/collections/DOMTokenList.zig @@ -321,5 +321,5 @@ pub const JsApi = struct { pub const entries = bridge.function(DOMTokenList.entries, .{}); pub const symbol_iterator = bridge.iterator(DOMTokenList.values, .{}); pub const forEach = bridge.function(DOMTokenList.forEach, .{}); - pub const @"[]" = bridge.indexed(DOMTokenList.item, .{ .null_as_undefined = true }); + pub const @"[]" = bridge.indexed(DOMTokenList.item, null, .{ .null_as_undefined = true }); }; diff --git a/src/browser/webapi/collections/HTMLAllCollection.zig b/src/browser/webapi/collections/HTMLAllCollection.zig index 32fb3056..acada474 100644 --- a/src/browser/webapi/collections/HTMLAllCollection.zig +++ b/src/browser/webapi/collections/HTMLAllCollection.zig @@ -170,7 +170,7 @@ pub const JsApi = struct { }; pub const length = bridge.accessor(HTMLAllCollection.length, null, .{}); - pub const @"[int]" = bridge.indexed(HTMLAllCollection.getAtIndex, .{ .null_as_undefined = true }); + pub const @"[int]" = bridge.indexed(HTMLAllCollection.getAtIndex, null, .{ .null_as_undefined = true }); pub const @"[str]" = bridge.namedIndexed(HTMLAllCollection.getByName, null, null, .{ .null_as_undefined = true }); pub const item = bridge.function(_item, .{}); diff --git a/src/browser/webapi/collections/HTMLCollection.zig b/src/browser/webapi/collections/HTMLCollection.zig index b4c2ccb7..0172541b 100644 --- a/src/browser/webapi/collections/HTMLCollection.zig +++ b/src/browser/webapi/collections/HTMLCollection.zig @@ -136,7 +136,7 @@ pub const JsApi = struct { }; pub const length = bridge.accessor(HTMLCollection.length, null, .{}); - pub const @"[int]" = bridge.indexed(HTMLCollection.getAtIndex, .{ .null_as_undefined = true }); + pub const @"[int]" = bridge.indexed(HTMLCollection.getAtIndex, null, .{ .null_as_undefined = true }); pub const @"[str]" = bridge.namedIndexed(HTMLCollection.getByName, null, null, .{ .null_as_undefined = true }); pub const item = bridge.function(_item, .{}); diff --git a/src/browser/webapi/collections/HTMLFormControlsCollection.zig b/src/browser/webapi/collections/HTMLFormControlsCollection.zig index 20f9210b..fe5f1a97 100644 --- a/src/browser/webapi/collections/HTMLFormControlsCollection.zig +++ b/src/browser/webapi/collections/HTMLFormControlsCollection.zig @@ -138,7 +138,7 @@ pub const JsApi = struct { }; pub const length = bridge.accessor(HTMLFormControlsCollection.length, null, .{}); - pub const @"[int]" = bridge.indexed(HTMLFormControlsCollection.getAtIndex, .{ .null_as_undefined = true }); + pub const @"[int]" = bridge.indexed(HTMLFormControlsCollection.getAtIndex, null, .{ .null_as_undefined = true }); pub const @"[str]" = bridge.namedIndexed(HTMLFormControlsCollection.namedItem, null, null, .{ .null_as_undefined = true }); pub const namedItem = bridge.function(HTMLFormControlsCollection.namedItem, .{}); }; diff --git a/src/browser/webapi/collections/HTMLOptionsCollection.zig b/src/browser/webapi/collections/HTMLOptionsCollection.zig index 781c858a..f743f4a4 100644 --- a/src/browser/webapi/collections/HTMLOptionsCollection.zig +++ b/src/browser/webapi/collections/HTMLOptionsCollection.zig @@ -102,7 +102,7 @@ pub const JsApi = struct { pub const length = bridge.accessor(HTMLOptionsCollection.length, null, .{}); // Indexed access - pub const @"[int]" = bridge.indexed(HTMLOptionsCollection.getAtIndex, .{ .null_as_undefined = true }); + pub const @"[int]" = bridge.indexed(HTMLOptionsCollection.getAtIndex, null, .{ .null_as_undefined = true }); pub const @"[str]" = bridge.namedIndexed(HTMLOptionsCollection.getByName, null, null, .{ .null_as_undefined = true }); pub const selectedIndex = bridge.accessor(HTMLOptionsCollection.getSelectedIndex, HTMLOptionsCollection.setSelectedIndex, .{}); diff --git a/src/browser/webapi/collections/NodeList.zig b/src/browser/webapi/collections/NodeList.zig index bf43ef85..2f236a27 100644 --- a/src/browser/webapi/collections/NodeList.zig +++ b/src/browser/webapi/collections/NodeList.zig @@ -125,11 +125,20 @@ pub const JsApi = struct { }; pub const length = bridge.accessor(NodeList.length, null, .{}); - pub const @"[]" = bridge.indexed(NodeList.indexedGet, .{ .null_as_undefined = true }); + pub const @"[]" = bridge.indexed(NodeList.indexedGet, getIndexes, .{ .null_as_undefined = true }); pub const item = bridge.function(NodeList.getAtIndex, .{}); pub const keys = bridge.function(NodeList.keys, .{}); pub const values = bridge.function(NodeList.values, .{}); pub const entries = bridge.function(NodeList.entries, .{}); pub const forEach = bridge.function(NodeList.forEach, .{}); pub const symbol_iterator = bridge.iterator(NodeList.values, .{}); + + fn getIndexes(self: *NodeList, page: *Page) !js.Array { + const len = try self.length(page); + var arr = page.js.local.?.newArray(len); + for (0..len) |i| { + _ = try arr.set(@intCast(i), i, .{}); + } + return arr; + } }; diff --git a/src/browser/webapi/collections/RadioNodeList.zig b/src/browser/webapi/collections/RadioNodeList.zig index f236f3a7..9504f28e 100644 --- a/src/browser/webapi/collections/RadioNodeList.zig +++ b/src/browser/webapi/collections/RadioNodeList.zig @@ -122,7 +122,7 @@ pub const JsApi = struct { }; pub const length = bridge.accessor(RadioNodeList.getLength, null, .{}); - pub const @"[]" = bridge.indexed(RadioNodeList.getAtIndex, .{ .null_as_undefined = true }); + pub const @"[]" = bridge.indexed(RadioNodeList.getAtIndex, null, .{ .null_as_undefined = true }); pub const item = bridge.function(RadioNodeList.getAtIndex, .{}); pub const value = bridge.accessor(RadioNodeList.getValue, RadioNodeList.setValue, .{}); }; diff --git a/src/browser/webapi/css/CSSRuleList.zig b/src/browser/webapi/css/CSSRuleList.zig index 4a700237..7e727a56 100644 --- a/src/browser/webapi/css/CSSRuleList.zig +++ b/src/browser/webapi/css/CSSRuleList.zig @@ -32,5 +32,5 @@ pub const JsApi = struct { }; pub const length = bridge.accessor(CSSRuleList.length, null, .{}); - pub const @"[]" = bridge.indexed(CSSRuleList.item, .{ .null_as_undefined = true }); + pub const @"[]" = bridge.indexed(CSSRuleList.item, null, .{ .null_as_undefined = true }); }; diff --git a/src/browser/webapi/css/StyleSheetList.zig b/src/browser/webapi/css/StyleSheetList.zig index 8a019a18..c44dc601 100644 --- a/src/browser/webapi/css/StyleSheetList.zig +++ b/src/browser/webapi/css/StyleSheetList.zig @@ -30,5 +30,5 @@ pub const JsApi = struct { }; pub const length = bridge.accessor(StyleSheetList.length, null, .{}); - pub const @"[]" = bridge.indexed(StyleSheetList.item, .{ .null_as_undefined = true }); + pub const @"[]" = bridge.indexed(StyleSheetList.item, null, .{ .null_as_undefined = true }); }; diff --git a/src/browser/webapi/element/Attribute.zig b/src/browser/webapi/element/Attribute.zig index e12fde69..dc28f8b7 100644 --- a/src/browser/webapi/element/Attribute.zig +++ b/src/browser/webapi/element/Attribute.zig @@ -524,7 +524,7 @@ pub const NamedNodeMap = struct { }; pub const length = bridge.accessor(NamedNodeMap.length, null, .{}); - pub const @"[int]" = bridge.indexed(NamedNodeMap.getAtIndex, .{ .null_as_undefined = true }); + pub const @"[int]" = bridge.indexed(NamedNodeMap.getAtIndex, null, .{ .null_as_undefined = true }); pub const @"[str]" = bridge.namedIndexed(NamedNodeMap.getByName, null, null, .{ .null_as_undefined = true }); pub const getNamedItem = bridge.function(NamedNodeMap.getByName, .{}); pub const setNamedItem = bridge.function(NamedNodeMap.set, .{}); From 7a417435cc782fb8a9e5eb532857f0c9c69c9b19 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Thu, 26 Feb 2026 10:53:16 +0800 Subject: [PATCH 058/103] Update src/browser/Session.zig Co-authored-by: Pierre Tachoire --- src/browser/Session.zig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/browser/Session.zig b/src/browser/Session.zig index c8d701cc..e919b480 100644 --- a/src/browser/Session.zig +++ b/src/browser/Session.zig @@ -302,7 +302,7 @@ fn _wait(self: *Session, page: *Page, wait_ms: u32) !WaitResult { // minimize how long we'll poll for network I/O. var ms_to_wait = @min(200, ms_to_next_task orelse 200); if (ms_to_wait > 10 and browser.hasBackgroundTasks()) { - // if we have bakcground tasks, we don't want ot wait too + // if we have background tasks, we don't want to wait too // long for a message from the client. We want to go back // to the top of the loop and run macrotasks. ms_to_wait = 10; From aedb823b4d81b575c2ef6bd4e4887a8a84f69bac Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Thu, 26 Feb 2026 10:55:02 +0800 Subject: [PATCH 059/103] update v8 dep --- .github/actions/install/action.yml | 2 +- Dockerfile | 2 +- build.zig.zon | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/actions/install/action.yml b/.github/actions/install/action.yml index 9e47eac2..6c98f2e5 100644 --- a/.github/actions/install/action.yml +++ b/.github/actions/install/action.yml @@ -13,7 +13,7 @@ inputs: zig-v8: description: 'zig v8 version to install' required: false - default: 'v0.3.0' + default: 'v0.3.1' v8: description: 'v8 version to install' required: false diff --git a/Dockerfile b/Dockerfile index 1aa5d592..75be1c9e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -3,7 +3,7 @@ FROM debian:stable-slim ARG MINISIG=0.12 ARG ZIG_MINISIG=RWSGOq2NVecA2UPNdBUZykf1CCb147pkmdtYxgb3Ti+JO/wCYvhbAb/U ARG V8=14.0.365.4 -ARG ZIG_V8=v0.3.0 +ARG ZIG_V8=v0.3.1 ARG TARGETPLATFORM RUN apt-get update -yq && \ diff --git a/build.zig.zon b/build.zig.zon index a1ab877c..b2819e04 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -6,7 +6,7 @@ .minimum_zig_version = "0.15.2", .dependencies = .{ .v8 = .{ - .url = "https://github.com/lightpanda-io/zig-v8-fork/archive/8c7e5df8b93e7cbd42f8f1c4ac24aaa7f05cd098.tar.gz", + .url = "https://github.com/lightpanda-io/zig-v8-fork/archive/refs/tags/v0.3.1.tar.gz", .hash = "v8-0.0.0-xddH64J7BAC81mkf6G9RbEJxS-W3TIRl5iFnShwbqCqy", }, From e9788578204138ac2421e65bb443770ba24a916b Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Thu, 26 Feb 2026 11:52:29 +0800 Subject: [PATCH 060/103] Fix possible overflow when parsing floats without an integer Fixes a WPT test, but I'm not exactly sure which one. --- src/browser/css/Tokenizer.zig | 2 +- src/browser/tests/element/css_style_properties.html | 8 ++++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/src/browser/css/Tokenizer.zig b/src/browser/css/Tokenizer.zig index 17104e95..e90c8d46 100644 --- a/src/browser/css/Tokenizer.zig +++ b/src/browser/css/Tokenizer.zig @@ -583,7 +583,7 @@ fn consumeNumeric(self: *Tokenizer) Token { }; self.advance(2); - } else if (self.hasAtLeast(1) and std.ascii.isDigit(self.byteAt(2))) { + } else if (self.hasAtLeast(2) and std.ascii.isDigit(self.byteAt(2))) { self.advance(1); } else { break :blk; diff --git a/src/browser/tests/element/css_style_properties.html b/src/browser/tests/element/css_style_properties.html index ab582132..6cb0e0bd 100644 --- a/src/browser/tests/element/css_style_properties.html +++ b/src/browser/tests/element/css_style_properties.html @@ -154,3 +154,11 @@ testing.expectEqual(true, typeof div.style.getPropertyPriority === 'function'); } + + +
+ From 21be3db51fa18d7f16919ebaf0d27895f0fa0cd7 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Thu, 26 Feb 2026 12:22:06 +0800 Subject: [PATCH 061/103] Callers to page.navigate ensure URL is properly encoded. Follow up to https://github.com/lightpanda-io/browser/pull/1646 The encodeURL (renamed to ensureEncoded and exposed in this commit) already handled already-encoded URLs, so this was largely a matter of exposing the functionality. The reason this isn't baked directly into Page.navigate is that, in some places e.g. internal navigation, the URL is already know to be encoded. So it's up to every caller to make sure they are passing a valid URL to navigate. --- src/browser/URL.zig | 151 +++++++++++++++++++++++++++++++++---- src/cdp/domains/page.zig | 4 +- src/cdp/domains/target.zig | 4 +- src/lightpanda.zig | 4 +- 4 files changed, 147 insertions(+), 16 deletions(-) diff --git a/src/browser/URL.zig b/src/browser/URL.zig index 3a2a0514..6616d636 100644 --- a/src/browser/URL.zig +++ b/src/browser/URL.zig @@ -30,10 +30,10 @@ pub fn resolve(allocator: Allocator, base: [:0]const u8, path: anytype, comptime if (base.len == 0 or isCompleteHTTPUrl(path)) { if (comptime opts.always_dupe or !isNullTerminated(PT)) { const duped = try allocator.dupeZ(u8, path); - return encodeURL(allocator, duped, opts); + return processResolved(allocator, duped, opts); } if (comptime opts.encode) { - return encodeURL(allocator, path, opts); + return processResolved(allocator, path, opts); } return path; } @@ -41,10 +41,10 @@ pub fn resolve(allocator: Allocator, base: [:0]const u8, path: anytype, comptime if (path.len == 0) { if (comptime opts.always_dupe) { const duped = try allocator.dupeZ(u8, base); - return encodeURL(allocator, duped, opts); + return processResolved(allocator, duped, opts); } if (comptime opts.encode) { - return encodeURL(allocator, base, opts); + return processResolved(allocator, base, opts); } return base; } @@ -52,12 +52,12 @@ pub fn resolve(allocator: Allocator, base: [:0]const u8, path: anytype, comptime if (path[0] == '?') { const base_path_end = std.mem.indexOfAny(u8, base, "?#") orelse base.len; const result = try std.mem.joinZ(allocator, "", &.{ base[0..base_path_end], path }); - return encodeURL(allocator, result, opts); + return processResolved(allocator, result, opts); } if (path[0] == '#') { const base_fragment_start = std.mem.indexOfScalar(u8, base, '#') orelse base.len; const result = try std.mem.joinZ(allocator, "", &.{ base[0..base_fragment_start], path }); - return encodeURL(allocator, result, opts); + return processResolved(allocator, result, opts); } if (std.mem.startsWith(u8, path, "//")) { @@ -65,16 +65,16 @@ pub fn resolve(allocator: Allocator, base: [:0]const u8, path: anytype, comptime const index = std.mem.indexOfScalar(u8, base, ':') orelse { if (comptime isNullTerminated(PT)) { if (comptime opts.encode) { - return encodeURL(allocator, path, opts); + return processResolved(allocator, path, opts); } return path; } const duped = try allocator.dupeZ(u8, path); - return encodeURL(allocator, duped, opts); + return processResolved(allocator, duped, opts); }; const protocol = base[0 .. index + 1]; const result = try std.mem.joinZ(allocator, "", &.{ protocol, path }); - return encodeURL(allocator, result, opts); + return processResolved(allocator, result, opts); } const scheme_end = std.mem.indexOf(u8, base, "://"); @@ -83,7 +83,7 @@ pub fn resolve(allocator: Allocator, base: [:0]const u8, path: anytype, comptime if (path[0] == '/') { const result = try std.mem.joinZ(allocator, "", &.{ base[0..path_start], path }); - return encodeURL(allocator, result, opts); + return processResolved(allocator, result, opts); } var normalized_base: []const u8 = base[0..path_start]; @@ -145,14 +145,17 @@ pub fn resolve(allocator: Allocator, base: [:0]const u8, path: anytype, comptime // we always have an extra space out[out_i] = 0; - return encodeURL(allocator, out[0..out_i :0], opts); + return processResolved(allocator, out[0..out_i :0], opts); } -fn encodeURL(allocator: Allocator, url: [:0]const u8, comptime opts: ResolveOpts) ![:0]const u8 { +fn processResolved(allocator: Allocator, url: [:0]const u8, comptime opts: ResolveOpts) ![:0]const u8 { if (!comptime opts.encode) { return url; } + return ensureEncoded(allocator, url); +} +pub fn ensureEncoded(allocator: Allocator, url: [:0]const u8) ![:0]const u8 { const scheme_end = std.mem.indexOf(u8, url, "://"); const authority_start = if (scheme_end) |end| end + 3 else 0; const path_start = std.mem.indexOfScalarPos(u8, url, authority_start, '/') orelse return url; @@ -180,7 +183,8 @@ fn encodeURL(allocator: Allocator, url: [:0]const u8, comptime opts: ResolveOpts if (encoded_path.ptr == path_to_encode.ptr and (encoded_query == null or encoded_query.?.ptr == url[query_start.? + 1 .. query_end].ptr) and - (encoded_fragment == null or encoded_fragment.?.ptr == url[fragment_start.? + 1 ..].ptr)) { + (encoded_fragment == null or encoded_fragment.?.ptr == url[fragment_start.? + 1 ..].ptr)) + { // nothing has changed return url; } @@ -817,6 +821,127 @@ test "URL: resolve" { } } +test "URL: ensureEncoded" { + defer testing.reset(); + + const Case = struct { + url: [:0]const u8, + expected: [:0]const u8, + }; + + const cases = [_]Case{ + .{ + .url = "https://example.com/over 9000!", + .expected = "https://example.com/over%209000!", + }, + .{ + .url = "http://example.com/hello world.html", + .expected = "http://example.com/hello%20world.html", + }, + .{ + .url = "https://example.com/file[1].html", + .expected = "https://example.com/file%5B1%5D.html", + }, + .{ + .url = "https://example.com/file{name}.html", + .expected = "https://example.com/file%7Bname%7D.html", + }, + .{ + .url = "https://example.com/page?query=hello world", + .expected = "https://example.com/page?query=hello%20world", + }, + .{ + .url = "https://example.com/page?a=1&b=value with spaces", + .expected = "https://example.com/page?a=1&b=value%20with%20spaces", + }, + .{ + .url = "https://example.com/page#section one", + .expected = "https://example.com/page#section%20one", + }, + .{ + .url = "https://example.com/my path?query=my value#my anchor", + .expected = "https://example.com/my%20path?query=my%20value#my%20anchor", + }, + .{ + .url = "https://example.com/already%20encoded", + .expected = "https://example.com/already%20encoded", + }, + .{ + .url = "https://example.com/file%5B1%5D.html", + .expected = "https://example.com/file%5B1%5D.html", + }, + .{ + .url = "https://example.com/caf%C3%A9", + .expected = "https://example.com/caf%C3%A9", + }, + .{ + .url = "https://example.com/page?query=already%20encoded", + .expected = "https://example.com/page?query=already%20encoded", + }, + .{ + .url = "https://example.com/page?a=1&b=value%20here", + .expected = "https://example.com/page?a=1&b=value%20here", + }, + .{ + .url = "https://example.com/page#section%20one", + .expected = "https://example.com/page#section%20one", + }, + .{ + .url = "https://example.com/part%20encoded and not", + .expected = "https://example.com/part%20encoded%20and%20not", + }, + .{ + .url = "https://example.com/page?a=encoded%20value&b=not encoded", + .expected = "https://example.com/page?a=encoded%20value&b=not%20encoded", + }, + .{ + .url = "https://example.com/my%20path?query=not encoded#encoded%20anchor", + .expected = "https://example.com/my%20path?query=not%20encoded#encoded%20anchor", + }, + .{ + .url = "https://example.com/fully%20encoded?query=also%20encoded#and%20this", + .expected = "https://example.com/fully%20encoded?query=also%20encoded#and%20this", + }, + .{ + .url = "https://example.com/path-with_under~tilde", + .expected = "https://example.com/path-with_under~tilde", + }, + .{ + .url = "https://example.com/sub-delims!$&'()*+,;=", + .expected = "https://example.com/sub-delims!$&'()*+,;=", + }, + .{ + .url = "https://example.com", + .expected = "https://example.com", + }, + .{ + .url = "https://example.com?query=value", + .expected = "https://example.com?query=value", + }, + .{ + .url = "https://example.com/clean/path", + .expected = "https://example.com/clean/path", + }, + .{ + .url = "https://example.com/path?clean=query#clean-fragment", + .expected = "https://example.com/path?clean=query#clean-fragment", + }, + .{ + .url = "https://example.com/100% complete", + .expected = "https://example.com/100%25%20complete", + }, + .{ + .url = "https://example.com/path?value=100% done", + .expected = "https://example.com/path?value=100%25%20done", + }, + }; + + for (cases) |case| { + const result = try ensureEncoded(testing.arena_allocator, case.url); + try testing.expectString(case.expected, result); + } +} + test "URL: resolve with encoding" { defer testing.reset(); diff --git a/src/cdp/domains/page.zig b/src/cdp/domains/page.zig index 5639cfde..3db5d67c 100644 --- a/src/cdp/domains/page.zig +++ b/src/cdp/domains/page.zig @@ -22,6 +22,7 @@ const lp = @import("lightpanda"); const id = @import("../id.zig"); const log = @import("../../log.zig"); const js = @import("../../browser/js/js.zig"); +const URL = @import("../../browser/URL.zig"); const Page = @import("../../browser/Page.zig"); const timestampF = @import("../../datetime.zig").timestamp; const Notification = @import("../../Notification.zig"); @@ -224,7 +225,8 @@ fn navigate(cmd: anytype) !void { page = try session.replacePage(); } - try page.navigate(params.url, .{ + const encoded_url = try URL.ensureEncoded(page.call_arena, params.url); + try page.navigate(encoded_url, .{ .reason = .address_bar, .cdp_id = cmd.input.id, .kind = .{ .push = null }, diff --git a/src/cdp/domains/target.zig b/src/cdp/domains/target.zig index 7dfe59ef..8a818a27 100644 --- a/src/cdp/domains/target.zig +++ b/src/cdp/domains/target.zig @@ -21,6 +21,7 @@ const lp = @import("lightpanda"); const id = @import("../id.zig"); const log = @import("../../log.zig"); +const URL = @import("../../browser/URL.zig"); const js = @import("../../browser/js/js.zig"); // TODO: hard coded IDs @@ -218,8 +219,9 @@ fn createTarget(cmd: anytype) !void { } if (!std.mem.eql(u8, "about:blank", params.url)) { + const encoded_url = try URL.ensureEncoded(page.call_arena, params.url); try page.navigate( - params.url, + encoded_url, .{ .reason = .address_bar, .kind = .{ .push = null } }, ); } diff --git a/src/lightpanda.zig b/src/lightpanda.zig index cdf1c6be..ca105ce5 100644 --- a/src/lightpanda.zig +++ b/src/lightpanda.zig @@ -20,6 +20,7 @@ const std = @import("std"); pub const App = @import("App.zig"); pub const Server = @import("Server.zig"); pub const Config = @import("Config.zig"); +pub const URL = @import("browser/URL.zig"); pub const Page = @import("browser/Page.zig"); pub const Browser = @import("browser/Browser.zig"); pub const Session = @import("browser/Session.zig"); @@ -92,7 +93,8 @@ pub fn fetch(app: *App, url: [:0]const u8, opts: FetchOpts) !void { // } // } - _ = try page.navigate(url, .{ + const encoded_url = try URL.ensureEncoded(page.call_arena, url); + _ = try page.navigate(encoded_url, .{ .reason = .address_bar, .kind = .{ .push = null }, }); From bad0fc386d6094e99bb629302dc821b8ec537837 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Thu, 26 Feb 2026 15:26:34 +0800 Subject: [PATCH 062/103] Don't assume that a 'keydown' event is a KeyboardEvent --- src/browser/Page.zig | 2 +- src/browser/tests/event/keyboard.html | 13 +++++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/src/browser/Page.zig b/src/browser/Page.zig index 435685c7..618a3aca 100644 --- a/src/browser/Page.zig +++ b/src/browser/Page.zig @@ -3069,7 +3069,7 @@ pub fn triggerKeyboard(self: *Page, keyboard_event: *KeyboardEvent) !void { } pub fn handleKeydown(self: *Page, target: *Node, event: *Event) !void { - const keyboard_event = event.as(KeyboardEvent); + const keyboard_event = event.is(KeyboardEvent) orelse return; const key = keyboard_event.getKey(); if (key == .Dead) { diff --git a/src/browser/tests/event/keyboard.html b/src/browser/tests/event/keyboard.html index b6c3ddd8..79baff7e 100644 --- a/src/browser/tests/event/keyboard.html +++ b/src/browser/tests/event/keyboard.html @@ -103,3 +103,16 @@ document.dispatchEvent(new KeyboardEvent('keytest', {key: 'b'})); testing.expectEqual(false, keyIsTrusted); + + From 137ab4a55715cb1a686de6d13f832f074f8e7541 Mon Sep 17 00:00:00 2001 From: Halil Durak Date: Tue, 24 Feb 2026 13:40:45 +0300 Subject: [PATCH 063/103] 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 064/103] 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 065/103] 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 066/103] 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 067/103] 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 068/103] 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 069/103] 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 070/103] 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 071/103] 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 072/103] 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); From ac5a64d77a8c13b5bd5e214980638bd2c1e5e222 Mon Sep 17 00:00:00 2001 From: Nikolay Govorov Date: Thu, 26 Feb 2026 08:41:01 +0000 Subject: [PATCH 073/103] Fix typo in build.zig Co-authored-by: Halil Durak --- build.zig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.zig b/build.zig index c50b8b6a..cd304454 100644 --- a/build.zig +++ b/build.zig @@ -642,7 +642,7 @@ fn buildCurl( "-DBUILDING_LIBCURL", }, .files = &.{ - // You can include all files from lib, libcurl uses an #ifdef-guards to exclude code for disabled functions + // You can include all files from lib, libcurl uses #ifdef-guards to exclude code for disabled functions "altsvc.c", "amigaos.c", "asyn-ares.c", "asyn-base.c", "asyn-thrdd.c", "bufq.c", "bufref.c", "cf-h1-proxy.c", "cf-h2-proxy.c", From e7d21c2dbe94981e2575cbaf8cf3576aeabfc845 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Thu, 26 Feb 2026 17:52:33 +0800 Subject: [PATCH 074/103] Add more properties to ScriptManager.Header recall This bug continues elusive. The latest crash logs show us that, somehow, 1 script is being resolved from 2 transfer objects. This doesn't seem possible, so I'm adding more properties to log the state of both transfers to try and figure out how this is happening. --- src/browser/ScriptManager.zig | 31 +++++++++++++++++++++++++++---- 1 file changed, 27 insertions(+), 4 deletions(-) diff --git a/src/browser/ScriptManager.zig b/src/browser/ScriptManager.zig index 4453ccd7..8334a1a7 100644 --- a/src/browser/ScriptManager.zig +++ b/src/browser/ScriptManager.zig @@ -634,6 +634,12 @@ pub const Script = struct { // for debugging a rare production issue debug_transfer_id: u32 = 0, + debug_transfer_tries: u8 = 0, + debug_transfer_aborted: bool = false, + debug_transfer_bytes_received: usize = 0, + debug_transfer_notified_fail: bool = false, + debug_transfer_redirecting: bool = false, + debug_transfer_intercept_state: u8 = 0, const Kind = enum { module, @@ -703,13 +709,30 @@ pub const Script = struct { // fails. Is the buffer just corrupt or is headerCallback really // being called twice? lp.assert(self.header_callback_called == false, "ScriptManager.Header recall", .{ - .thd = transfer._header_done_called, - .t1 = self.debug_transfer_id, - .t2 = transfer.id, - .tries = transfer._tries, + .m = @tagName(std.meta.activeTag(self.mode)), + .a1 = self.debug_transfer_id, + .a2 = self.debug_transfer_tries, + .a3 = self.debug_transfer_aborted, + .a4 = self.debug_transfer_bytes_received, + .a5 = self.debug_transfer_notified_fail, + .a6 = self.debug_transfer_redirecting, + .a7 = self.debug_transfer_intercept_state, + .b1 = transfer.id, + .b2 = transfer._tries, + .b3 = transfer.aborted, + .b4 = transfer.bytes_received, + .b5 = transfer._notified_fail, + .b6 = transfer._redirecting, + .b7 = @intFromEnum(transfer._intercept_state), }); self.header_callback_called = true; self.debug_transfer_id = transfer.id; + self.debug_transfer_tries = transfer._tries; + self.debug_transfer_aborted = transfer.aborted; + self.debug_transfer_bytes_received = transfer.bytes_received; + self.debug_transfer_notified_fail = transfer._notified_fail; + self.debug_transfer_redirecting = transfer._redirecting; + self.debug_transfer_intercept_state = @intFromEnum(transfer._intercept_state); } lp.assert(self.source.remote.capacity == 0, "ScriptManager.Header buffer", .{ .capacity = self.source.remote.capacity }); From 8504e4cd2227c5c0d18d75cf8e4839bdd75d8abd Mon Sep 17 00:00:00 2001 From: Pierre Tachoire Date: Thu, 26 Feb 2026 18:33:18 +0100 Subject: [PATCH 075/103] add limit for cookie and jar size --- src/browser/webapi/storage/Cookie.zig | 75 +++++++++++++++++++++++++++ 1 file changed, 75 insertions(+) diff --git a/src/browser/webapi/storage/Cookie.zig b/src/browser/webapi/storage/Cookie.zig index da499efb..649a04cc 100644 --- a/src/browser/webapi/storage/Cookie.zig +++ b/src/browser/webapi/storage/Cookie.zig @@ -27,6 +27,10 @@ const public_suffix_list = @import("../../../data/public_suffix_list.zig").looku const Cookie = @This(); +const max_cookie_size = 4 * 1024; +const max_cookie_header_size = 8 * 1024; +const max_jar_size = 1024; + arena: ArenaAllocator, name: []const u8, value: []const u8, @@ -62,6 +66,10 @@ pub fn deinit(self: *const Cookie) void { // Duplicate attributes - use the last valid // Value-less attributes with a value? Ignore the value pub fn parse(allocator: Allocator, url: [:0]const u8, str: []const u8) !Cookie { + if (str.len > max_cookie_header_size) { + return error.CookieHeaderSizeExceeded; + } + try validateCookieString(str); const cookie_name, const cookie_value, const rest = parseNameValue(str) catch { @@ -119,6 +127,10 @@ pub fn parse(allocator: Allocator, url: [:0]const u8, str: []const u8) !Cookie { return error.InsecureSameSite; } + if (cookie_value.len > max_cookie_size) { + return error.CookieSizeExceeded; + } + var arena = ArenaAllocator.init(allocator); errdefer arena.deinit(); const aa = arena.allocator(); @@ -415,6 +427,13 @@ pub const Jar = struct { cookie.deinit(); }; + if (self.cookies.items.len >= max_jar_size) { + return error.CookieJarQuotaExceeded; + } + if (cookie.value.len > max_cookie_size) { + return error.CookieSizeExceeded; + } + for (self.cookies.items, 0..) |*c, i| { if (areCookiesEqual(&cookie, c)) { c.deinit(); @@ -635,6 +654,57 @@ test "Jar: add" { try expectCookies(&.{ .{ "over", "9000!!" }, .{ "spice", "flows" }, .{ "over", "9002" } }, jar); } +test "Jar: add limit" { + var jar = Jar.init(testing.allocator); + defer jar.deinit(); + + const now = std.time.timestamp(); + + // add a too big cookie value. + try testing.expectError(error.CookieSizeExceeded, jar.add(.{ + .arena = std.heap.ArenaAllocator.init(testing.allocator), + .name = "v", + .domain = "lightpanda.io", + .path = "/", + .expires = null, + .value = "v" ** 4096 ++ "v", + }, now)); + + // generate unique names. + const names = comptime blk: { + @setEvalBranchQuota(max_jar_size); + var result: [max_jar_size][]const u8 = undefined; + for (0..max_jar_size) |i| { + result[i] = "v" ** i; + } + break :blk result; + }; + + // test the max number limit + var i: usize = 0; + while (i < max_jar_size) : (i += 1) { + const c = Cookie{ + .arena = std.heap.ArenaAllocator.init(testing.allocator), + .name = names[i], + .domain = "lightpanda.io", + .path = "/", + .expires = null, + .value = "v", + }; + + try jar.add(c, now); + } + + try testing.expectError(error.CookieJarQuotaExceeded, jar.add(.{ + .arena = std.heap.ArenaAllocator.init(testing.allocator), + .name = "last", + .domain = "lightpanda.io", + .path = "/", + .expires = null, + .value = "v", + }, now)); +} + test "Jar: forRequest" { const expectCookies = struct { fn expect(expected: []const u8, jar: *Jar, target_url: [:0]const u8, opts: Jar.LookupOpts) !void { @@ -959,6 +1029,11 @@ test "Cookie: parse domain" { try expectError(error.InvalidDomain, "http://lightpanda.io/", "b;domain=other.example.com"); } +test "Cookie: parse limit" { + try expectError(error.CookieHeaderSizeExceeded, "http://lightpanda.io/", "v" ** 8192 ++ ";domain=lightpanda.io"); + try expectError(error.CookieSizeExceeded, "http://lightpanda.io/", "v" ** 4096 ++ "v;domain=lightpanda.io"); +} + const ExpectedCookie = struct { name: []const u8, value: []const u8, From 76dcdfb98cceee9d3451dfffedd50c1f6eaacf80 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Fri, 27 Feb 2026 07:02:06 +0800 Subject: [PATCH 076/103] Use github repo for curl source Since we already rely on github for builds, this removes a point of failure. Also curl.se consistently fails from a VPS machine for me - not sure if they're blocking IP ranges, but it works fine on github. --- build.zig.zon | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.zig.zon b/build.zig.zon index 635a6b7b..b7525c77 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -28,7 +28,7 @@ .hash = "boringssl-0.1.0-VtJeWehMAAA4RNnwRnzEvKcS9rjsR1QVRw1uJrwXxmVK", }, .curl = .{ - .url = "https://curl.se/download/curl-8.18.0.tar.gz", + .url = "https://github.com/curl/curl/releases/download/curl-8_18_0/curl-8.18.0.tar.gz", .hash = "N-V-__8AALp9QAGn6CCHZ6fK_FfMyGtG824LSHYHHasM3w-y", }, }, From 315c9a2d92b3fe2c81d7947afd77a443d96f24d2 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Fri, 27 Feb 2026 10:17:30 +0800 Subject: [PATCH 077/103] Add RC support to NodeList Most importantly, this allows the Selector.List to be self-contained with an arena from the ArenaPool. Selector.List can be both relatively large and relatively common, so moving it off the page.arena is a nice win. Also applied this to ChildNodes, which is much smaller but could also be called often. I was initially going to hook into the v8::Object's internal fields to store the referencing v8::Object. So the v8::Object representing the Iterator would store the v8::Object representing the NodeList inside of its internal field - which the GC would trace/detect/respect. And that is probably the fastest and most v8-ish solution, but I couldn't come up with an elegant solution. The best I had was having a "afterCreate" callback which passed the v8 object (this is similar to the old postAttach callback we had, but used for a different purpose). However, since "acquireRef" was recently added to events, re-using that was much simpler and worked well. --- .../tests/document/query_selector_all.html | 26 ++++++++++++ src/browser/webapi/collections/ChildNodes.zig | 17 ++++++-- .../HTMLFormControlsCollection.zig | 2 +- src/browser/webapi/collections/NodeList.zig | 42 ++++++++++++++----- src/browser/webapi/collections/iterator.zig | 17 +++++++- src/browser/webapi/collections/node_live.zig | 2 +- src/browser/webapi/selector/List.zig | 12 +++++- src/browser/webapi/selector/Selector.zig | 10 +++-- src/cdp/Node.zig | 30 +++++++++---- src/cdp/domains/dom.zig | 4 ++ 10 files changed, 131 insertions(+), 31 deletions(-) diff --git a/src/browser/tests/document/query_selector_all.html b/src/browser/tests/document/query_selector_all.html index 4e46f7e3..ae95ed9e 100644 --- a/src/browser/tests/document/query_selector_all.html +++ b/src/browser/tests/document/query_selector_all.html @@ -442,3 +442,29 @@ } + diff --git a/src/browser/webapi/collections/ChildNodes.zig b/src/browser/webapi/collections/ChildNodes.zig index fa65a3fb..5ba6256a 100644 --- a/src/browser/webapi/collections/ChildNodes.zig +++ b/src/browser/webapi/collections/ChildNodes.zig @@ -26,6 +26,7 @@ const GenericIterator = @import("iterator.zig").Entry; // No need to go through a TreeWalker or add any filtering. const ChildNodes = @This(); +_arena: std.mem.Allocator, _last_index: usize, _last_length: ?u32, _last_node: ?*std.DoublyLinkedList.Node, @@ -37,13 +38,23 @@ pub const ValueIterator = GenericIterator(Iterator, "1"); pub const EntryIterator = GenericIterator(Iterator, null); pub fn init(node: *Node, page: *Page) !*ChildNodes { - return page._factory.create(ChildNodes{ + const arena = try page.getArena(.{ .debug = "ChildNodes" }); + errdefer page.releaseArena(arena); + + const self = try arena.create(ChildNodes); + self.* = .{ ._node = node, + ._arena = arena, ._last_index = 0, ._last_node = null, ._last_length = null, ._cached_version = page.version, - }); + }; + return self; +} + +pub fn deinit(self: *const ChildNodes, page: *Page) void { + page.releaseArena(self._arena); } pub fn length(self: *ChildNodes, page: *Page) !u32 { @@ -115,7 +126,7 @@ fn versionCheck(self: *ChildNodes, page: *Page) bool { const NodeList = @import("NodeList.zig"); pub fn runtimeGenericWrap(self: *ChildNodes, page: *Page) !*NodeList { - return page._factory.create(NodeList{ .data = .{ .child_nodes = self } }); + return page._factory.create(NodeList{ ._data = .{ .child_nodes = self } }); } const Iterator = struct { diff --git a/src/browser/webapi/collections/HTMLFormControlsCollection.zig b/src/browser/webapi/collections/HTMLFormControlsCollection.zig index fe5f1a97..c6b68fe0 100644 --- a/src/browser/webapi/collections/HTMLFormControlsCollection.zig +++ b/src/browser/webapi/collections/HTMLFormControlsCollection.zig @@ -85,7 +85,7 @@ pub fn namedItem(self: *HTMLFormControlsCollection, name: []const u8, page: *Pag ._name = try page.dupeString(name), }); - radio_node_list._proto = try page._factory.create(NodeList{ .data = .{ .radio_node_list = radio_node_list } }); + radio_node_list._proto = try page._factory.create(NodeList{ ._data = .{ .radio_node_list = radio_node_list } }); return .{ .radio_node_list = radio_node_list }; } diff --git a/src/browser/webapi/collections/NodeList.zig b/src/browser/webapi/collections/NodeList.zig index 2f236a27..0237a76c 100644 --- a/src/browser/webapi/collections/NodeList.zig +++ b/src/browser/webapi/collections/NodeList.zig @@ -28,24 +28,36 @@ const RadioNodeList = @import("RadioNodeList.zig"); const SelectorList = @import("../selector/List.zig"); const NodeLive = @import("node_live.zig").NodeLive; -const Mode = enum { - child_nodes, - selector_list, - radio_node_list, - name, -}; - const NodeList = @This(); -data: union(Mode) { +_data: union(enum) { child_nodes: *ChildNodes, selector_list: *SelectorList, radio_node_list: *RadioNodeList, name: NodeLive(.name), }, +_rc: usize = 0, + +pub fn deinit(self: *NodeList, _: bool, page: *Page) void { + const rc = self._rc; + if (rc > 1) { + self._rc = rc - 1; + return; + } + + switch (self._data) { + .selector_list => |list| list.deinit(page), + .child_nodes => |cn| cn.deinit(page), + else => {}, + } +} + +pub fn acquireRef(self: *NodeList) void { + self._rc += 1; +} pub fn length(self: *NodeList, page: *Page) !u32 { - return switch (self.data) { + return switch (self._data) { .child_nodes => |impl| impl.length(page), .selector_list => |impl| @intCast(impl.getLength()), .radio_node_list => |impl| impl.getLength(), @@ -58,7 +70,7 @@ pub fn indexedGet(self: *NodeList, index: usize, page: *Page) !*Node { } pub fn getAtIndex(self: *NodeList, index: usize, page: *Page) !?*Node { - return switch (self.data) { + return switch (self._data) { .child_nodes => |impl| impl.getAtIndex(index, page), .selector_list => |impl| impl.getAtIndex(index), .radio_node_list => |impl| impl.getAtIndex(index, page), @@ -106,6 +118,14 @@ const Iterator = struct { const Entry = struct { u32, *Node }; + pub fn deinit(self: *Iterator, shutdown: bool, page: *Page) void { + self.list.deinit(shutdown, page); + } + + pub fn acquireRef(self: *Iterator) void { + self.list.acquireRef(); + } + pub fn next(self: *Iterator, page: *Page) !?Entry { const index = self.index; const node = try self.list.getAtIndex(index, page) orelse return null; @@ -122,6 +142,8 @@ pub const JsApi = struct { pub const prototype_chain = bridge.prototypeChain(); pub var class_id: bridge.ClassId = undefined; pub const enumerable = false; + pub const weak = true; + pub const finalizer = bridge.finalizer(NodeList.deinit); }; pub const length = bridge.accessor(NodeList.length, null, .{}); diff --git a/src/browser/webapi/collections/iterator.zig b/src/browser/webapi/collections/iterator.zig index 0c063e84..3c43f3f8 100644 --- a/src/browser/webapi/collections/iterator.zig +++ b/src/browser/webapi/collections/iterator.zig @@ -21,8 +21,7 @@ const js = @import("../../js/js.zig"); const Page = @import("../../Page.zig"); pub fn Entry(comptime Inner: type, comptime field: ?[]const u8) type { - const InnerStruct = Inner; - const R = reflect(InnerStruct, field); + const R = reflect(Inner, field); return struct { inner: Inner, @@ -40,6 +39,18 @@ pub fn Entry(comptime Inner: type, comptime field: ?[]const u8) type { return page._factory.create(Self{ .inner = inner }); } + pub fn deinit(self: *Self, shutdown: bool, page: *Page) void { + if (@hasDecl(Inner, "deinit")) { + self.inner.deinit(shutdown, page); + } + } + + pub fn acquireRef(self: *Self) void { + if (@hasDecl(Inner, "acquireRef")) { + self.inner.acquireRef(); + } + } + pub fn next(self: *Self, page: *Page) if (R.has_error_return) anyerror!Result else Result { const entry = (if (comptime R.has_error_return) try self.inner.next(page) else self.inner.next(page)) orelse { return .{ .done = true, .value = null }; @@ -61,6 +72,8 @@ pub fn Entry(comptime Inner: type, comptime field: ?[]const u8) type { pub const Meta = struct { pub const prototype_chain = bridge.prototypeChain(); pub var class_id: bridge.ClassId = undefined; + pub const weak = true; + pub const finalizer = bridge.finalizer(Self.deinit); }; pub const next = bridge.function(Self.next, .{ .null_as_undefined = true }); diff --git a/src/browser/webapi/collections/node_live.zig b/src/browser/webapi/collections/node_live.zig index 5f6bd330..65fdfbf7 100644 --- a/src/browser/webapi/collections/node_live.zig +++ b/src/browser/webapi/collections/node_live.zig @@ -349,7 +349,7 @@ pub fn NodeLive(comptime mode: Mode) type { pub fn runtimeGenericWrap(self: Self, page: *Page) !if (mode == .name) *NodeList else *HTMLCollection { const collection = switch (mode) { - .name => return page._factory.create(NodeList{ .data = .{ .name = self } }), + .name => return page._factory.create(NodeList{ ._data = .{ .name = self } }), .tag => HTMLCollection{ ._data = .{ .tag = self } }, .tag_name => HTMLCollection{ ._data = .{ .tag_name = self } }, .tag_name_ns => HTMLCollection{ ._data = .{ .tag_name_ns = self } }, diff --git a/src/browser/webapi/selector/List.zig b/src/browser/webapi/selector/List.zig index 3ed295c4..04055910 100644 --- a/src/browser/webapi/selector/List.zig +++ b/src/browser/webapi/selector/List.zig @@ -40,6 +40,10 @@ pub const EntryIterator = GenericIterator(Iterator, null); pub const KeyIterator = GenericIterator(Iterator, "0"); pub const ValueIterator = GenericIterator(Iterator, "1"); +pub fn deinit(self: *const List, page: *Page) void { + page.releaseArena(self._arena); +} + pub fn collect( allocator: std.mem.Allocator, root: *Node, @@ -207,8 +211,12 @@ pub fn getAtIndex(self: *const List, index: usize) !?*Node { } const NodeList = @import("../collections/NodeList.zig"); -pub fn runtimeGenericWrap(self: *List, page: *Page) !*NodeList { - return page._factory.create(NodeList{ .data = .{ .selector_list = self } }); +pub fn runtimeGenericWrap(self: *List, _: *const Page) !*NodeList { + const nl = try self._arena.create(NodeList); + nl.* = .{ + ._data = .{ .selector_list = self }, + }; + return nl; } const IdAnchor = struct { diff --git a/src/browser/webapi/selector/Selector.zig b/src/browser/webapi/selector/Selector.zig index 4a236fc3..3e987a92 100644 --- a/src/browser/webapi/selector/Selector.zig +++ b/src/browser/webapi/selector/Selector.zig @@ -62,7 +62,9 @@ pub fn querySelectorAll(root: *Node, input: []const u8, page: *Page) !*List { return error.SyntaxError; } - const arena = page.arena; + const arena = try page.getArena(.{ .debug = "querySelectorAll" }); + errdefer page.releaseArena(arena); + var nodes: std.AutoArrayHashMapUnmanaged(*Node, void) = .empty; const selectors = try Parser.parseList(arena, input, page); @@ -70,10 +72,12 @@ pub fn querySelectorAll(root: *Node, input: []const u8, page: *Page) !*List { try List.collect(arena, root, selector, &nodes, page); } - return page._factory.create(List{ + const list = try arena.create(List); + list.* = .{ ._arena = arena, ._nodes = nodes.keys(), - }); + }; + return list; } pub fn matches(el: *Node.Element, input: []const u8, page: *Page) !bool { diff --git a/src/cdp/Node.zig b/src/cdp/Node.zig index 885fa93b..6a7bb114 100644 --- a/src/cdp/Node.zig +++ b/src/cdp/Node.zig @@ -400,20 +400,32 @@ test "cdp Node: search list" { defer page._session.removePage(); var doc = page.window._document; - const s1 = try search_list.create((try doc.querySelectorAll(.wrap("a"), page))._nodes); - try testing.expectEqual("1", s1.name); - try testing.expectEqualSlices(u32, &.{ 1, 2 }, s1.node_ids); + { + const l1 = try doc.querySelectorAll(.wrap("a"), page); + defer l1.deinit(page); + const s1 = try search_list.create(l1._nodes); + try testing.expectEqual("1", s1.name); + try testing.expectEqualSlices(u32, &.{ 1, 2 }, s1.node_ids); + } try testing.expectEqual(2, registry.lookup_by_id.count()); try testing.expectEqual(2, registry.lookup_by_node.count()); - const s2 = try search_list.create((try doc.querySelectorAll(.wrap("#a1"), page))._nodes); - try testing.expectEqual("2", s2.name); - try testing.expectEqualSlices(u32, &.{1}, s2.node_ids); + { + const l2 = try doc.querySelectorAll(.wrap("#a1"), page); + defer l2.deinit(page); + const s2 = try search_list.create(l2._nodes); + try testing.expectEqual("2", s2.name); + try testing.expectEqualSlices(u32, &.{1}, s2.node_ids); + } - const s3 = try search_list.create((try doc.querySelectorAll(.wrap("#a2"), page))._nodes); - try testing.expectEqual("3", s3.name); - try testing.expectEqualSlices(u32, &.{2}, s3.node_ids); + { + const l3 = try doc.querySelectorAll(.wrap("#a2"), page); + defer l3.deinit(page); + const s3 = try search_list.create(l3._nodes); + try testing.expectEqual("3", s3.name); + try testing.expectEqualSlices(u32, &.{2}, s3.node_ids); + } try testing.expectEqual(2, registry.lookup_by_id.count()); try testing.expectEqual(2, registry.lookup_by_node.count()); diff --git a/src/cdp/domains/dom.zig b/src/cdp/domains/dom.zig index 45bcbd59..2f2befa5 100644 --- a/src/cdp/domains/dom.zig +++ b/src/cdp/domains/dom.zig @@ -98,6 +98,8 @@ fn performSearch(cmd: anytype) !void { const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded; const page = bc.session.currentPage() orelse return error.PageNotLoaded; const list = try Selector.querySelectorAll(page.window._document.asNode(), params.query, page); + defer list.deinit(page); + const search = try bc.node_search_list.create(list._nodes); // dispatch setChildNodesEvents to inform the client of the subpart of node @@ -247,6 +249,8 @@ fn querySelectorAll(cmd: anytype) !void { }; const selected_nodes = try Selector.querySelectorAll(node.dom, params.selector, page); + defer selected_nodes.deinit(page); + const nodes = selected_nodes._nodes; const node_ids = try cmd.arena.alloc(Node.Id, nodes.len); From 38bc912e4ec7553b837d6c2c8fad46d2312c5756 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Fri, 27 Feb 2026 11:17:06 +0800 Subject: [PATCH 078/103] Expand the strings we intern Based on analysis of a handful of websites (amazon product, github, DDG, reddit) --- src/string.zig | 43 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/src/string.zig b/src/string.zig index bbbc374a..ceb092a0 100644 --- a/src/string.zig +++ b/src/string.zig @@ -181,24 +181,67 @@ pub const String = packed struct { '\r' => return "\r", '\t' => return "\t", ' ' => return " ", + '0' => return "0", + '1' => return "1", + '2' => return "2", + '3' => return "3", + '4' => return "4", + '5' => return "5", + '6' => return "6", + '7' => return "7", + '8' => return "8", + '9' => return "9", + '.' => return ".", + ',' => return ",", + '-' => return "-", + '(' => return "(", + ')' => return ")", + '?' => return "?", + ';' => return ";", + '=' => return "=", else => {}, }, 2 => switch (@as(u16, @bitCast(input[0..2].*))) { asUint("id") => return "id", asUint(" ") => return " ", asUint("\r\n") => return "\r\n", + asUint(", ") => return ", ", + asUint("·") => return "·", else => {}, }, 3 => switch (@as(u24, @bitCast(input[0..3].*))) { asUint(" ") => return " ", + asUint("•") => return "•", else => {}, }, 4 => switch (@as(u32, @bitCast(input[0..4].*))) { asUint(" ") => return " ", + asUint(" to ") => return " to ", else => {}, }, 5 => switch (@as(u40, @bitCast(input[0..5].*))) { asUint(" ") => return " ", + asUint(" › ") => return " › ", + else => {}, + }, + 6 => switch (@as(u48, @bitCast(input[0..6].*))) { + asUint(" ") => return " ", + else => {}, + }, + 7 => switch (@as(u56, @bitCast(input[0..7].*))) { + asUint(" ") => return " ", + else => {}, + }, + 8 => switch (@as(u64, @bitCast(input[0..8].*))) { + asUint(" ") => return " ", + else => {}, + }, + 9 => switch (@as(u72, @bitCast(input[0..9].*))) { + asUint(" ") => return " ", + else => {}, + }, + 10 => switch (@as(u80, @bitCast(input[0..10].*))) { + asUint(" ") => return " ", else => {}, }, 13 => switch (@as(u104, @bitCast(input[0..13].*))) { From 870fd1654dac1068d0ff381a3c1b5265454496e1 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Fri, 27 Feb 2026 12:53:54 +0800 Subject: [PATCH 079/103] Change CData._data from []const to String (SSO) After looking at a handful of websites, the # of Text and Commend nodes that are small (<= 12 bytes) is _really_ high. Ranging from 85% to 98%. I thought that was high, but a lot of it is indentation or a sentence that's broken down into multiple nodes, eg:
sale! $1.99 buy now
So what looks like 1 sentence to us, is actually 3 text nodes. On a typical website, we should see thousands of fewer allocations in the page arena for the text in text nodes. --- src/browser/Page.zig | 27 ++-- src/browser/dump.zig | 8 +- src/browser/markdown.zig | 2 +- src/browser/webapi/CData.zig | 79 +++++----- src/browser/webapi/MutationObserver.zig | 4 +- src/browser/webapi/Node.zig | 18 +-- src/browser/webapi/Range.zig | 34 +++-- src/browser/webapi/Selection.zig | 14 +- src/browser/webapi/cdata/Text.zig | 5 +- src/browser/webapi/element/html/TextArea.zig | 6 +- src/cdp/Node.zig | 6 +- src/string.zig | 147 +++++++++++++++++++ 12 files changed, 252 insertions(+), 98 deletions(-) diff --git a/src/browser/Page.zig b/src/browser/Page.zig index 688c9cb2..1b068374 100644 --- a/src/browser/Page.zig +++ b/src/browser/Page.zig @@ -1360,10 +1360,8 @@ pub fn appendNew(self: *Page, parent: *Node, child: Node.NodeOrText) !void { if (parent.lastChild()) |sibling| { if (sibling.is(CData.Text)) |tn| { const cdata = tn._proto; - const existing = cdata.getData(); - // @metric - // Inefficient, but we don't expect this to happen often. - cdata._data = try std.mem.concat(self.arena, u8, &.{ existing, txt }); + const existing = cdata.getData().str(); + cdata._data = try String.concat(self.arena, &.{ existing, txt }); return; } } @@ -2193,28 +2191,24 @@ fn populateElementAttributes(self: *Page, element: *Element, list: anytype) !voi } pub fn createTextNode(self: *Page, text: []const u8) !*Node { - // might seem unlikely that we get an intern hit, but we'll get some nodes - // with just '\n' - const owned_text = try self.dupeString(text); const cd = try self._factory.node(CData{ ._proto = undefined, ._type = .{ .text = .{ ._proto = undefined, } }, - ._data = owned_text, + ._data = try self.dupeSSO(text), }); cd._type.text._proto = cd; return cd.asNode(); } pub fn createComment(self: *Page, text: []const u8) !*Node { - const owned_text = try self.dupeString(text); const cd = try self._factory.node(CData{ ._proto = undefined, ._type = .{ .comment = .{ ._proto = undefined, } }, - ._data = owned_text, + ._data = try self.dupeSSO(text), }); cd._type.comment._proto = cd; return cd.asNode(); @@ -2226,8 +2220,6 @@ pub fn createCDATASection(self: *Page, data: []const u8) !*Node { return error.InvalidCharacterError; } - const owned_data = try self.dupeString(data); - // First allocate the Text node separately const text_node = try self._factory.create(CData.Text{ ._proto = undefined, @@ -2239,7 +2231,7 @@ pub fn createCDATASection(self: *Page, data: []const u8) !*Node { ._type = .{ .cdata_section = .{ ._proto = text_node, } }, - ._data = owned_data, + ._data = try self.dupeSSO(data), }); // Set up the back pointer from Text to CData @@ -2261,7 +2253,6 @@ pub fn createProcessingInstruction(self: *Page, target: []const u8, data: []cons try validateXmlName(target); const owned_target = try self.dupeString(target); - const owned_data = try self.dupeString(data); const pi = try self._factory.create(CData.ProcessingInstruction{ ._proto = undefined, @@ -2271,7 +2262,7 @@ pub fn createProcessingInstruction(self: *Page, target: []const u8, data: []cons const cd = try self._factory.node(CData{ ._proto = undefined, ._type = .{ .processing_instruction = pi }, - ._data = owned_data, + ._data = try self.dupeSSO(data), }); // Set up the back pointer from ProcessingInstruction to CData @@ -2344,6 +2335,10 @@ pub fn dupeString(self: *Page, value: []const u8) ![]const u8 { return self.arena.dupe(u8, value); } +pub fn dupeSSO(self: *Page, value: []const u8) !String { + return String.init(self.arena, value, .{ .dupe = true }); +} + const RemoveNodeOpts = struct { will_be_reconnected: bool, }; @@ -2747,7 +2742,7 @@ pub fn setCustomizedBuiltInDefinition(self: *Page, element: *Element, definition pub fn characterDataChange( self: *Page, target: *Node, - old_value: []const u8, + old_value: String, ) void { var it: ?*std.DoublyLinkedList.Node = self._mutation_observers.first; while (it) |node| : (it = node.next) { diff --git a/src/browser/dump.zig b/src/browser/dump.zig index ad7402fe..bb666e7f 100644 --- a/src/browser/dump.zig +++ b/src/browser/dump.zig @@ -82,19 +82,19 @@ fn _deep(node: *Node, opts: Opts, comptime force_slot: bool, writer: *std.Io.Wri .cdata => |cd| { if (node.is(Node.CData.Comment)) |_| { try writer.writeAll(""); } else if (node.is(Node.CData.ProcessingInstruction)) |pi| { try writer.writeAll(""); } else { if (shouldEscapeText(node._parent)) { - try writeEscapedText(cd.getData(), writer); + try writeEscapedText(cd.getData().str(), writer); } else { - try writer.writeAll(cd.getData()); + try writer.writeAll(cd.getData().str()); } } }, diff --git a/src/browser/markdown.zig b/src/browser/markdown.zig index cdb8e87c..26a281bf 100644 --- a/src/browser/markdown.zig +++ b/src/browser/markdown.zig @@ -145,7 +145,7 @@ fn render(node: *Node, state: *State, writer: *std.Io.Writer, page: *Page) error }, .cdata => |cd| { if (node.is(Node.CData.Text)) |_| { - var text = cd.getData(); + var text = cd.getData().str(); if (state.pre_node) |pre| { if (node.parentNode() == pre and node.nextSibling() == null) { text = std.mem.trimRight(u8, text, " \t\r\n"); diff --git a/src/browser/webapi/CData.zig b/src/browser/webapi/CData.zig index 52a555b4..13e075ad 100644 --- a/src/browser/webapi/CData.zig +++ b/src/browser/webapi/CData.zig @@ -17,6 +17,7 @@ // along with this program. If not, see . const std = @import("std"); +const String = @import("../../string.zig").String; const js = @import("../js/js.zig"); const Page = @import("../Page.zig"); @@ -31,7 +32,7 @@ const CData = @This(); _type: Type, _proto: *Node, -_data: []const u8 = "", +_data: String = .empty, /// Count UTF-16 code units in a UTF-8 string. /// 4-byte UTF-8 sequences (codepoints >= U+10000) produce 2 UTF-16 code units (surrogate pair), @@ -157,7 +158,7 @@ pub fn is(self: *CData, comptime T: type) ?*T { return null; } -pub fn getData(self: *const CData) []const u8 { +pub fn getData(self: *const CData) String { return self._data; } @@ -172,7 +173,7 @@ pub fn render(self: *const CData, writer: *std.io.Writer, opts: RenderOpts) !boo var start: usize = 0; var prev_w: ?bool = null; var is_w: bool = undefined; - const s = self._data; + const s = self._data.str(); for (s, 0..) |c, i| { is_w = std.ascii.isWhitespace(c); @@ -222,9 +223,9 @@ pub fn setData(self: *CData, value: ?[]const u8, page: *Page) !void { const old_value = self._data; if (value) |v| { - self._data = try page.dupeString(v); + self._data = try page.dupeSSO(v); } else { - self._data = ""; + self._data = .empty; } page.characterDataChange(self.asNode(), old_value); @@ -243,15 +244,15 @@ pub fn _setData(self: *CData, value: js.Value, page: *Page) !void { pub fn format(self: *const CData, writer: *std.io.Writer) !void { return switch (self._type) { - .text => writer.print("{s}", .{self._data}), - .comment => writer.print("", .{self._data}), - .cdata_section => writer.print("", .{self._data}), - .processing_instruction => |pi| writer.print("", .{ pi._target, self._data }), + .text => writer.print("{f}", .{self._data}), + .comment => writer.print("", .{self._data}), + .cdata_section => writer.print("", .{self._data}), + .processing_instruction => |pi| writer.print("", .{ pi._target, self._data }), }; } pub fn getLength(self: *const CData) usize { - return utf16Len(self._data); + return utf16Len(self._data.str()); } pub fn isEqualNode(self: *const CData, other: *const CData) bool { @@ -267,58 +268,64 @@ pub fn isEqualNode(self: *const CData, other: *const CData) bool { // if the _targets are equal, we still want to compare the data } - return std.mem.eql(u8, self.getData(), other.getData()); + return self._data.eql(other._data); } pub fn appendData(self: *CData, data: []const u8, page: *Page) !void { - const new_data = try std.mem.concat(page.arena, u8, &.{ self._data, data }); - try self.setData(new_data, page); + const old_value = self._data; + self._data = try String.concat(page.arena, &.{ self._data.str(), data }); + page.characterDataChange(self.asNode(), old_value); } pub fn deleteData(self: *CData, offset: usize, count: usize, page: *Page) !void { const end_utf16 = std.math.add(usize, offset, count) catch std.math.maxInt(usize); - const range = try utf16RangeToUtf8(self._data, offset, end_utf16); + const range = try utf16RangeToUtf8(self._data.str(), offset, end_utf16); - // Just slice - original data stays in arena - const old_value = self._data; + const old_data = self._data; + const old_value = old_data.str(); if (range.start == 0) { - self._data = self._data[range.end..]; - } else if (range.end >= self._data.len) { - self._data = self._data[0..range.start]; + self._data = try page.dupeSSO(old_value[range.end..]); + } else if (range.end >= old_value.len) { + self._data = try page.dupeSSO(old_value[0..range.start]); } else { - self._data = try std.mem.concat(page.arena, u8, &.{ - self._data[0..range.start], - self._data[range.end..], + // Deleting from middle - concat prefix and suffix + self._data = try String.concat(page.arena, &.{ + old_value[0..range.start], + old_value[range.end..], }); } - page.characterDataChange(self.asNode(), old_value); + page.characterDataChange(self.asNode(), old_data); } pub fn insertData(self: *CData, offset: usize, data: []const u8, page: *Page) !void { - const byte_offset = try utf16OffsetToUtf8(self._data, offset); - const new_data = try std.mem.concat(page.arena, u8, &.{ - self._data[0..byte_offset], + const byte_offset = try utf16OffsetToUtf8(self._data.str(), offset); + const old_value = self._data; + const existing = old_value.str(); + self._data = try String.concat(page.arena, &.{ + existing[0..byte_offset], data, - self._data[byte_offset..], + existing[byte_offset..], }); - try self.setData(new_data, page); + page.characterDataChange(self.asNode(), old_value); } pub fn replaceData(self: *CData, offset: usize, count: usize, data: []const u8, page: *Page) !void { const end_utf16 = std.math.add(usize, offset, count) catch std.math.maxInt(usize); - const range = try utf16RangeToUtf8(self._data, offset, end_utf16); - const new_data = try std.mem.concat(page.arena, u8, &.{ - self._data[0..range.start], + const range = try utf16RangeToUtf8(self._data.str(), offset, end_utf16); + const old_value = self._data; + const existing = old_value.str(); + self._data = try String.concat(page.arena, &.{ + existing[0..range.start], data, - self._data[range.end..], + existing[range.end..], }); - try self.setData(new_data, page); + page.characterDataChange(self.asNode(), old_value); } pub fn substringData(self: *const CData, offset: usize, count: usize) ![]const u8 { const end_utf16 = std.math.add(usize, offset, count) catch std.math.maxInt(usize); - const range = try utf16RangeToUtf8(self._data, offset, end_utf16); - return self._data[range.start..range.end]; + const range = try utf16RangeToUtf8(self._data.str(), offset, end_utf16); + return self._data.str()[range.start..range.end]; } pub fn remove(self: *CData, page: *Page) !void { @@ -451,7 +458,7 @@ test "WebApi: CData.render" { const cdata = CData{ ._type = .{ .text = undefined }, ._proto = undefined, - ._data = test_case.value, + ._data = .wrap(test_case.value), }; const result = try cdata.render(&buffer.writer, test_case.opts); diff --git a/src/browser/webapi/MutationObserver.zig b/src/browser/webapi/MutationObserver.zig index 68d85fb4..a5dd15dd 100644 --- a/src/browser/webapi/MutationObserver.zig +++ b/src/browser/webapi/MutationObserver.zig @@ -243,7 +243,7 @@ pub fn notifyAttributeChange( pub fn notifyCharacterDataChange( self: *MutationObserver, target: *Node, - old_value: ?[]const u8, + old_value: ?String, page: *Page, ) !void { for (self._observing.items) |obs| { @@ -267,7 +267,7 @@ pub fn notifyCharacterDataChange( ._target = target, ._attribute_name = null, ._old_value = if (obs.options.characterDataOldValue and old_value != null) - try arena.dupe(u8, old_value.?) + try arena.dupe(u8, old_value.?.str()) else null, ._added_nodes = &.{}, diff --git a/src/browser/webapi/Node.zig b/src/browser/webapi/Node.zig index 7849622a..a62b0b95 100644 --- a/src/browser/webapi/Node.zig +++ b/src/browser/webapi/Node.zig @@ -270,7 +270,7 @@ pub fn getTextContent(self: *Node, writer: *std.Io.Writer) error{WriteFailed}!vo try child.getTextContent(writer); } }, - .cdata => |c| try writer.writeAll(c.getData()), + .cdata => |c| try writer.writeAll(c.getData().str()), .document => {}, .document_type => {}, .attribute => |attr| try writer.writeAll(attr._value.str()), @@ -293,7 +293,7 @@ pub fn setTextContent(self: *Node, data: []const u8, page: *Page) !void { } return el.replaceChildren(&.{.{ .text = data }}, page); }, - .cdata => |c| c._data = try page.arena.dupe(u8, data), + .cdata => |c| c._data = try page.dupeSSO(data), .document => {}, .document_type => {}, .document_fragment => |frag| { @@ -599,10 +599,10 @@ pub fn replaceChild(self: *Node, new_child: *Node, old_child: *Node, page: *Page return old_child; } -pub fn getNodeValue(self: *const Node) ?[]const u8 { +pub fn getNodeValue(self: *const Node) ?String { return switch (self._type) { .cdata => |c| c.getData(), - .attribute => |attr| attr._value.str(), + .attribute => |attr| attr._value, .element => null, .document => null, .document_type => null, @@ -694,10 +694,10 @@ pub fn getChildAt(self: *Node, index: u32) ?*Node { return null; } -pub fn getData(self: *const Node) []const u8 { +pub fn getData(self: *const Node) String { return switch (self._type) { .cdata => |c| c.getData(), - else => "", + else => .empty, }; } @@ -729,7 +729,7 @@ pub fn cloneNode(self: *Node, deep_: ?bool, page: *Page) CloneError!*Node { const deep = deep_ orelse false; switch (self._type) { .cdata => |cd| { - const data = cd.getData(); + const data = cd.getData().str(); return switch (cd._type) { .text => page.createTextNode(data), .cdata_section => page.createCDATASection(data), @@ -884,7 +884,7 @@ fn _normalize(self: *Node, allocator: Allocator, buffer: *std.ArrayList(u8), pag next_node = node_to_merge.nextSibling(); page.removeNode(self, to_remove, .{ .will_be_reconnected = false }); } - text_node._proto._data = try page.dupeString(buffer.items); + text_node._proto._data = try page.dupeSSO(buffer.items); buffer.clearRetainingCapacity(); } } @@ -1028,7 +1028,7 @@ pub const JsApi = struct { try self.getTextContent(&buf.writer); return buf.written(); }, - .cdata => |cdata| return cdata.getData(), + .cdata => |cdata| return cdata.getData().str(), .attribute => |attr| return attr._value.str(), .document => return null, .document_type => return null, diff --git a/src/browser/webapi/Range.zig b/src/browser/webapi/Range.zig index 758f81e7..d7e7d8c5 100644 --- a/src/browser/webapi/Range.zig +++ b/src/browser/webapi/Range.zig @@ -17,9 +17,11 @@ // along with this program. If not, see . const std = @import("std"); -const js = @import("../js/js.zig"); +const String = @import("../../string.zig").String; +const js = @import("../js/js.zig"); const Page = @import("../Page.zig"); + const Node = @import("Node.zig"); const DocumentFragment = @import("DocumentFragment.zig"); const AbstractRange = @import("AbstractRange.zig"); @@ -326,7 +328,7 @@ pub fn insertNode(self: *Range, node: *Node, page: *Page) !void { if (offset == 0) { _ = try parent.insertBefore(node, container, page); } else { - const text_data = container.getData(); + const text_data = container.getData().str(); if (offset >= text_data.len) { _ = try parent.insertBefore(node, container.nextSibling(), page); } else { @@ -362,15 +364,15 @@ pub fn deleteContents(self: *Range, page: *Page) !void { // Simple case: same container if (self._proto._start_container == self._proto._end_container) { - if (self._proto._start_container.is(Node.CData)) |_| { + if (self._proto._start_container.is(Node.CData)) |cdata| { // Delete part of text node - const text_data = self._proto._start_container.getData(); - const new_text = try std.mem.concat( + const old_value = cdata.getData(); + const text_data = old_value.str(); + cdata._data = try String.concat( page.arena, - u8, &.{ text_data[0..self._proto._start_offset], text_data[self._proto._end_offset..] }, ); - try self._proto._start_container.setData(new_text, page); + page.characterDataChange(self._proto._start_container, old_value); } else { // Delete child nodes in range var offset = self._proto._start_offset; @@ -387,7 +389,7 @@ pub fn deleteContents(self: *Range, page: *Page) !void { // Complex case: different containers // Handle start container - if it's a text node, truncate it if (self._proto._start_container.is(Node.CData)) |_| { - const text_data = self._proto._start_container.getData(); + const text_data = self._proto._start_container.getData().str(); if (self._proto._start_offset < text_data.len) { // Keep only the part before start_offset const new_text = text_data[0..self._proto._start_offset]; @@ -397,7 +399,7 @@ pub fn deleteContents(self: *Range, page: *Page) !void { // Handle end container - if it's a text node, truncate it if (self._proto._end_container.is(Node.CData)) |_| { - const text_data = self._proto._end_container.getData(); + const text_data = self._proto._end_container.getData().str(); if (self._proto._end_offset < text_data.len) { // Keep only the part from end_offset onwards const new_text = text_data[self._proto._end_offset..]; @@ -433,7 +435,7 @@ pub fn cloneContents(self: *const Range, page: *Page) !*DocumentFragment { if (self._proto._start_container == self._proto._end_container) { if (self._proto._start_container.is(Node.CData)) |_| { // Clone part of text node - const text_data = self._proto._start_container.getData(); + const text_data = self._proto._start_container.getData().str(); if (self._proto._start_offset < text_data.len and self._proto._end_offset <= text_data.len) { const cloned_text = text_data[self._proto._start_offset..self._proto._end_offset]; const text_node = try page.createTextNode(cloned_text); @@ -453,7 +455,7 @@ pub fn cloneContents(self: *const Range, page: *Page) !*DocumentFragment { // Complex case: different containers // Clone partial start container if (self._proto._start_container.is(Node.CData)) |_| { - const text_data = self._proto._start_container.getData(); + const text_data = self._proto._start_container.getData().str(); if (self._proto._start_offset < text_data.len) { // Clone from start_offset to end of text const cloned_text = text_data[self._proto._start_offset..]; @@ -474,7 +476,7 @@ pub fn cloneContents(self: *const Range, page: *Page) !*DocumentFragment { // Clone partial end container if (self._proto._end_container.is(Node.CData)) |_| { - const text_data = self._proto._end_container.getData(); + const text_data = self._proto._end_container.getData().str(); if (self._proto._end_offset > 0 and self._proto._end_offset <= text_data.len) { // Clone from start to end_offset const cloned_text = text_data[0..self._proto._end_offset]; @@ -560,7 +562,7 @@ fn writeTextContent(self: *const Range, writer: *std.Io.Writer) !void { if (start_node == end_node) { if (start_node.is(Node.CData)) |cdata| { if (!isCommentOrPI(cdata)) { - const data = cdata.getData(); + const data = cdata.getData().str(); const s = @min(start_offset, data.len); const e = @min(end_offset, data.len); try writer.writeAll(data[s..e]); @@ -574,7 +576,7 @@ fn writeTextContent(self: *const Range, writer: *std.Io.Writer) !void { // Partial start: if start container is a text node, write from offset to end if (start_node.is(Node.CData)) |cdata| { if (!isCommentOrPI(cdata)) { - const data = cdata.getData(); + const data = cdata.getData().str(); const s = @min(start_offset, data.len); try writer.writeAll(data[s..]); } @@ -601,7 +603,7 @@ fn writeTextContent(self: *const Range, writer: *std.Io.Writer) !void { } if (n.is(Node.CData)) |cdata| { if (!isCommentOrPI(cdata)) { - try writer.writeAll(cdata.getData()); + try writer.writeAll(cdata.getData().str()); } } current = nextInTreeOrder(n, root); @@ -612,7 +614,7 @@ fn writeTextContent(self: *const Range, writer: *std.Io.Writer) !void { if (start_node != end_node) { if (end_node.is(Node.CData)) |cdata| { if (!isCommentOrPI(cdata)) { - const data = cdata.getData(); + const data = cdata.getData().str(); const e = @min(end_offset, data.len); try writer.writeAll(data[0..e]); } diff --git a/src/browser/webapi/Selection.zig b/src/browser/webapi/Selection.zig index 9320fcfd..a13390a3 100644 --- a/src/browser/webapi/Selection.zig +++ b/src/browser/webapi/Selection.zig @@ -500,20 +500,20 @@ fn modifyByWord(self: *Selection, alter: ModifyAlter, forward: bool, range: *Ran if (isTextNode(focus_node)) { if (forward) { - const i = nextWordEnd(new_node.getData(), new_offset); + const i = nextWordEnd(new_node.getData().str(), new_offset); if (i > new_offset) { new_offset = i; } else if (nextTextNode(focus_node)) |next| { new_node = next; - new_offset = nextWordEnd(next.getData(), 0); + new_offset = nextWordEnd(next.getData().str(), 0); } } else { - const i = prevWordStart(new_node.getData(), new_offset); + const i = prevWordStart(new_node.getData().str(), new_offset); if (i < new_offset) { new_offset = i; } else if (prevTextNode(focus_node)) |prev| { new_node = prev; - new_offset = prevWordStart(prev.getData(), @intCast(prev.getData().len)); + new_offset = prevWordStart(prev.getData().str(), @intCast(prev.getData().len)); } } } else { @@ -524,7 +524,7 @@ fn modifyByWord(self: *Selection, alter: ModifyAlter, forward: bool, range: *Ran const child = focus_node.getChildAt(focus_offset) orelse { if (nextTextNodeAfter(focus_node)) |next| { new_node = next; - new_offset = nextWordEnd(next.getData(), 0); + new_offset = nextWordEnd(next.getData().str(), 0); } return self.applyModify(alter, new_node, new_offset, page); }; @@ -534,7 +534,7 @@ fn modifyByWord(self: *Selection, alter: ModifyAlter, forward: bool, range: *Ran }; new_node = t; - new_offset = nextWordEnd(t.getData(), 0); + new_offset = nextWordEnd(t.getData().str(), 0); } else { var idx = focus_offset; while (idx > 0) { @@ -544,7 +544,7 @@ fn modifyByWord(self: *Selection, alter: ModifyAlter, forward: bool, range: *Ran while (bottom.lastChild()) |c| bottom = c; if (isTextNode(bottom)) { new_node = bottom; - new_offset = prevWordStart(bottom.getData(), bottom.getLength()); + new_offset = prevWordStart(bottom.getData().str(), bottom.getLength()); break; } } diff --git a/src/browser/webapi/cdata/Text.zig b/src/browser/webapi/cdata/Text.zig index 1e5e644d..5eb096f3 100644 --- a/src/browser/webapi/cdata/Text.zig +++ b/src/browser/webapi/cdata/Text.zig @@ -16,6 +16,7 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . +const String = @import("../../../string.zig").String; const js = @import("../../js/js.zig"); const Page = @import("../../Page.zig"); const CData = @import("../CData.zig"); @@ -30,11 +31,11 @@ pub fn init(str: ?js.NullableString, page: *Page) !*Text { } pub fn getWholeText(self: *Text) []const u8 { - return self._proto._data; + return self._proto._data.str(); } pub fn splitText(self: *Text, offset: usize, page: *Page) !*Text { - const data = self._proto._data; + const data = self._proto._data.str(); const byte_offset = CData.utf16OffsetToUtf8(data, offset) catch return error.IndexSizeError; diff --git a/src/browser/webapi/element/html/TextArea.zig b/src/browser/webapi/element/html/TextArea.zig index 916f67d8..e08831a2 100644 --- a/src/browser/webapi/element/html/TextArea.zig +++ b/src/browser/webapi/element/html/TextArea.zig @@ -88,18 +88,16 @@ pub fn getDefaultValue(self: *const TextArea) []const u8 { } pub fn setDefaultValue(self: *TextArea, value: []const u8, page: *Page) !void { - const owned = try page.dupeString(value); - const node = self.asNode(); if (node.firstChild()) |child| { if (child.is(Node.CData.Text)) |txt| { - txt._proto._data = owned; + txt._proto._data = try page.dupeSSO(value); return; } } // No text child exists, create one - const text_node = try page.createTextNode(owned); + const text_node = try page.createTextNode(value); _ = try node.appendChild(text_node, page); } diff --git a/src/cdp/Node.zig b/src/cdp/Node.zig index 885fa93b..a1f688f1 100644 --- a/src/cdp/Node.zig +++ b/src/cdp/Node.zig @@ -307,7 +307,11 @@ pub const Writer = struct { try w.write(dom_node.getNodeName(&name_buf)); try w.objectField("nodeValue"); - try w.write(dom_node.getNodeValue() orelse ""); + if (dom_node.getNodeValue()) |nv| { + try w.write(nv.str()); + } else { + try w.write(""); + } if (include_child_count) { try w.objectField("childNodeCount"); diff --git a/src/string.zig b/src/string.zig index bbbc374a..202cfdc9 100644 --- a/src/string.zig +++ b/src/string.zig @@ -111,6 +111,38 @@ pub const String = packed struct { return .init(allocator, self.str(), .{ .dupe = true }); } + pub fn concat(allocator: Allocator, parts: []const []const u8) !String { + var total_len: usize = 0; + for (parts) |part| { + total_len += part.len; + } + + if (total_len <= 12) { + var content: [12]u8 = @splat(0); + var pos: usize = 0; + for (parts) |part| { + @memcpy(content[pos..][0..part.len], part); + pos += part.len; + } + return .{ .len = @intCast(total_len), .payload = .{ .content = content } }; + } + + const result = try allocator.alloc(u8, total_len); + var pos: usize = 0; + for (parts) |part| { + @memcpy(result[pos..][0..part.len], part); + pos += part.len; + } + + return .{ + .len = @intCast(total_len), + .payload = .{ .heap = .{ + .prefix = result[0..4].*, + .ptr = (intern(result) orelse result).ptr, + } }, + }; + } + pub fn str(self: *const String) []const u8 { const l = self.len; if (l < 0) { @@ -272,3 +304,118 @@ test "String" { try testing.expectEqual(false, str.eqlSlice("other_long" ** 100)); } } + +test "String.concat" { + { + const result = try String.concat(testing.allocator, &.{}); + defer result.deinit(testing.allocator); + try testing.expectEqual(@as(usize, 0), result.str().len); + try testing.expectEqual("", result.str()); + } + + { + const result = try String.concat(testing.allocator, &.{"hello"}); + defer result.deinit(testing.allocator); + try testing.expectEqual("hello", result.str()); + } + + { + const result = try String.concat(testing.allocator, &.{ "foo", "bar" }); + defer result.deinit(testing.allocator); + try testing.expectEqual("foobar", result.str()); + try testing.expectEqual(@as(i32, 6), result.len); + } + + { + const result = try String.concat(testing.allocator, &.{ "test", "ing", "1234" }); + defer result.deinit(testing.allocator); + try testing.expectEqual("testing1234", result.str()); + try testing.expectEqual(@as(i32, 11), result.len); + } + + { + const result = try String.concat(testing.allocator, &.{ "foo", "bar", "baz", "qux" }); + defer result.deinit(testing.allocator); + try testing.expectEqual("foobarbazqux", result.str()); + try testing.expectEqual(@as(i32, 12), result.len); + } + + { + const result = try String.concat(testing.allocator, &.{ "hello", " world!" }); + defer result.deinit(testing.allocator); + try testing.expectEqual("hello world!", result.str()); + try testing.expectEqual(@as(i32, 12), result.len); + } + + { + const result = try String.concat(testing.allocator, &.{ "a", "b", "c", "d", "e" }); + defer result.deinit(testing.allocator); + try testing.expectEqual("abcde", result.str()); + try testing.expectEqual(@as(i32, 5), result.len); + } + + { + const result = try String.concat(testing.allocator, &.{ "one", " ", "two", " ", "three", " ", "four" }); + defer result.deinit(testing.allocator); + try testing.expectEqual("one two three four", result.str()); + try testing.expectEqual(@as(i32, 18), result.len); + } + + { + const result = try String.concat(testing.allocator, &.{ "hello", "", "world" }); + defer result.deinit(testing.allocator); + try testing.expectEqual("helloworld", result.str()); + } + + { + const result = try String.concat(testing.allocator, &.{ "", "", "" }); + defer result.deinit(testing.allocator); + try testing.expectEqual("", result.str()); + try testing.expectEqual(@as(i32, 0), result.len); + } + + { + const result = try String.concat(testing.allocator, &.{ "café", " ☕" }); + defer result.deinit(testing.allocator); + try testing.expectEqual("café ☕", result.str()); + } + + { + const result = try String.concat(testing.allocator, &.{ "Hello ", "世界", " and ", "مرحبا" }); + defer result.deinit(testing.allocator); + try testing.expectEqual("Hello 世界 and مرحبا", result.str()); + } + + { + const result = try String.concat(testing.allocator, &.{ " ", "test", " " }); + defer result.deinit(testing.allocator); + try testing.expectEqual(" test ", result.str()); + } + + { + const result = try String.concat(testing.allocator, &.{ " ", " " }); + defer result.deinit(testing.allocator); + try testing.expectEqual(" ", result.str()); + try testing.expectEqual(@as(i32, 4), result.len); + } + + { + const result = try String.concat(testing.allocator, &.{ "Item ", "1", "2", "3" }); + defer result.deinit(testing.allocator); + try testing.expectEqual("Item 123", result.str()); + } + + { + const original = "Hello, world!"; + const result = try String.concat(testing.allocator, &.{ original[0..5], original[7..] }); + defer result.deinit(testing.allocator); + try testing.expectEqual("Helloworld!", result.str()); + } + + { + const original = "Hello!"; + const result = try String.concat(testing.allocator, &.{ original[0..5], " world", original[5..] }); + defer result.deinit(testing.allocator); + try testing.expectEqual("Hello world!", result.str()); + } +} From 24491f0dfeeff991259c8b1b43cae975c87a115f Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Fri, 27 Feb 2026 14:34:20 +0800 Subject: [PATCH 080/103] fix String copy/reference --- src/browser/webapi/Node.zig | 4 ++-- src/browser/webapi/Range.zig | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/browser/webapi/Node.zig b/src/browser/webapi/Node.zig index a62b0b95..4b351b6f 100644 --- a/src/browser/webapi/Node.zig +++ b/src/browser/webapi/Node.zig @@ -270,7 +270,7 @@ pub fn getTextContent(self: *Node, writer: *std.Io.Writer) error{WriteFailed}!vo try child.getTextContent(writer); } }, - .cdata => |c| try writer.writeAll(c.getData().str()), + .cdata => |c| try writer.writeAll(c._data.str()), .document => {}, .document_type => {}, .attribute => |attr| try writer.writeAll(attr._value.str()), @@ -1028,7 +1028,7 @@ pub const JsApi = struct { try self.getTextContent(&buf.writer); return buf.written(); }, - .cdata => |cdata| return cdata.getData().str(), + .cdata => |cdata| return cdata._data.str(), .attribute => |attr| return attr._value.str(), .document => return null, .document_type => return null, diff --git a/src/browser/webapi/Range.zig b/src/browser/webapi/Range.zig index d7e7d8c5..840fa227 100644 --- a/src/browser/webapi/Range.zig +++ b/src/browser/webapi/Range.zig @@ -388,8 +388,8 @@ pub fn deleteContents(self: *Range, page: *Page) !void { // Complex case: different containers // Handle start container - if it's a text node, truncate it - if (self._proto._start_container.is(Node.CData)) |_| { - const text_data = self._proto._start_container.getData().str(); + if (self._proto._start_container.is(Node.CData)) |cdata| { + const text_data = cdata._data.str(); if (self._proto._start_offset < text_data.len) { // Keep only the part before start_offset const new_text = text_data[0..self._proto._start_offset]; @@ -398,8 +398,8 @@ pub fn deleteContents(self: *Range, page: *Page) !void { } // Handle end container - if it's a text node, truncate it - if (self._proto._end_container.is(Node.CData)) |_| { - const text_data = self._proto._end_container.getData().str(); + if (self._proto._end_container.is(Node.CData)) |cdata| { + const text_data = cdata._data.str(); if (self._proto._end_offset < text_data.len) { // Keep only the part from end_offset onwards const new_text = text_data[self._proto._end_offset..]; From ad226b6fb1bdfe5f30c3a9e0cd50cc91c84a49f7 Mon Sep 17 00:00:00 2001 From: Pierre Tachoire Date: Thu, 26 Feb 2026 15:30:32 +0100 Subject: [PATCH 081/103] implement storage size limit per origin --- src/browser/webapi/storage/storage.zig | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/browser/webapi/storage/storage.zig b/src/browser/webapi/storage/storage.zig index 64dfae23..752ef1d3 100644 --- a/src/browser/webapi/storage/storage.zig +++ b/src/browser/webapi/storage/storage.zig @@ -60,6 +60,9 @@ pub const Bucket = struct { local: Lookup = .{}, session: Lookup = .{} }; pub const Lookup = struct { _data: std.StringHashMapUnmanaged([]const u8) = .empty, + _size: usize = 0, + + const max_size = 5 * 1024 * 1024; pub fn getItem(self: *const Lookup, key_: ?[]const u8) ?[]const u8 { const k = key_ orelse return null; @@ -69,6 +72,11 @@ pub const Lookup = struct { pub fn setItem(self: *Lookup, key_: ?[]const u8, value: []const u8, page: *Page) !void { const k = key_ orelse return; + if (self._size + value.len > max_size) { + return error.QuotaExceeded; + } + defer self._size += value.len; + const key_owned = try page.dupeString(k); const value_owned = try page.dupeString(value); @@ -112,7 +120,7 @@ pub const Lookup = struct { pub const length = bridge.accessor(Lookup.getLength, null, .{}); pub const getItem = bridge.function(Lookup.getItem, .{}); - pub const setItem = bridge.function(Lookup.setItem, .{}); + pub const setItem = bridge.function(Lookup.setItem, .{ .dom_exception = true }); pub const removeItem = bridge.function(Lookup.removeItem, .{}); pub const clear = bridge.function(Lookup.clear, .{}); pub const key = bridge.function(Lookup.key, .{}); From c61eda0d241b73ae7fa89d9f8f8fb1b5854bec70 Mon Sep 17 00:00:00 2001 From: Pierre Tachoire Date: Thu, 26 Feb 2026 15:51:01 +0100 Subject: [PATCH 082/103] crypto: use dom exception to return QuotaExceededError --- src/browser/webapi/Crypto.zig | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/browser/webapi/Crypto.zig b/src/browser/webapi/Crypto.zig index 89324695..a79fbc40 100644 --- a/src/browser/webapi/Crypto.zig +++ b/src/browser/webapi/Crypto.zig @@ -32,7 +32,7 @@ pub fn getRandomValues(_: *const Crypto, js_obj: js.Object) !js.Object { var into = try js_obj.toZig(RandomValues); const buf = into.asBuffer(); if (buf.len > 65_536) { - return error.QuotaExceededError; + return error.QuotaExceeded; } std.crypto.random.bytes(buf); return js_obj; @@ -82,7 +82,7 @@ pub const JsApi = struct { pub const empty_with_no_proto = true; }; - pub const getRandomValues = bridge.function(Crypto.getRandomValues, .{}); + pub const getRandomValues = bridge.function(Crypto.getRandomValues, .{ .dom_exception = true }); pub const randomUUID = bridge.function(Crypto.randomUUID, .{}); pub const subtle = bridge.accessor(Crypto.getSubtle, null, .{}); }; From ef6a7a6904d865e3b86232535260731641a005fa Mon Sep 17 00:00:00 2001 From: Pierre Tachoire Date: Fri, 27 Feb 2026 08:51:22 +0100 Subject: [PATCH 083/103] storage: maintain Lookup size correctly --- src/browser/tests/storage.html | 9 +++++++++ src/browser/webapi/storage/storage.zig | 6 +++++- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/src/browser/tests/storage.html b/src/browser/tests/storage.html index 7633293b..cb41bb3d 100644 --- a/src/browser/tests/storage.html +++ b/src/browser/tests/storage.html @@ -88,3 +88,12 @@ localStorage.clear(); testing.expectEqual(0, localStorage.length) + + diff --git a/src/browser/webapi/storage/storage.zig b/src/browser/webapi/storage/storage.zig index 752ef1d3..1787dfee 100644 --- a/src/browser/webapi/storage/storage.zig +++ b/src/browser/webapi/storage/storage.zig @@ -86,11 +86,15 @@ pub const Lookup = struct { pub fn removeItem(self: *Lookup, key_: ?[]const u8) void { const k = key_ orelse return; - _ = self._data.remove(k); + if (self._data.get(k)) |value| { + self._size -= value.len; + _ = self._data.remove(k); + } } pub fn clear(self: *Lookup) void { self._data.clearRetainingCapacity(); + self._size = 0; } pub fn key(self: *const Lookup, index: u32) ?[]const u8 { From 24b6934d3b19eedcdbca667a0f9e4bcdff70253a Mon Sep 17 00:00:00 2001 From: Pierre Tachoire Date: Mon, 23 Feb 2026 15:53:40 +0100 Subject: [PATCH 084/103] remove WPT specific code Using both lightpanda-io/wpt and lightpanda-io/demo/wptrunner remove the need for code specific to run WPT from browser. --- .gitmodules | 3 - Makefile | 11 +- README.md | 64 ++++- build.zig | 26 -- src/main_wpt.zig | 616 ----------------------------------------------- tests/wpt | 1 - 6 files changed, 53 insertions(+), 668 deletions(-) delete mode 100644 .gitmodules delete mode 100644 src/main_wpt.zig delete mode 160000 tests/wpt diff --git a/.gitmodules b/.gitmodules deleted file mode 100644 index cc314b9b..00000000 --- a/.gitmodules +++ /dev/null @@ -1,3 +0,0 @@ -[submodule "tests/wpt"] - path = tests/wpt - url = https://github.com/lightpanda-io/wpt diff --git a/Makefile b/Makefile index 0e34a5e7..72dbcb7d 100644 --- a/Makefile +++ b/Makefile @@ -47,7 +47,7 @@ help: # $(ZIG) commands # ------------ -.PHONY: build build-v8-snapshot build-dev run run-release shell test bench wpt data end2end +.PHONY: build build-v8-snapshot build-dev run run-release shell test bench data end2end ## Build v8 snapshot build-v8-snapshot: @@ -82,15 +82,6 @@ shell: @printf "\033[36mBuilding shell...\033[0m\n" @$(ZIG) build shell || (printf "\033[33mBuild ERROR\033[0m\n"; exit 1;) -## Run WPT tests -wpt: - @printf "\033[36mBuilding wpt...\033[0m\n" - @$(ZIG) build wpt -- $(filter-out $@,$(MAKECMDGOALS)) || (printf "\033[33mBuild ERROR\033[0m\n"; exit 1;) - -wpt-summary: - @printf "\033[36mBuilding wpt...\033[0m\n" - @$(ZIG) build wpt -- --summary $(filter-out $@,$(MAKECMDGOALS)) || (printf "\033[33mBuild ERROR\033[0m\n"; exit 1;) - ## Test - `grep` is used to filter out the huge compile command on build ifeq ($(OS), macos) test: diff --git a/README.md b/README.md index d7c78d16..141c8040 100644 --- a/README.md +++ b/README.md @@ -281,35 +281,75 @@ make end2end Lightpanda is tested against the standardized [Web Platform Tests](https://web-platform-tests.org/). -The relevant tests cases are committed in a [dedicated repository](https://github.com/lightpanda-io/wpt) which is fetched by the `make install-submodule` command. - -All the tests cases executed are located in the `tests/wpt` sub-directory. +We use [a fork](https://github.com/lightpanda-io/wpt/tree/fork) including a custom +[`testharnessreport.js`](https://github.com/lightpanda-io/wpt/commit/01a3115c076a3ad0c84849dbbf77a6e3d199c56f). For reference, you can easily execute a WPT test case with your browser via [wpt.live](https://wpt.live). +#### Configure WPT HTTP server + +To run the test, you must clone the repository, configure the custom hosts and generate the +`MANIFEST.json` file. + +Clone the repository with the `fork` branch. +``` +git clone -b fork --depth=1 git@github.com:lightpanda-io/wpt.git +``` + +Enter into the `wpt/` dir. + +Install custom domains in your `/etc/hosts` +``` +./wpt make-hosts-file | sudo tee -a /etc/hosts +``` + +Generate `MANIFEST.json` +``` +./wpt manifest +``` +Use the [WPT's setup +guide](https://web-platform-tests.org/running-tests/from-local-system.html) for +details. + #### Run WPT test suite -To run all the tests: +An external [Go](https://go.dev) runner is provided by +[github.com/lightpanda-io/demo/](https://github.com/lightpanda-io/demo/) +repository, located into `wptrunner/` dir. +You need to clone the project first. + +First start the WPT's HTTP server from your `wpt/` clone dir. +``` +./wpt serve +``` + +Run a Lightpanda browser ``` -make wpt +zig build run +``` + +Then you can start the wptrunner from the Demo's clone dir: +``` +cd wptrunner && go run . ``` Or one specific test: ``` -make wpt Node-childNodes.html +cd wptrunner && go run . Node-childNodes.html ``` -#### Add a new WPT test case +`wptrunner` command accepts `--summary` and `--json` options modifying output. +Also `--concurrency` define the concurrency limit. -We add new relevant tests cases files when we implemented changes in Lightpanda. +:warning: Running the whole test suite will take a long time. In this case, +it's useful to build in `releaseFast` mode to make tests faster. -To add a new test, copy the file you want from the [WPT -repo](https://github.com/web-platform-tests/wpt) into the `tests/wpt` directory. - -:warning: Please keep the original directory tree structure of `tests/wpt`. +``` +zig build -Doptimize=ReleaseFast run +``` ## Contributing diff --git a/build.zig b/build.zig index cd304454..7e3a2817 100644 --- a/build.zig +++ b/build.zig @@ -146,32 +146,6 @@ pub fn build(b: *Build) !void { const run_step = b.step("legacy_test", "Run the app"); run_step.dependOn(&run_cmd.step); } - - { - // wpt - const exe = b.addExecutable(.{ - .name = "lightpanda-wpt", - .use_llvm = true, - .root_module = b.createModule(.{ - .root_source_file = b.path("src/main_wpt.zig"), - .target = target, - .optimize = optimize, - .sanitize_c = enable_csan, - .sanitize_thread = enable_tsan, - .imports = &.{ - .{ .name = "lightpanda", .module = lightpanda_module }, - }, - }), - }); - b.installArtifact(exe); - - const run_cmd = b.addRunArtifact(exe); - if (b.args) |args| { - run_cmd.addArgs(args); - } - const run_step = b.step("wpt", "Run WPT tests"); - run_step.dependOn(&run_cmd.step); - } } fn linkV8( diff --git a/src/main_wpt.zig b/src/main_wpt.zig deleted file mode 100644 index 3db9d92b..00000000 --- a/src/main_wpt.zig +++ /dev/null @@ -1,616 +0,0 @@ -// Copyright (C) 2023-2025 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 Allocator = std.mem.Allocator; -const ArenaAllocator = std.heap.ArenaAllocator; - -const WPT_DIR = "tests/wpt"; - -// use in custom panic handler -var current_test: ?[]const u8 = null; - -pub fn main() !void { - var gpa: std.heap.DebugAllocator(.{}) = .init; - defer _ = gpa.deinit(); - - const allocator = gpa.allocator(); - - var http_server = try TestHTTPServer.init(); - defer http_server.deinit(); - - { - var wg: std.Thread.WaitGroup = .{}; - wg.startMany(1); - var thrd = try std.Thread.spawn(.{}, TestHTTPServer.run, .{ &http_server, &wg }); - thrd.detach(); - wg.wait(); - } - - // An arena for the runner itself, lives for the duration of the the process - var ra = ArenaAllocator.init(allocator); - defer ra.deinit(); - const runner_arena = ra.allocator(); - - const cmd = try parseArgs(runner_arena); - - var it = try TestIterator.init(allocator, WPT_DIR, cmd.filters); - defer it.deinit(); - - var writer = try Writer.init(allocator, cmd.format); - defer writer.deinit(); - - lp.log.opts.level = .warn; - const config = try lp.Config.init(allocator, "lightpanda-wpt", .{ .serve = .{ - .common = .{ - .tls_verify_host = false, - .user_agent_suffix = "internal-tester", - }, - } }); - defer config.deinit(allocator); - - var app = try lp.App.init(allocator, &config); - defer app.deinit(); - - const http_client = try app.http.createClient(allocator); - defer http_client.deinit(); - - var browser = try lp.Browser.init(app, .{ .http_client = http_client }); - defer browser.deinit(); - - // An arena for running each tests. Is reset after every test. - var test_arena = ArenaAllocator.init(allocator); - defer test_arena.deinit(); - - var i: usize = 0; - while (try it.next()) |test_file| { - defer _ = test_arena.reset(.retain_capacity); - - defer current_test = null; - current_test = test_file; - - var err_out: ?[]const u8 = null; - const result = run( - test_arena.allocator(), - &browser, - test_file, - &err_out, - ) catch |err| blk: { - if (err_out == null) { - err_out = @errorName(err); - } - break :blk null; - }; - try writer.process(test_file, result, err_out); - // if (@mod(i, 10) == 0) { - // std.debug.print("\n\n=== V8 Memory {d}===\n", .{i}); - // browser.env.dumpMemoryStats(); - // } - i += 1; - } - try writer.finalize(); -} - -fn run( - arena: Allocator, - browser: *lp.Browser, - test_file: []const u8, - err_out: *?[]const u8, -) ![]const u8 { - const notification = try lp.Notification.init(browser.allocator); - defer notification.deinit(); - - const session = try browser.newSession(notification); - defer browser.closeSession(); - - const page = try session.createPage(); - defer session.removePage(); - - const url = try std.fmt.allocPrintSentinel(arena, "http://localhost:9582/{s}", .{test_file}, 0); - try page.navigate(url, .{}); - - _ = session.wait(2000); - - var ls: lp.js.Local.Scope = undefined; - page.js.localScope(&ls); - defer ls.deinit(); - - var try_catch: lp.js.TryCatch = undefined; - try_catch.init(&ls.local); - defer try_catch.deinit(); - - // Check the final test status. - ls.local.eval("report.status", "teststatus") catch |err| { - const caught = try_catch.caughtOrError(arena, err); - err_out.* = caught.exception; - return err; - }; - - // return the detailed result. - const value = ls.local.exec("report.log", "report") catch |err| { - const caught = try_catch.caughtOrError(arena, err); - err_out.* = caught.exception; - return err; - }; - - return value.toStringSliceWithAlloc(arena); -} - -const Writer = struct { - format: Format, - allocator: Allocator, - pass_count: usize = 0, - fail_count: usize = 0, - case_pass_count: usize = 0, - case_fail_count: usize = 0, - writer: std.fs.File.Writer, - cases: std.ArrayList(Case) = .{}, - - const Format = enum { json, text, summary, quiet }; - - fn init(allocator: Allocator, format: Format) !Writer { - const out = std.fs.File.stdout(); - var writer = out.writer(&.{}); - - if (format == .json) { - try writer.interface.writeByte('['); - } - - return .{ - .format = format, - .writer = writer, - .allocator = allocator, - }; - } - - fn deinit(self: *Writer) void { - self.cases.deinit(self.allocator); - } - - fn finalize(self: *Writer) !void { - var writer = &self.writer.interface; - if (self.format == .json) { - // When we write a test output, we add a trailing comma to act as - // a separator for the next test. We need to add this dummy entry - // to make it valid json. - // Better option could be to change the formatter to work on JSONL: - // https://github.com/lightpanda-io/perf-fmt/blob/main/wpt/wpt.go - try writer.writeAll("{\"name\":\"empty\",\"pass\": true, \"cases\": []}]"); - } else { - try writer.print("\n==Summary==\nTests: {d}/{d}\nCases: {d}/{d}\n", .{ - self.pass_count, - self.pass_count + self.fail_count, - self.case_pass_count, - self.case_pass_count + self.case_fail_count, - }); - } - } - - fn process(self: *Writer, test_file: []const u8, result_: ?[]const u8, err_: ?[]const u8) !void { - var writer = &self.writer.interface; - if (err_) |err| { - self.fail_count += 1; - switch (self.format) { - .text => return writer.print("Fail\t{s}\n\t{s}\n", .{ test_file, err }), - .summary => return writer.print("Fail 0/0\t{s}\n", .{test_file}), - .json => { - try std.json.Stringify.value(Test{ - .pass = false, - .name = test_file, - .cases = &.{}, - }, .{ .whitespace = .indent_2 }, writer); - return writer.writeByte(','); - }, - .quiet => {}, - } - // just make sure we didn't fall through by mistake - unreachable; - } - - // if we don't have an error, we must have a result - const result = result_ orelse return error.InvalidResult; - - var cases = &self.cases; - cases.clearRetainingCapacity(); // from previous run - - var pass = true; - var case_pass_count: usize = 0; - var case_fail_count: usize = 0; - - var lines = std.mem.splitScalar(u8, result, '\n'); - while (lines.next()) |line| { - if (line.len == 0) { - break; - } - // case names can have | in them, so we can't simply split on | - var case_name = line; - var case_pass = false; // so pessimistic! - var case_message: []const u8 = ""; - - if (std.mem.endsWith(u8, line, "|Pass")) { - case_name = line[0 .. line.len - 5]; - case_pass = true; - case_pass_count += 1; - } else { - // both cases names and messages can have | in them. Our only - // chance to "parse" this is to anchor off the |$Status. - const statuses = [_][]const u8{ "|Fail", "|Timeout", "|Not Run", "|Optional Feature Unsupported" }; - var pos_: ?usize = null; - var message_start: usize = 0; - for (statuses) |status| { - if (std.mem.indexOf(u8, line, status)) |idx| { - pos_ = idx; - message_start = idx + status.len; - break; - } - } - const pos = pos_ orelse { - std.debug.print("invalid result line: {s}\n", .{line}); - return error.InvalidResult; - }; - - case_name = line[0..pos]; - case_message = line[message_start..]; - pass = false; - case_fail_count += 1; - } - - try cases.append(self.allocator, .{ - .name = case_name, - .pass = case_pass, - .message = case_message, - }); - } - - // our global counters - if (pass) { - self.pass_count += 1; - } else { - self.fail_count += 1; - } - self.case_pass_count += case_pass_count; - self.case_fail_count += case_fail_count; - - switch (self.format) { - .summary => try writer.print("{s} {d}/{d}\t{s}\n", .{ statusText(pass), case_pass_count, case_pass_count + case_fail_count, test_file }), - .text => { - try writer.print("{s}\t{s}\n", .{ statusText(pass), test_file }); - for (cases.items) |c| { - try writer.print("\t{s}\t{s}\n", .{ statusText(c.pass), c.name }); - if (c.message) |msg| { - try writer.print("\t\t{s}\n", .{msg}); - } - } - }, - .json => { - try std.json.Stringify.value(Test{ - .pass = pass, - .name = test_file, - .cases = cases.items, - }, .{ .whitespace = .indent_2 }, writer); - // separator, see `finalize` for the hack we use to terminate this - try writer.writeByte(','); - }, - .quiet => {}, - } - } - - fn statusText(pass: bool) []const u8 { - return if (pass) "Pass" else "Fail"; - } -}; - -const Command = struct { - format: Writer.Format, - filters: [][]const u8, -}; - -fn parseArgs(arena: Allocator) !Command { - const usage = - \\usage: {s} [options] [test filter] - \\ Run the Web Test Platform. - \\ - \\ -h, --help Print this help message and exit. - \\ --json result is formatted in JSON. - \\ --summary print a summary result. Incompatible w/ --json or --quiet - \\ --quiet No output. Incompatible w/ --json or --summary - \\ - ; - - var args = try std.process.argsWithAllocator(arena); - - // get the exec name. - const exec_name = args.next().?; - - var format = Writer.Format.text; - var filters: std.ArrayList([]const u8) = .{}; - - while (args.next()) |arg| { - if (std.mem.eql(u8, "-h", arg) or std.mem.eql(u8, "--help", arg)) { - std.debug.print(usage, .{exec_name}); - std.posix.exit(0); - } - - if (std.mem.eql(u8, "--json", arg)) { - format = .json; - } else if (std.mem.eql(u8, "--summary", arg)) { - format = .summary; - } else if (std.mem.eql(u8, "--quiet", arg)) { - format = .quiet; - } else { - try filters.append(arena, arg); - } - } - - return .{ - .format = format, - .filters = filters.items, - }; -} - -const TestIterator = struct { - dir: Dir, - walker: Dir.Walker, - filters: [][]const u8, - read_arena: ArenaAllocator, - - const Dir = std.fs.Dir; - - fn init(allocator: Allocator, root: []const u8, filters: [][]const u8) !TestIterator { - var dir = try std.fs.cwd().openDir(root, .{ .iterate = true, .no_follow = true }); - errdefer dir.close(); - - return .{ - .dir = dir, - .filters = filters, - .walker = try dir.walk(allocator), - .read_arena = ArenaAllocator.init(allocator), - }; - } - - fn deinit(self: *TestIterator) void { - self.walker.deinit(); - self.dir.close(); - self.read_arena.deinit(); - } - - fn next(self: *TestIterator) !?[]const u8 { - NEXT: while (try self.walker.next()) |entry| { - if (entry.kind != .file) { - continue; - } - - if (std.mem.startsWith(u8, entry.path, "resources/")) { - // resources for running the tests themselves, not actual tests - continue; - } - - if (!std.mem.endsWith(u8, entry.basename, ".html") and !std.mem.endsWith(u8, entry.basename, ".htm")) { - continue; - } - - const path = entry.path; - for (self.filters) |filter| { - if (std.mem.indexOf(u8, path, filter) == null) { - continue :NEXT; - } - } - - { - defer _ = self.read_arena.reset(.retain_capacity); - // We need to read the file's content to see if there's a - // "testharness.js" in it. If there isn't, it isn't a test. - // Shame we have to do this. - - const arena = self.read_arena.allocator(); - const full_path = try std.fs.path.join(arena, &.{ WPT_DIR, path }); - const file = try std.fs.cwd().openFile(full_path, .{}); - defer file.close(); - const html = try file.readToEndAlloc(arena, 128 * 1024); - - if (std.mem.indexOf(u8, html, "testharness.js") == null) { - // This isn't a test. A lot of files are helpers/content for tests to - // make use of. - continue :NEXT; - } - } - - return path; - } - - return null; - } -}; - -const Case = struct { - pass: bool, - name: []const u8, - message: ?[]const u8, -}; - -const Test = struct { - pass: bool, - crash: bool = false, - name: []const u8, - cases: []Case, -}; - -const TestHTTPServer = struct { - shutdown: bool, - dir: std.fs.Dir, - listener: ?std.net.Server, - - pub fn init() !TestHTTPServer { - return .{ - .dir = try std.fs.cwd().openDir(WPT_DIR, .{}), - .shutdown = true, - .listener = null, - }; - } - - pub fn deinit(self: *TestHTTPServer) void { - self.shutdown = true; - if (self.listener) |*listener| { - listener.deinit(); - } - self.dir.close(); - } - - pub fn run(self: *TestHTTPServer, wg: *std.Thread.WaitGroup) !void { - const address = try std.net.Address.parseIp("127.0.0.1", 9582); - - self.listener = try address.listen(.{ .reuse_address = true }); - var listener = &self.listener.?; - - wg.finish(); - - while (true) { - const conn = listener.accept() catch |err| { - if (self.shutdown) { - return; - } - return err; - }; - const thrd = try std.Thread.spawn(.{}, handleConnection, .{ self, conn }); - thrd.detach(); - } - } - - fn handleConnection(self: *TestHTTPServer, conn: std.net.Server.Connection) !void { - defer conn.stream.close(); - - var req_buf: [2048]u8 = undefined; - var conn_reader = conn.stream.reader(&req_buf); - var conn_writer = conn.stream.writer(&req_buf); - - var http_server = std.http.Server.init(conn_reader.interface(), &conn_writer.interface); - - while (true) { - var req = http_server.receiveHead() catch |err| switch (err) { - error.ReadFailed => continue, - error.HttpConnectionClosing => continue, - else => { - std.debug.print("Test HTTP Server error: {}\n", .{err}); - return err; - }, - }; - - self.handler(&req) catch |err| { - std.debug.print("test http error '{s}': {}\n", .{ req.head.target, err }); - try req.respond("server error", .{ .status = .internal_server_error }); - return; - }; - } - } - - fn handler(server: *TestHTTPServer, req: *std.http.Server.Request) !void { - const path = req.head.target; - - if (std.mem.eql(u8, path, "/")) { - // There's 1 test that does an XHR request to this, and it just seems - // to want a 200 success. - return req.respond("Hello!", .{}); - } - - // strip out leading '/' to make the path relative - const file = try server.dir.openFile(path[1..], .{}); - defer file.close(); - - const stat = try file.stat(); - var send_buffer: [4096]u8 = undefined; - - var res = try req.respondStreaming(&send_buffer, .{ - .content_length = stat.size, - .respond_options = .{ - .extra_headers = &.{ - .{ .name = "content-type", .value = getContentType(path) }, - }, - }, - }); - - var read_buffer: [4096]u8 = undefined; - var reader = file.reader(&read_buffer); - _ = try res.writer.sendFileAll(&reader, .unlimited); - try res.writer.flush(); - try res.end(); - } - - pub fn sendFile(req: *std.http.Server.Request, file_path: []const u8) !void { - var file = std.fs.cwd().openFile(file_path, .{}) catch |err| switch (err) { - error.FileNotFound => return req.respond("server error", .{ .status = .not_found }), - else => return err, - }; - defer file.close(); - - const stat = try file.stat(); - var send_buffer: [4096]u8 = undefined; - - var res = try req.respondStreaming(&send_buffer, .{ - .content_length = stat.size, - .respond_options = .{ - .extra_headers = &.{ - .{ .name = "content-type", .value = getContentType(file_path) }, - }, - }, - }); - - var read_buffer: [4096]u8 = undefined; - var reader = file.reader(&read_buffer); - _ = try res.writer.sendFileAll(&reader, .unlimited); - try res.writer.flush(); - try res.end(); - } - - fn getContentType(file_path: []const u8) []const u8 { - if (std.mem.endsWith(u8, file_path, ".js")) { - return "application/json"; - } - - if (std.mem.endsWith(u8, file_path, ".html")) { - return "text/html"; - } - - if (std.mem.endsWith(u8, file_path, ".htm")) { - return "text/html"; - } - - if (std.mem.endsWith(u8, file_path, ".xml")) { - // some wpt tests do this - return "text/xml"; - } - - if (std.mem.endsWith(u8, file_path, ".mjs")) { - // mjs are ECMAScript modules - return "application/json"; - } - - std.debug.print("TestHTTPServer asked to serve an unknown file type: {s}\n", .{file_path}); - return "text/html"; - } -}; - -pub const panic = std.debug.FullPanic(struct { - pub fn panicFn(msg: []const u8, first_trace_addr: ?usize) noreturn { - if (current_test) |ct| { - std.debug.print("===panic running: {s}===\n", .{ct}); - } - std.debug.defaultPanic(msg, first_trace_addr); - } -}.panicFn); diff --git a/tests/wpt b/tests/wpt deleted file mode 160000 index 69c6afab..00000000 --- a/tests/wpt +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 69c6afabd893c00c7cb982ba66306e1a68db90c3 From 25c941b847203c5e0585110214e76c66b8e918a2 Mon Sep 17 00:00:00 2001 From: Pierre Tachoire Date: Tue, 24 Feb 2026 12:06:37 +0100 Subject: [PATCH 085/103] use wptrunner and wpt HTTP server to run wpt tests --- .github/workflows/wpt.yml | 88 +++++++++++++++++++++++++++++++++++---- 1 file changed, 81 insertions(+), 7 deletions(-) diff --git a/.github/workflows/wpt.yml b/.github/workflows/wpt.yml index ecea3cc4..24b0721b 100644 --- a/.github/workflows/wpt.yml +++ b/.github/workflows/wpt.yml @@ -15,11 +15,11 @@ on: workflow_dispatch: jobs: - wpt: - name: web platform tests json output + wpt-build-release: + name: zig build release runs-on: ubuntu-latest - timeout-minutes: 90 + timeout-minutes: 15 steps: - uses: actions/checkout@v6 @@ -30,11 +30,85 @@ jobs: - uses: ./.github/actions/install - - name: build wpt - run: zig build -Dprebuilt_v8_path=v8/libc_v8.a -Doptimize=ReleaseFast -- version + - name: zig build release + run: zig build -Dprebuilt_v8_path=v8/libc_v8.a -Doptimize=ReleaseFast + + - name: upload artifact + uses: actions/upload-artifact@v4 + with: + name: lightpanda-build-release + path: | + zig-out/bin/lightpanda + retention-days: 1 + + wpt-build-runner: + name: build wpt runner + + runs-on: ubuntu-latest + timeout-minutes: 90 + + steps: + - uses: actions/checkout@v6 + with: + repository: 'lightpanda-io/demo' + fetch-depth: 0 + + - run: | + cd ./wptrunner + CGO_ENABLED=0 go build + + - name: upload artifact + uses: actions/upload-artifact@v4 + with: + name: wptrunner + path: | + wptrunner/wptrunner + retention-days: 1 + + run-wpt: + name: web platform tests json output + needs: + - wpt-build-release + - wpt-build-runner + + runs-on: ubuntu-latest + timeout-minutes: 90 + + steps: + - uses: actions/checkout@v6 + with: + ref: fork + repository: 'lightpanda-io/wpt' + fetch-depth: 0 + + - name: create custom hosts + run: ./wpt make-hosts-file | sudo tee -a /etc/hosts + + - name: generate manifest + run: ./wpt manifest + + - name: download lightpanda release + uses: actions/download-artifact@v4 + with: + name: lightpanda-build-release + + - run: chmod a+x ./lightpanda + + - name: download wptrunner + uses: actions/download-artifact@v4 + with: + name: wptrunner + + - run: chmod a+x ./wptrunner - name: run test with json output - run: zig-out/bin/lightpanda-wpt --json > wpt.json + run: | + ./wpt serve & echo $! > WPT.pid + sleep 10s + ./lightpanda serve & echo $! > LPD.pid + sleep 1s + ./wptrunner -json -concurrency 2 > wpt.json + kill `cat WPT.pid` `cat LPD.pid` - name: write commit run: | @@ -51,7 +125,7 @@ jobs: perf-fmt: name: perf-fmt - needs: wpt + needs: run-wpt runs-on: ubuntu-latest timeout-minutes: 15 From 3f92e388be195183ffce504c1ae225ad63277282 Mon Sep 17 00:00:00 2001 From: Pierre Tachoire Date: Wed, 25 Feb 2026 08:55:35 +0100 Subject: [PATCH 086/103] allow insecure TLS when running WPT tests --- .github/workflows/wpt.yml | 2 +- README.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/wpt.yml b/.github/workflows/wpt.yml index 24b0721b..a66e6547 100644 --- a/.github/workflows/wpt.yml +++ b/.github/workflows/wpt.yml @@ -105,7 +105,7 @@ jobs: run: | ./wpt serve & echo $! > WPT.pid sleep 10s - ./lightpanda serve & echo $! > LPD.pid + ./lightpanda serve --insecure_disable_tls_host_verification --log_level error & echo $! > LPD.pid sleep 1s ./wptrunner -json -concurrency 2 > wpt.json kill `cat WPT.pid` `cat LPD.pid` diff --git a/README.md b/README.md index 141c8040..92e36cad 100644 --- a/README.md +++ b/README.md @@ -327,7 +327,7 @@ First start the WPT's HTTP server from your `wpt/` clone dir. Run a Lightpanda browser ``` -zig build run +zig build run -- --insecure_disable_tls_host_verification ``` Then you can start the wptrunner from the Demo's clone dir: From de5a7d5b9992b4f283ce352a98e6654bde39f5b4 Mon Sep 17 00:00:00 2001 From: Pierre Tachoire Date: Wed, 25 Feb 2026 15:23:48 +0100 Subject: [PATCH 087/103] wpt: use auo-restart browser feature of wpt runner --- .github/workflows/wpt.yml | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/.github/workflows/wpt.yml b/.github/workflows/wpt.yml index a66e6547..ac606158 100644 --- a/.github/workflows/wpt.yml +++ b/.github/workflows/wpt.yml @@ -31,7 +31,7 @@ jobs: - uses: ./.github/actions/install - name: zig build release - run: zig build -Dprebuilt_v8_path=v8/libc_v8.a -Doptimize=ReleaseFast + run: zig build -Dprebuilt_v8_path=v8/libc_v8.a -Doptimize=ReleaseFast -Dcpu=x86_64 -Dgit_commit=$(git rev-parse --short ${{ github.sha }}) - name: upload artifact uses: actions/upload-artifact@v4 @@ -71,7 +71,8 @@ jobs: - wpt-build-release - wpt-build-runner - runs-on: ubuntu-latest + # use a self host runner. + runs-on: lpd-bench-hetzner timeout-minutes: 90 steps: @@ -103,12 +104,10 @@ jobs: - name: run test with json output run: | - ./wpt serve & echo $! > WPT.pid + ./wpt serve 2> /dev/null & echo $! > WPT.pid sleep 10s - ./lightpanda serve --insecure_disable_tls_host_verification --log_level error & echo $! > LPD.pid - sleep 1s - ./wptrunner -json -concurrency 2 > wpt.json - kill `cat WPT.pid` `cat LPD.pid` + ./wptrunner -lpd-path ./lightpanda -json -concurrency 5 > wpt.json + kill `cat WPT.pid` - name: write commit run: | From a4a7040b9875b5859a8ff2ffe2adc1b1f0dd650a Mon Sep 17 00:00:00 2001 From: Pierre Tachoire Date: Thu, 26 Feb 2026 07:54:15 +0100 Subject: [PATCH 088/103] wpt: configure hosts manually for self host runner --- .github/workflows/wpt.yml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/wpt.yml b/.github/workflows/wpt.yml index ac606158..94a31af6 100644 --- a/.github/workflows/wpt.yml +++ b/.github/workflows/wpt.yml @@ -82,8 +82,9 @@ jobs: repository: 'lightpanda-io/wpt' fetch-depth: 0 - - name: create custom hosts - run: ./wpt make-hosts-file | sudo tee -a /etc/hosts + # The hosts are configured manually on the self host runner. + # - name: create custom hosts + # run: ./wpt make-hosts-file | sudo tee -a /etc/hosts - name: generate manifest run: ./wpt manifest From 178fbf0fca1f072faea828744f70492db909e47a Mon Sep 17 00:00:00 2001 From: Pierre Tachoire Date: Fri, 27 Feb 2026 11:37:44 +0100 Subject: [PATCH 089/103] wpt: reduce concurrency --- .github/workflows/wpt.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/wpt.yml b/.github/workflows/wpt.yml index 94a31af6..38405591 100644 --- a/.github/workflows/wpt.yml +++ b/.github/workflows/wpt.yml @@ -107,7 +107,7 @@ jobs: run: | ./wpt serve 2> /dev/null & echo $! > WPT.pid sleep 10s - ./wptrunner -lpd-path ./lightpanda -json -concurrency 5 > wpt.json + ./wptrunner -lpd-path ./lightpanda -json -concurrency 1 > wpt.json kill `cat WPT.pid` - name: write commit From 7358d48e35e7025b766d9032d214b89f7183408a Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Fri, 27 Feb 2026 18:46:07 +0800 Subject: [PATCH 090/103] Fix our custom element name validation Passes all WPT tests: /custom-elements/registries/valid-custom-element-names.html Also, apply validation to whenDefined, which we were not doing. --- src/browser/webapi/CustomElementRegistry.zig | 29 +++++++++++++------- 1 file changed, 19 insertions(+), 10 deletions(-) diff --git a/src/browser/webapi/CustomElementRegistry.zig b/src/browser/webapi/CustomElementRegistry.zig index 077d229b..6702a52e 100644 --- a/src/browser/webapi/CustomElementRegistry.zig +++ b/src/browser/webapi/CustomElementRegistry.zig @@ -24,6 +24,7 @@ const Page = @import("../Page.zig"); const Node = @import("Node.zig"); const Element = @import("Element.zig"); +const DOMException = @import("DOMException.zig"); const Custom = @import("element/html/Custom.zig"); const CustomElementDefinition = @import("CustomElementDefinition.zig"); @@ -124,6 +125,10 @@ pub fn whenDefined(self: *CustomElementRegistry, name: []const u8, page: *Page) return local.resolvePromise(definition.constructor); } + validateName(name) catch |err| { + return local.rejectPromise(DOMException.fromError(err) orelse unreachable); + }; + const gop = try self._when_defined.getOrPut(page.arena, name); if (gop.found_existing) { return local.toLocal(gop.value_ptr.*).promise(); @@ -200,15 +205,15 @@ pub fn upgradeCustomElement(custom: *Custom, definition: *CustomElementDefinitio fn validateName(name: []const u8) !void { if (name.len == 0) { - return error.InvalidCustomElementName; + return error.SyntaxError; } if (std.mem.indexOf(u8, name, "-") == null) { - return error.InvalidCustomElementName; + return error.SyntaxError; } if (name[0] < 'a' or name[0] > 'z') { - return error.InvalidCustomElementName; + return error.SyntaxError; } const reserved_names = [_][]const u8{ @@ -224,16 +229,20 @@ fn validateName(name: []const u8) !void { for (reserved_names) |reserved| { if (std.mem.eql(u8, name, reserved)) { - return error.InvalidCustomElementName; + return error.SyntaxError; } } for (name) |c| { - const valid = (c >= 'a' and c <= 'z') or - (c >= '0' and c <= '9') or - c == '-'; - if (!valid) { - return error.InvalidCustomElementName; + if (c >= 'A' and c <= 'Z') { + return error.SyntaxError; + } + + // Reject control characters and specific invalid characters + // per elementLocalNameRegex: [^\0\t\n\f\r\u0020/>]* + switch (c) { + 0, '\t', '\n', '\r', 0x0C, ' ', '/', '>' => return error.SyntaxError, + else => {}, } } } @@ -250,7 +259,7 @@ pub const JsApi = struct { pub const define = bridge.function(CustomElementRegistry.define, .{ .dom_exception = true }); pub const get = bridge.function(CustomElementRegistry.get, .{ .null_as_undefined = true }); pub const upgrade = bridge.function(CustomElementRegistry.upgrade, .{}); - pub const whenDefined = bridge.function(CustomElementRegistry.whenDefined, .{}); + pub const whenDefined = bridge.function(CustomElementRegistry.whenDefined, .{ .dom_exception = true }); }; const testing = @import("../../testing.zig"); From 6857b746237fda11fd006ce292bf775241a10cfc Mon Sep 17 00:00:00 2001 From: Pierre Tachoire Date: Fri, 27 Feb 2026 14:27:48 +0100 Subject: [PATCH 091/103] accept must accept unpadded data in atob according with https://infra.spec.whatwg.org/#forgiving-base64-decode --- src/browser/tests/window/window.html | 10 ++++++++++ src/browser/webapi/Window.zig | 23 +++++++++++++++++++++-- 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/src/browser/tests/window/window.html b/src/browser/tests/window/window.html index 5d36e74d..8a024089 100644 --- a/src/browser/tests/window/window.html +++ b/src/browser/tests/window/window.html @@ -75,6 +75,16 @@ testing.expectEqual('abc', atob('YWJj')); testing.expectEqual('0123456789', atob('MDEyMzQ1Njc4OQ==')); testing.expectEqual('The quick brown fox jumps over the lazy dog', atob('VGhlIHF1aWNrIGJyb3duIGZveCBqdW1wcyBvdmVyIHRoZSBsYXp5IGRvZw==')); + + // atob must accept unpadded base64 (forgiving-base64 decode per HTML spec) + testing.expectEqual('a', atob('YQ')); // 2 chars, len%4==2, needs '==' + testing.expectEqual('ab', atob('YWI')); // 3 chars, len%4==3, needs '=' + testing.expectEqual('ceil', atob('Y2VpbA')); // 6 chars, len%4==2, needs '==' + + // length % 4 == 1 must still throw + testing.expectError('Error: InvalidCharacterError', () => { + atob('Y'); + }); + + + + + + + + + + + + diff --git a/src/browser/webapi/Window.zig b/src/browser/webapi/Window.zig index 194ce397..079760f3 100644 --- a/src/browser/webapi/Window.zig +++ b/src/browser/webapi/Window.zig @@ -515,6 +515,24 @@ pub fn scrollTo(self: *Window, opts: ScrollToOpts, y: ?i32, page: *Page) !void { ); } +pub fn scrollBy(self: *Window, opts: ScrollToOpts, y: ?i32, page: *Page) !void { + // The scroll is relative to the current position. So compute to new + // absolute position. + var absx: i32 = undefined; + var absy: i32 = undefined; + switch (opts) { + .x => |x| { + absx = @as(i32, @intCast(self._scroll_pos.x)) + x; + absy = @as(i32, @intCast(self._scroll_pos.y)) + (y orelse 0); + }, + .opts => |o| { + absx = @as(i32, @intCast(self._scroll_pos.x)) + o.left; + absy = @as(i32, @intCast(self._scroll_pos.y)) + o.top; + }, + } + return self.scrollTo(.{ .x = absx }, absy, page); +} + pub fn unhandledPromiseRejection(self: *Window, rejection: js.PromiseRejection, page: *Page) !void { if (comptime IS_DEBUG) { log.debug(.js, "unhandled rejection", .{ @@ -784,6 +802,7 @@ pub const JsApi = struct { pub const pageYOffset = bridge.accessor(Window.getScrollY, null, .{}); pub const scrollTo = bridge.function(Window.scrollTo, .{}); pub const scroll = bridge.function(Window.scrollTo, .{}); + pub const scrollBy = bridge.function(Window.scrollBy, .{}); // Return false since we don't have secure-context-only APIs implemented // (webcam, geolocation, clipboard, etc.) @@ -818,3 +837,7 @@ const testing = @import("../../testing.zig"); test "WebApi: Window" { try testing.htmlRunner("window", .{}); } + +test "WebApi: Window scroll" { + try testing.htmlRunner("window_scroll.html", .{}); +} From 1473e58a41114147f73eb3d382630e792229187a Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Sat, 28 Feb 2026 10:32:00 +0800 Subject: [PATCH 094/103] Initialize charset to safe default Fixes a WPT crash (not sure which, but in `/fetch/content-type/`) --- src/browser/Mime.zig | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/src/browser/Mime.zig b/src/browser/Mime.zig index 02ebb9f9..6c51d75f 100644 --- a/src/browser/Mime.zig +++ b/src/browser/Mime.zig @@ -24,10 +24,11 @@ params: []const u8 = "", // IANA defines max. charset value length as 40. // We keep 41 for null-termination since HTML parser expects in this format. charset: [41]u8 = default_charset, -charset_len: usize = 5, +charset_len: usize = default_charset_len, /// String "UTF-8" continued by null characters. -pub const default_charset = .{ 'U', 'T', 'F', '-', '8' } ++ .{0} ** 36; +const default_charset = .{ 'U', 'T', 'F', '-', '8' } ++ .{0} ** 36; +const default_charset_len = 5; /// Mime with unknown Content-Type, empty params and empty charset. pub const unknown = Mime{ .content_type = .{ .unknown = {} } }; @@ -127,8 +128,8 @@ pub fn parse(input: []u8) !Mime { const params = trimLeft(normalized[type_len..]); - var charset: [41]u8 = undefined; - var charset_len: usize = undefined; + var charset: [41]u8 = default_charset; + var charset_len: usize = default_charset_len; var it = std.mem.splitScalar(u8, params, ';'); while (it.next()) |attr| { @@ -435,6 +436,12 @@ test "Mime: parse charset" { .charset = "custom-non-standard-charset-value", .params = "charset=\"custom-non-standard-charset-value\"", }, "text/xml;charset=\"custom-non-standard-charset-value\""); + + try expect(.{ + .content_type = .{ .text_html = {} }, + .charset = "UTF-8", + .params = "x=\"", + }, "text/html;x=\""); } test "Mime: isHTML" { From 7fc6e97cd8a8eab91e00588112df759320674b75 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Sat, 28 Feb 2026 11:22:31 +0800 Subject: [PATCH 095/103] Re-implement forgiving base64 decode without intermediate allocation Was looking at, what I thought was a related issue, and started to extract this code to re-use it (in DataURIs). Realized it could be written without the intermediate allocation. Then I realized the dataURI issue is something else, but wanted to keep this improvement. --- src/browser/webapi/Window.zig | 31 +++++++++++-------------------- 1 file changed, 11 insertions(+), 20 deletions(-) diff --git a/src/browser/webapi/Window.zig b/src/browser/webapi/Window.zig index ed5423ef..897dfaad 100644 --- a/src/browser/webapi/Window.zig +++ b/src/browser/webapi/Window.zig @@ -396,28 +396,19 @@ pub fn btoa(_: *const Window, input: []const u8, page: *Page) ![]const u8 { pub fn atob(_: *const Window, input: []const u8, page: *Page) ![]const u8 { const trimmed = std.mem.trim(u8, input, &std.ascii.whitespace); - // Per HTML spec "forgiving-base64 decode" algorithm: + // Forgiving base64 decode per WHATWG spec: // https://infra.spec.whatwg.org/#forgiving-base64-decode - const padded: []const u8 = switch (trimmed.len % 4) { - 1 => return error.InvalidCharacterError, - 2 => blk: { - const buf = try page.call_arena.alloc(u8, trimmed.len + 2); - @memcpy(buf[0..trimmed.len], trimmed); - buf[trimmed.len] = '='; - buf[trimmed.len + 1] = '='; - break :blk buf; - }, - 3 => blk: { - const buf = try page.call_arena.alloc(u8, trimmed.len + 1); - @memcpy(buf[0..trimmed.len], trimmed); - buf[trimmed.len] = '='; - break :blk buf; - }, - else => trimmed, - }; - const decoded_len = std.base64.standard.Decoder.calcSizeForSlice(padded) catch return error.InvalidCharacterError; + // Remove trailing padding to use standard_no_pad decoder + const unpadded = std.mem.trimRight(u8, trimmed, "="); + + // Length % 4 == 1 is invalid (can't represent valid base64) + if (unpadded.len % 4 == 1) { + return error.InvalidCharacterError; + } + + const decoded_len = std.base64.standard_no_pad.Decoder.calcSizeForSlice(unpadded) catch return error.InvalidCharacterError; const decoded = try page.call_arena.alloc(u8, decoded_len); - std.base64.standard.Decoder.decode(decoded, padded) catch return error.InvalidCharacterError; + std.base64.standard_no_pad.Decoder.decode(decoded, unpadded) catch return error.InvalidCharacterError; return decoded; } From 3d51667fc89876c901e97d6c705d59a9c3e35e1c Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Sat, 28 Feb 2026 12:24:26 +0800 Subject: [PATCH 096/103] Escape DataURIs Support forgiving base64 decoder Support non-encoded DataURIs --- src/browser/ScriptManager.zig | 36 +++++--- src/browser/URL.zig | 92 +++++++++++++++++++ .../tests/legacy/html/script/script.html | 10 ++ 3 files changed, 126 insertions(+), 12 deletions(-) diff --git a/src/browser/ScriptManager.zig b/src/browser/ScriptManager.zig index 8334a1a7..403a1199 100644 --- a/src/browser/ScriptManager.zig +++ b/src/browser/ScriptManager.zig @@ -1051,23 +1051,35 @@ fn parseDataURI(allocator: Allocator, src: []const u8) !?[]const u8 { const uri = src[5..]; const data_starts = std.mem.indexOfScalar(u8, uri, ',') orelse return null; + const data = uri[data_starts + 1 ..]; - var data = uri[data_starts + 1 ..]; + const unescaped = try URL.unescape(allocator, data); - // Extract the encoding. const metadata = uri[0..data_starts]; - if (std.mem.endsWith(u8, metadata, ";base64")) { - const decoder = std.base64.standard.Decoder; - const decoded_size = try decoder.calcSizeForSlice(data); - - const buffer = try allocator.alloc(u8, decoded_size); - errdefer allocator.free(buffer); - - try decoder.decode(buffer, data); - data = buffer; + if (std.mem.endsWith(u8, metadata, ";base64") == false) { + return unescaped; } - return data; + // Forgiving base64 decode per WHATWG spec: + // https://infra.spec.whatwg.org/#forgiving-base64-decode + // Step 1: Remove all ASCII whitespace + var stripped = try std.ArrayList(u8).initCapacity(allocator, unescaped.len); + for (unescaped) |c| { + if (!std.ascii.isWhitespace(c)) { + stripped.appendAssumeCapacity(c); + } + } + const trimmed = std.mem.trimRight(u8, stripped.items, "="); + + // Length % 4 == 1 is invalid + if (trimmed.len % 4 == 1) { + return error.InvalidCharacterError; + } + + const decoded_size = std.base64.standard_no_pad.Decoder.calcSizeForSlice(trimmed) catch return error.InvalidCharacterError; + const buffer = try allocator.alloc(u8, decoded_size); + std.base64.standard_no_pad.Decoder.decode(buffer, trimmed) catch return error.InvalidCharacterError; + return buffer; } const testing = @import("../testing.zig"); diff --git a/src/browser/URL.zig b/src/browser/URL.zig index 6616d636..b8d8d563 100644 --- a/src/browser/URL.zig +++ b/src/browser/URL.zig @@ -642,6 +642,33 @@ pub fn getRobotsUrl(arena: Allocator, url: [:0]const u8) ![:0]const u8 { ); } +pub fn unescape(arena: Allocator, input: []const u8) ![]const u8 { + if (std.mem.indexOfScalar(u8, input, '%') == null) { + return input; + } + + var result = try std.ArrayList(u8).initCapacity(arena, input.len); + + var i: usize = 0; + while (i < input.len) { + if (input[i] == '%' and i + 2 < input.len) { + const hex = input[i + 1 .. i + 3]; + const byte = std.fmt.parseInt(u8, hex, 16) catch { + result.appendAssumeCapacity(input[i]); + i += 1; + continue; + }; + result.appendAssumeCapacity(byte); + i += 3; + } else { + result.appendAssumeCapacity(input[i]); + i += 1; + } + } + + return result.items; +} + const testing = @import("../testing.zig"); test "URL: isCompleteHTTPUrl" { try testing.expectEqual(true, isCompleteHTTPUrl("http://example.com/about")); @@ -1233,3 +1260,68 @@ test "URL: getRobotsUrl" { try testing.expectString("https://example.com/robots.txt", url); } } + +test "URL: unescape" { + defer testing.reset(); + const arena = testing.arena_allocator; + + { + const result = try unescape(arena, "hello world"); + try testing.expectEqual("hello world", result); + } + + { + const result = try unescape(arena, "hello%20world"); + try testing.expectEqual("hello world", result); + } + + { + const result = try unescape(arena, "%48%65%6c%6c%6f"); + try testing.expectEqual("Hello", result); + } + + { + const result = try unescape(arena, "%48%65%6C%6C%6F"); + try testing.expectEqual("Hello", result); + } + + { + const result = try unescape(arena, "a%3Db"); + try testing.expectEqual("a=b", result); + } + + { + const result = try unescape(arena, "a%3DB"); + try testing.expectEqual("a=B", result); + } + + { + const result = try unescape(arena, "ZDIgPSAndHdvJzs%3D"); + try testing.expectEqual("ZDIgPSAndHdvJzs=", result); + } + + { + const result = try unescape(arena, "%5a%44%4d%67%50%53%41%6e%64%47%68%79%5a%57%55%6e%4f%77%3D%3D"); + try testing.expectEqual("ZDMgPSAndGhyZWUnOw==", result); + } + + { + const result = try unescape(arena, "hello%2world"); + try testing.expectEqual("hello%2world", result); + } + + { + const result = try unescape(arena, "hello%ZZworld"); + try testing.expectEqual("hello%ZZworld", result); + } + + { + const result = try unescape(arena, "hello%"); + try testing.expectEqual("hello%", result); + } + + { + const result = try unescape(arena, "hello%2"); + try testing.expectEqual("hello%2", result); + } +} diff --git a/src/browser/tests/legacy/html/script/script.html b/src/browser/tests/legacy/html/script/script.html index 5049e4bb..d5910a62 100644 --- a/src/browser/tests/legacy/html/script/script.html +++ b/src/browser/tests/legacy/html/script/script.html @@ -19,3 +19,13 @@ + + + + + + + + + + From e65667963fe1832dd3eb84661df91606146cf1ec Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Sat, 28 Feb 2026 12:48:45 +0800 Subject: [PATCH 097/103] Correctly JSON encode URL I think this code comes from some serialization tweak from when everything was an std.Uri and by switch to [:0]const u8 everywhere not only was the tweak unecessary, it was also wrong - possibly resulting in the generation of invalid JSON. --- src/cdp/domains/network.zig | 22 +++------------------- 1 file changed, 3 insertions(+), 19 deletions(-) diff --git a/src/cdp/domains/network.zig b/src/cdp/domains/network.zig index bd175978..bad29b2d 100644 --- a/src/cdp/domains/network.zig +++ b/src/cdp/domains/network.zig @@ -300,29 +300,19 @@ pub const TransferAsRequestWriter = struct { self._jsonStringify(jws) catch return error.WriteFailed; } fn _jsonStringify(self: *const TransferAsRequestWriter, jws: anytype) !void { - const writer = jws.writer; const transfer = self.transfer; try jws.beginObject(); { try jws.objectField("url"); - try jws.beginWriteRaw(); - try writer.writeByte('\"'); - // #ZIGDOM shouldn't include the hash? - try writer.writeAll(transfer.url); - try writer.writeByte('\"'); - jws.endWriteRaw(); + try jws.write(transfer.url); } { const frag = URL.getHash(transfer.url); if (frag.len > 0) { try jws.objectField("urlFragment"); - try jws.beginWriteRaw(); - try writer.writeAll("\"#"); - try writer.writeAll(frag); - try writer.writeByte('\"'); - jws.endWriteRaw(); + try jws.write(frag); } } @@ -366,18 +356,12 @@ const TransferAsResponseWriter = struct { } fn _jsonStringify(self: *const TransferAsResponseWriter, jws: anytype) !void { - const writer = jws.writer; const transfer = self.transfer; try jws.beginObject(); { try jws.objectField("url"); - try jws.beginWriteRaw(); - try writer.writeByte('\"'); - // #ZIGDOM shouldn't include the hash? - try writer.writeAll(transfer.url); - try writer.writeByte('\"'); - jws.endWriteRaw(); + try jws.write(transfer.url); } if (transfer.response_header) |*rh| { From 059fb85e22dd516dc56cf5319c11096600335b9a Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Sat, 28 Feb 2026 14:42:43 +0800 Subject: [PATCH 098/103] Escape XHR URL, Lax MIME parameter parsing Follow up to https://github.com/lightpanda-io/browser/pull/1646 applies the same change to XHR URLs. Following specs, ignores unknown/invalid parameters of the Content-Type when parsing the MIME (rather than rejecting the entire header). --- src/browser/Mime.zig | 27 ++++++++++++++++++----- src/browser/webapi/element/Html.zig | 2 +- src/browser/webapi/net/XMLHttpRequest.zig | 2 +- 3 files changed, 23 insertions(+), 8 deletions(-) diff --git a/src/browser/Mime.zig b/src/browser/Mime.zig index 6c51d75f..43ca3632 100644 --- a/src/browser/Mime.zig +++ b/src/browser/Mime.zig @@ -133,12 +133,12 @@ pub fn parse(input: []u8) !Mime { var it = std.mem.splitScalar(u8, params, ';'); while (it.next()) |attr| { - const i = std.mem.indexOfScalarPos(u8, attr, 0, '=') orelse return error.Invalid; + const i = std.mem.indexOfScalarPos(u8, attr, 0, '=') orelse continue; const name = trimLeft(attr[0..i]); const value = trimRight(attr[i + 1 ..]); if (value.len == 0) { - return error.Invalid; + continue; } const attribute_name = std.meta.stringToEnum(enum { @@ -151,7 +151,7 @@ pub fn parse(input: []u8) !Mime { break; } - const attribute_value = try parseCharset(value); + const attribute_value = parseCharset(value) catch continue; @memcpy(charset[0..attribute_value.len], attribute_value); // Null-terminate right after attribute value. charset[attribute_value.len] = 0; @@ -335,6 +335,19 @@ test "Mime: invalid" { "text/ html", "text / html", "text/html other", + }; + + for (invalids) |invalid| { + const mutable_input = try testing.arena_allocator.dupe(u8, invalid); + try testing.expectError(error.Invalid, Mime.parse(mutable_input)); + } +} + +test "Mime: malformed parameters are ignored" { + defer testing.reset(); + + // These should all parse successfully as text/html with malformed params ignored + const valid_with_malformed_params = [_][]const u8{ "text/html; x", "text/html; x=", "text/html; x= ", @@ -343,11 +356,13 @@ test "Mime: invalid" { "text/html; charset=\"\"", "text/html; charset=\"", "text/html; charset=\"\\", + "text/html;\"", }; - for (invalids) |invalid| { - const mutable_input = try testing.arena_allocator.dupe(u8, invalid); - try testing.expectError(error.Invalid, Mime.parse(mutable_input)); + for (valid_with_malformed_params) |input| { + const mutable_input = try testing.arena_allocator.dupe(u8, input); + const mime = try Mime.parse(mutable_input); + try testing.expectEqual(.text_html, std.meta.activeTag(mime.content_type)); } } diff --git a/src/browser/webapi/element/Html.zig b/src/browser/webapi/element/Html.zig index d6201010..07187384 100644 --- a/src/browser/webapi/element/Html.zig +++ b/src/browser/webapi/element/Html.zig @@ -415,7 +415,7 @@ fn setAttributeListener( ) !void { if (comptime IS_DEBUG) { log.debug(.event, "Html.setAttributeListener", .{ - .type = self._type, + .type = std.meta.activeTag(self._type), .listener_type = listener_type, }); } diff --git a/src/browser/webapi/net/XMLHttpRequest.zig b/src/browser/webapi/net/XMLHttpRequest.zig index fa37ff42..8e839ff6 100644 --- a/src/browser/webapi/net/XMLHttpRequest.zig +++ b/src/browser/webapi/net/XMLHttpRequest.zig @@ -183,7 +183,7 @@ pub fn open(self: *XMLHttpRequest, method_: []const u8, url: [:0]const u8) !void const page = self._page; self._method = try parseMethod(method_); - self._url = try URL.resolve(self._arena, page.base(), url, .{ .always_dupe = true }); + self._url = try URL.resolve(self._arena, page.base(), url, .{ .always_dupe = true, .encode = true }); try self.stateChanged(.opened, page.js.local.?, page); } From b9e4c44d63bd25e2e85ce4a6ac3802caaa924be4 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Sat, 28 Feb 2026 18:05:03 +0800 Subject: [PATCH 099/103] Noop when document.open is called during iframe parsing I'm not sure what the correct behavior is, but this fixes a WPT crash: /html/browsers/sandboxing/sandbox-inherited-from-required-csp.html The issue is iframe-specific as, with an iframe, you document.write can be called during parsing when there's no document._current_script (because it's being executed from the parent). --- src/browser/webapi/Document.zig | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/src/browser/webapi/Document.zig b/src/browser/webapi/Document.zig index 82266306..10af05fc 100644 --- a/src/browser/webapi/Document.zig +++ b/src/browser/webapi/Document.zig @@ -653,11 +653,13 @@ pub fn write(self: *Document, text: []const []const u8, page: *Page) !void { } if (html.len > 0) { - self._script_created_parser.?.read(html) catch |err| { - log.warn(.dom, "document.write parser error", .{ .err = err }); - // was alrady closed - self._script_created_parser = null; - }; + if (self._script_created_parser) |*parser| { + parser.read(html) catch |err| { + log.warn(.dom, "document.write parser error", .{ .err = err }); + // was alrady closed + self._script_created_parser = null; + }; + } } return; } From 45196e022b76391cdc7162618f9bc82d9696bcb9 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Sat, 28 Feb 2026 19:08:58 +0800 Subject: [PATCH 100/103] Add a "wpt" dump mode Adds a not-documented "wpt" mode to --dump which outputs a formatted report.cases. This is meant to make working on a single WPT test case easier, particularly with some coding tool. Claude recommended this output for its own use. Instead of telling claude to start the browser in serve mode, then run the wptrunner, and merge the two outputs (and then stop the server), you can do: zig build run -- fetch --dump wpt "http://localhost:8000/dom/nodes/CharacterData-appendChild.html" (you still need the wpt server up) --- src/Config.zig | 1 + src/lightpanda.zig | 51 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+) diff --git a/src/Config.zig b/src/Config.zig index 2fb59dfc..1e5cc9ab 100644 --- a/src/Config.zig +++ b/src/Config.zig @@ -185,6 +185,7 @@ pub const Serve = struct { pub const DumpFormat = enum { html, markdown, + wpt, }; pub const Fetch = struct { diff --git a/src/lightpanda.zig b/src/lightpanda.zig index ca105ce5..5a30c5ff 100644 --- a/src/lightpanda.zig +++ b/src/lightpanda.zig @@ -105,11 +105,62 @@ pub fn fetch(app: *App, url: [:0]const u8, opts: FetchOpts) !void { switch (mode) { .html => try dump.root(page.window._document, opts.dump, writer, page), .markdown => try markdown.dump(page.window._document.asNode(), .{}, writer, page), + .wpt => try dumpWPT(page, writer), } } try writer.flush(); } +fn dumpWPT(page: *Page, writer: *std.Io.Writer) !void { + var ls: js.Local.Scope = undefined; + page.js.localScope(&ls); + defer ls.deinit(); + + var try_catch: js.TryCatch = undefined; + try_catch.init(&ls.local); + defer try_catch.deinit(); + + // return the detailed result. + const dump_script = + \\ JSON.stringify((() => { + \\ const statuses = ['Pass', 'Fail', 'Timeout', 'Not Run', 'Optional Feature Unsupported']; + \\ const parse = (raw) => { + \\ for (const status of statuses) { + \\ const idx = raw.indexOf('|' + status); + \\ if (idx !== -1) { + \\ const name = raw.slice(0, idx); + \\ const rest = raw.slice(idx + status.length + 1); + \\ const message = rest.length > 0 && rest[0] === '|' ? rest.slice(1) : null; + \\ return { name, status, message }; + \\ } + \\ } + \\ return { name: raw, status: 'Unknown', message: null }; + \\ }; + \\ const cases = Object.values(report.cases).map(parse); + \\ return { + \\ url: window.location.href, + \\ status: report.status, + \\ message: report.message, + \\ summary: { + \\ total: cases.length, + \\ passed: cases.filter(c => c.status === 'Pass').length, + \\ failed: cases.filter(c => c.status === 'Fail').length, + \\ timeout: cases.filter(c => c.status === 'Timeout').length, + \\ notrun: cases.filter(c => c.status === 'Not Run').length, + \\ unsupported: cases.filter(c => c.status === 'Optional Feature Unsupported').length + \\ }, + \\ cases + \\ }; + \\ })(), null, 2) + ; + const value = ls.local.exec(dump_script, "dump_script") catch |err| { + const caught = try_catch.caughtOrError(page.call_arena, err); + return writer.print("Caught error trying to access WPT's report: {f}\n", .{caught}); + }; + try writer.writeAll("== WPT Results==\n"); + try writer.writeAll(try value.toStringSliceWithAlloc(page.call_arena)); +} + pub inline fn assert(ok: bool, comptime ctx: []const u8, args: anytype) void { if (!ok) { if (comptime IS_DEBUG) { From 7c18d857f0e52c33a3869ebc59411d59eb5c3715 Mon Sep 17 00:00:00 2001 From: ireydiak Date: Sat, 28 Feb 2026 12:03:51 -0500 Subject: [PATCH 101/103] chore: removed gitsubmodules from README and Makefile --- Makefile | 7 +------ README.md | 12 ------------ 2 files changed, 1 insertion(+), 18 deletions(-) diff --git a/Makefile b/Makefile index 72dbcb7d..a85d4e69 100644 --- a/Makefile +++ b/Makefile @@ -102,13 +102,8 @@ end2end: # ------------ .PHONY: install -## Install and build dependencies for release -install: install-submodule +install: build data: cd src/data && go run public_suffix_list_gen.go > public_suffix_list.zig -## Init and update git submodule -install-submodule: - @git submodule init && \ - git submodule update diff --git a/README.md b/README.md index 92e36cad..1a860fc7 100644 --- a/README.md +++ b/README.md @@ -220,18 +220,6 @@ For **MacOS**, you need cmake and [Rust](https://rust-lang.org/tools/install/). brew install cmake ``` -### Install Git submodules - -The project uses git submodules for dependencies. - -To init or update the submodules in the `vendor/` directory: - -``` -make install-submodule -``` - -This is an alias for `git submodule init && git submodule update`. - ### Build and run You an build the entire browser with `make build` or `make build-dev` for debug From 84a949e7c76afb57de94391e76970be3bc643a5b Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Sun, 1 Mar 2026 18:00:00 +0800 Subject: [PATCH 102/103] Fix load event for page with no external scripts but with iframes Previously the "load" event happened when all external scripts were done. In the case that there was no external script, the "load" event would fire immediately after parsing. With iframes, it now waits for external script AND iframes to complete but the no-external-script code was never updated to consider iframes and would thus fire load events prematurely. --- src/browser/Page.zig | 5 ----- src/browser/ScriptManager.zig | 6 ------ 2 files changed, 11 deletions(-) diff --git a/src/browser/Page.zig b/src/browser/Page.zig index 1b068374..e2ec7885 100644 --- a/src/browser/Page.zig +++ b/src/browser/Page.zig @@ -866,11 +866,6 @@ fn pageDoneCallback(ctx: *anyopaque) !void { .html => |buf| { parser.parse(buf.items); self._script_manager.staticScriptsDone(); - if (self._script_manager.isDone()) { - // No scripts, or just inline scripts that were already processed - // we need to trigger this ourselves - self.documentIsComplete(); - } self._parse_state = .complete; }, .text => |*buf| { diff --git a/src/browser/ScriptManager.zig b/src/browser/ScriptManager.zig index 403a1199..61d4aef0 100644 --- a/src/browser/ScriptManager.zig +++ b/src/browser/ScriptManager.zig @@ -582,12 +582,6 @@ fn evaluate(self: *ScriptManager) void { } } -pub fn isDone(self: *const ScriptManager) bool { - return self.static_scripts_done and // page is done processing initial html - self.defer_scripts.first == null and // no deferred scripts - self.async_scripts.first == null; // no async scripts -} - fn parseImportmap(self: *ScriptManager, script: *const Script) !void { const content = script.source.content(); From 3fb8a143485f13280a984e8d916e5d1387292126 Mon Sep 17 00:00:00 2001 From: Pierre Tachoire Date: Sun, 1 Mar 2026 11:22:37 +0100 Subject: [PATCH 103/103] ci: reduce log_level for integration test --- .github/workflows/e2e-integration-test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/e2e-integration-test.yml b/.github/workflows/e2e-integration-test.yml index 5bb24d1f..1a0217bb 100644 --- a/.github/workflows/e2e-integration-test.yml +++ b/.github/workflows/e2e-integration-test.yml @@ -63,6 +63,6 @@ jobs: - name: run end to end integration tests run: | - ./lightpanda serve & echo $! > LPD.pid + ./lightpanda serve --log_level error & echo $! > LPD.pid go run integration/main.go kill `cat LPD.pid`