diff --git a/src/browser/Session.zig b/src/browser/Session.zig
index 096f00d2..939d5f1f 100644
--- a/src/browser/Session.zig
+++ b/src/browser/Session.zig
@@ -569,30 +569,29 @@ fn processRootQueuedNavigation(self: *Session) !void {
// immediate-swap path for them.
const is_synthetic = qn.is_about_blank or std.mem.startsWith(u8, qn.url, "blob:");
- if (is_synthetic) {
- return self.replaceRootImmediate(current_frame._frame_id, qn);
- }
-
// The qn arena is consumed here regardless of success — frame.navigate
// dupes the URL into the page's own arena, so we can release the qn
// arena as soon as navigate returns.
defer self.arena_pool.release(qn.arena);
+ if (is_synthetic) {
+ return self.replaceRootImmediate(current_frame._frame_id, qn.url, qn.opts);
+ }
return self.initiateRootNavigation(current_frame._frame_id, qn.url, qn.opts);
}
-// Legacy immediate-swap path: tear down the active page and create a new one
-// in its place before issuing the navigation. Used for synthetic navigations
-// (about:blank, blob:) where there is no in-flight HTTP and therefore no
-// "pending" window to span.
-fn replaceRootImmediate(self: *Session, frame_id: u32, qn: *QueuedNavigation) !void {
- defer self.arena_pool.release(qn.arena);
-
+// Immediate-swap path for synthetic navigations (about:blank, blob:): there is
+// no in-flight HTTP and therefore no "pending" window to span — and no
+// frameHeaderDoneCallback to commit a pending Page. Tear down the active page
+// and create a new one in its place, then navigate it. Reached from both the
+// queued-navigation path (processRootQueuedNavigation) and the CDP entry point
+// (initiateRootNavigation); each caller owns any arena tied to `url`/`opts`.
+fn replaceRootImmediate(self: *Session, frame_id: u32, url: [:0]const u8, opts: Frame.NavigateOpts) !void {
self.tearDownActivePage();
const new_frame = try self.installNewActivePage(frame_id);
- new_frame.navigate(qn.url, qn.opts) catch |err| {
- log.err(.browser, "queued navigation error", .{ .err = err });
+ new_frame.navigate(url, opts) catch |err| {
+ log.err(.browser, "synthetic navigation error", .{ .err = err, .url = url });
return err;
};
}
@@ -605,6 +604,14 @@ fn replaceRootImmediate(self: *Session, frame_id: u32, qn: *QueuedNavigation) !v
pub fn initiateRootNavigation(self: *Session, frame_id: u32, url: [:0]const u8, opts: Frame.NavigateOpts) !void {
self.discardPendingPage();
+ // Synthetic navigations (about:blank, blob:) have no HTTP round-trip and
+ // therefore no frameHeaderDoneCallback to commit a pending Page. Swap the
+ // active Page immediately instead of allocating a pending one that would
+ // never be promoted, leaving the previous document in place (issue #2363).
+ if (std.mem.eql(u8, "about:blank", url) or std.mem.startsWith(u8, url, "blob:")) {
+ return self.replaceRootImmediate(frame_id, url, opts);
+ }
+
const page = try self.allocatePage(frame_id);
errdefer self.destroyPage(page);
diff --git a/src/browser/tests/file.html b/src/browser/tests/file.html
index 3db5fdfe..0b301b79 100644
--- a/src/browser/tests/file.html
+++ b/src/browser/tests/file.html
@@ -1,12 +1,52 @@
- Test Document Title
+ Test File Web API
-
+
+
+
+
diff --git a/src/browser/webapi/Blob.zig b/src/browser/webapi/Blob.zig
index 2bead21d..e41c0094 100644
--- a/src/browser/webapi/Blob.zig
+++ b/src/browser/webapi/Blob.zig
@@ -171,7 +171,7 @@ const vector_sizes = blk: {
};
/// Writes a single part with optional line ending normalization.
-fn writePartWithEndings(part: []const u8, use_native_endings: bool, writer: *Writer) !void {
+pub fn writePartWithEndings(part: []const u8, use_native_endings: bool, writer: *Writer) !void {
// Transparent - no conversion needed.
if (!use_native_endings) {
try writer.writeAll(part);
diff --git a/src/browser/webapi/File.zig b/src/browser/webapi/File.zig
index a0dce126..43a28933 100644
--- a/src/browser/webapi/File.zig
+++ b/src/browser/webapi/File.zig
@@ -1,4 +1,4 @@
-// Copyright (C) 2023-2025 Lightpanda (Selecy SAS)
+// Copyright (C) 2023-2026 Lightpanda (Selecy SAS)
//
// Francis Bouvier
// Pierre Tachoire
@@ -27,13 +27,46 @@ const Blob = @import("Blob.zig");
const File = @This();
_proto: *Blob,
+_name: []const u8,
+_last_modified: i64,
-// TODO: Implement File API.
-pub fn init(page: *Page) !*File {
- const session = page.session;
- const arena = try session.getArena(.tiny, "File");
- errdefer session.releaseArena(arena);
- return page.factory.blob(arena, File{ ._proto = undefined });
+pub const InitOptions = struct {
+ type: []const u8 = "",
+ endings: []const u8 = "transparent",
+ lastModified: ?i64 = null,
+};
+
+pub fn init(
+ parts_: ?[]const js.Value,
+ name: []const u8,
+ opts_: ?InitOptions,
+ page: *Page,
+) !*File {
+ const opts = opts_ orelse InitOptions{};
+ const blob = try Blob.init(parts_, .{
+ .type = opts.type,
+ .endings = opts.endings,
+ }, page);
+
+ errdefer blob.deinit(page);
+
+ const file = try blob._arena.create(File);
+ file.* = .{
+ ._proto = blob,
+ ._name = try blob._arena.dupe(u8, name),
+ ._last_modified = opts.lastModified orelse std.time.milliTimestamp(),
+ };
+ blob._type = .{ .file = file };
+
+ return file;
+}
+
+pub fn getName(self: *const File) []const u8 {
+ return self._name;
+}
+
+pub fn getLastModified(self: *const File) f64 {
+ return @floatFromInt(self._last_modified);
}
pub const JsApi = struct {
@@ -46,6 +79,8 @@ pub const JsApi = struct {
};
pub const constructor = bridge.constructor(File.init, .{});
+ pub const name = bridge.accessor(File.getName, null, .{});
+ pub const lastModified = bridge.accessor(File.getLastModified, null, .{});
};
const testing = @import("../../testing.zig");
diff --git a/src/browser/webapi/WebDriver.zig b/src/browser/webapi/WebDriver.zig
index 9b73d819..e21514d6 100644
--- a/src/browser/webapi/WebDriver.zig
+++ b/src/browser/webapi/WebDriver.zig
@@ -20,6 +20,9 @@ const std = @import("std");
const js = @import("../js/js.zig");
const Page = @import("../Page.zig");
+const Frame = @import("../Frame.zig");
+
+const Element = @import("Element.zig");
// This type is only included when the binary is built with the -Dwpt_extensions flag
const WebDriver = @This();
@@ -30,6 +33,12 @@ pub fn deleteAllCookies(_: *const WebDriver, page: *Page) void {
page.session.cookie_jar.clearRetainingCapacity();
}
+pub fn getComputedLabel(_: *const WebDriver, element: *Element, frame: *Frame) ![]const u8 {
+ const AXNode = @import("../../cdp/AXNode.zig");
+ const axnode = AXNode.fromNode(element.asNode());
+ return (try axnode.getName(frame, frame.call_arena)) orelse "";
+}
+
pub const JsApi = struct {
pub const bridge = js.Bridge(WebDriver);
@@ -40,4 +49,5 @@ pub const JsApi = struct {
pub const empty_with_no_proto = true;
};
pub const deleteAllCookies = bridge.function(WebDriver.deleteAllCookies, .{});
+ pub const getComputedLabel = bridge.function(WebDriver.getComputedLabel, .{});
};
diff --git a/src/cdp/domains/page.zig b/src/cdp/domains/page.zig
index bb0274e1..f84e496f 100644
--- a/src/cdp/domains/page.zig
+++ b/src/cdp/domains/page.zig
@@ -1309,6 +1309,42 @@ test "cdp.frame: navigate inherits original fragment across redirect" {
}
}
+test "cdp.frame: navigate to about:blank replaces a non-blank document" {
+ // Regression test for #2363. Page.navigate("about:blank") issued against a
+ // tab that already holds a real document must replace the active document
+ // with a fresh about:blank page — not leave the previous page in place.
+ // A synthetic (no-HTTP) navigation has no response-headers callback to
+ // commit a pending Page, so it must swap the active Page immediately.
+ var ctx = try testing.context();
+ defer ctx.deinit();
+
+ var bc = try ctx.loadBrowserContext(.{ .id = "BID-AB", .url = "hi.html", .target_id = "TID-AB-0000000".* });
+
+ // Precondition: the tab is on a non-blank document.
+ {
+ const frame = bc.session.currentFrame() orelse unreachable;
+ try testing.expect(std.mem.endsWith(u8, frame.url, "/hi.html"));
+ }
+
+ try ctx.processMessage(.{ .id = 70, .method = "Page.navigate", .params = .{ .url = "about:blank" } });
+ {
+ var runner = try bc.session.runner(.{});
+ try runner.wait(.{ .ms = 2000 });
+ }
+
+ // The active frame must now point at the replaced about:blank document.
+ const frame = bc.session.currentFrame() orelse unreachable;
+ try testing.expectEqualSlices(u8, "about:blank", frame.url);
+
+ // ...and the active page's JS context must agree — the exact symptom in the
+ // bug report was window.location.href staying on the previous URL.
+ var ls: js.Local.Scope = undefined;
+ frame.js.localScope(&ls);
+ defer ls.deinit();
+ const v = try ls.local.exec("window.location.href === 'about:blank'", null);
+ try testing.expect(v.toBool());
+}
+
test "cdp.frame: anchor click sends Referer matching the originating page" {
// HTML Living Standard "navigate" algorithm + Fetch §4.5 "request's referrer":
// when a navigation is initiated by a hyperlink click (or form submit, or
diff --git a/src/network/http.zig b/src/network/http.zig
index 8c3fba2c..bec25af5 100644
--- a/src/network/http.zig
+++ b/src/network/http.zig
@@ -204,8 +204,9 @@ pub const AuthChallenge = struct {
.scheme = null,
};
- const pos = std.mem.indexOfPos(u8, std.mem.trim(u8, value, std.ascii.whitespace[0..]), 0, " ") orelse value.len;
- const _scheme = value[0..pos];
+ const challenge_value = std.mem.trim(u8, value, std.ascii.whitespace[0..]);
+ const pos = std.mem.indexOfPos(u8, challenge_value, 0, " ") orelse challenge_value.len;
+ const _scheme = challenge_value[0..pos];
if (std.ascii.eqlIgnoreCase(_scheme, "basic")) {
ac.scheme = .basic;
} else if (std.ascii.eqlIgnoreCase(_scheme, "digest")) {