From ec9a2d815519d23154677f3b385c1a4d86111043 Mon Sep 17 00:00:00 2001 From: egrs Date: Sun, 8 Mar 2026 15:44:07 +0100 Subject: [PATCH 1/8] execute dynamically inserted inline script elements Scripts created via createElement('script') with inline content (textContent, text, or innerHTML) and inserted into the DOM now execute per the HTML spec. Previously all dynamically inserted scripts without a src attribute were skipped, breaking most JS framework hydration patterns. --- src/browser/Page.zig | 9 ++-- .../element/html/script/dynamic_inline.html | 54 +++++++++++++++++++ 2 files changed, 60 insertions(+), 3 deletions(-) create mode 100644 src/browser/tests/element/html/script/dynamic_inline.html diff --git a/src/browser/Page.zig b/src/browser/Page.zig index dedb2a6e..8a98aaa0 100644 --- a/src/browser/Page.zig +++ b/src/browser/Page.zig @@ -2908,9 +2908,12 @@ fn nodeIsReady(self: *Page, comptime from_parser: bool, node: *Node) !void { } if (node.is(Element.Html.Script)) |script| { if ((comptime from_parser == false) and script._src.len == 0) { - // script was added via JavaScript, but without a src, don't try - // to execute it (we'll execute it if/when the src is set) - return; + // Script was added via JavaScript without a src attribute. + // Only skip if it has no inline content either — scripts with + // textContent/text should still execute per spec. + if (script.asConstElement().asConstNode().firstChild() == null) { + return; + } } self.scriptAddedCallback(from_parser, script) catch |err| { diff --git a/src/browser/tests/element/html/script/dynamic_inline.html b/src/browser/tests/element/html/script/dynamic_inline.html new file mode 100644 index 00000000..d5cb60f2 --- /dev/null +++ b/src/browser/tests/element/html/script/dynamic_inline.html @@ -0,0 +1,54 @@ + + + + + + + + + + + + + + + From 1cb5d263449f3e405de6385e2b7c080f1379bb24 Mon Sep 17 00:00:00 2001 From: Pierre Tachoire Date: Sun, 8 Mar 2026 12:07:54 +0100 Subject: [PATCH 2/8] wpt: use a pool of browser to run tests --- .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 306f1457..71d485d0 100644 --- a/.github/workflows/wpt.yml +++ b/.github/workflows/wpt.yml @@ -108,7 +108,7 @@ jobs: run: | ./wpt serve 2> /dev/null & echo $! > WPT.pid sleep 10s - ./wptrunner -lpd-path ./lightpanda -json -concurrency 6 > wpt.json + ./wptrunner -lpd-path ./lightpanda -json -concurrency 10 -pool 3 > wpt.json kill `cat WPT.pid` - name: write commit From aa051434cb8ce0c31576ebd16bec52549d7d390a Mon Sep 17 00:00:00 2001 From: egrs Date: Mon, 9 Mar 2026 08:14:41 +0100 Subject: [PATCH 3/8] add FontFace constructor and FontFaceSet.add() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit headless stub for the FontFace API — constructor stores family/source, status is always "loaded", load() resolves immediately. enables sites that use new FontFace() for programmatic font loading (e.g. boursorama). --- src/browser/js/bridge.zig | 1 + src/browser/tests/css/font_face.html | 63 +++++++++++++++++ src/browser/webapi/css/FontFace.zig | 98 ++++++++++++++++++++++++++ src/browser/webapi/css/FontFaceSet.zig | 7 ++ 4 files changed, 169 insertions(+) create mode 100644 src/browser/tests/css/font_face.html create mode 100644 src/browser/webapi/css/FontFace.zig diff --git a/src/browser/js/bridge.zig b/src/browser/js/bridge.zig index 2801f86b..fcbbea21 100644 --- a/src/browser/js/bridge.zig +++ b/src/browser/js/bridge.zig @@ -730,6 +730,7 @@ pub const JsApis = flattenTypes(&.{ @import("../webapi/css/CSSStyleRule.zig"), @import("../webapi/css/CSSStyleSheet.zig"), @import("../webapi/css/CSSStyleProperties.zig"), + @import("../webapi/css/FontFace.zig"), @import("../webapi/css/FontFaceSet.zig"), @import("../webapi/css/MediaQueryList.zig"), @import("../webapi/css/StyleSheetList.zig"), diff --git a/src/browser/tests/css/font_face.html b/src/browser/tests/css/font_face.html new file mode 100644 index 00000000..b515984b --- /dev/null +++ b/src/browser/tests/css/font_face.html @@ -0,0 +1,63 @@ + + + + + + + + + + + + + + + + + + diff --git a/src/browser/webapi/css/FontFace.zig b/src/browser/webapi/css/FontFace.zig new file mode 100644 index 00000000..2196350f --- /dev/null +++ b/src/browser/webapi/css/FontFace.zig @@ -0,0 +1,98 @@ +const js = @import("../../js/js.zig"); +const Page = @import("../../Page.zig"); + +const FontFace = @This(); + +_family: []const u8, +_source: []const u8, +_style: []const u8, +_weight: []const u8, +_stretch: []const u8, +_unicode_range: []const u8, +_variant: []const u8, +_feature_settings: []const u8, +_display: []const u8, + +pub fn init(family: []const u8, source: []const u8, page: *Page) !*FontFace { + return page._factory.create(FontFace{ + ._family = try page.dupeString(family), + ._source = try page.dupeString(source), + ._style = "normal", + ._weight = "normal", + ._stretch = "normal", + ._unicode_range = "U+0-10FFFF", + ._variant = "normal", + ._feature_settings = "normal", + ._display = "auto", + }); +} + +pub fn getFamily(self: *const FontFace) []const u8 { + return self._family; +} + +pub fn getStyle(self: *const FontFace) []const u8 { + return self._style; +} + +pub fn getWeight(self: *const FontFace) []const u8 { + return self._weight; +} + +pub fn getStretch(self: *const FontFace) []const u8 { + return self._stretch; +} + +pub fn getUnicodeRange(self: *const FontFace) []const u8 { + return self._unicode_range; +} + +pub fn getVariant(self: *const FontFace) []const u8 { + return self._variant; +} + +pub fn getFeatureSettings(self: *const FontFace) []const u8 { + return self._feature_settings; +} + +pub fn getDisplay(self: *const FontFace) []const u8 { + return self._display; +} + +// load() - resolves immediately; headless browser has no real font loading. +pub fn load(_: *FontFace, page: *Page) !js.Promise { + return page.js.local.?.resolvePromise({}); +} + +// loaded - returns an already-resolved Promise. +pub fn getLoaded(_: *FontFace, page: *Page) !js.Promise { + return page.js.local.?.resolvePromise({}); +} + +pub const JsApi = struct { + pub const bridge = js.Bridge(FontFace); + + pub const Meta = struct { + pub const name = "FontFace"; + pub const prototype_chain = bridge.prototypeChain(); + pub var class_id: bridge.ClassId = undefined; + }; + + pub const constructor = bridge.constructor(FontFace.init, .{}); + pub const family = bridge.accessor(FontFace.getFamily, null, .{}); + pub const style = bridge.accessor(FontFace.getStyle, null, .{}); + pub const weight = bridge.accessor(FontFace.getWeight, null, .{}); + pub const stretch = bridge.accessor(FontFace.getStretch, null, .{}); + pub const unicodeRange = bridge.accessor(FontFace.getUnicodeRange, null, .{}); + pub const variant = bridge.accessor(FontFace.getVariant, null, .{}); + pub const featureSettings = bridge.accessor(FontFace.getFeatureSettings, null, .{}); + pub const display = bridge.accessor(FontFace.getDisplay, null, .{}); + pub const status = bridge.property("loaded", .{ .template = false, .readonly = true }); + pub const loaded = bridge.accessor(FontFace.getLoaded, null, .{}); + pub const load = bridge.function(FontFace.load, .{}); +}; + +const testing = @import("../../../testing.zig"); +test "WebApi: FontFace" { + try testing.htmlRunner("css/font_face.html", .{}); +} diff --git a/src/browser/webapi/css/FontFaceSet.zig b/src/browser/webapi/css/FontFaceSet.zig index 28703080..c91ebd58 100644 --- a/src/browser/webapi/css/FontFaceSet.zig +++ b/src/browser/webapi/css/FontFaceSet.zig @@ -1,6 +1,7 @@ const std = @import("std"); const js = @import("../../js/js.zig"); const Page = @import("../../Page.zig"); +const FontFace = @import("FontFace.zig"); const FontFaceSet = @This(); @@ -29,6 +30,11 @@ pub fn load(_: *FontFaceSet, font: []const u8, page: *Page) !js.Promise { return page.js.local.?.resolvePromise({}); } +// add(fontFace) - no-op; headless browser does not track loaded fonts. +pub fn add(self: *FontFaceSet, _: *FontFace) *FontFaceSet { + return self; +} + pub const JsApi = struct { pub const bridge = js.Bridge(FontFaceSet); @@ -43,6 +49,7 @@ pub const JsApi = struct { pub const ready = bridge.accessor(FontFaceSet.getReady, null, .{}); pub const check = bridge.function(FontFaceSet.check, .{}); pub const load = bridge.function(FontFaceSet.load, .{}); + pub const add = bridge.function(FontFaceSet.add, .{}); }; const testing = @import("../../../testing.zig"); From 245a92a644e388740314f6d22e608e80da891740 Mon Sep 17 00:00:00 2001 From: egrs Date: Mon, 9 Mar 2026 08:31:54 +0100 Subject: [PATCH 4/8] use node.firstChild() directly per review feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit node is already available in scope — no need to traverse back through script.asConstElement().asConstNode(). --- src/browser/Page.zig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/browser/Page.zig b/src/browser/Page.zig index 8a98aaa0..78195490 100644 --- a/src/browser/Page.zig +++ b/src/browser/Page.zig @@ -2911,7 +2911,7 @@ fn nodeIsReady(self: *Page, comptime from_parser: bool, node: *Node) !void { // Script was added via JavaScript without a src attribute. // Only skip if it has no inline content either — scripts with // textContent/text should still execute per spec. - if (script.asConstElement().asConstNode().firstChild() == null) { + if (node.firstChild() == null) { return; } } From 08a7fb4de08a31cfede6ce8a3d94084e9381e00d Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Mon, 9 Mar 2026 15:51:26 +0800 Subject: [PATCH 5/8] Fix leak introduced in inner navigation refactoring A inner-navigate event can override an existing pending queued navigation. When it does, the previously queued navigation has to be cleaned up. We were doing this, but it must have been stripped out when navigation was refactored to work with frames. --- src/browser/Page.zig | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/browser/Page.zig b/src/browser/Page.zig index dedb2a6e..57083e8b 100644 --- a/src/browser/Page.zig +++ b/src/browser/Page.zig @@ -652,6 +652,10 @@ fn scheduleNavigationWithArena(originator: *Page, arena: Allocator, request_url: .navigation_type = std.meta.activeTag(nt), }; + if (target._queued_navigation) |existing| { + target.arena_pool.release(existing.arena); + } + target._queued_navigation = qn; return session.scheduleNavigation(target); } From 8adad6fa61af573aacba917d327ec2302db6182f Mon Sep 17 00:00:00 2001 From: egrs Date: Mon, 9 Mar 2026 08:58:07 +0100 Subject: [PATCH 6/8] fix module re-import when previous compilation failed When a module's compilation fails after its imported_modules entry has been consumed by waitForImport, sibling modules that import the same dependency would get UnknownModule errors. Fix by re-preloading modules whose cache entry exists but has no compiled module. --- src/browser/js/Context.zig | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/src/browser/js/Context.zig b/src/browser/js/Context.zig index 7400744e..9180223c 100644 --- a/src/browser/js/Context.zig +++ b/src/browser/js/Context.zig @@ -535,6 +535,14 @@ fn postCompileModule(self: *Context, mod: js.Module, url: [:0]const u8, local: * nested_gop.key_ptr.* = owned_specifier; nested_gop.value_ptr.* = .{}; try script_manager.preloadImport(owned_specifier, url); + } else if (nested_gop.value_ptr.module == null) { + // Entry exists but module failed to compile previously. + // The imported_modules entry may have been consumed, so + // re-preload to ensure waitForImport can find it. + // Key was stored via dupeZ so it has a sentinel in memory. + const key = nested_gop.key_ptr.*; + const key_z: [:0]const u8 = key.ptr[0..key.len :0]; + try script_manager.preloadImport(key_z, url); } } } @@ -683,7 +691,15 @@ fn _resolveModuleCallback(self: *Context, referrer: js.Module, specifier: [:0]co return local.toLocal(m).handle; } - var source = try self.script_manager.?.waitForImport(normalized_specifier); + var source = self.script_manager.?.waitForImport(normalized_specifier) catch |err| switch (err) { + error.UnknownModule => blk: { + // Module is in cache but was consumed from imported_modules + // (e.g., by a previous failed resolution). Re-preload and retry. + try self.script_manager.?.preloadImport(normalized_specifier, referrer_path); + break :blk try self.script_manager.?.waitForImport(normalized_specifier); + }, + else => return err, + }; defer source.deinit(); var try_catch: js.TryCatch = undefined; From e91da78ebb77b63a6ba09edcdec7ccb45c31efd3 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Mon, 9 Mar 2026 16:08:17 +0800 Subject: [PATCH 7/8] Optimize FontFace Follow up to https://github.com/lightpanda-io/browser/pull/1743 Allow eager cleanup with finalizer. User properties for (what are currently) constants. --- src/browser/webapi/css/FontFace.zig | 100 ++++++++++++------------- src/browser/webapi/css/FontFaceSet.zig | 38 +++++++++- 2 files changed, 81 insertions(+), 57 deletions(-) diff --git a/src/browser/webapi/css/FontFace.zig b/src/browser/webapi/css/FontFace.zig index 2196350f..0da560fc 100644 --- a/src/browser/webapi/css/FontFace.zig +++ b/src/browser/webapi/css/FontFace.zig @@ -1,64 +1,54 @@ +// 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 Allocator = std.mem.Allocator; + const FontFace = @This(); +_arena: Allocator, _family: []const u8, -_source: []const u8, -_style: []const u8, -_weight: []const u8, -_stretch: []const u8, -_unicode_range: []const u8, -_variant: []const u8, -_feature_settings: []const u8, -_display: []const u8, pub fn init(family: []const u8, source: []const u8, page: *Page) !*FontFace { - return page._factory.create(FontFace{ - ._family = try page.dupeString(family), - ._source = try page.dupeString(source), - ._style = "normal", - ._weight = "normal", - ._stretch = "normal", - ._unicode_range = "U+0-10FFFF", - ._variant = "normal", - ._feature_settings = "normal", - ._display = "auto", - }); + _ = source; + + const arena = try page.getArena(.{.debug = "FontFace"}); + errdefer page.releaseArena(arena); + + const self = try arena.create(FontFace); + self.* = .{ + ._arena = arena, + ._family = try arena.dupe(u8, family), + }; + return self; +} + +pub fn deinit(self: *FontFace, _: bool, page: *Page) void { + page.releaseArena(self._arena); } pub fn getFamily(self: *const FontFace) []const u8 { return self._family; } -pub fn getStyle(self: *const FontFace) []const u8 { - return self._style; -} - -pub fn getWeight(self: *const FontFace) []const u8 { - return self._weight; -} - -pub fn getStretch(self: *const FontFace) []const u8 { - return self._stretch; -} - -pub fn getUnicodeRange(self: *const FontFace) []const u8 { - return self._unicode_range; -} - -pub fn getVariant(self: *const FontFace) []const u8 { - return self._variant; -} - -pub fn getFeatureSettings(self: *const FontFace) []const u8 { - return self._feature_settings; -} - -pub fn getDisplay(self: *const FontFace) []const u8 { - return self._display; -} - // load() - resolves immediately; headless browser has no real font loading. pub fn load(_: *FontFace, page: *Page) !js.Promise { return page.js.local.?.resolvePromise({}); @@ -76,18 +66,20 @@ pub const JsApi = struct { pub const name = "FontFace"; pub const prototype_chain = bridge.prototypeChain(); pub var class_id: bridge.ClassId = undefined; + pub const weak = true; + pub const finalizer = bridge.finalizer(FontFace.deinit); }; pub const constructor = bridge.constructor(FontFace.init, .{}); pub const family = bridge.accessor(FontFace.getFamily, null, .{}); - pub const style = bridge.accessor(FontFace.getStyle, null, .{}); - pub const weight = bridge.accessor(FontFace.getWeight, null, .{}); - pub const stretch = bridge.accessor(FontFace.getStretch, null, .{}); - pub const unicodeRange = bridge.accessor(FontFace.getUnicodeRange, null, .{}); - pub const variant = bridge.accessor(FontFace.getVariant, null, .{}); - pub const featureSettings = bridge.accessor(FontFace.getFeatureSettings, null, .{}); - pub const display = bridge.accessor(FontFace.getDisplay, null, .{}); pub const status = bridge.property("loaded", .{ .template = false, .readonly = true }); + pub const style = bridge.property("normal", .{ .template = false, .readonly = true }); + pub const weight = bridge.property("normal", .{ .template = false, .readonly = true }); + pub const stretch = bridge.property("normal", .{ .template = false, .readonly = true }); + pub const unicodeRange = bridge.property("U+0-10FFFF", .{ .template = false, .readonly = true }); + pub const variant = bridge.property("normal", .{ .template = false, .readonly = true }); + pub const featureSettings = bridge.property("normal", .{ .template = false, .readonly = true }); + pub const display = bridge.property("auto", .{ .template = false, .readonly = true }); pub const loaded = bridge.accessor(FontFace.getLoaded, null, .{}); pub const load = bridge.function(FontFace.load, .{}); }; diff --git a/src/browser/webapi/css/FontFaceSet.zig b/src/browser/webapi/css/FontFaceSet.zig index c91ebd58..12f090bd 100644 --- a/src/browser/webapi/css/FontFaceSet.zig +++ b/src/browser/webapi/css/FontFaceSet.zig @@ -1,15 +1,45 @@ +// 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 FontFace = @import("FontFace.zig"); +const Allocator = std.mem.Allocator; + const FontFaceSet = @This(); -// Padding to avoid zero-size struct, which causes identity_map pointer collisions. -_pad: bool = false, +_arena: Allocator, pub fn init(page: *Page) !*FontFaceSet { - return page._factory.create(FontFaceSet{}); + const arena = try page.getArena(.{.debug = "FontFaceSet"}); + errdefer page.releaseArena(arena); + + const self = try arena.create(FontFaceSet); + self.* = .{ + ._arena = arena, + }; + return self; +} + +pub fn deinit(self: *FontFaceSet, _: bool, page: *Page) void { + page.releaseArena(self._arena); } // FontFaceSet.ready - returns an already-resolved Promise. @@ -42,6 +72,8 @@ pub const JsApi = struct { pub const name = "FontFaceSet"; pub const prototype_chain = bridge.prototypeChain(); pub var class_id: bridge.ClassId = undefined; + pub const weak = true; + pub const finalizer = bridge.finalizer(FontFaceSet.deinit); }; pub const size = bridge.property(0, .{ .template = false, .readonly = true }); From 71c4fce87fb30c6e6e55fedb2d458e643cb27b2d Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Mon, 9 Mar 2026 16:39:44 +0800 Subject: [PATCH 8/8] Empty :is() and :where() pseudoselectors are valid (and return nothing) --- src/browser/tests/element/pseudo_classes.html | 11 +++++++++++ src/browser/tests/element/selector_invalid.html | 2 -- src/browser/webapi/selector/Parser.zig | 4 ++-- 3 files changed, 13 insertions(+), 4 deletions(-) diff --git a/src/browser/tests/element/pseudo_classes.html b/src/browser/tests/element/pseudo_classes.html index fe75ab84..355d2ee7 100644 --- a/src/browser/tests/element/pseudo_classes.html +++ b/src/browser/tests/element/pseudo_classes.html @@ -81,6 +81,17 @@ } + +
diff --git a/src/browser/webapi/selector/Parser.zig b/src/browser/webapi/selector/Parser.zig index 376bbcdc..f9b1bb23 100644 --- a/src/browser/webapi/selector/Parser.zig +++ b/src/browser/webapi/selector/Parser.zig @@ -487,7 +487,7 @@ fn pseudoClass(self: *Parser, arena: Allocator, page: *Page) !Selector.PseudoCla if (self.peek() != ')') return error.InvalidPseudoClass; self.input = self.input[1..]; - if (selectors.items.len == 0) return error.InvalidPseudoClass; + // Empty :is() is valid per spec - matches nothing return .{ .is = selectors.items }; } @@ -514,7 +514,7 @@ fn pseudoClass(self: *Parser, arena: Allocator, page: *Page) !Selector.PseudoCla if (self.peek() != ')') return error.InvalidPseudoClass; self.input = self.input[1..]; - if (selectors.items.len == 0) return error.InvalidPseudoClass; + // Empty :where() is valid per spec - matches nothing return .{ .where = selectors.items }; }