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
diff --git a/src/browser/Page.zig b/src/browser/Page.zig
index dedb2a6e..0cc7d8d9 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);
}
@@ -2908,9 +2912,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 (node.firstChild() == null) {
+ return;
+ }
}
self.scriptAddedCallback(from_parser, script) catch |err| {
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;
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/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 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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/css/FontFace.zig b/src/browser/webapi/css/FontFace.zig
new file mode 100644
index 00000000..0da560fc
--- /dev/null
+++ b/src/browser/webapi/css/FontFace.zig
@@ -0,0 +1,90 @@
+// 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,
+
+pub fn init(family: []const u8, source: []const u8, page: *Page) !*FontFace {
+ _ = 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;
+}
+
+// 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 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 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, .{});
+};
+
+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..12f090bd 100644
--- a/src/browser/webapi/css/FontFaceSet.zig
+++ b/src/browser/webapi/css/FontFaceSet.zig
@@ -1,14 +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.
@@ -29,6 +60,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);
@@ -36,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 });
@@ -43,6 +81,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");
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 };
}