From c3ad054bb3050b5819f2e45da753ac8bde9387f1 Mon Sep 17 00:00:00 2001 From: Muki Kiboigo Date: Tue, 23 Sep 2025 20:44:51 -0700 Subject: [PATCH 1/6] add toJson object and fromJson value --- src/browser/html/History.zig | 118 +++++++++++++++++++++++++++++++++++ src/browser/html/history.zig | 93 --------------------------- src/browser/html/html.zig | 2 +- src/browser/html/window.zig | 7 +-- src/browser/session.zig | 5 ++ src/runtime/js.zig | 12 ++++ src/tests/html/history.html | 7 +++ 7 files changed, 146 insertions(+), 98 deletions(-) create mode 100644 src/browser/html/History.zig delete mode 100644 src/browser/html/history.zig diff --git a/src/browser/html/History.zig b/src/browser/html/History.zig new file mode 100644 index 00000000..eabae11d --- /dev/null +++ b/src/browser/html/History.zig @@ -0,0 +1,118 @@ +// Copyright (C) 2023-2024 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 Env = @import("../env.zig").Env; +const Page = @import("../page.zig").Page; + +// https://html.spec.whatwg.org/multipage/nav-history-apis.html#the-history-interface +const History = @This(); + +const HistoryEntry = struct { + url: ?[]const u8, + // Serialized Env.JsObject + state: []u8, +}; + +const ScrollRestorationMode = enum { + auto, + manual, + + pub fn fromString(str: []const u8) ?ScrollRestorationMode { + for (std.enums.values(ScrollRestorationMode)) |mode| { + if (std.ascii.eqlIgnoreCase(str, @tagName(mode))) { + return mode; + } + } else { + return null; + } + } +}; + +scrollRestoration: ScrollRestorationMode = .auto, +stack: std.ArrayListUnmanaged(HistoryEntry) = .empty, +current: ?usize = null, + +pub fn get_length(self: *History) u32 { + return @intCast(self.stack.items.len); +} + +pub fn get_scrollRestoration(self: *History) []const u8 { + return switch (self.scrollRestoration) { + .auto => "auto", + .manual => "manual", + }; +} + +pub fn set_scrollRestoration(self: *History, mode: []const u8) void { + self.scrollRestoration = ScrollRestorationMode.fromString(mode) orelse self.scrollRestoration; +} + +pub fn get_state(self: *History, page: *Page) !?Env.JsObject { + if (self.current) |curr| { + const entry = self.stack.items[curr]; + const object = try Env.JsObject.fromJson(page.main_context, entry.state); + return object; + } else { + return null; + } +} + +pub fn _pushState(self: *History, state: Env.JsObject, _: ?[]const u8, url: ?[]const u8, page: *Page) !void { + const json = try state.toJson(page.arena); + const entry = HistoryEntry{ .state = json, .url = url }; + try self.stack.append(page.session.arena, entry); + self.current = self.stack.items.len; +} + +// TODO implement the function +// data must handle any argument. We could expect a std.json.Value but +// https://github.com/lightpanda-io/zig-js-runtime/issues/267 is missing. +pub fn _replaceState(self: *History, state: Env.JsObject, _: ?[]const u8, url: ?[]const u8) void { + _ = self; + _ = url; + _ = state; +} + +// TODO implement the function +pub fn _go(self: *History, delta: ?i32) void { + _ = self; + _ = delta; +} + +pub fn _back(self: *History) void { + if (self.current) |curr| { + if (curr > 0) { + self.current = curr - 1; + } + } +} + +pub fn _forward(self: *History) void { + if (self.current) |curr| { + if (curr < self.stack.items.len) { + self.current = curr + 1; + } + } +} + +const testing = @import("../../testing.zig"); +test "Browser: HTML.History" { + try testing.htmlRunner("html/history.html"); +} diff --git a/src/browser/html/history.zig b/src/browser/html/history.zig deleted file mode 100644 index e7d6726b..00000000 --- a/src/browser/html/history.zig +++ /dev/null @@ -1,93 +0,0 @@ -// Copyright (C) 2023-2024 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"); - -// https://html.spec.whatwg.org/multipage/nav-history-apis.html#the-history-interface -pub const History = struct { - const ScrollRestorationMode = enum { - auto, - manual, - }; - - scrollRestoration: ScrollRestorationMode = .auto, - state: std.json.Value = .null, - - // count tracks the history length until we implement correctly pushstate. - count: u32 = 0, - - pub fn get_length(self: *History) u32 { - // TODO return the real history length value. - return self.count; - } - - pub fn get_scrollRestoration(self: *History) []const u8 { - return switch (self.scrollRestoration) { - .auto => "auto", - .manual => "manual", - }; - } - - pub fn set_scrollRestoration(self: *History, mode: []const u8) void { - if (std.mem.eql(u8, "manual", mode)) self.scrollRestoration = .manual; - if (std.mem.eql(u8, "auto", mode)) self.scrollRestoration = .auto; - } - - pub fn get_state(self: *History) std.json.Value { - return self.state; - } - - // TODO implement the function - // data must handle any argument. We could expect a std.json.Value but - // https://github.com/lightpanda-io/zig-js-runtime/issues/267 is missing. - pub fn _pushState(self: *History, data: []const u8, _: ?[]const u8, url: ?[]const u8) void { - self.count += 1; - _ = url; - _ = data; - } - - // TODO implement the function - // data must handle any argument. We could expect a std.json.Value but - // https://github.com/lightpanda-io/zig-js-runtime/issues/267 is missing. - pub fn _replaceState(self: *History, data: []const u8, _: ?[]const u8, url: ?[]const u8) void { - _ = self; - _ = url; - _ = data; - } - - // TODO implement the function - pub fn _go(self: *History, delta: ?i32) void { - _ = self; - _ = delta; - } - - // TODO implement the function - pub fn _back(self: *History) void { - _ = self; - } - - // TODO implement the function - pub fn _forward(self: *History) void { - _ = self; - } -}; - -const testing = @import("../../testing.zig"); -test "Browser: HTML.History" { - try testing.htmlRunner("html/history.html"); -} diff --git a/src/browser/html/html.zig b/src/browser/html/html.zig index d722ad53..ef8a99f7 100644 --- a/src/browser/html/html.zig +++ b/src/browser/html/html.zig @@ -21,7 +21,7 @@ const HTMLElem = @import("elements.zig"); const SVGElem = @import("svg_elements.zig"); const Window = @import("window.zig").Window; const Navigator = @import("navigator.zig").Navigator; -const History = @import("history.zig").History; +const History = @import("History.zig"); const Location = @import("location.zig").Location; const MediaQueryList = @import("media_query_list.zig").MediaQueryList; diff --git a/src/browser/html/window.zig b/src/browser/html/window.zig index e32f5e85..0478bea8 100644 --- a/src/browser/html/window.zig +++ b/src/browser/html/window.zig @@ -24,7 +24,7 @@ const Env = @import("../env.zig").Env; const Page = @import("../page.zig").Page; const Navigator = @import("navigator.zig").Navigator; -const History = @import("history.zig").History; +const History = @import("History.zig"); const Location = @import("location.zig").Location; const Crypto = @import("../crypto/crypto.zig").Crypto; const Console = @import("../console/console.zig").Console; @@ -54,7 +54,6 @@ pub const Window = struct { document: *parser.DocumentHTML, target: []const u8 = "", - history: History = .{}, location: Location = .{}, storage_shelf: ?*storage.Shelf = null, @@ -179,8 +178,8 @@ pub const Window = struct { return self.document; } - pub fn get_history(self: *Window) *History { - return &self.history; + pub fn get_history(_: *Window, page: *Page) *History { + return &page.session.history; } // The interior height of the window in pixels, including the height of the horizontal scroll bar, if present. diff --git a/src/browser/session.zig b/src/browser/session.zig index 2f69f9ef..5241859e 100644 --- a/src/browser/session.zig +++ b/src/browser/session.zig @@ -24,6 +24,7 @@ const Env = @import("env.zig").Env; const Page = @import("page.zig").Page; const Browser = @import("browser.zig").Browser; const NavigateOpts = @import("page.zig").NavigateOpts; +const History = @import("html/History.zig"); const log = @import("../log.zig"); const parser = @import("netsurf.zig"); @@ -53,6 +54,10 @@ pub const Session = struct { storage_shed: storage.Shed, cookie_jar: storage.CookieJar, + // History is persistent across the "tab". + // https://developer.mozilla.org/en-US/docs/Web/API/History + history: History = .{}, + page: ?Page = null, // If the current page want to navigate to a new page diff --git a/src/runtime/js.zig b/src/runtime/js.zig index 977c96be..10708714 100644 --- a/src/runtime/js.zig +++ b/src/runtime/js.zig @@ -2005,6 +2005,12 @@ pub fn Env(comptime State: type, comptime WebApis: type) type { return writer.writeAll(try self.toString()); } + pub fn toJson(self: JsObject, allocator: std.mem.Allocator) ![]u8 { + const json_string = try v8.Json.stringify(self.js_context.v8_context, self.js_obj.toValue(), null); + const str = try jsStringToZig(allocator, json_string, self.js_context.isolate); + return str; + } + pub fn persist(self: JsObject) !JsObject { var js_context = self.js_context; const js_obj = self.js_obj; @@ -2400,6 +2406,12 @@ pub fn Env(comptime State: type, comptime WebApis: type) type { const js_context = self.js_context; return valueToString(allocator, self.value, js_context.isolate, js_context.v8_context); } + + pub fn fromJson(ctx: *JsContext, json: []const u8) !Value { + const json_string = v8.String.initUtf8(ctx.isolate, json); + const value = try v8.Json.parse(ctx.v8_context, json_string); + return Value{ .js_context = ctx, .value = value }; + } }; pub const ValueIterator = struct { diff --git a/src/tests/html/history.html b/src/tests/html/history.html index 796640e9..45ef8056 100644 --- a/src/tests/html/history.html +++ b/src/tests/html/history.html @@ -22,3 +22,10 @@ testing.expectEqual(undefined, history.forward()); testing.expectEqual(undefined, history.back()); + + From f03fcc9a3147d10f9cfa055b9d52aa080222ec4f Mon Sep 17 00:00:00 2001 From: Muki Kiboigo Date: Tue, 23 Sep 2025 23:37:02 -0700 Subject: [PATCH 2/6] support for returning Env.Value --- src/runtime/js.zig | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/runtime/js.zig b/src/runtime/js.zig index 10708714..8e264707 100644 --- a/src/runtime/js.zig +++ b/src/runtime/js.zig @@ -2889,6 +2889,10 @@ pub fn Env(comptime State: type, comptime WebApis: type) type { return value.js_obj.toValue(); } + if (T == Value) { + return value.value; + } + if (T == Promise) { // we're returning a v8.Promise return value.promise.toObject().toValue(); From 05e70791788f11871c41485f272ad359c2f47ad6 Mon Sep 17 00:00:00 2001 From: Muki Kiboigo Date: Tue, 23 Sep 2025 23:37:36 -0700 Subject: [PATCH 3/6] functional history WebAPI --- src/browser/html/History.zig | 92 +++++++++++++++++++++++------------- src/browser/page.zig | 4 ++ src/cdp/domains/page.zig | 2 +- 3 files changed, 64 insertions(+), 34 deletions(-) diff --git a/src/browser/html/History.zig b/src/browser/html/History.zig index eabae11d..9e5428fd 100644 --- a/src/browser/html/History.zig +++ b/src/browser/html/History.zig @@ -25,9 +25,9 @@ const Page = @import("../page.zig").Page; const History = @This(); const HistoryEntry = struct { - url: ?[]const u8, - // Serialized Env.JsObject - state: []u8, + url: []const u8, + // Serialized as JSON. + state: ?[]u8, }; const ScrollRestorationMode = enum { @@ -64,52 +64,78 @@ pub fn set_scrollRestoration(self: *History, mode: []const u8) void { self.scrollRestoration = ScrollRestorationMode.fromString(mode) orelse self.scrollRestoration; } -pub fn get_state(self: *History, page: *Page) !?Env.JsObject { +pub fn get_state(self: *History, page: *Page) !?Env.Value { if (self.current) |curr| { const entry = self.stack.items[curr]; - const object = try Env.JsObject.fromJson(page.main_context, entry.state); - return object; + if (entry.state) |state| { + const value = try Env.Value.fromJson(page.main_context, state); + return value; + } else { + return null; + } } else { return null; } } -pub fn _pushState(self: *History, state: Env.JsObject, _: ?[]const u8, url: ?[]const u8, page: *Page) !void { - const json = try state.toJson(page.arena); +pub fn pushNavigation(self: *History, _url: []const u8, page: *Page) !void { + const arena = page.session.arena; + const url = try arena.dupe(u8, _url); + + const entry = HistoryEntry{ .state = null, .url = url }; + try self.stack.append(arena, entry); + self.current = self.stack.items.len - 1; +} + +pub fn _pushState(self: *History, state: Env.JsObject, _: ?[]const u8, _url: ?[]const u8, page: *Page) !void { + const arena = page.session.arena; + + const url = if (_url) |u| try arena.dupe(u8, u) else try arena.dupe(u8, page.url.raw); + const json = try state.toJson(page.session.arena); const entry = HistoryEntry{ .state = json, .url = url }; - try self.stack.append(page.session.arena, entry); - self.current = self.stack.items.len; + try self.stack.append(arena, entry); + self.current = self.stack.items.len - 1; } -// TODO implement the function -// data must handle any argument. We could expect a std.json.Value but -// https://github.com/lightpanda-io/zig-js-runtime/issues/267 is missing. -pub fn _replaceState(self: *History, state: Env.JsObject, _: ?[]const u8, url: ?[]const u8) void { - _ = self; - _ = url; - _ = state; -} +pub fn _replaceState(self: *History, state: Env.JsObject, _: ?[]const u8, _url: ?[]const u8, page: *Page) !void { + const arena = page.session.arena; -// TODO implement the function -pub fn _go(self: *History, delta: ?i32) void { - _ = self; - _ = delta; -} - -pub fn _back(self: *History) void { if (self.current) |curr| { - if (curr > 0) { - self.current = curr - 1; - } + const entry = &self.stack.items[curr]; + const url = if (_url) |u| try arena.dupe(u8, u) else try arena.dupe(u8, page.url.raw); + const json = try state.toJson(arena); + entry.* = HistoryEntry{ .state = json, .url = url }; + } else { + try self._pushState(state, "", _url, page); } } -pub fn _forward(self: *History) void { - if (self.current) |curr| { - if (curr < self.stack.items.len) { - self.current = curr + 1; - } +pub fn go(self: *History, delta: i32, page: *Page) !void { + // 0 behaves the same as no argument, both reloading the page. + // If this is getting called, there SHOULD be an entry, atleast from pushNavigation. + const current = self.current.?; + + const index_s: i64 = @intCast(@as(i64, @intCast(current)) + @as(i64, @intCast(delta))); + if (index_s < 0 or index_s > self.stack.items.len - 1) { + return; } + + const index = @as(usize, @intCast(index_s)); + const entry = self.stack.items[index]; + self.current = index; + try page.navigateFromWebAPI(entry.url, .{ .reason = .history }); +} + +pub fn _go(self: *History, _delta: ?i32, page: *Page) !void { + try self.go(_delta orelse 0, page); +} + +pub fn _back(self: *History, page: *Page) !void { + try self.go(-1, page); +} + +pub fn _forward(self: *History, page: *Page) !void { + try self.go(1, page); } const testing = @import("../../testing.zig"); diff --git a/src/browser/page.zig b/src/browser/page.zig index 87b42534..de9d6617 100644 --- a/src/browser/page.zig +++ b/src/browser/page.zig @@ -806,6 +806,9 @@ pub const Page = struct { unreachable; }, } + + // Push the navigation after a successful load. + try self.session.history.pushNavigation(self.url.raw, self); } fn pageErrorCallback(ctx: *anyopaque, err: anyerror) void { @@ -1136,6 +1139,7 @@ pub const NavigateReason = enum { address_bar, form, script, + history, }; pub const NavigateOpts = struct { diff --git a/src/cdp/domains/page.zig b/src/cdp/domains/page.zig index 719c0782..74beb4cc 100644 --- a/src/cdp/domains/page.zig +++ b/src/cdp/domains/page.zig @@ -174,7 +174,7 @@ pub fn pageNavigate(arena: Allocator, bc: anytype, event: *const Notification.Pa var cdp = bc.cdp; const reason_: ?[]const u8 = switch (event.opts.reason) { .anchor => "anchorClick", - .script => "scriptInitiated", + .script, .history => "scriptInitiated", .form => switch (event.opts.method) { .GET => "formSubmissionGet", .POST => "formSubmissionPost", From 464f42a121eb3c65e29ffffe83ef7198a73f68c7 Mon Sep 17 00:00:00 2001 From: Muki Kiboigo Date: Tue, 23 Sep 2025 23:37:50 -0700 Subject: [PATCH 4/6] add history tests --- src/testing.zig | 7 +++++++ src/tests/html/history.html | 42 ++++++++++++++++++------------------- src/tests/testing.js | 24 +++++++++++++++++++++ 3 files changed, 52 insertions(+), 21 deletions(-) diff --git a/src/testing.zig b/src/testing.zig index 5abdd024..b3808597 100644 --- a/src/testing.zig +++ b/src/testing.zig @@ -404,6 +404,13 @@ pub fn htmlRunner(file: []const u8) !void { try page.navigate(url, .{}); _ = page.wait(2000); + const needs_second_wait = try js_context.exec("testing._onPageWait.length > 0", "check_onPageWait"); + if (needs_second_wait.value.toBool(page.main_context.isolate)) { + // sets the isSecondWait flag in testing. + _ = js_context.exec("testing._isSecondWait = true", "set_second_wait_flag") catch {}; + _ = page.wait(2000); + } + @import("root").js_runner_duration += std.time.Instant.since(try std.time.Instant.now(), start); const value = js_context.exec("testing.getStatus()", "testing.getStatus()") catch |err| { diff --git a/src/tests/html/history.html b/src/tests/html/history.html index 45ef8056..3714d5cd 100644 --- a/src/tests/html/history.html +++ b/src/tests/html/history.html @@ -2,30 +2,30 @@ - - diff --git a/src/tests/testing.js b/src/tests/testing.js index bf0ea07f..779cca8c 100644 --- a/src/tests/testing.js +++ b/src/tests/testing.js @@ -50,6 +50,15 @@ function getStatus() { // if we're already in a fail state, return fail, nothing can recover this if (testing._status === 'fail') return 'fail'; + + if (testing._isSecondWait) { + for (const pw of (testing._onPageWait)) { + testing._captured = pw[1]; + pw[0](); + testing._captured = null; + } + } + // run any eventually's that we've captured for (const ev of testing._eventually) { testing._captured = ev[1]; @@ -92,6 +101,18 @@ _registerErrorCallback(); } + // Set expectations to happen on the next time that `page.wait` is executed. + // + // History specifically uses this as it queues navigation that needs to be checked + // when the next page is loaded. + function onPageWait(fn) { + // Store callbacks to run when page.wait() happens + testing._onPageWait.push([fn, { + script_id: document.currentScript.id, + stack: new Error().stack, + }]); + } + async function async(promise, cb) { const script_id = document.currentScript ? document.currentScript.id : '