From 186655e6149ed45131a64688af0def9038be2a88 Mon Sep 17 00:00:00 2001 From: Muki Kiboigo Date: Sun, 12 Oct 2025 15:39:22 -0700 Subject: [PATCH 01/20] initial Navigation scaffolding --- src/browser/html/Navigation.zig | 221 ++++++++++++++++++++++++++++++++ src/browser/session.zig | 2 + 2 files changed, 223 insertions(+) create mode 100644 src/browser/html/Navigation.zig diff --git a/src/browser/html/Navigation.zig b/src/browser/html/Navigation.zig new file mode 100644 index 00000000..a0818828 --- /dev/null +++ b/src/browser/html/Navigation.zig @@ -0,0 +1,221 @@ +// 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 URL = @import("../../url.zig").URL; + +const Js = @import("../js/js.zig"); +const Page = @import("../page.zig").Page; + +// https://developer.mozilla.org/en-US/docs/Web/API/Navigation +const Navigation = @This(); + +const EventTarget = @import("../dom/event_target.zig").EventTarget; +const EventHandler = @import("../events/event.zig").EventHandler; + +const parser = @import("../netsurf.zig"); + +const Interfaces = .{ + Navigation, + NavigationActivation, + NavigationHistoryEntry, +}; + +pub const prototype = *EventTarget; +base: parser.EventTargetTBase = parser.EventTargetTBase{ .internal_target_type = .plain }, + +index: usize = 0, +entries: std.ArrayListUnmanaged(NavigationHistoryEntry) = .empty, +next_entry_id: usize = 0, +// TODO: key->index mapping + +// https://developer.mozilla.org/en-US/docs/Web/API/NavigationHistoryEntry +const NavigationHistoryEntry = struct { + pub const prototype = *EventTarget; + base: parser.EventTargetTBase = parser.EventTargetTBase{ .internal_target_type = .plain }, + + id: []const u8, + index: usize, + key: []const u8, + url: ?[]const u8, + same_document: bool, + state: ?[]const u8, + + pub fn get_id(self: *const NavigationHistoryEntry) []const u8 { + return self.id; + } + + pub fn get_index(self: *const NavigationHistoryEntry) usize { + return self.index; + } + + pub fn get_key(self: *const NavigationHistoryEntry) []const u8 { + return self.key; + } + + pub fn get_sameDocument(self: *const NavigationHistoryEntry) bool { + return self.same_document; + } + + pub fn get_url(self: *const NavigationHistoryEntry) ?[]const u8 { + return self.url; + } + + pub fn _getState(self: *const NavigationHistoryEntry, page: *Page) !?Js.Value { + if (self.state) |state| { + return try Js.Value.fromJson(page.main_context, state); + } else { + return null; + } + } +}; + +// https://developer.mozilla.org/en-US/docs/Web/API/NavigationActivation +const NavigationActivation = struct { + const NavigationActivationType = enum { + push, + reload, + replace, + traverse, + + pub fn toString(self: NavigationActivationType) []const u8 { + return @tagName(self); + } + }; + + entry: NavigationHistoryEntry, + from: ?NavigationHistoryEntry = null, + type: NavigationActivationType, + + pub fn get_entry(self: *const NavigationActivation) NavigationHistoryEntry { + return self.entry; + } + + pub fn get_from(self: *const NavigationActivation) ?NavigationHistoryEntry { + return self.from; + } + + pub fn get_navigationType(self: *const NavigationActivation) NavigationActivationType { + return self.type; + } +}; + +pub fn get_canGoBack(self: *const Navigation) bool { + return self.index > 0; +} + +pub fn get_canGoForward(self: *const Navigation) bool { + return self.entries.items.len > self.index + 1; +} + +pub fn get_currentEntry(_: *const Navigation) NavigationHistoryEntry { + // TODO + unreachable; +} + +const NavigationReturn = struct { + comitted: Js.Promise, + finished: Js.Promise, +}; + +pub fn _back(_: *const Navigation) !NavigationReturn { + unreachable; +} + +pub fn _entries(self: *const Navigation) []NavigationHistoryEntry { + return self.entries.items; +} + +pub fn _forward(_: *const Navigation) !NavigationReturn { + unreachable; +} + +const NavigateOptions = struct { + const NavigateOptionsHistory = enum { + auto, + push, + replace, + }; + + state: ?Js.Object = null, + info: ?Js.Object = null, + history: NavigateOptionsHistory = .auto, +}; + +pub fn _navigate(self: *Navigation, _url: []const u8, _opts: ?NavigateOptions, page: *Page) !NavigationReturn { + const arena = page.session.arena; + + const options = _opts orelse NavigateOptions{}; + const url = try arena.dupe(u8, _url); + + // TODO: handle push history NotSupportedError. + + const index = self.entries.items.len; + const id = self.next_entry_id; + self.next_entry_id += 1; + + const id_str = try std.fmt.allocPrint(arena, "{d}", .{id}); + + const state: ?[]const u8 = blk: { + if (options.state) |s| { + break :blk try s.toJson(arena); + } else { + break :blk null; + } + }; + + const entry = NavigationHistoryEntry{ + .id = id_str, + .index = index, + .same_document = false, + .url = url, + .key = id_str, + .state = state, + }; + + try self.entries.append(arena, entry); + + // https://github.com/WICG/navigation-api/issues/95 + // + // These will only settle on same-origin navigation (mostly intended for SPAs). + // It is fine (and expected) for these to not settle on cross-origin requests :) + const committed = try page.main_context.createPersistentPromiseResolver(.page); + const finished = try page.main_context.createPersistentPromiseResolver(.page); + + if (entry.same_document) { + page.url = try URL.parse(url, null); + try committed.resolve(void); + + // todo: Fire navigate event + // + + } else { + page.navigateFromWebAPI(url, .{ .reason = .navigation }); + } + + return .{ + .comitted = committed, + .finished = finished, + }; +} + +// const testing = @import("../../testing.zig"); +// test "Browser: Navigation" { +// try testing.htmlRunner("html/navigation.html"); +// } diff --git a/src/browser/session.zig b/src/browser/session.zig index a5651d20..3456f159 100644 --- a/src/browser/session.zig +++ b/src/browser/session.zig @@ -25,6 +25,7 @@ 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 Navigation = @import("html/Navigation.zig"); const log = @import("../log.zig"); const parser = @import("netsurf.zig"); @@ -57,6 +58,7 @@ pub const Session = struct { // History is persistent across the "tab". // https://developer.mozilla.org/en-US/docs/Web/API/History history: History = .{}, + navigation: Navigation = .{}, page: ?Page = null, From 8ab9364f1982801c68b08b06030a7eed3c2e76ed Mon Sep 17 00:00:00 2001 From: Muki Kiboigo Date: Sun, 12 Oct 2025 15:45:40 -0700 Subject: [PATCH 02/20] add `ENUM_JS_USE_TAG` for enums --- src/browser/html/History.zig | 20 ++++---------------- src/browser/js/Context.zig | 13 ++++++++++--- src/browser/js/js.zig | 5 +++++ 3 files changed, 19 insertions(+), 19 deletions(-) diff --git a/src/browser/html/History.zig b/src/browser/html/History.zig index f8be6bb3..b920d66d 100644 --- a/src/browser/html/History.zig +++ b/src/browser/html/History.zig @@ -33,22 +33,10 @@ const HistoryEntry = struct { }; const ScrollRestorationMode = enum { + pub const ENUM_JS_USE_TAG = true; + 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, @@ -63,8 +51,8 @@ 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 set_scrollRestoration(self: *History, mode: ScrollRestorationMode) void { + self.scroll_restoration = mode; } pub fn get_state(self: *History, page: *Page) !?js.Value { diff --git a/src/browser/js/Context.zig b/src/browser/js/Context.zig index d4ccb887..be7cf7c4 100644 --- a/src/browser/js/Context.zig +++ b/src/browser/js/Context.zig @@ -750,9 +750,16 @@ pub fn jsValueToZig(self: *Context, comptime named_function: NamedFunction, comp unreachable; }, .@"enum" => |e| { - switch (@typeInfo(e.tag_type)) { - .int => return std.meta.intToEnum(T, try jsIntToZig(e.tag_type, js_value, self.v8_context)), - else => @compileError(named_function.full_name ++ " has an unsupported enum parameter type: " ++ @typeName(T)), + if (@hasDecl(T, "ENUM_JS_USE_TAG")) { + const str = try self.jsValueToZig(named_function, []const u8, js_value); + return std.meta.stringToEnum(T, str) orelse return error.InvalidEnumValue; + } else { + switch (@typeInfo(e.tag_type)) { + .int => return std.meta.intToEnum(T, try jsIntToZig(e.tag_type, js_value, self.v8_context)), + else => { + @compileError(named_function.full_name ++ " has an unsupported enum parameter type: " ++ @typeName(T)); + }, + } } }, else => {}, diff --git a/src/browser/js/js.zig b/src/browser/js/js.zig index 31cf5442..aeff6540 100644 --- a/src/browser/js/js.zig +++ b/src/browser/js/js.zig @@ -378,8 +378,13 @@ pub fn simpleZigValueToJs(isolate: v8.Isolate, value: anytype, comptime fail: bo .@"enum" => { const T = @TypeOf(value); if (@hasDecl(T, "toString")) { + // This should be deprecated in favor of the ENUM_JS_USE_TAG. return simpleZigValueToJs(isolate, value.toString(), fail); } + + if (@hasDecl(T, "ENUM_JS_USE_TAG")) { + return simpleZigValueToJs(isolate, @tagName(value), fail); + } }, else => {}, } From 70a009a52b12ff5c1120d967e2ecc1c10afbc7bc Mon Sep 17 00:00:00 2001 From: Muki Kiboigo Date: Sun, 12 Oct 2025 16:00:04 -0700 Subject: [PATCH 03/20] add eqlDocument comparison --- src/url.zig | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/url.zig b/src/url.zig index acfac256..21cec7f1 100644 --- a/src/url.zig +++ b/src/url.zig @@ -217,6 +217,18 @@ pub const URL = struct { buf.appendSliceAssumeCapacity(query_string); return buf.items; } + + // Compares two URLs, returning true if it is the same document. + pub fn eqlDocument(self: *const URL, other: *const URL, arena: Allocator) !bool { + if (!std.mem.eql(u8, self.scheme(), other.scheme())) return false; + if (!std.mem.eql(u8, self.host(), other.host())) return false; + if (self.port() != other.port()) return false; + + const path1 = try self.uri.path.toRawMaybeAlloc(arena); + const path2 = try other.uri.path.toRawMaybeAlloc(arena); + + return std.mem.eql(u8, path1, path2); + } }; const StitchOpts = struct { From e80c8d5bff5806929527f04fe78b404c34833d33 Mon Sep 17 00:00:00 2001 From: Muki Kiboigo Date: Sun, 12 Oct 2025 16:03:33 -0700 Subject: [PATCH 04/20] add functional Navigation --- src/browser/html/Navigation.zig | 215 ++++++++++++++++++++++---------- src/browser/html/html.zig | 1 + src/browser/html/window.zig | 5 + 3 files changed, 153 insertions(+), 68 deletions(-) diff --git a/src/browser/html/Navigation.zig b/src/browser/html/Navigation.zig index a0818828..7968d821 100644 --- a/src/browser/html/Navigation.zig +++ b/src/browser/html/Navigation.zig @@ -20,18 +20,18 @@ const std = @import("std"); const log = @import("../../log.zig"); const URL = @import("../../url.zig").URL; -const Js = @import("../js/js.zig"); +const js = @import("../js/js.zig"); const Page = @import("../page.zig").Page; -// https://developer.mozilla.org/en-US/docs/Web/API/Navigation -const Navigation = @This(); - const EventTarget = @import("../dom/event_target.zig").EventTarget; const EventHandler = @import("../events/event.zig").EventHandler; const parser = @import("../netsurf.zig"); -const Interfaces = .{ +// https://developer.mozilla.org/en-US/docs/Web/API/Navigation +const Navigation = @This(); + +pub const Interfaces = .{ Navigation, NavigationActivation, NavigationHistoryEntry, @@ -51,39 +51,76 @@ const NavigationHistoryEntry = struct { base: parser.EventTargetTBase = parser.EventTargetTBase{ .internal_target_type = .plain }, id: []const u8, - index: usize, key: []const u8, url: ?[]const u8, - same_document: bool, state: ?[]const u8, pub fn get_id(self: *const NavigationHistoryEntry) []const u8 { return self.id; } - pub fn get_index(self: *const NavigationHistoryEntry) usize { - return self.index; + pub fn get_index(self: *const NavigationHistoryEntry, page: *Page) i32 { + const navigation = page.session.navigation; + for (navigation.entries.items, 0..) |*entry, i| { + if (std.mem.eql(u8, entry.id, self.id)) { + return @intCast(i); + } + } + + return -1; } pub fn get_key(self: *const NavigationHistoryEntry) []const u8 { return self.key; } - pub fn get_sameDocument(self: *const NavigationHistoryEntry) bool { - return self.same_document; + pub fn get_sameDocument(self: *const NavigationHistoryEntry, page: *Page) !bool { + const _url = self.url orelse return false; + const url = try URL.parse(_url, null); + return page.url.eqlDocument(&url, page.arena); } pub fn get_url(self: *const NavigationHistoryEntry) ?[]const u8 { return self.url; } - pub fn _getState(self: *const NavigationHistoryEntry, page: *Page) !?Js.Value { + pub fn _getState(self: *const NavigationHistoryEntry, page: *Page) !?js.Value { if (self.state) |state| { - return try Js.Value.fromJson(page.main_context, state); + return try js.Value.fromJson(page.js, state); } else { return null; } } + + pub fn navigate(entry: NavigationHistoryEntry, reload: enum { none, force }, page: *Page) !NavigationReturn { + const arena = page.session.arena; + const url = entry.url orelse return error.MissingURL; + + // https://github.com/WICG/navigation-api/issues/95 + // + // These will only settle on same-origin navigation (mostly intended for SPAs). + // It is fine (and expected) for these to not settle on cross-origin requests :) + const committed = try page.js.createPromiseResolver(.page); + const finished = try page.js.createPromiseResolver(.page); + + const new_url = try URL.parse(url, null); + if (try page.url.eqlDocument(&new_url, arena) or reload == .force) { + page.url = new_url; + try committed.resolve({}); + + // todo: Fire navigate event + + try finished.resolve({}); + } else { + // TODO: Change to history + try page.navigateFromWebAPI(url, .{ .reason = .history }); + } + + return .{ + .committed = committed.promise(), + .finished = finished.promise(), + }; + } }; // https://developer.mozilla.org/en-US/docs/Web/API/NavigationActivation @@ -124,49 +161,61 @@ pub fn get_canGoForward(self: *const Navigation) bool { return self.entries.items.len > self.index + 1; } -pub fn get_currentEntry(_: *const Navigation) NavigationHistoryEntry { - // TODO - unreachable; +pub fn currentEntry(self: *Navigation) *NavigationHistoryEntry { + return &self.entries.items[self.index]; +} + +pub fn get_currentEntry(self: *const Navigation) NavigationHistoryEntry { + return self.entries.items[self.index]; } const NavigationReturn = struct { - comitted: Js.Promise, - finished: Js.Promise, + committed: js.Promise, + finished: js.Promise, }; -pub fn _back(_: *const Navigation) !NavigationReturn { - unreachable; +pub fn _back(self: *Navigation, page: *Page) !NavigationReturn { + if (!self.get_canGoBack()) { + return error.InvalidStateError; + } + + const new_index = self.index - 1; + const next_entry = self.entries.items[new_index]; + self.index = new_index; + + return next_entry.navigate(.none, page); } pub fn _entries(self: *const Navigation) []NavigationHistoryEntry { return self.entries.items; } -pub fn _forward(_: *const Navigation) !NavigationReturn { - unreachable; +pub fn _forward(self: *Navigation, page: *Page) !NavigationReturn { + if (!self.get_canGoForward()) { + return error.InvalidStateError; + } + + const new_index = self.index + 1; + const next_entry = self.entries.items[new_index]; + self.index = new_index; + + return next_entry.navigate(.none, page); } -const NavigateOptions = struct { - const NavigateOptionsHistory = enum { - auto, - push, - replace, - }; - - state: ?Js.Object = null, - info: ?Js.Object = null, - history: NavigateOptionsHistory = .auto, -}; - -pub fn _navigate(self: *Navigation, _url: []const u8, _opts: ?NavigateOptions, page: *Page) !NavigationReturn { +/// Pushes an entry into the Navigation stack WITHOUT actually navigating to it. +/// For that, use `navigate`. +pub fn pushEntry(self: *Navigation, _url: ?[]const u8, _opts: ?NavigateOptions, page: *Page) !NavigationHistoryEntry { const arena = page.session.arena; const options = _opts orelse NavigateOptions{}; - const url = try arena.dupe(u8, _url); + const url = if (_url) |u| try arena.dupe(u8, u) else null; - // TODO: handle push history NotSupportedError. + // truncates our history here. + if (self.entries.items.len > self.index + 1) { + self.entries.shrinkRetainingCapacity(self.index + 1); + } + self.index = self.entries.items.len; - const index = self.entries.items.len; const id = self.next_entry_id; self.next_entry_id += 1; @@ -174,7 +223,7 @@ pub fn _navigate(self: *Navigation, _url: []const u8, _opts: ?NavigateOptions, p const state: ?[]const u8 = blk: { if (options.state) |s| { - break :blk try s.toJson(arena); + break :blk s.toJson(arena) catch return error.DataClone; } else { break :blk null; } @@ -182,40 +231,70 @@ pub fn _navigate(self: *Navigation, _url: []const u8, _opts: ?NavigateOptions, p const entry = NavigationHistoryEntry{ .id = id_str, - .index = index, - .same_document = false, - .url = url, .key = id_str, + .url = url, .state = state, }; try self.entries.append(arena, entry); - // https://github.com/WICG/navigation-api/issues/95 - // - // These will only settle on same-origin navigation (mostly intended for SPAs). - // It is fine (and expected) for these to not settle on cross-origin requests :) - const committed = try page.main_context.createPersistentPromiseResolver(.page); - const finished = try page.main_context.createPersistentPromiseResolver(.page); - - if (entry.same_document) { - page.url = try URL.parse(url, null); - try committed.resolve(void); - - // todo: Fire navigate event - // - - } else { - page.navigateFromWebAPI(url, .{ .reason = .navigation }); - } - - return .{ - .comitted = committed, - .finished = finished, - }; + return entry; } -// const testing = @import("../../testing.zig"); -// test "Browser: Navigation" { -// try testing.htmlRunner("html/navigation.html"); -// } +const NavigateOptions = struct { + const NavigateOptionsHistory = enum { + pub const ENUM_JS_USE_TAG = true; + + auto, + push, + replace, + }; + + state: ?js.Object = null, + info: ?js.Object = null, + history: NavigateOptionsHistory = .auto, +}; + +pub fn _navigate(self: *Navigation, _url: []const u8, _opts: ?NavigateOptions, page: *Page) !NavigationReturn { + const entry = try self.pushEntry(_url, _opts, page); + return entry.navigate(.none, page); +} + +pub const ReloadOptions = struct { + state: ?js.Object = null, + info: ?js.Object = null, +}; + +pub fn _reload(self: *Navigation, _opts: ?ReloadOptions, page: *Page) !NavigationReturn { + const arena = page.session.arena; + + const opts = _opts orelse ReloadOptions{}; + const entry = self.currentEntry(); + if (opts.state) |state| { + entry.state = state.toJson(arena) catch return error.DataClone; + } + + return entry.navigate(.force, page); +} + +pub fn _transition(_: *const Navigation) !NavigationReturn { + unreachable; +} + +pub fn _traverseTo(_: *const Navigation, _: []const u8) !NavigationReturn { + unreachable; +} + +pub const UpdateCurrentEntryOptions = struct { + state: js.Object, +}; + +pub fn _updateCurrentEntry(self: *Navigation, options: UpdateCurrentEntryOptions, page: *Page) !void { + const arena = page.session.arena; + self.currentEntry().state = options.state.toJson(arena) catch return error.DataClone; +} + +const testing = @import("../../testing.zig"); +test "Browser: Navigation" { + try testing.htmlRunner("html/navigation.html"); +} diff --git a/src/browser/html/html.zig b/src/browser/html/html.zig index ef8a99f7..d5aa81a1 100644 --- a/src/browser/html/html.zig +++ b/src/browser/html/html.zig @@ -34,6 +34,7 @@ pub const Interfaces = .{ Window, Navigator, History, + @import("Navigation.zig").Interfaces, Location, MediaQueryList, @import("DataSet.zig"), diff --git a/src/browser/html/window.zig b/src/browser/html/window.zig index ef015492..e6ea1963 100644 --- a/src/browser/html/window.zig +++ b/src/browser/html/window.zig @@ -25,6 +25,7 @@ const Page = @import("../page.zig").Page; const Navigator = @import("navigator.zig").Navigator; const History = @import("History.zig"); +const Navigation = @import("Navigation.zig"); const Location = @import("location.zig").Location; const Crypto = @import("../crypto/crypto.zig").Crypto; const Console = @import("../console/console.zig").Console; @@ -190,6 +191,10 @@ pub const Window = struct { return &page.session.history; } + pub fn get_navigation(_: *Window, page: *Page) *Navigation { + return &page.session.navigation; + } + // The interior height of the window in pixels, including the height of the horizontal scroll bar, if present. pub fn get_innerHeight(_: *Window, page: *Page) u32 { // We do not have scrollbars or padding so this is the same as Element.clientHeight From f97697535f2e5233028e583a72e111a0d4b08404 Mon Sep 17 00:00:00 2001 From: Muki Kiboigo Date: Sun, 12 Oct 2025 16:03:52 -0700 Subject: [PATCH 05/20] History as compat layer over Navigation --- src/browser/html/History.zig | 81 +++++++++++----------------------- src/browser/page.zig | 2 +- src/tests/html/history.html | 1 - src/tests/html/navigation.html | 49 ++++++++++++++++++++ 4 files changed, 76 insertions(+), 57 deletions(-) create mode 100644 src/tests/html/navigation.html diff --git a/src/browser/html/History.zig b/src/browser/html/History.zig index b920d66d..d67da3f5 100644 --- a/src/browser/html/History.zig +++ b/src/browser/html/History.zig @@ -25,13 +25,6 @@ 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 { pub const ENUM_JS_USE_TAG = true; @@ -40,11 +33,9 @@ const ScrollRestorationMode = enum { }; 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_length(_: *History, page: *Page) u32 { + return @intCast(page.session.navigation.entries.items.len); } pub fn get_scrollRestoration(self: *History) ScrollRestorationMode { @@ -55,29 +46,15 @@ pub fn set_scrollRestoration(self: *History, mode: ScrollRestorationMode) void { self.scroll_restoration = mode; } -pub fn get_state(self: *History, page: *Page) !?js.Value { - if (self.current) |curr| { - const entry = self.stack.items[curr]; - if (entry.state) |state| { - const value = try js.Value.fromJson(page.js, state); - return value; - } else { - return null; - } +pub fn get_state(_: *History, page: *Page) !?js.Value { + if (page.session.navigation.currentEntry().state) |state| { + const value = try js.Value.fromJson(page.js, state); + return value; } 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", @@ -101,48 +78,42 @@ fn _dispatchPopStateEvent(state: ?[]const u8, page: *Page) !void { ); } -pub fn _pushState(self: *History, state: js.Object, _: ?[]const u8, _url: ?[]const u8, page: *Page) !void { +pub fn _pushState(_: *const History, state: js.Object, _: ?[]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); + _ = try page.session.navigation.pushEntry(url, .{ .state = state }, page); +} + +pub fn _replaceState(_: *const History, state: js.Object, _: ?[]const u8, _url: ?[]const u8, page: *Page) !void { const arena = page.session.arena; + const entry = page.session.navigation.currentEntry(); 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; + + entry.state = json; + entry.url = url; } -pub fn _replaceState(self: *History, state: js.Object, _: ?[]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 { +pub fn go(_: *const 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 current = page.session.navigation.index; 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) { + if (index_s < 0 or index_s > page.session.navigation.entries.items.len - 1) { return; } const index = @as(usize, @intCast(index_s)); - const entry = self.stack.items[index]; - self.current = index; + const entry = page.session.navigation.entries.items[index]; - if (try page.isSameOrigin(entry.url)) { - History.dispatchPopStateEvent(entry.state, page); + if (entry.url) |url| { + if (try page.isSameOrigin(url)) { + History.dispatchPopStateEvent(entry.state, page); + } } - try page.navigateFromWebAPI(entry.url, .{ .reason = .history }); + _ = try entry.navigate(page, .force); } pub fn _go(self: *History, _delta: ?i32, page: *Page) !void { diff --git a/src/browser/page.zig b/src/browser/page.zig index 833c683f..f6283f07 100644 --- a/src/browser/page.zig +++ b/src/browser/page.zig @@ -816,7 +816,7 @@ pub const Page = struct { } // Push the navigation after a successful load. - try self.session.history.pushNavigation(self.url.raw, self); + _ = try self.session.navigation.pushEntry(self.url.raw, null, self); } fn pageErrorCallback(ctx: *anyopaque, err: anyerror) void { diff --git a/src/tests/html/history.html b/src/tests/html/history.html index 2c5591a4..0f4ff95f 100644 --- a/src/tests/html/history.html +++ b/src/tests/html/history.html @@ -5,7 +5,6 @@ testing.expectEqual('auto', history.scrollRestoration); history.scrollRestoration = 'manual'; - history.scrollRestoration = 'foo'; testing.expectEqual('manual', history.scrollRestoration); history.scrollRestoration = 'auto'; diff --git a/src/tests/html/navigation.html b/src/tests/html/navigation.html new file mode 100644 index 00000000..f1ff61fb --- /dev/null +++ b/src/tests/html/navigation.html @@ -0,0 +1,49 @@ + + + From e9b08f19cfd0d7dc8b8872eaa8468b9fa3c74326 Mon Sep 17 00:00:00 2001 From: Muki Kiboigo Date: Fri, 10 Oct 2025 04:02:42 -0700 Subject: [PATCH 06/20] fix navigation and related tests --- src/browser/html/History.zig | 6 +- src/browser/html/Navigation.zig | 175 ++++++++++++++++++++++---------- src/browser/html/document.zig | 2 +- src/browser/html/location.zig | 6 +- src/browser/html/window.zig | 2 +- src/browser/page.zig | 14 ++- src/browser/session.zig | 2 + src/cdp/domains/page.zig | 2 +- src/testing.zig | 10 +- src/tests/html/history.html | 11 +- src/tests/html/history2.html | 6 ++ src/tests/html/navigation.html | 47 ++------- src/tests/html/navigation2.html | 8 ++ src/tests/testing.js | 23 ----- 14 files changed, 168 insertions(+), 146 deletions(-) create mode 100644 src/tests/html/history2.html create mode 100644 src/tests/html/navigation2.html diff --git a/src/browser/html/History.zig b/src/browser/html/History.zig index d67da3f5..5ef5ac3a 100644 --- a/src/browser/html/History.zig +++ b/src/browser/html/History.zig @@ -81,7 +81,9 @@ fn _dispatchPopStateEvent(state: ?[]const u8, page: *Page) !void { pub fn _pushState(_: *const History, state: js.Object, _: ?[]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); - _ = try page.session.navigation.pushEntry(url, .{ .state = state }, page); + + const json = state.toJson(arena) catch return error.DataClone; + _ = try page.session.navigation.pushEntry(url, json, page); } pub fn _replaceState(_: *const History, state: js.Object, _: ?[]const u8, _url: ?[]const u8, page: *Page) !void { @@ -113,7 +115,7 @@ pub fn go(_: *const History, delta: i32, page: *Page) !void { } } - _ = try entry.navigate(page, .force); + _ = try page.session.navigation.navigate(entry.url, .{ .traverse = index }, page); } pub fn _go(self: *History, _delta: ?i32, page: *Page) !void { diff --git a/src/browser/html/Navigation.zig b/src/browser/html/Navigation.zig index 7968d821..c7eb9276 100644 --- a/src/browser/html/Navigation.zig +++ b/src/browser/html/Navigation.zig @@ -34,16 +34,24 @@ const Navigation = @This(); pub const Interfaces = .{ Navigation, NavigationActivation, + NavigationTransition, NavigationHistoryEntry, }; +pub const NavigationKind = union(enum) { + initial, + push: ?[]const u8, + replace, + traverse: usize, + reload, +}; + pub const prototype = *EventTarget; base: parser.EventTargetTBase = parser.EventTargetTBase{ .internal_target_type = .plain }, index: usize = 0, entries: std.ArrayListUnmanaged(NavigationHistoryEntry) = .empty, next_entry_id: usize = 0, -// TODO: key->index mapping // https://developer.mozilla.org/en-US/docs/Web/API/NavigationHistoryEntry const NavigationHistoryEntry = struct { @@ -91,49 +99,17 @@ const NavigationHistoryEntry = struct { return null; } } - - pub fn navigate(entry: NavigationHistoryEntry, reload: enum { none, force }, page: *Page) !NavigationReturn { - const arena = page.session.arena; - const url = entry.url orelse return error.MissingURL; - - // https://github.com/WICG/navigation-api/issues/95 - // - // These will only settle on same-origin navigation (mostly intended for SPAs). - // It is fine (and expected) for these to not settle on cross-origin requests :) - const committed = try page.js.createPromiseResolver(.page); - const finished = try page.js.createPromiseResolver(.page); - - const new_url = try URL.parse(url, null); - if (try page.url.eqlDocument(&new_url, arena) or reload == .force) { - page.url = new_url; - try committed.resolve({}); - - // todo: Fire navigate event - - try finished.resolve({}); - } else { - // TODO: Change to history - try page.navigateFromWebAPI(url, .{ .reason = .history }); - } - - return .{ - .committed = committed.promise(), - .finished = finished.promise(), - }; - } }; // https://developer.mozilla.org/en-US/docs/Web/API/NavigationActivation const NavigationActivation = struct { const NavigationActivationType = enum { + pub const ENUM_JS_USE_TAG = true; + push, reload, replace, traverse, - - pub fn toString(self: NavigationActivationType) []const u8 { - return @tagName(self); - } }; entry: NavigationHistoryEntry, @@ -153,6 +129,13 @@ const NavigationActivation = struct { } }; +// https://developer.mozilla.org/en-US/docs/Web/API/NavigationTransition +const NavigationTransition = struct { + finished: js.Promise, + from: NavigationHistoryEntry, + navigation_type: NavigationActivation.NavigationActivationType, +}; + pub fn get_canGoBack(self: *const Navigation) bool { return self.index > 0; } @@ -169,6 +152,11 @@ pub fn get_currentEntry(self: *const Navigation) NavigationHistoryEntry { return self.entries.items[self.index]; } +pub fn get_transition(_: *const Navigation) ?NavigationTransition { + // For now, all transitions are just considered complete. + return null; +} + const NavigationReturn = struct { committed: js.Promise, finished: js.Promise, @@ -183,7 +171,7 @@ pub fn _back(self: *Navigation, page: *Page) !NavigationReturn { const next_entry = self.entries.items[new_index]; self.index = new_index; - return next_entry.navigate(.none, page); + return self.navigate(next_entry.url, .{ .traverse = new_index }, page); } pub fn _entries(self: *const Navigation) []NavigationHistoryEntry { @@ -199,15 +187,33 @@ pub fn _forward(self: *Navigation, page: *Page) !NavigationReturn { const next_entry = self.entries.items[new_index]; self.index = new_index; - return next_entry.navigate(.none, page); + return self.navigate(next_entry.url, .{ .traverse = new_index }, page); +} + +// This is for after true navigation processing, where we need to ensure that our entries are up to date. +pub fn processNavigation(self: *Navigation, url: []const u8, kind: NavigationKind, page: *Page) !void { + switch (kind) { + .initial => { + _ = try self.pushEntry(url, null, page); + }, + .replace => { + // When replacing, we just update the URL but the state is nullified. + const entry = self.currentEntry(); + entry.url = url; + entry.state = null; + }, + .push => |state| { + _ = try self.pushEntry(url, state, page); + }, + .traverse, .reload => {}, + } } /// Pushes an entry into the Navigation stack WITHOUT actually navigating to it. /// For that, use `navigate`. -pub fn pushEntry(self: *Navigation, _url: ?[]const u8, _opts: ?NavigateOptions, page: *Page) !NavigationHistoryEntry { +pub fn pushEntry(self: *Navigation, _url: ?[]const u8, state: ?[]const u8, page: *Page) !NavigationHistoryEntry { const arena = page.session.arena; - const options = _opts orelse NavigateOptions{}; const url = if (_url) |u| try arena.dupe(u8, u) else null; // truncates our history here. @@ -221,14 +227,6 @@ pub fn pushEntry(self: *Navigation, _url: ?[]const u8, _opts: ?NavigateOptions, const id_str = try std.fmt.allocPrint(arena, "{d}", .{id}); - const state: ?[]const u8 = blk: { - if (options.state) |s| { - break :blk s.toJson(arena) catch return error.DataClone; - } else { - break :blk null; - } - }; - const entry = NavigationHistoryEntry{ .id = id_str, .key = id_str, @@ -237,7 +235,6 @@ pub fn pushEntry(self: *Navigation, _url: ?[]const u8, _opts: ?NavigateOptions, }; try self.entries.append(arena, entry); - return entry; } @@ -255,9 +252,67 @@ const NavigateOptions = struct { history: NavigateOptionsHistory = .auto, }; +pub fn navigate( + self: *Navigation, + _url: ?[]const u8, + kind: NavigationKind, + page: *Page, +) !NavigationReturn { + const arena = page.session.arena; + const url = _url orelse return error.MissingURL; + + // https://github.com/WICG/navigation-api/issues/95 + // + // These will only settle on same-origin navigation (mostly intended for SPAs). + // It is fine (and expected) for these to not settle on cross-origin requests :) + const committed = try page.js.createPromiseResolver(.page); + const finished = try page.js.createPromiseResolver(.page); + + const new_url = try URL.parse(url, null); + const is_same_document = try page.url.eqlDocument(&new_url, arena); + + switch (kind) { + .push => |state| { + if (is_same_document) { + page.url = new_url; + try committed.resolve({}); + // todo: Fire navigate event + try finished.resolve({}); + + _ = try self.pushEntry(url, state, page); + } else { + try page.navigateFromWebAPI(url, .{ .reason = .navigation }, kind); + } + }, + .traverse => |index| { + self.index = index; + + if (is_same_document) { + page.url = new_url; + + try committed.resolve({}); + // todo: Fire navigate event + try finished.resolve({}); + } else { + try page.navigateFromWebAPI(url, .{ .reason = .navigation }, kind); + } + }, + .reload => { + try page.navigateFromWebAPI(url, .{ .reason = .navigation }, kind); + }, + else => unreachable, + } + + return .{ + .committed = committed.promise(), + .finished = finished.promise(), + }; +} + pub fn _navigate(self: *Navigation, _url: []const u8, _opts: ?NavigateOptions, page: *Page) !NavigationReturn { - const entry = try self.pushEntry(_url, _opts, page); - return entry.navigate(.none, page); + const opts = _opts orelse NavigateOptions{}; + const json = if (opts.state) |state| state.toJson(page.session.arena) catch return error.DataClone else null; + return try self.navigate(_url, .{ .push = json }, page); } pub const ReloadOptions = struct { @@ -274,15 +329,23 @@ pub fn _reload(self: *Navigation, _opts: ?ReloadOptions, page: *Page) !Navigatio entry.state = state.toJson(arena) catch return error.DataClone; } - return entry.navigate(.force, page); + return self.navigate(entry.url, .reload, page); } -pub fn _transition(_: *const Navigation) !NavigationReturn { - unreachable; -} +pub const TraverseToOptions = struct { + info: ?js.Object = null, +}; -pub fn _traverseTo(_: *const Navigation, _: []const u8) !NavigationReturn { - unreachable; +pub fn _traverseTo(self: *Navigation, key: []const u8, _: ?TraverseToOptions, page: *Page) !NavigationReturn { + // const opts = _opts orelse TraverseToOptions{}; + + for (self.entries.items, 0..) |entry, i| { + if (std.mem.eql(u8, key, entry.key)) { + return try self.navigate(entry.url, .{ .traverse = i }, page); + } + } + + return error.InvalidStateError; } pub const UpdateCurrentEntryOptions = struct { diff --git a/src/browser/html/document.zig b/src/browser/html/document.zig index 4020b498..d0fee6d2 100644 --- a/src/browser/html/document.zig +++ b/src/browser/html/document.zig @@ -195,7 +195,7 @@ pub const HTMLDocument = struct { } pub fn set_location(_: *const parser.DocumentHTML, url: []const u8, page: *Page) !void { - return page.navigateFromWebAPI(url, .{ .reason = .script }); + return page.navigateFromWebAPI(url, .{ .reason = .script }, .{ .push = null }); } pub fn get_designMode(_: *parser.DocumentHTML) []const u8 { diff --git a/src/browser/html/location.zig b/src/browser/html/location.zig index 3e3e593b..220b4974 100644 --- a/src/browser/html/location.zig +++ b/src/browser/html/location.zig @@ -74,15 +74,15 @@ pub const Location = struct { } pub fn _assign(_: *const Location, url: []const u8, page: *Page) !void { - return page.navigateFromWebAPI(url, .{ .reason = .script }); + return page.navigateFromWebAPI(url, .{ .reason = .script }, .{ .push = null }); } pub fn _replace(_: *const Location, url: []const u8, page: *Page) !void { - return page.navigateFromWebAPI(url, .{ .reason = .script }); + return page.navigateFromWebAPI(url, .{ .reason = .script }, .replace); } pub fn _reload(_: *const Location, page: *Page) !void { - return page.navigateFromWebAPI(page.url.raw, .{ .reason = .script }); + return page.navigateFromWebAPI(page.url.raw, .{ .reason = .script }, .reload); } pub fn _toString(self: *Location, page: *Page) ![]const u8 { diff --git a/src/browser/html/window.zig b/src/browser/html/window.zig index e6ea1963..3947c526 100644 --- a/src/browser/html/window.zig +++ b/src/browser/html/window.zig @@ -143,7 +143,7 @@ pub const Window = struct { } pub fn set_location(_: *const Window, url: []const u8, page: *Page) !void { - return page.navigateFromWebAPI(url, .{ .reason = .script }); + return page.navigateFromWebAPI(url, .{ .reason = .script }, .{ .push = null }); } // frames return the window itself, but accessing it via a pseudo diff --git a/src/browser/page.zig b/src/browser/page.zig index f6283f07..15caacad 100644 --- a/src/browser/page.zig +++ b/src/browser/page.zig @@ -34,6 +34,7 @@ const Http = @import("../http/Http.zig"); const ScriptManager = @import("ScriptManager.zig"); const SlotChangeMonitor = @import("SlotChangeMonitor.zig"); const HTMLDocument = @import("html/document.zig").HTMLDocument; +const NavigationKind = @import("html/Navigation.zig").NavigationKind; const js = @import("js/js.zig"); const URL = @import("../url.zig").URL; @@ -815,8 +816,8 @@ pub const Page = struct { }, } - // Push the navigation after a successful load. - _ = try self.session.navigation.pushEntry(self.url.raw, null, self); + // We need to handle different navigation types differently. + try self.session.navigation.processNavigation(self.url.raw, self.session.navigation_kind, self); } fn pageErrorCallback(ctx: *anyopaque, err: anyerror) void { @@ -906,7 +907,7 @@ pub const Page = struct { .a => { const element: *parser.Element = @ptrCast(node); const href = (try parser.elementGetAttribute(element, "href")) orelse return; - try self.navigateFromWebAPI(href, .{}); + try self.navigateFromWebAPI(href, .{}, .{ .push = null }); }, .input => { const element: *parser.Element = @ptrCast(node); @@ -1043,7 +1044,7 @@ pub const Page = struct { // As such we schedule the function to be called as soon as possible. // The page.arena is safe to use here, but the transfer_arena exists // specifically for this type of lifetime. - pub fn navigateFromWebAPI(self: *Page, url: []const u8, opts: NavigateOpts) !void { + pub fn navigateFromWebAPI(self: *Page, url: []const u8, opts: NavigateOpts, kind: NavigationKind) !void { const session = self.session; if (session.queued_navigation != null) { // It might seem like this should never happen. And it might not, @@ -1070,6 +1071,8 @@ pub const Page = struct { .url = try URL.stitch(session.transfer_arena, url, self.url.raw, .{ .alloc = .always }), }; + session.navigation_kind = kind; + self.http_client.abort(); // In v8, this throws an exception which JS code cannot catch. @@ -1120,7 +1123,7 @@ pub const Page = struct { } else { action = try URL.concatQueryString(transfer_arena, action, buf.items); } - try self.navigateFromWebAPI(action, opts); + try self.navigateFromWebAPI(action, opts, .{ .push = null }); } pub fn isNodeAttached(self: *const Page, node: *parser.Node) bool { @@ -1178,6 +1181,7 @@ pub const NavigateReason = enum { form, script, history, + navigation, }; pub const NavigateOpts = struct { diff --git a/src/browser/session.zig b/src/browser/session.zig index 3456f159..d491cf27 100644 --- a/src/browser/session.zig +++ b/src/browser/session.zig @@ -22,6 +22,7 @@ const Allocator = std.mem.Allocator; const js = @import("js/js.zig"); const Page = @import("page.zig").Page; +const NavigationKind = @import("html/Navigation.zig").NavigationKind; const Browser = @import("browser.zig").Browser; const NavigateOpts = @import("page.zig").NavigateOpts; const History = @import("html/History.zig"); @@ -59,6 +60,7 @@ pub const Session = struct { // https://developer.mozilla.org/en-US/docs/Web/API/History history: History = .{}, navigation: Navigation = .{}, + navigation_kind: NavigationKind = .initial, page: ?Page = null, diff --git a/src/cdp/domains/page.zig b/src/cdp/domains/page.zig index 1f6b720a..c7bfcfb9 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, .history => "scriptInitiated", + .script, .history, .navigation => "scriptInitiated", .form => switch (event.opts.method) { .GET => "formSubmissionGet", .POST => "formSubmissionPost", diff --git a/src/testing.zig b/src/testing.zig index 93c1abad..92ae34f0 100644 --- a/src/testing.zig +++ b/src/testing.zig @@ -402,19 +402,13 @@ pub fn htmlRunner(file: []const u8) !void { const url = try std.fmt.allocPrint(arena_allocator, "http://localhost:9582/src/tests/{s}", .{file}); try page.navigate(url, .{}); - _ = page.wait(2000); + test_session.fetchWait(2000); + // page exits more aggressively in tests. We want to make sure this is called // at lease once. page.session.browser.runMicrotasks(); page.session.browser.runMessageLoop(); - const needs_second_wait = try js_context.exec("testing._onPageWait.length > 0", "check_onPageWait"); - if (needs_second_wait.value.toBool(page.js.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 0f4ff95f..60b54b52 100644 --- a/src/tests/html/history.html +++ b/src/tests/html/history.html @@ -11,10 +11,12 @@ testing.expectEqual('auto', history.scrollRestoration); testing.expectEqual(null, history.state) - history.pushState({ testInProgress: true }, null, 'http://127.0.0.1:9582/xhr/json'); + history.pushState({ testInProgress: true }, null, 'http://127.0.0.1:9582/src/tests/html/history2.html'); testing.expectEqual({ testInProgress: true }, history.state); + history.pushState({ testInProgress: false }, null, 'http://127.0.0.1:9582/xhr/json'); history.replaceState({ "new": "field", testComplete: true }, null); + let state = { "new": "field", testComplete: true }; testing.expectEqual(state, history.state); @@ -31,10 +33,5 @@ testing.expectEqual(state, popstateEventState); }) - testing.onPageWait(() => { - testing.expectEqual(true, history.state && history.state.testComplete); - testing.expectEqual(state, history.state); - }); - - testing.expectEqual(undefined, history.go()); + history.back(); diff --git a/src/tests/html/history2.html b/src/tests/html/history2.html new file mode 100644 index 00000000..735c71e9 --- /dev/null +++ b/src/tests/html/history2.html @@ -0,0 +1,6 @@ + + + + diff --git a/src/tests/html/navigation.html b/src/tests/html/navigation.html index f1ff61fb..e3744f55 100644 --- a/src/tests/html/navigation.html +++ b/src/tests/html/navigation.html @@ -1,5 +1,6 @@ + diff --git a/src/tests/html/navigation2.html b/src/tests/html/navigation2.html new file mode 100644 index 00000000..3b8ad282 --- /dev/null +++ b/src/tests/html/navigation2.html @@ -0,0 +1,8 @@ + + + + diff --git a/src/tests/testing.js b/src/tests/testing.js index 779cca8c..e83744ee 100644 --- a/src/tests/testing.js +++ b/src/tests/testing.js @@ -51,14 +51,6 @@ // 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]; @@ -101,18 +93,6 @@ _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 : ' + + + diff --git a/src/tests/html/history2.html b/src/tests/html/history/history_after_nav.html similarity index 75% rename from src/tests/html/history2.html rename to src/tests/html/history/history_after_nav.html index 735c71e9..d9e4e66d 100644 --- a/src/tests/html/history2.html +++ b/src/tests/html/history/history_after_nav.html @@ -1,5 +1,5 @@ - + + diff --git a/src/tests/html/navigation2.html b/src/tests/html/navigation/navigation2.html similarity index 85% rename from src/tests/html/navigation2.html rename to src/tests/html/navigation/navigation2.html index 3b8ad282..b16fa917 100644 --- a/src/tests/html/navigation2.html +++ b/src/tests/html/navigation/navigation2.html @@ -1,5 +1,5 @@ - + diff --git a/src/tests/html/navigation/navigation_currententrychange.html b/src/tests/html/navigation/navigation_currententrychange.html new file mode 100644 index 00000000..c84bcbad --- /dev/null +++ b/src/tests/html/navigation/navigation_currententrychange.html @@ -0,0 +1,15 @@ + + + + From 8b4ffeb9112c43d1c8f0538c9d99f6a2ff01cc94 Mon Sep 17 00:00:00 2001 From: Muki Kiboigo Date: Wed, 15 Oct 2025 07:53:58 -0700 Subject: [PATCH 12/20] fix NavigationCurrentEntryChange Constructor --- src/browser/html/location.zig | 6 +----- src/browser/navigation/navigation.zig | 6 +++--- src/browser/page.zig | 2 +- 3 files changed, 5 insertions(+), 9 deletions(-) diff --git a/src/browser/html/location.zig b/src/browser/html/location.zig index abe097bc..ea7d45e1 100644 --- a/src/browser/html/location.zig +++ b/src/browser/html/location.zig @@ -38,7 +38,7 @@ pub const Location = struct { } pub fn set_href(_: *const Location, href: []const u8, page: *Page) !void { - return page.navigateFromWebAPI(href, .{ .reason = .script }); + return page.navigateFromWebAPI(href, .{ .reason = .script }, .{ .push = null }); } pub fn get_protocol(self: *Location) []const u8 { @@ -73,10 +73,6 @@ pub const Location = struct { return self.url.get_origin(page); } - pub fn set_href(_: *Location, url: []const u8, page: *Page) !void { - return page.navigateFromWebAPI(url, .{ .reason = .script }, .{ .push = null }); - } - pub fn _assign(_: *const Location, url: []const u8, page: *Page) !void { return page.navigateFromWebAPI(url, .{ .reason = .script }, .{ .push = null }); } diff --git a/src/browser/navigation/navigation.zig b/src/browser/navigation/navigation.zig index 9e08bf04..469e13fc 100644 --- a/src/browser/navigation/navigation.zig +++ b/src/browser/navigation/navigation.zig @@ -147,7 +147,7 @@ pub const NavigationCurrentEntryChangeEvent = struct { pub const EventInit = struct { from: *NavigationHistoryEntry, - navigation_type: ?NavigationType = null, + navigationType: ?NavigationType = null, }; proto: parser.Event, @@ -164,7 +164,7 @@ pub const NavigationCurrentEntryChangeEvent = struct { return .{ .proto = event.*, .from = opts.from, - .navigation_type = opts.navigation_type, + .navigation_type = opts.navigationType, }; } @@ -184,7 +184,7 @@ pub const NavigationCurrentEntryChangeEvent = struct { var evt = NavigationCurrentEntryChangeEvent.constructor( "currententrychange", - .{ .from = from, .navigation_type = typ }, + .{ .from = from, .navigationType = typ }, ) catch |err| { log.err(.app, "event constructor error", .{ .err = err, diff --git a/src/browser/page.zig b/src/browser/page.zig index ff6ecfbe..2e7bf9a2 100644 --- a/src/browser/page.zig +++ b/src/browser/page.zig @@ -34,7 +34,7 @@ const Http = @import("../http/Http.zig"); const ScriptManager = @import("ScriptManager.zig"); const SlotChangeMonitor = @import("SlotChangeMonitor.zig"); const HTMLDocument = @import("html/document.zig").HTMLDocument; -const NavigationKind = @import("html/Navigation.zig").NavigationKind; +const NavigationKind = @import("navigation/navigation.zig").NavigationKind; const js = @import("js/js.zig"); const URL = @import("../url.zig").URL; From 9778eed1edc64be8e85636b1da3ea1442c9fd5ec Mon Sep 17 00:00:00 2001 From: Muki Kiboigo Date: Wed, 15 Oct 2025 22:33:12 -0700 Subject: [PATCH 13/20] clean up Navigation test names --- src/browser/navigation/navigation.zig | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/browser/navigation/navigation.zig b/src/browser/navigation/navigation.zig index 469e13fc..cfec6718 100644 --- a/src/browser/navigation/navigation.zig +++ b/src/browser/navigation/navigation.zig @@ -211,9 +211,5 @@ pub const NavigationCurrentEntryChangeEvent = struct { const testing = @import("../../testing.zig"); test "Browser: Navigation" { try testing.htmlRunner("html/navigation/navigation.html"); - // try testing.htmlRunner("html/navigation/navigation_currententrychange.html"); -} - -test "Browser: NavigationCurrentEntry" { try testing.htmlRunner("html/navigation/navigation_currententrychange.html"); } From 0eb639ac76a63216d90eef1691306a4c2958beb1 Mon Sep 17 00:00:00 2001 From: Muki Kiboigo Date: Fri, 17 Oct 2025 07:57:10 -0700 Subject: [PATCH 14/20] fix navigation shortcut URL stitching --- src/browser/page.zig | 25 ++++++++++++------------- 1 file changed, 12 insertions(+), 13 deletions(-) diff --git a/src/browser/page.zig b/src/browser/page.zig index 2e7bf9a2..46f6db92 100644 --- a/src/browser/page.zig +++ b/src/browser/page.zig @@ -34,7 +34,9 @@ const Http = @import("../http/Http.zig"); const ScriptManager = @import("ScriptManager.zig"); const SlotChangeMonitor = @import("SlotChangeMonitor.zig"); const HTMLDocument = @import("html/document.zig").HTMLDocument; + const NavigationKind = @import("navigation/navigation.zig").NavigationKind; +const NavigationCurrentEntryChangeEvent = @import("navigation/navigation.zig").NavigationCurrentEntryChangeEvent; const js = @import("js/js.zig"); const URL = @import("../url.zig").URL; @@ -737,9 +739,6 @@ pub const Page = struct { var self: *Page = @ptrCast(@alignCast(ctx)); self.clearTransferArena(); - // We need to handle different navigation types differently. - try self.session.navigation.processNavigation(self); - switch (self.mode) { .pre => { // Received a response without a body like: https://httpbin.io/status/200 @@ -818,6 +817,9 @@ pub const Page = struct { unreachable; }, } + + // We need to handle different navigation types differently. + try self.session.navigation.processNavigation(self); } fn pageErrorCallback(ctx: *anyopaque, err: anyerror) void { @@ -1046,22 +1048,19 @@ pub const Page = struct { // specifically for this type of lifetime. pub fn navigateFromWebAPI(self: *Page, url: []const u8, opts: NavigateOpts, kind: NavigationKind) !void { const session = self.session; + const stitched_url = try URL.stitch(session.transfer_arena, url, self.url.raw, .{ .alloc = .always }); // Force will force a page load. - // - // TODO: This needs to be reworked but just ensuring events get fired right. + // Otherwise, we need to check if this is a true navigation. if (!opts.force) { // If we are navigating within the same document, just change URL. - const new_url = try URL.parse(url, null); - if (try self.url.eqlDocument(&new_url, self.arena)) { - const new_duped_url = try session.arena.dupe(u8, url); - self.url = try URL.parse(new_duped_url, null); + const new_url = try URL.parse(stitched_url, null); + + if (try self.url.eqlDocument(&new_url, session.transfer_arena)) { + self.url = new_url; - // TODO: remove this temporary snippet. const prev = session.navigation.currentEntry(); - const NavigationCurrentEntryChangeEvent = @import("navigation/navigation.zig").NavigationCurrentEntryChangeEvent; NavigationCurrentEntryChangeEvent.dispatch(&self.session.navigation, prev, kind); - return; } } @@ -1088,7 +1087,7 @@ pub const Page = struct { session.queued_navigation = .{ .opts = opts, - .url = try URL.stitch(session.transfer_arena, url, self.url.raw, .{ .alloc = .always }), + .url = stitched_url, }; session.navigation_kind = kind; From 9c4367b26ea3d9d323a8fe1f3aa09e220a800aed Mon Sep 17 00:00:00 2001 From: Muki Kiboigo Date: Tue, 21 Oct 2025 19:23:14 -0700 Subject: [PATCH 15/20] check query on eqlDocument --- src/url.zig | 40 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 39 insertions(+), 1 deletion(-) diff --git a/src/url.zig b/src/url.zig index 62387dec..59098b6c 100644 --- a/src/url.zig +++ b/src/url.zig @@ -227,6 +227,14 @@ pub const URL = struct { const path1 = try self.uri.path.toRawMaybeAlloc(arena); const path2 = try other.uri.path.toRawMaybeAlloc(arena); + if ((self.uri.query == null) != (other.uri.query == null)) return false; + if (self.uri.query) |self_query| { + const other_query = other.uri.query.?; + const query1 = try self_query.toRawMaybeAlloc(arena); + const query2 = try other_query.toRawMaybeAlloc(arena); + if (!std.mem.eql(u8, query1, query2)) return false; + } + return std.mem.eql(u8, path1, path2); } }; @@ -603,7 +611,7 @@ test "URL: eqlDocument" { { const url1 = try URL.parse("https://lightpanda.io/about?foo=bar", null); const url2 = try URL.parse("https://lightpanda.io/about?baz=qux", null); - try testing.expectEqual(true, try url1.eqlDocument(&url2, arena)); + try testing.expectEqual(false, try url1.eqlDocument(&url2, arena)); } { @@ -623,4 +631,34 @@ test "URL: eqlDocument" { const url2 = try URL.parse("https://lightpanda.io", null); try testing.expectEqual(false, try url1.eqlDocument(&url2, arena)); } + + { + const url1 = try URL.parse("https://lightpanda.io/about?foo=bar", null); + const url2 = try URL.parse("https://lightpanda.io/about", null); + try testing.expectEqual(false, try url1.eqlDocument(&url2, arena)); + } + + { + const url1 = try URL.parse("https://lightpanda.io/about", null); + const url2 = try URL.parse("https://lightpanda.io/about?foo=bar", null); + try testing.expectEqual(false, try url1.eqlDocument(&url2, arena)); + } + + { + const url1 = try URL.parse("https://lightpanda.io/about?foo=bar", null); + const url2 = try URL.parse("https://lightpanda.io/about?foo=bar", null); + try testing.expectEqual(true, try url1.eqlDocument(&url2, arena)); + } + + { + const url1 = try URL.parse("https://lightpanda.io/about?", null); + const url2 = try URL.parse("https://lightpanda.io/about", null); + try testing.expectEqual(false, try url1.eqlDocument(&url2, arena)); + } + + { + const url1 = try URL.parse("https://duckduckgo.com/", null); + const url2 = try URL.parse("https://duckduckgo.com/?q=lightpanda", null); + try testing.expectEqual(false, try url1.eqlDocument(&url2, arena)); + } } From b40e7ece91d2dcb7c4420e49bdd0e118e6ef5de5 Mon Sep 17 00:00:00 2001 From: Muki Kiboigo Date: Tue, 21 Oct 2025 19:25:24 -0700 Subject: [PATCH 16/20] no nullable url on Navigation pushEntry --- src/browser/navigation/Navigation.zig | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/browser/navigation/Navigation.zig b/src/browser/navigation/Navigation.zig index 71fbe9d8..5610dd41 100644 --- a/src/browser/navigation/Navigation.zig +++ b/src/browser/navigation/Navigation.zig @@ -127,10 +127,10 @@ pub fn processNavigation(self: *Navigation, page: *Page) !void { /// Pushes an entry into the Navigation stack WITHOUT actually navigating to it. /// For that, use `navigate`. -pub fn pushEntry(self: *Navigation, _url: ?[]const u8, state: ?[]const u8, page: *Page, dispatch: bool) !*NavigationHistoryEntry { +pub fn pushEntry(self: *Navigation, _url: []const u8, state: ?[]const u8, page: *Page, dispatch: bool) !*NavigationHistoryEntry { const arena = page.session.arena; - const url = if (_url) |u| try arena.dupe(u8, u) else null; + const url = try arena.dupe(u8, _url); // truncates our history here. if (self.entries.items.len > self.index + 1) { From 80ae3c9fc6f24feea0eac3de6246fea4e83c70c8 Mon Sep 17 00:00:00 2001 From: Muki Kiboigo Date: Tue, 21 Oct 2025 19:26:41 -0700 Subject: [PATCH 17/20] not implemented on Navigation traverseTo --- src/browser/navigation/Navigation.zig | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/browser/navigation/Navigation.zig b/src/browser/navigation/Navigation.zig index 5610dd41..8a35aeaa 100644 --- a/src/browser/navigation/Navigation.zig +++ b/src/browser/navigation/Navigation.zig @@ -267,8 +267,10 @@ pub const TraverseToOptions = struct { info: ?js.Object = null, }; -pub fn _traverseTo(self: *Navigation, key: []const u8, _: ?TraverseToOptions, page: *Page) !NavigationReturn { - // const opts = _opts orelse TraverseToOptions{}; +pub fn _traverseTo(self: *Navigation, key: []const u8, _opts: ?TraverseToOptions, page: *Page) !NavigationReturn { + if (_opts != null) { + log.debug(.browser, "not implemented", .{ .options = _opts }); + } for (self.entries.items, 0..) |entry, i| { if (std.mem.eql(u8, key, entry.key)) { From 6b924e8a4cab351cef3291f4a582a9941c72c789 Mon Sep 17 00:00:00 2001 From: Muki Kiboigo Date: Tue, 21 Oct 2025 19:28:18 -0700 Subject: [PATCH 18/20] use toEventTarget in NavigationEventTarget --- src/browser/navigation/NavigationEventTarget.zig | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/browser/navigation/NavigationEventTarget.zig b/src/browser/navigation/NavigationEventTarget.zig index 261e60a8..7a2704ec 100644 --- a/src/browser/navigation/NavigationEventTarget.zig +++ b/src/browser/navigation/NavigationEventTarget.zig @@ -22,7 +22,7 @@ fn register( typ: []const u8, listener: EventHandler.Listener, ) !?js.Function { - const target = @as(*parser.EventTarget, @ptrCast(self)); + const target = parser.toEventTarget(NavigationEventTarget, self); // The only time this can return null if the listener is already // registered. But before calling `register`, all of our functions @@ -33,7 +33,7 @@ fn register( } fn unregister(self: *NavigationEventTarget, typ: []const u8, cbk_id: usize) !void { - const et = @as(*parser.EventTarget, @ptrCast(self)); + const et = parser.toEventTarget(NavigationEventTarget, self); // check if event target has already this listener const lst = try parser.eventTargetHasListener(et, typ, false, cbk_id); if (lst == null) { From 6e42df2e71abab610f2163ad8553e2acb6557b52 Mon Sep 17 00:00:00 2001 From: Muki Kiboigo Date: Wed, 22 Oct 2025 08:42:26 -0700 Subject: [PATCH 19/20] set oncurrententrychange callback to null --- src/browser/navigation/NavigationEventTarget.zig | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/browser/navigation/NavigationEventTarget.zig b/src/browser/navigation/NavigationEventTarget.zig index 7a2704ec..e3358a1f 100644 --- a/src/browser/navigation/NavigationEventTarget.zig +++ b/src/browser/navigation/NavigationEventTarget.zig @@ -52,5 +52,7 @@ pub fn set_oncurrententrychange(self: *NavigationEventTarget, listener: ?EventHa if (self.oncurrententrychange_cbk) |cbk| try self.unregister("currententrychange", cbk.id); if (listener) |listen| { self.oncurrententrychange_cbk = try self.register(page.arena, "currententrychange", listen); + } else { + self.oncurrententrychange_cbk = null; } } From 28ec8d4b94cf9ce95575c819ebe099ea031f5d23 Mon Sep 17 00:00:00 2001 From: Muki Kiboigo Date: Wed, 22 Oct 2025 08:42:53 -0700 Subject: [PATCH 20/20] use page arena in get_sameDocument --- src/browser/navigation/navigation.zig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/browser/navigation/navigation.zig b/src/browser/navigation/navigation.zig index cfec6718..bfe5830f 100644 --- a/src/browser/navigation/navigation.zig +++ b/src/browser/navigation/navigation.zig @@ -88,7 +88,7 @@ pub const NavigationHistoryEntry = struct { pub fn get_sameDocument(self: *const NavigationHistoryEntry, page: *Page) !bool { const _url = self.url orelse return false; const url = try URL.parse(_url, null); - return page.url.eqlDocument(&url, page.arena); + return page.url.eqlDocument(&url, page.call_arena); } pub fn get_url(self: *const NavigationHistoryEntry) ?[]const u8 {