Merge pull request #1086 from lightpanda-io/history

Implement `History` WebAPI.
This commit is contained in:
Pierre Tachoire
2025-09-26 12:15:07 +02:00
committed by GitHub
14 changed files with 317 additions and 119 deletions

View File

@@ -36,6 +36,7 @@ const MouseEvent = @import("mouse_event.zig").MouseEvent;
const KeyboardEvent = @import("keyboard_event.zig").KeyboardEvent; const KeyboardEvent = @import("keyboard_event.zig").KeyboardEvent;
const ErrorEvent = @import("../html/error_event.zig").ErrorEvent; const ErrorEvent = @import("../html/error_event.zig").ErrorEvent;
const MessageEvent = @import("../dom/MessageChannel.zig").MessageEvent; const MessageEvent = @import("../dom/MessageChannel.zig").MessageEvent;
const PopStateEvent = @import("../html/History.zig").PopStateEvent;
// Event interfaces // Event interfaces
pub const Interfaces = .{ pub const Interfaces = .{
@@ -46,6 +47,7 @@ pub const Interfaces = .{
KeyboardEvent, KeyboardEvent,
ErrorEvent, ErrorEvent,
MessageEvent, MessageEvent,
PopStateEvent,
}; };
pub const Union = generate.Union(Interfaces); pub const Union = generate.Union(Interfaces);
@@ -73,6 +75,7 @@ pub const Event = struct {
.error_event => .{ .ErrorEvent = @as(*ErrorEvent, @ptrCast(evt)).* }, .error_event => .{ .ErrorEvent = @as(*ErrorEvent, @ptrCast(evt)).* },
.message_event => .{ .MessageEvent = @as(*MessageEvent, @ptrCast(evt)).* }, .message_event => .{ .MessageEvent = @as(*MessageEvent, @ptrCast(evt)).* },
.keyboard_event => .{ .KeyboardEvent = @as(*parser.KeyboardEvent, @ptrCast(evt)) }, .keyboard_event => .{ .KeyboardEvent = @as(*parser.KeyboardEvent, @ptrCast(evt)) },
.pop_state => .{ .PopStateEvent = @as(*PopStateEvent, @ptrCast(evt)).* },
}; };
} }

View File

