diff --git a/src/browser/events/event.zig b/src/browser/events/event.zig index 0754a6ad..ef039fac 100644 --- a/src/browser/events/event.zig +++ b/src/browser/events/event.zig @@ -36,6 +36,7 @@ const MouseEvent = @import("mouse_event.zig").MouseEvent; const KeyboardEvent = @import("keyboard_event.zig").KeyboardEvent; const ErrorEvent = @import("../html/error_event.zig").ErrorEvent; const MessageEvent = @import("../dom/MessageChannel.zig").MessageEvent; +const PopStateEvent = @import("../html/History.zig").PopStateEvent; // Event interfaces pub const Interfaces = .{ @@ -46,6 +47,7 @@ pub const Interfaces = .{ KeyboardEvent, ErrorEvent, MessageEvent, + PopStateEvent, }; pub const Union = generate.Union(Interfaces); @@ -73,6 +75,7 @@ pub const Event = struct { .error_event => .{ .ErrorEvent = @as(*ErrorEvent, @ptrCast(evt)).* }, .message_event => .{ .MessageEvent = @as(*MessageEvent, @ptrCast(evt)).* }, .keyboard_event => .{ .KeyboardEvent = @as(*parser.KeyboardEvent, @ptrCast(evt)) }, + .pop_state => .{ .PopStateEvent = @as(*PopStateEvent, @ptrCast(evt)).* }, }; } diff --git a/src/browser/fetch/fetch.zig b/src/browser/fetch/fetch.zig index 9dd073df..e1e29a81 100644 --- a/src/browser/fetch/fetch.zig +++ b/src/browser/fetch/fetch.zig @@ -64,7 +64,7 @@ pub const FetchContext = struct { var headers: Headers = .{}; // 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. // 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(); } -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"); test "fetch: fetch" { try testing.htmlRunner("fetch/fetch.html"); diff --git a/src/browser/html/History.zig b/src/browser/html/History.zig new file mode 100644 index 00000000..308bfb1f --- /dev/null +++ b/src/browser/html/History.zig @@ -0,0 +1,215 @@ +// 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 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"); +} 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/netsurf.zig b/src/browser/netsurf.zig index 114d4578..5cf3ba13 100644 --- a/src/browser/netsurf.zig +++ b/src/browser/netsurf.zig @@ -558,6 +558,7 @@ pub const EventType = enum(u8) { xhr_event = 6, message_event = 7, keyboard_event = 8, + pop_state = 9, }; pub const MutationEvent = c.dom_mutation_event; diff --git a/src/browser/page.zig b/src/browser/page.zig index 87b42534..49a5daca 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 { @@ -1129,6 +1132,11 @@ pub const Page = struct { } 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 { @@ -1136,6 +1144,7 @@ pub const NavigateReason = enum { address_bar, form, script, + history, }; pub const NavigateOpts = struct { 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/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", diff --git a/src/runtime/js.zig b/src/runtime/js.zig index 977c96be..8e264707 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 { @@ -2877,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(); 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 796640e9..2c5591a4 100644 --- a/src/tests/html/history.html +++ b/src/tests/html/history.html @@ -2,23 +2,40 @@ 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 : '