@@ -64,7 +64,7 @@ pub const FetchContext = struct {
var headers: Headers = .{}; var headers: Headers = .{};
// seems to be the highest priority // seems to be the highest priority
const same_origin = try isSameOriginAsPage(self.url, self.page); const same_origin = try self.page.isSameOrigin(self.url);
// If the mode is "no-cors", we need to return this opaque/stripped Response. // If the mode is "no-cors", we need to return this opaque/stripped Response.
// https://developer.mozilla.org/en-US/docs/Web/API/Response/type // https://developer.mozilla.org/en-US/docs/Web/API/Response/type
@@ -237,11 +237,6 @@ pub fn fetch(input: RequestInput, options: ?RequestInit, page: *Page) !Env.Promi
return resolver.promise(); return resolver.promise();
} }
fn isSameOriginAsPage(url: []const u8, page: *const Page) !bool {
const origin = try page.origin(page.call_arena);
return std.mem.startsWith(u8, url, origin);
}
const testing = @import("../../testing.zig"); const testing = @import("../../testing.zig");
test "fetch: fetch" { test "fetch: fetch" {
try testing.htmlRunner("fetch/fetch.html"); try testing.htmlRunner("fetch/fetch.html");

View File

@@ -0,0 +1,215 @@
// Copyright (C) 2023-2024 Lightpanda (Selecy SAS)
//
// Francis Bouvier <francis@lightpanda.io>
// Pierre Tachoire <pierre@lightpanda.io>
//
// 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 <https://www.gnu.org/licenses/>.
const std = @import("std");
const log = @import("../../log.zig");
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,
// This is serialized as JSON because
// History must survive a JsContext.
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;
}
}
pub fn toString(self: ScrollRestorationMode) []const u8 {
return @tagName(self);
}
};
scroll_restoration: 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) ScrollRestorationMode {
return self.scroll_restoration;
}
pub fn set_scrollRestoration(self: *History, mode: []const u8) void {
self.scroll_restoration = ScrollRestorationMode.fromString(mode) orelse self.scroll_restoration;
}
pub fn get_state(self: *History, page: *Page) !?Env.Value {
if (self.current) |curr| {
const entry = self.stack.items[curr];
if (entry.state) |state| {
const value = try Env.Value.fromJson(page.main_context, state);
return value;
} else {
return null;
}
} else {
return null;
}
}
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 dispatchPopStateEvent(state: ?[]const u8, page: *Page) void {
log.debug(.script_event, "dispatch popstate event", .{
.type = "popstate",
.source = "history",
});
History._dispatchPopStateEvent(state, page) catch |err| {
log.err(.app, "dispatch popstate event error", .{
.err = err,
.type = "popstate",
.source = "history",
});
};
}
fn _dispatchPopStateEvent(state: ?[]const u8, page: *Page) !void {
var evt = try PopStateEvent.constructor("popstate", .{ .state = state });
_ = try parser.eventTargetDispatchEvent(
@as(*parser.EventTarget, @ptrCast(&page.window)),
&evt.proto,
);
}
pub fn _pushState(self: *History, state: Env.JsObject, _: ?[]const u8, _url: ?[]const u8, page: *Page) !void {
const arena = page.session.arena;
const json = try state.toJson(arena);
const url = if (_url) |u| try arena.dupe(u8, u) else try arena.dupe(u8, page.url.raw);
const entry = HistoryEntry{ .state = json, .url = url };
try self.stack.append(arena, entry);
self.current = self.stack.items.len - 1;
}
pub fn _replaceState(self: *History, state: Env.JsObject, _: ?[]const u8, _url: ?[]const u8, page: *Page) !void {
const arena = page.session.arena;
if (self.current) |curr| {
const entry = &self.stack.items[curr];
const json = try state.toJson(arena);
const url = if (_url) |u| try arena.dupe(u8, u) else try arena.dupe(u8, page.url.raw);
entry.* = HistoryEntry{ .state = json, .url = url };
} else {
try self._pushState(state, "", _url, page);
}
}
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;
if (try page.isSameOrigin(entry.url)) {
History.dispatchPopStateEvent(entry.state, page);
}
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 parser = @import("../netsurf.zig");
const Event = @import("../events/event.zig").Event;
pub const PopStateEvent = struct {
pub const prototype = *Event;
pub const union_make_copy = true;
pub const EventInit = struct {
state: ?[]const u8 = null,
};
proto: parser.Event,
state: ?[]const u8,
pub fn constructor(event_type: []const u8, opts: ?EventInit) !PopStateEvent {
const event = try parser.eventCreate();
defer parser.eventDestroy(event);
try parser.eventInit(event, event_type, .{});
parser.eventSetInternalType(event, .pop_state);
const o = opts orelse EventInit{};
return .{
.proto = event.*,
.state = o.state,
};
}
// `hasUAVisualTransition` is not implemented. It isn't baseline so this is okay.
pub fn get_state(self: *const PopStateEvent, page: *Page) !?Env.Value {
if (self.state) |state| {
const value = try Env.Value.fromJson(page.main_context, state);
return value;
} else {
return null;
}
}
};
const testing = @import("../../testing.zig");
test "Browser: HTML.History" {
try testing.htmlRunner("html/history.html");
}

View File

@@ -1,93 +0,0 @@
// Copyright (C) 2023-2024 Lightpanda (Selecy SAS)
//
// Francis Bouvier <francis@lightpanda.io>
// Pierre Tachoire <pierre@lightpanda.io>
//
// 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 <https://www.gnu.org/licenses/>.
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");
}

View File

@@ -21,7 +21,7 @@ const HTMLElem = @import("elements.zig");
const SVGElem = @import("svg_elements.zig"); const SVGElem = @import("svg_elements.zig");
const Window = @import("window.zig").Window; const Window = @import("window.zig").Window;
const Navigator = @import("navigator.zig").Navigator; const Navigator = @import("navigator.zig").Navigator;
const History = @import("history.zig").History; const History = @import("History.zig");
const Location = @import("location.zig").Location; const Location = @import("location.zig").Location;
const MediaQueryList = @import("media_query_list.zig").MediaQueryList; const MediaQueryList = @import("media_query_list.zig").MediaQueryList;

View File

@@ -24,7 +24,7 @@ const Env = @import("../env.zig").Env;
const Page = @import("../page.zig").Page; const Page = @import("../page.zig").Page;
const Navigator = @import("navigator.zig").Navigator; const Navigator = @import("navigator.zig").Navigator;
const History = @import("history.zig").History; const History = @import("History.zig");
const Location = @import("location.zig").Location; const Location = @import("location.zig").Location;
const Crypto = @import("../crypto/crypto.zig").Crypto; const Crypto = @import("../crypto/crypto.zig").Crypto;
const Console = @import("../console/console.zig").Console; const Console = @import("../console/console.zig").Console;
@@ -54,7 +54,6 @@ pub const Window = struct {
document: *parser.DocumentHTML, document: *parser.DocumentHTML,
target: []const u8 = "", target: []const u8 = "",
history: History = .{},
location: Location = .{}, location: Location = .{},
storage_shelf: ?*storage.Shelf = null, storage_shelf: ?*storage.Shelf = null,
@@ -179,8 +178,8 @@ pub const Window = struct {
return self.document; return self.document;
} }
pub fn get_history(self: *Window) *History { pub fn get_history(_: *Window, page: *Page) *History {
return &self.history; return &page.session.history;
} }
// The interior height of the window in pixels, including the height of the horizontal scroll bar, if present. // The interior height of the window in pixels, including the height of the horizontal scroll bar, if present.

View File

@@ -558,6 +558,7 @@ pub const EventType = enum(u8) {
xhr_event = 6, xhr_event = 6,
message_event = 7, message_event = 7,
keyboard_event = 8, keyboard_event = 8,
pop_state = 9,
}; };
pub const MutationEvent = c.dom_mutation_event; pub const MutationEvent = c.dom_mutation_event;

View File

@@ -806,6 +806,9 @@ pub const Page = struct {
unreachable; unreachable;
}, },
} }
// Push the navigation after a successful load.
try self.session.history.pushNavigation(self.url.raw, self);
} }
fn pageErrorCallback(ctx: *anyopaque, err: anyerror) void { fn pageErrorCallback(ctx: *anyopaque, err: anyerror) void {
@@ -1129,6 +1132,11 @@ pub const Page = struct {
} }
self.slot_change_monitor = try SlotChangeMonitor.init(self); self.slot_change_monitor = try SlotChangeMonitor.init(self);
} }
pub fn isSameOrigin(self: *const Page, url: []const u8) !bool {
const current_origin = try self.origin(self.call_arena);
return std.mem.startsWith(u8, url, current_origin);
}
}; };
pub const NavigateReason = enum { pub const NavigateReason = enum {
@@ -1136,6 +1144,7 @@ pub const NavigateReason = enum {
address_bar, address_bar,
form, form,
script, script,
history,
}; };
pub const NavigateOpts = struct { pub const NavigateOpts = struct {

View File

@@ -24,6 +24,7 @@ const Env = @import("env.zig").Env;
const Page = @import("page.zig").Page; const Page = @import("page.zig").Page;
const Browser = @import("browser.zig").Browser; const Browser = @import("browser.zig").Browser;
const NavigateOpts = @import("page.zig").NavigateOpts; const NavigateOpts = @import("page.zig").NavigateOpts;
const History = @import("html/History.zig");
const log = @import("../log.zig"); const log = @import("../log.zig");
const parser = @import("netsurf.zig"); const parser = @import("netsurf.zig");
@@ -53,6 +54,10 @@ pub const Session = struct {
storage_shed: storage.Shed, storage_shed: storage.Shed,
cookie_jar: storage.CookieJar, 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, page: ?Page = null,
// If the current page want to navigate to a new page // If the current page want to navigate to a new page

View File

@@ -174,7 +174,7 @@ pub fn pageNavigate(arena: Allocator, bc: anytype, event: *const Notification.Pa
var cdp = bc.cdp; var cdp = bc.cdp;
const reason_: ?[]const u8 = switch (event.opts.reason) { const reason_: ?[]const u8 = switch (event.opts.reason) {
.anchor => "anchorClick", .anchor => "anchorClick",
.script => "scriptInitiated", .script, .history => "scriptInitiated",
.form => switch (event.opts.method) { .form => switch (event.opts.method) {
.GET => "formSubmissionGet", .GET => "formSubmissionGet",
.POST => "formSubmissionPost", .POST => "formSubmissionPost",

View File

@@ -2005,6 +2005,12 @@ pub fn Env(comptime State: type, comptime WebApis: type) type {
return writer.writeAll(try self.toString()); 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 { pub fn persist(self: JsObject) !JsObject {
var js_context = self.js_context; var js_context = self.js_context;
const js_obj = self.js_obj; 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; const js_context = self.js_context;
return valueToString(allocator, self.value, js_context.isolate, js_context.v8_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 { pub const ValueIterator = struct {
@@ -2877,6 +2889,10 @@ pub fn Env(comptime State: type, comptime WebApis: type) type {
return value.js_obj.toValue(); return value.js_obj.toValue();
} }
if (T == Value) {
return value.value;
}
if (T == Promise) { if (T == Promise) {
// we're returning a v8.Promise // we're returning a v8.Promise
return value.promise.toObject().toValue(); return value.promise.toObject().toValue();

View File

@@ -404,6 +404,13 @@ pub fn htmlRunner(file: []const u8) !void {
try page.navigate(url, .{}); try page.navigate(url, .{});
_ = page.wait(2000); _ = 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); @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| { const value = js_context.exec("testing.getStatus()", "testing.getStatus()") catch |err| {

View File

@@ -2,23 +2,40 @@
<script src="../testing.js"></script> <script src="../testing.js"></script>
<script id=history> <script id=history>
testing.expectEqual('auto', history.scrollRestoration); testing.expectEqual('auto', history.scrollRestoration);
history.scrollRestoration = 'manual'; history.scrollRestoration = 'manual';
history.scrollRestoration = 'foo'; history.scrollRestoration = 'foo';
testing.expectEqual('manual', history.scrollRestoration); testing.expectEqual('manual', history.scrollRestoration);
history.scrollRestoration = 'auto'; history.scrollRestoration = 'auto';
testing.expectEqual('auto', history.scrollRestoration); testing.expectEqual('auto', history.scrollRestoration);
testing.expectEqual(null, history.state)
testing.expectEqual(null, history.state) history.pushState({ testInProgress: true }, null, 'http://127.0.0.1:9582/xhr/json');
testing.expectEqual({ testInProgress: true }, history.state);
history.pushState({}, null, ''); history.replaceState({ "new": "field", testComplete: true }, null);
history.replaceState({}, null, ''); let state = { "new": "field", testComplete: true };
testing.expectEqual(state, history.state);
testing.expectEqual(undefined, history.go()); let popstateEventFired = false;
testing.expectEqual(undefined, history.go(1)); let popstateEventState = null;
testing.expectEqual(undefined, history.go(-1));
testing.expectEqual(undefined, history.forward()); window.addEventListener('popstate', (event) => {
testing.expectEqual(undefined, history.back()); popstateEventFired = true;
popstateEventState = event.state;
});
testing.eventually(() => {
testing.expectEqual(true, popstateEventFired);
testing.expectEqual(state, popstateEventState);
})
testing.onPageWait(() => {
testing.expectEqual(true, history.state && history.state.testComplete);
testing.expectEqual(state, history.state);
});
testing.expectEqual(undefined, history.go());
</script> </script>

View File

@@ -50,6 +50,15 @@
function getStatus() { function getStatus() {
// if we're already in a fail state, return fail, nothing can recover this // if we're already in a fail state, return fail, nothing can recover this
if (testing._status === 'fail') return 'fail'; 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 // run any eventually's that we've captured
for (const ev of testing._eventually) { for (const ev of testing._eventually) {
testing._captured = ev[1]; testing._captured = ev[1];
@@ -92,6 +101,18 @@
_registerErrorCallback(); _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) { async function async(promise, cb) {
const script_id = document.currentScript ? document.currentScript.id : '<script id is unavailable in browsers>'; const script_id = document.currentScript ? document.currentScript.id : '<script id is unavailable in browsers>';
const stack = new Error().stack; const stack = new Error().stack;
@@ -171,12 +192,15 @@
window.testing = { window.testing = {
_status: 'empty', _status: 'empty',
_eventually: [], _eventually: [],
_onPageWait: [],
_executed_scripts: new Set(), _executed_scripts: new Set(),
_captured: null, _captured: null,
_isSecondWait: false,
skip: skip, skip: skip,
async: async, async: async,
getStatus: getStatus, getStatus: getStatus,
eventually: eventually, eventually: eventually,
onPageWait: onPageWait,
expectEqual: expectEqual, expectEqual: expectEqual,
expectError: expectError, expectError: expectError,
withError: withError, withError: withError,