From 370c3a49a7b7e758d06097713e37b87a27a0d7f2 Mon Sep 17 00:00:00 2001 From: Muki Kiboigo Date: Mon, 8 Dec 2025 05:16:45 -0800 Subject: [PATCH 01/19] initial Navigation --- src/browser/EventManager.zig | 2 +- src/browser/Page.zig | 26 ++ src/browser/Session.zig | 13 +- src/browser/URL.zig | 35 ++ src/browser/js/Context.zig | 2 +- src/browser/js/Value.zig | 6 + src/browser/js/bridge.zig | 4 + src/browser/webapi/Event.zig | 1 + src/browser/webapi/EventTarget.zig | 1 + src/browser/webapi/URL.zig | 1 + .../NavigationCurrentEntryChangeEvent.zig | 61 +++ src/browser/webapi/navigation/Navigation.zig | 403 ++++++++++++++++++ .../navigation/NavigationEventTarget.zig | 62 +++ .../navigation/NavigationHistoryEntry.zig | 88 ++++ src/browser/webapi/navigation/root.zig | 72 ++++ src/cdp/domains/page.zig | 2 +- 16 files changed, 775 insertions(+), 4 deletions(-) create mode 100644 src/browser/webapi/event/NavigationCurrentEntryChangeEvent.zig create mode 100644 src/browser/webapi/navigation/Navigation.zig create mode 100644 src/browser/webapi/navigation/NavigationEventTarget.zig create mode 100644 src/browser/webapi/navigation/NavigationHistoryEntry.zig create mode 100644 src/browser/webapi/navigation/root.zig diff --git a/src/browser/EventManager.zig b/src/browser/EventManager.zig index 408d67446..af117cc3c 100644 --- a/src/browser/EventManager.zig +++ b/src/browser/EventManager.zig @@ -117,7 +117,7 @@ pub fn dispatch(self: *EventManager, target: *EventTarget, event: *Event) !void switch (target._type) { .node => |node| try self.dispatchNode(node, event, &was_handled), - .xhr, .window, .abort_signal, .media_query_list, .message_port, .text_track_cue => { + .xhr, .window, .abort_signal, .media_query_list, .message_port, .text_track_cue, .navigation => { const list = self.lookup.getPtr(@intFromPtr(target)) orelse return; try self.dispatchAll(list, target, event, &was_handled); }, diff --git a/src/browser/Page.zig b/src/browser/Page.zig index 48daeeb54..b4d4ee2e6 100644 --- a/src/browser/Page.zig +++ b/src/browser/Page.zig @@ -272,6 +272,27 @@ fn registerBackgroundTasks(self: *Page) !void { } pub fn navigate(self: *Page, request_url: [:0]const u8, opts: NavigateOpts) !void { + const session = self._session; + + const resolved_url = try URL.resolve( + session.transfer_arena, + self.url, + request_url, + .{ .always_dupe = true }, + ); + + // setting opts.force = true will force a page load. + // otherwise, we will need to ensure this is a true (not document) navigation. + if (!opts.force) { + // If we are navigating within the same document, just change URL. + if (URL.eqlDocument(self.url, resolved_url)) { + self.url = resolved_url; + // 3. change window.location + try session.navigation.updateEntries("", .{ .push = null }, self, true); + return; + } + } + if (self._parse_state != .pre) { // it's possible for navigate to be called multiple times on the // same page (via CDP). We want to reset the page between each call. @@ -493,6 +514,9 @@ fn pageDoneCallback(ctx: *anyopaque) !void { var self: *Page = @ptrCast(@alignCast(ctx)); self.clearTransferArena(); + //We need to handle different navigation types differently. + try self._session.navigation.processNavigation(self); + defer if (comptime IS_DEBUG) { log.debug(.page, "page.load.complete", .{ .url = self.url }); }; @@ -1868,6 +1892,7 @@ pub const NavigateReason = enum { form, script, history, + navigation, }; pub const NavigateOpts = struct { @@ -1876,6 +1901,7 @@ pub const NavigateOpts = struct { method: Http.Method = .GET, body: ?[]const u8 = null, header: ?[:0]const u8 = null, + force: bool = false, }; const RequestCookieOpts = struct { diff --git a/src/browser/Session.zig b/src/browser/Session.zig index cabbf5519..d8b4f0d1b 100644 --- a/src/browser/Session.zig +++ b/src/browser/Session.zig @@ -22,6 +22,7 @@ const log = @import("../log.zig"); const js = @import("js/js.zig"); const storage = @import("webapi/storage/storage.zig"); +const Navigation = @import("webapi/navigation/Navigation.zig"); const Page = @import("Page.zig"); const Browser = @import("Browser.zig"); @@ -54,6 +55,8 @@ executor: js.ExecutionWorld, cookie_jar: storage.Cookie.Jar, storage_shed: storage.Shed, +navigation: Navigation, + page: ?*Page = null, // If the current page want to navigate to a new page @@ -67,13 +70,16 @@ pub fn init(self: *Session, browser: *Browser) !void { errdefer executor.deinit(); const allocator = browser.app.allocator; + const session_allocator = browser.session_arena.allocator(); + self.* = .{ .browser = browser, .executor = executor, .storage_shed = .{}, .queued_navigation = null, - .arena = browser.session_arena.allocator(), + .arena = session_allocator, .cookie_jar = storage.Cookie.Jar.init(allocator), + .navigation = Navigation.init(session_allocator), .transfer_arena = browser.transfer_arena.allocator(), }; } @@ -98,6 +104,9 @@ pub fn createPage(self: *Session) !*Page { self.page = try Page.init(page_arena.allocator(), self.browser.call_arena.allocator(), self); const page = self.page.?; + // Creates a new NavigationEventTarget for this page. + try self.navigation.onNewPage(page); + log.debug(.browser, "create page", .{}); // start JS env // Inform CDP the main page has been created such that additional context for other Worlds can be created as well @@ -115,6 +124,8 @@ pub fn removePage(self: *Session) void { self.page.?.deinit(); self.page = null; + self.navigation.onRemovePage(); + log.debug(.browser, "remove page", .{}); } diff --git a/src/browser/URL.zig b/src/browser/URL.zig index 3d77acf9d..a429eae99 100644 --- a/src/browser/URL.zig +++ b/src/browser/URL.zig @@ -268,6 +268,17 @@ pub fn getHost(raw: [:0]const u8) []const u8 { return authority[0..path_start]; } +// Returns true if these two URLs point to the same document. +pub fn eqlDocument(first: [:0]const u8, second: [:0]const u8) bool { + if (!std.mem.eql(u8, getHost(first), getHost(second))) return false; + if (!std.mem.eql(u8, getPort(first), getPort(second))) return false; + if (!std.mem.eql(u8, getPathname(first), getPathname(second))) return false; + if (!std.mem.eql(u8, getSearch(first), getSearch(second))) return false; + if (!std.mem.eql(u8, getHash(first), getHash(second))) return false; + + return true; +} + const KnownProtocol = enum { @"http:", @"https:", @@ -286,6 +297,30 @@ test "URL: isCompleteHTTPUrl" { try testing.expectEqual(false, isCompleteHTTPUrl("about")); } +// TODO: uncomment +// test "URL: resolve regression (#1093)" { +// defer testing.reset(); + +// const Case = struct { +// base: []const u8, +// path: []const u8, +// expected: []const u8, +// }; + +// const cases = [_]Case{ +// .{ +// .base = "https://alas.aws.amazon.com/alas2.html", +// .path = "../static/bootstrap.min.css", +// .expected = "https://alas.aws.amazon.com/static/bootstrap.min.css", +// }, +// }; + +// for (cases) |case| { +// const result = try resolve(testing.arena_allocator, case.path, case.base, .{}); +// try testing.expectString(case.expected, result); +// } +// } + test "URL: resolve" { defer testing.reset(); diff --git a/src/browser/js/Context.zig b/src/browser/js/Context.zig index 24f52decc..3afcdbeaa 100644 --- a/src/browser/js/Context.zig +++ b/src/browser/js/Context.zig @@ -515,7 +515,7 @@ pub fn zigValueToJs(self: *Context, value: anytype, comptime opts: Caller.CallOp } if (T == js.Value) { - return value.value; + return value.js_val; } if (T == js.Promise) { diff --git a/src/browser/js/Value.zig b/src/browser/js/Value.zig index 143d221a4..8cba16884 100644 --- a/src/browser/js/Value.zig +++ b/src/browser/js/Value.zig @@ -43,6 +43,12 @@ pub fn toString(self: Value, allocator: Allocator) ![]const u8 { return self.context.valueToString(self.js_val, .{ .allocator = allocator }); } +pub fn fromJson(ctx: *js.Context, 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{ .context = ctx, .js_val = value }; +} + pub fn toObject(self: Value) js.Object { return .{ .context = self.context, diff --git a/src/browser/js/bridge.zig b/src/browser/js/bridge.zig index 7cd2aace3..5a07e5a27 100644 --- a/src/browser/js/bridge.zig +++ b/src/browser/js/bridge.zig @@ -567,6 +567,7 @@ pub const JsApis = flattenTypes(&.{ @import("../webapi/event/ErrorEvent.zig"), @import("../webapi/event/MessageEvent.zig"), @import("../webapi/event/ProgressEvent.zig"), + @import("../webapi/event/NavigationCurrentEntryChangeEvent.zig"), @import("../webapi/MessageChannel.zig"), @import("../webapi/MessagePort.zig"), @import("../webapi/media/MediaError.zig"), @@ -599,4 +600,7 @@ pub const JsApis = flattenTypes(&.{ @import("../webapi/File.zig"), @import("../webapi/Screen.zig"), @import("../webapi/PerformanceObserver.zig"), + @import("../webapi/navigation/Navigation.zig"), + @import("../webapi/navigation/NavigationEventTarget.zig"), + @import("../webapi/navigation/NavigationHistoryEntry.zig"), }); diff --git a/src/browser/webapi/Event.zig b/src/browser/webapi/Event.zig index dafb0204c..5db5de858 100644 --- a/src/browser/webapi/Event.zig +++ b/src/browser/webapi/Event.zig @@ -56,6 +56,7 @@ pub const Type = union(enum) { message_event: *@import("event/MessageEvent.zig"), progress_event: *@import("event/ProgressEvent.zig"), composition_event: *@import("event/CompositionEvent.zig"), + navigation_current_entry_change_event: *@import("event/NavigationCurrentEntryChangeEvent.zig"), }; const Options = struct { diff --git a/src/browser/webapi/EventTarget.zig b/src/browser/webapi/EventTarget.zig index 9792f2b39..4f0eabfe5 100644 --- a/src/browser/webapi/EventTarget.zig +++ b/src/browser/webapi/EventTarget.zig @@ -37,6 +37,7 @@ pub const Type = union(enum) { media_query_list: *@import("css/MediaQueryList.zig"), message_port: *@import("MessagePort.zig"), text_track_cue: *@import("media/TextTrackCue.zig"), + navigation: *@import("navigation/NavigationEventTarget.zig"), }; pub fn dispatchEvent(self: *EventTarget, event: *Event, page: *Page) !bool { diff --git a/src/browser/webapi/URL.zig b/src/browser/webapi/URL.zig index 15beb6c09..49c03b1dc 100644 --- a/src/browser/webapi/URL.zig +++ b/src/browser/webapi/URL.zig @@ -33,6 +33,7 @@ _search_params: ?*URLSearchParams = null, // convenience pub const resolve = @import("../URL.zig").resolve; +pub const eqlDocument = @import("../URL.zig").eqlDocument; pub fn init(url: [:0]const u8, base_: ?[:0]const u8, page: *Page) !*URL { const url_is_absolute = @import("../URL.zig").isCompleteHTTPUrl(url); diff --git a/src/browser/webapi/event/NavigationCurrentEntryChangeEvent.zig b/src/browser/webapi/event/NavigationCurrentEntryChangeEvent.zig new file mode 100644 index 000000000..34dc8a3c8 --- /dev/null +++ b/src/browser/webapi/event/NavigationCurrentEntryChangeEvent.zig @@ -0,0 +1,61 @@ +const std = @import("std"); +const Event = @import("../Event.zig"); +const Page = @import("../../Page.zig"); +const Navigaton = @import("../navigation/Navigation.zig"); +const NavigationHistoryEntry = @import("../navigation/NavigationHistoryEntry.zig"); +const NavigationType = @import("../navigation/root.zig").NavigationType; +const js = @import("../../js/js.zig"); + +const NavigationCurrentEntryChangeEvent = @This(); + +_proto: *Event, +_from: *NavigationHistoryEntry, +_navigation_type: ?NavigationType, + +pub const EventInit = struct { + from: *NavigationHistoryEntry, + navigationType: ?[]const u8 = null, +}; + +pub fn init( + typ: []const u8, + init_obj: EventInit, + page: *Page, +) !*NavigationCurrentEntryChangeEvent { + const navigation_type = if (init_obj.navigationType) |nav_type_str| + std.meta.stringToEnum(NavigationType, nav_type_str) + else + null; + + return page._factory.event(typ, NavigationCurrentEntryChangeEvent{ + ._proto = undefined, + ._from = init_obj.from, + ._navigation_type = navigation_type, + }); +} + +pub fn asEvent(self: *NavigationCurrentEntryChangeEvent) *Event { + return self._proto; +} + +pub fn getFrom(self: *NavigationCurrentEntryChangeEvent) *NavigationHistoryEntry { + return self._from; +} + +pub fn getNavigationType(self: *const NavigationCurrentEntryChangeEvent) ?NavigationType { + return self._navigation_type; +} + +pub const JsApi = struct { + pub const bridge = js.Bridge(NavigationCurrentEntryChangeEvent); + + pub const Meta = struct { + pub const name = "NavigationCurrentEntryChangeEvent"; + pub const prototype_chain = bridge.prototypeChain(); + pub var class_id: bridge.ClassId = undefined; + }; + + pub const constructor = bridge.constructor(NavigationCurrentEntryChangeEvent.init, .{}); + pub const from = bridge.accessor(NavigationCurrentEntryChangeEvent.getFrom, null, .{}); + pub const navigationType = bridge.accessor(NavigationCurrentEntryChangeEvent.getNavigationType, null, .{}); +}; diff --git a/src/browser/webapi/navigation/Navigation.zig b/src/browser/webapi/navigation/Navigation.zig new file mode 100644 index 000000000..f6c26d801 --- /dev/null +++ b/src/browser/webapi/navigation/Navigation.zig @@ -0,0 +1,403 @@ +// 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"); + +const js = @import("../../js/js.zig"); +const Page = @import("../../Page.zig"); + +const EventTarget = @import("../EventTarget.zig"); + +// https://developer.mozilla.org/en-US/docs/Web/API/Navigation +const Navigation = @This(); + +const NavigationKind = @import("root.zig").NavigationKind; +const NavigationHistoryEntry = @import("NavigationHistoryEntry.zig"); +const NavigationTransition = @import("root.zig").NavigationTransition; +const NavigationState = @import("root.zig").NavigationState; + +const NavigationCurrentEntryChangeEvent = @import("../event/NavigationCurrentEntryChangeEvent.zig"); +const NavigationEventTarget = @import("NavigationEventTarget.zig"); + +_proto: *NavigationEventTarget = undefined, +_arena: std.mem.Allocator, +_current_navigation_kind: ?NavigationKind = null, + +_index: usize = 0, +// Need to be stable pointers, because Events can reference entries. +_entries: std.ArrayListUnmanaged(*NavigationHistoryEntry) = .empty, +_next_entry_id: usize = 0, + +pub fn init(arena: std.mem.Allocator) Navigation { + return Navigation{ ._arena = arena }; +} + +fn asEventTarget(self: *Navigation) *EventTarget { + return self._proto.asEventTarget(); +} + +pub fn onRemovePage(self: *Navigation) void { + self._proto = undefined; +} + +pub fn onNewPage(self: *Navigation, page: *Page) !void { + self._proto = try page._factory.eventTarget( + NavigationEventTarget{ ._proto = undefined }, + ); +} + +pub fn getCanGoBack(self: *const Navigation) bool { + return self._index > 0; +} + +pub fn getCanGoForward(self: *const Navigation) bool { + return self._entries.items.len > self._index + 1; +} + +pub fn getCurrentEntry(self: *Navigation) *NavigationHistoryEntry { + return self._entries.items[self._index]; +} + +pub fn getTransition(_: *const Navigation) ?NavigationTransition { + // For now, all transitions are just considered complete. + return null; +} + +const NavigationReturn = struct { + committed: js.Promise, + finished: js.Promise, +}; + +pub fn back(self: *Navigation, page: *Page) !NavigationReturn { + if (!self.getCanGoBack()) { + return error.InvalidStateError; + } + + const new_index = self._index - 1; + const next_entry = self._entries.items[new_index]; + self._index = new_index; + + return self.navigateInner(next_entry._url, .{ .traverse = new_index }, page); +} + +pub fn entries(self: *const Navigation) []*NavigationHistoryEntry { + return self._entries.items; +} + +pub fn forward(self: *Navigation, page: *Page) !NavigationReturn { + if (!self.getCanGoForward()) { + return error.InvalidStateError; + } + + const new_index = self._index + 1; + const next_entry = self._entries.items[new_index]; + self._index = new_index; + + return self.navigateInner(next_entry._url, .{ .traverse = new_index }, page); +} + +pub fn updateEntries(self: *Navigation, url: [:0]const u8, kind: NavigationKind, page: *Page, dispatch: bool) !void { + switch (kind) { + .replace => { + _ = try self.replaceEntry(url, .{ .source = .navigation, .value = null }, page, dispatch); + }, + .push => |state| { + _ = try self.pushEntry(url, .{ .source = .navigation, .value = state }, page, dispatch); + }, + .traverse => |index| { + self._index = index; + }, + .reload => {}, + } +} + +// This is for after true navigation processing, where we need to ensure that our entries are up to date. +// This is only really safe to run in the `pageDoneCallback` where we can guarantee that the URL and NavigationKind are correct. +pub fn processNavigation(self: *Navigation, page: *Page) !void { + const url = page.url; + const kind: NavigationKind = self._current_navigation_kind orelse .{ .push = null }; + try self.updateEntries(url, kind, page, false); +} + +/// Pushes an entry into the Navigation stack WITHOUT actually navigating to it. +/// For that, use `navigate`. +pub fn pushEntry( + self: *Navigation, + _url: [:0]const u8, + state: NavigationState, + page: *Page, + dispatch: bool, +) !*NavigationHistoryEntry { + const arena = self._arena; + const url = try arena.dupeZ(u8, _url); + + // truncates our history here. + if (self._entries.items.len > self._index + 1) { + self._entries.shrinkRetainingCapacity(self._index + 1); + } + + 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 entry = try arena.create(NavigationHistoryEntry); + entry.* = NavigationHistoryEntry{ + ._id = id_str, + ._key = id_str, + ._url = url, + ._state = state, + }; + + // we don't always have a current entry... + const previous = if (self._entries.items.len > 0) self.getCurrentEntry() else null; + try self._entries.append(arena, entry); + self._index = index; + + if (previous) |prev| { + if (dispatch) { + const event = try NavigationCurrentEntryChangeEvent.init( + "currententrychange", + .{ .from = prev, .navigationType = @tagName(.push) }, + page, + ); + try self._proto.dispatch(.{ .currententrychange = event }, page); + } + } + + return entry; +} + +pub fn replaceEntry( + self: *Navigation, + _url: [:0]const u8, + state: NavigationState, + page: *Page, + dispatch: bool, +) !*NavigationHistoryEntry { + const arena = self._arena; + const url = try arena.dupeZ(u8, _url); + + const previous = self.getCurrentEntry(); + + const id = self._next_entry_id; + self._next_entry_id += 1; + const id_str = try std.fmt.allocPrint(arena, "{d}", .{id}); + + const entry = try arena.create(NavigationHistoryEntry); + entry.* = NavigationHistoryEntry{ + ._id = id_str, + ._key = previous._key, + ._url = url, + ._state = state, + }; + + self._entries.items[self._index] = entry; + + if (dispatch) { + const event = try NavigationCurrentEntryChangeEvent.init( + "currententrychange", + .{ .from = previous, .navigationType = @tagName(.replace) }, + page, + ); + try self._proto.dispatch(.{ .currententrychange = event }, page); + } + + return entry; +} + +const NavigateOptions = struct { + state: ?js.Object = null, + info: ?js.Object = null, + history: ?[]const u8 = null, +}; + +pub fn navigateInner( + self: *Navigation, + _url: ?[:0]const u8, + kind: NavigationKind, + page: *Page, +) !NavigationReturn { + const arena = self._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.resolve(arena, url, page.url, .{}); + const is_same_document = URL.eqlDocument(new_url, page.url); + + switch (kind) { + .push => |state| { + if (is_same_document) { + page.url = new_url; + + committed.resolve("navigation push", {}); + // todo: Fire navigate event + finished.resolve("navigation push", {}); + + _ = try self.pushEntry(url, .{ .source = .navigation, .value = state }, page, true); + } else { + // try page.navigate(url, .{ .reason = .navigation }, kind); + try page.navigate(url, .{ .reason = .navigation }); + } + }, + .replace => |state| { + if (is_same_document) { + page.url = new_url; + + committed.resolve("navigation replace", {}); + // todo: Fire navigate event + finished.resolve("navigation replace", {}); + + _ = try self.replaceEntry(url, .{ .source = .navigation, .value = state }, page, true); + } else { + // try page.navigate(url, .{ .reason = .navigation }, kind); + try page.navigate(url, .{ .reason = .navigation }); + } + }, + .traverse => |index| { + self._index = index; + + if (is_same_document) { + page.url = new_url; + + committed.resolve("navigation traverse", {}); + // todo: Fire navigate event + finished.resolve("navigation traverse", {}); + } else { + // try page.navigate(url, .{ .reason = .navigation }, kind); + try page.navigate(url, .{ .reason = .navigation }); + } + }, + .reload => { + // try page.navigate(url, .{ .reason = .navigation }, kind); + try page.navigate(url, .{ .reason = .navigation }); + }, + } + + return .{ + .committed = committed.promise(), + .finished = finished.promise(), + }; +} + +pub fn navigate(self: *Navigation, _url: [:0]const u8, _opts: ?NavigateOptions, page: *Page) !NavigationReturn { + const opts = _opts orelse NavigateOptions{}; + const json = if (opts.state) |state| state.toJson(self._arena) catch return error.DataClone else null; + + const kind: NavigationKind = if (opts.history) |history| + if (std.mem.eql(u8, "replace", history)) .{ .replace = json } else .{ .push = json } + else + .{ .push = json }; + + return try self.navigateInner(_url, kind, 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 = self._arena; + + const opts = _opts orelse ReloadOptions{}; + const entry = self.getCurrentEntry(); + if (opts.state) |state| { + const previous = entry; + entry.state = .{ .source = .navigation, .value = state.toJson(arena) catch return error.DataClone }; + + const event = try NavigationCurrentEntryChangeEvent.init( + "currententrychange", + .{ .from = previous, .navigationType = @tagName(.reload) }, + page, + ); + try self._proto.dispatch(.{ .currententrychange = event }, page); + } + + return self.navigateInner(entry._url, .reload, page); +} + +pub const TraverseToOptions = struct { + info: ?js.Object = null, +}; + +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)) { + return try self.navigateInner(entry._url, .{ .traverse = i }, page); + } + } + + return error.InvalidStateError; +} + +pub const UpdateCurrentEntryOptions = struct { + state: js.Object, +}; + +pub fn updateCurrentEntry(self: *Navigation, options: UpdateCurrentEntryOptions, page: *Page) !void { + const arena = self._arena; + + const previous = self.getCurrentEntry(); + self.getCurrentEntry()._state = .{ + .source = .navigation, + .value = options.state.toJson(arena) catch return error.DataClone, + }; + + const event = try NavigationCurrentEntryChangeEvent.init( + "currententrychange", + .{ .from = previous, .navigationType = null }, + page, + ); + try self._proto.dispatch(.{ .currententrychange = event }, page); +} + +pub const JsApi = struct { + pub const bridge = js.Bridge(Navigation); + + pub const Meta = struct { + pub const name = "Navigation"; + pub const prototype_chain = bridge.prototypeChain(); + pub var class_id: bridge.ClassId = undefined; + }; + + pub const canGoBack = bridge.accessor(Navigation.getCanGoBack, null, .{}); + pub const canGoForward = bridge.accessor(Navigation.getCanGoForward, null, .{}); + pub const currentEntry = bridge.accessor(Navigation.getCurrentEntry, null, .{}); + pub const transition = bridge.accessor(Navigation.getTransition, null, .{}); + pub const back = bridge.function(Navigation.back, .{}); + pub const entries = bridge.function(Navigation.entries, .{}); + pub const forward = bridge.function(Navigation.forward, .{}); + pub const navigate = bridge.function(Navigation.navigate, .{}); + pub const traverseTo = bridge.function(Navigation.traverseTo, .{}); + pub const updateCurrentEntry = bridge.function(Navigation.updateCurrentEntry, .{}); +}; diff --git a/src/browser/webapi/navigation/NavigationEventTarget.zig b/src/browser/webapi/navigation/NavigationEventTarget.zig new file mode 100644 index 000000000..d262d3b49 --- /dev/null +++ b/src/browser/webapi/navigation/NavigationEventTarget.zig @@ -0,0 +1,62 @@ +const std = @import("std"); +const EventTarget = @import("../EventTarget.zig"); +const js = @import("../../js/js.zig"); +const Page = @import("../../Page.zig"); +const Event = @import("../Event.zig"); +const NavigationCurrentEntryChangeEvent = @import("../event/NavigationCurrentEntryChangeEvent.zig"); + +pub const NavigationEventTarget = @This(); + +_proto: *EventTarget, +_on_currententrychange: ?js.Function = null, + +pub fn asEventTarget(self: *NavigationEventTarget) *EventTarget { + return self._proto; +} + +const DispatchType = union(enum) { + currententrychange: *NavigationCurrentEntryChangeEvent, +}; + +pub fn dispatch(self: *NavigationEventTarget, event_type: DispatchType, page: *Page) !void { + const event, const field = blk: { + break :blk switch (event_type) { + .currententrychange => |cec| .{ cec.asEvent(), "_on_currententrychange" }, + }; + }; + + return page._event_manager.dispatchWithFunction( + self.asEventTarget(), + event, + @field(self, field), + .{ .context = "Navigation" }, + ); +} + +pub fn getOnCurrentEntryChange(self: *NavigationEventTarget) ?js.Function { + return self._on_currententrychange; +} + +pub fn setOnCurrentEntryChange(self: *NavigationEventTarget, listener: ?js.Function) !void { + if (listener) |listen| { + self._on_currententrychange = try listen.withThis(self); + } else { + self._on_currententrychange = null; + } +} + +pub const JsApi = struct { + pub const bridge = js.Bridge(NavigationEventTarget); + + pub const Meta = struct { + pub const name = "NavigationEventTarget"; + pub const prototype_chain = bridge.prototypeChain(); + pub var class_id: bridge.ClassId = undefined; + }; + + pub const oncurrententrychange = bridge.accessor( + NavigationEventTarget.getOnCurrentEntryChange, + NavigationEventTarget.setOnCurrentEntryChange, + .{}, + ); +}; diff --git a/src/browser/webapi/navigation/NavigationHistoryEntry.zig b/src/browser/webapi/navigation/NavigationHistoryEntry.zig new file mode 100644 index 000000000..206e37955 --- /dev/null +++ b/src/browser/webapi/navigation/NavigationHistoryEntry.zig @@ -0,0 +1,88 @@ +const std = @import("std"); +const URL = @import("../URL.zig"); +const EventTarget = @import("../EventTarget.zig"); +const NavigationState = @import("root.zig").NavigationState; +const Page = @import("../../Page.zig"); +const js = @import("../../js/js.zig"); + +const NavigationHistoryEntry = @This(); + +// https://developer.mozilla.org/en-US/docs/Web/API/NavigationHistoryEntry +// no proto for now +// _proto: ?*EventTarget, +_id: []const u8, +_key: []const u8, +_url: ?[:0]const u8, +_state: NavigationState, + +// fn asEventTarget(self: *NavigationHistoryEntry) *EventTarget { +// return self._proto.?.asEventTarget(); +// } + +// pub fn onRemovePage(self: *NavigationHistoryEntry) void { +// self._proto = null; +// } + +// pub fn onNewPage(self: *NavigationHistoryEntry, page: *Page) !void { +// self._proto = try page._factory.eventTarget( +// NavigationHistoryEntryEventTarget{ ._proto = undefined }, +// ); +// } + +pub fn id(self: *const NavigationHistoryEntry) []const u8 { + return self._id; +} + +pub fn 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 key(self: *const NavigationHistoryEntry) []const u8 { + return self._key; +} + +pub fn sameDocument(self: *const NavigationHistoryEntry, page: *Page) bool { + const got_url = self._url orelse return false; + return URL.eqlDocument(got_url, page.url); +} + +pub fn url(self: *const NavigationHistoryEntry) ?[:0]const u8 { + return self._url; +} + +pub const StateReturn = union(enum) { value: ?js.Value, undefined: void }; + +pub fn state(self: *const NavigationHistoryEntry, page: *Page) !StateReturn { + if (self._state.source == .navigation) { + if (self._state.value) |value| { + return .{ .value = try js.Value.fromJson(page.js, value) }; + } + } + + return .undefined; +} + +pub const JsApi = struct { + pub const bridge = js.Bridge(NavigationHistoryEntry); + + pub const Meta = struct { + pub const name = "NavigationHistoryEntry"; + pub const prototype_chain = bridge.prototypeChain(); + pub var class_id: bridge.ClassId = undefined; + }; + + pub const id = bridge.accessor(NavigationHistoryEntry.id, null, .{}); + pub const index = bridge.accessor(NavigationHistoryEntry.index, null, .{}); + pub const key = bridge.accessor(NavigationHistoryEntry.key, null, .{}); + pub const sameDocument = bridge.accessor(NavigationHistoryEntry.sameDocument, null, .{}); + pub const url = bridge.accessor(NavigationHistoryEntry.url, null, .{}); + pub const state = bridge.accessor(NavigationHistoryEntry.state, null, .{}); +}; diff --git a/src/browser/webapi/navigation/root.zig b/src/browser/webapi/navigation/root.zig new file mode 100644 index 000000000..e2a5c1733 --- /dev/null +++ b/src/browser/webapi/navigation/root.zig @@ -0,0 +1,72 @@ +// 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 js = @import("../../js/js.zig"); +const Page = @import("../../Page.zig"); + +const Navigation = @import("Navigation.zig"); +const NavigationEventTarget = @import("NavigationEventTarget.zig"); +const NavigationHistoryEntry = @import("NavigationHistoryEntry.zig"); + +pub const NavigationType = enum { + push, + replace, + traverse, + reload, +}; + +pub const NavigationKind = union(NavigationType) { + push: ?[]const u8, + replace: ?[]const u8, + traverse: usize, + reload, +}; + +pub const NavigationState = struct { + source: enum { history, navigation }, + value: ?[]const u8, +}; + +// https://developer.mozilla.org/en-US/docs/Web/API/NavigationActivation +pub const NavigationActivation = struct { + entry: NavigationHistoryEntry, + from: ?NavigationHistoryEntry = null, + type: NavigationType, + + 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) NavigationType { + return self.type; + } +}; + +// https://developer.mozilla.org/en-US/docs/Web/API/NavigationTransition +pub const NavigationTransition = struct { + finished: js.Promise, + from: NavigationHistoryEntry, + navigation_type: NavigationType, +}; diff --git a/src/cdp/domains/page.zig b/src/cdp/domains/page.zig index 1be6f3c91..bc961fe16 100644 --- a/src/cdp/domains/page.zig +++ b/src/cdp/domains/page.zig @@ -206,7 +206,7 @@ pub fn pageNavigate(arena: Allocator, bc: anytype, event: *const Notification.Pa .POST => "formSubmissionPost", else => unreachable, }, - .address_bar => null, + .address_bar, .navigation => null, }; if (reason_) |reason| { try cdp.sendEvent("Page.frameScheduledNavigation", .{ From 0e4cfbfe6b72bcc4f9915831e768f002e65ef2a7 Mon Sep 17 00:00:00 2001 From: Muki Kiboigo Date: Mon, 8 Dec 2025 05:16:45 -0800 Subject: [PATCH 02/19] backport the resolve/stitch regression test --- src/browser/URL.zig | 53 +++++++++++++++++++++++---------------------- 1 file changed, 27 insertions(+), 26 deletions(-) diff --git a/src/browser/URL.zig b/src/browser/URL.zig index a429eae99..6fdc97b97 100644 --- a/src/browser/URL.zig +++ b/src/browser/URL.zig @@ -68,9 +68,11 @@ pub fn resolve(allocator: Allocator, base: [:0]const u8, path: anytype, comptime return std.mem.joinZ(allocator, "", &.{ base[0..path_start], path }); } - var normalized_base: []const u8 = base; - if (std.mem.lastIndexOfScalar(u8, normalized_base[authority_start..], '/')) |pos| { - normalized_base = normalized_base[0 .. pos + authority_start]; + var normalized_base: []const u8 = base[0..path_start]; + if (path_start < base.len) { + if (std.mem.lastIndexOfScalar(u8, base[path_start + 1 ..], '/')) |pos| { + normalized_base = base[0 .. path_start + 1 + pos]; + } } // trailing space so that we always have space to append the null terminator @@ -297,29 +299,28 @@ test "URL: isCompleteHTTPUrl" { try testing.expectEqual(false, isCompleteHTTPUrl("about")); } -// TODO: uncomment -// test "URL: resolve regression (#1093)" { -// defer testing.reset(); - -// const Case = struct { -// base: []const u8, -// path: []const u8, -// expected: []const u8, -// }; - -// const cases = [_]Case{ -// .{ -// .base = "https://alas.aws.amazon.com/alas2.html", -// .path = "../static/bootstrap.min.css", -// .expected = "https://alas.aws.amazon.com/static/bootstrap.min.css", -// }, -// }; - -// for (cases) |case| { -// const result = try resolve(testing.arena_allocator, case.path, case.base, .{}); -// try testing.expectString(case.expected, result); -// } -// } +test "URL: resolve regression (#1093)" { + defer testing.reset(); + + const Case = struct { + base: [:0]const u8, + path: [:0]const u8, + expected: [:0]const u8, + }; + + const cases = [_]Case{ + .{ + .base = "https://alas.aws.amazon.com/alas2.html", + .path = "../static/bootstrap.min.css", + .expected = "https://alas.aws.amazon.com/static/bootstrap.min.css", + }, + }; + + for (cases) |case| { + const result = try resolve(testing.arena_allocator, case.base, case.path, .{}); + try testing.expectString(case.expected, result); + } +} test "URL: resolve" { defer testing.reset(); From 8ec9f634b46b17a1dd1b4bcaeb0797a29f8aea5d Mon Sep 17 00:00:00 2001 From: Muki Kiboigo Date: Mon, 8 Dec 2025 05:16:45 -0800 Subject: [PATCH 03/19] backport URL eqlDocument tests --- src/browser/URL.zig | 71 ++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 70 insertions(+), 1 deletion(-) diff --git a/src/browser/URL.zig b/src/browser/URL.zig index 6fdc97b97..11739b070 100644 --- a/src/browser/URL.zig +++ b/src/browser/URL.zig @@ -272,11 +272,12 @@ pub fn getHost(raw: [:0]const u8) []const u8 { // Returns true if these two URLs point to the same document. pub fn eqlDocument(first: [:0]const u8, second: [:0]const u8) bool { + if (!std.mem.eql(u8, getProtocol(first), getProtocol(second))) return false; if (!std.mem.eql(u8, getHost(first), getHost(second))) return false; if (!std.mem.eql(u8, getPort(first), getPort(second))) return false; if (!std.mem.eql(u8, getPathname(first), getPathname(second))) return false; if (!std.mem.eql(u8, getSearch(first), getSearch(second))) return false; - if (!std.mem.eql(u8, getHash(first), getHash(second))) return false; + // hashes are allowed to be different. return true; } @@ -449,3 +450,71 @@ test "URL: resolve" { try testing.expectString(case.expected, result); } } + +test "URL: eqlDocument" { + defer testing.reset(); + { + const url = "https://lightpanda.io/about"; + try testing.expectEqual(true, eqlDocument(url, url)); + } + { + const url1 = "https://lightpanda.io/about"; + const url2 = "http://lightpanda.io/about"; + try testing.expectEqual(false, eqlDocument(url1, url2)); + } + { + const url1 = "https://lightpanda.io/about"; + const url2 = "https://example.com/about"; + try testing.expectEqual(false, eqlDocument(url1, url2)); + } + { + const url1 = "https://lightpanda.io:8080/about"; + const url2 = "https://lightpanda.io:9090/about"; + try testing.expectEqual(false, eqlDocument(url1, url2)); + } + { + const url1 = "https://lightpanda.io/about"; + const url2 = "https://lightpanda.io/contact"; + try testing.expectEqual(false, eqlDocument(url1, url2)); + } + { + const url1 = "https://lightpanda.io/about?foo=bar"; + const url2 = "https://lightpanda.io/about?baz=qux"; + try testing.expectEqual(false, eqlDocument(url1, url2)); + } + { + const url1 = "https://lightpanda.io/about#section1"; + const url2 = "https://lightpanda.io/about#section2"; + try testing.expectEqual(true, eqlDocument(url1, url2)); + } + { + const url1 = "https://lightpanda.io/about"; + const url2 = "https://lightpanda.io/about/"; + try testing.expectEqual(false, eqlDocument(url1, url2)); + } + { + const url1 = "https://lightpanda.io/about?foo=bar"; + const url2 = "https://lightpanda.io/about"; + try testing.expectEqual(false, eqlDocument(url1, url2)); + } + { + const url1 = "https://lightpanda.io/about"; + const url2 = "https://lightpanda.io/about?foo=bar"; + try testing.expectEqual(false, eqlDocument(url1, url2)); + } + { + const url1 = "https://lightpanda.io/about?foo=bar"; + const url2 = "https://lightpanda.io/about?foo=bar"; + try testing.expectEqual(true, eqlDocument(url1, url2)); + } + { + const url1 = "https://lightpanda.io/about?"; + const url2 = "https://lightpanda.io/about"; + try testing.expectEqual(false, eqlDocument(url1, url2)); + } + { + const url1 = "https://duckduckgo.com/"; + const url2 = "https://duckduckgo.com/?q=lightpanda"; + try testing.expectEqual(false, eqlDocument(url1, url2)); + } +} From 6a5e088c52aecd37e82c2280dc7062d2872775d1 Mon Sep 17 00:00:00 2001 From: Muki Kiboigo Date: Mon, 8 Dec 2025 05:16:45 -0800 Subject: [PATCH 04/19] update wpt to include Navigation --- tests/wpt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/wpt b/tests/wpt index d9978ebd7..3df84d931 160000 --- a/tests/wpt +++ b/tests/wpt @@ -1 +1 @@ -Subproject commit d9978ebd7b3cc8d91eea9c43b65099b860ab3821 +Subproject commit 3df84d931c47559065c6de3edc07dea95bedcf70 From 71d57c1e27bc7aa408e50486b4e1f1f6c678c629 Mon Sep 17 00:00:00 2001 From: Muki Kiboigo Date: Mon, 8 Dec 2025 05:16:46 -0800 Subject: [PATCH 05/19] add Navigation to Window --- src/browser/webapi/Window.zig | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/browser/webapi/Window.zig b/src/browser/webapi/Window.zig index 6bcde4dd1..ea7467c1d 100644 --- a/src/browser/webapi/Window.zig +++ b/src/browser/webapi/Window.zig @@ -24,6 +24,7 @@ const log = @import("../../log.zig"); const Page = @import("../Page.zig"); const Console = @import("Console.zig"); const History = @import("History.zig"); +const Navigation = @import("../webapi/navigation/Navigation.zig"); const Crypto = @import("Crypto.zig"); const CSS = @import("CSS.zig"); const Navigator = @import("Navigator.zig"); @@ -117,6 +118,10 @@ pub fn getHistory(self: *Window) *History { return &self._history; } +pub fn getNavigation(_: *Window, page: *Page) *Navigation { + return &page._session.navigation; +} + pub fn getCustomElements(self: *Window) *CustomElementRegistry { return &self._custom_elements; } @@ -487,6 +492,7 @@ pub const JsApi = struct { pub const document = bridge.accessor(Window.getDocument, null, .{ .cache = "document" }); pub const location = bridge.accessor(Window.getLocation, null, .{ .cache = "location" }); pub const history = bridge.accessor(Window.getHistory, null, .{ .cache = "history" }); + pub const navigation = bridge.accessor(Window.getNavigation, null, .{}); pub const crypto = bridge.accessor(Window.getCrypto, null, .{ .cache = "crypto" }); pub const CSS = bridge.accessor(Window.getCSS, null, .{ .cache = "CSS" }); pub const customElements = bridge.accessor(Window.getCustomElements, null, .{ .cache = "customElements" }); From 7c34cb5852407c981a67856b425c6af8fcdb3195 Mon Sep 17 00:00:00 2001 From: Muki Kiboigo Date: Mon, 8 Dec 2025 05:16:46 -0800 Subject: [PATCH 06/19] fix getState on NavigationHistoryEntry --- src/browser/webapi/navigation/NavigationHistoryEntry.zig | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/browser/webapi/navigation/NavigationHistoryEntry.zig b/src/browser/webapi/navigation/NavigationHistoryEntry.zig index 206e37955..3ef16bdce 100644 --- a/src/browser/webapi/navigation/NavigationHistoryEntry.zig +++ b/src/browser/webapi/navigation/NavigationHistoryEntry.zig @@ -60,7 +60,7 @@ pub fn url(self: *const NavigationHistoryEntry) ?[:0]const u8 { pub const StateReturn = union(enum) { value: ?js.Value, undefined: void }; -pub fn state(self: *const NavigationHistoryEntry, page: *Page) !StateReturn { +pub fn getState(self: *const NavigationHistoryEntry, page: *Page) !StateReturn { if (self._state.source == .navigation) { if (self._state.value) |value| { return .{ .value = try js.Value.fromJson(page.js, value) }; @@ -84,5 +84,5 @@ pub const JsApi = struct { pub const key = bridge.accessor(NavigationHistoryEntry.key, null, .{}); pub const sameDocument = bridge.accessor(NavigationHistoryEntry.sameDocument, null, .{}); pub const url = bridge.accessor(NavigationHistoryEntry.url, null, .{}); - pub const state = bridge.accessor(NavigationHistoryEntry.state, null, .{}); + pub const getState = bridge.function(NavigationHistoryEntry.getState, .{}); }; From ab165d3f1fcb2e972f4fa55979cb27d1a9d9b173 Mon Sep 17 00:00:00 2001 From: Muki Kiboigo Date: Mon, 8 Dec 2025 05:16:46 -0800 Subject: [PATCH 07/19] getNavigationType return string --- .../webapi/event/NavigationCurrentEntryChangeEvent.zig | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/browser/webapi/event/NavigationCurrentEntryChangeEvent.zig b/src/browser/webapi/event/NavigationCurrentEntryChangeEvent.zig index 34dc8a3c8..4d0166cfa 100644 --- a/src/browser/webapi/event/NavigationCurrentEntryChangeEvent.zig +++ b/src/browser/webapi/event/NavigationCurrentEntryChangeEvent.zig @@ -42,8 +42,8 @@ pub fn getFrom(self: *NavigationCurrentEntryChangeEvent) *NavigationHistoryEntry return self._from; } -pub fn getNavigationType(self: *const NavigationCurrentEntryChangeEvent) ?NavigationType { - return self._navigation_type; +pub fn getNavigationType(self: *const NavigationCurrentEntryChangeEvent) ?[]const u8 { + return if (self._navigation_type) |nav_type| @tagName(nav_type) else null; } pub const JsApi = struct { From cc53fec08d43f25f842d6e925de1e386da943fae Mon Sep 17 00:00:00 2001 From: Muki Kiboigo Date: Mon, 8 Dec 2025 05:16:47 -0800 Subject: [PATCH 08/19] backport run microtasks before firing onload --- src/browser/Page.zig | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/browser/Page.zig b/src/browser/Page.zig index b4d4ee2e6..2cdf63519 100644 --- a/src/browser/Page.zig +++ b/src/browser/Page.zig @@ -422,6 +422,9 @@ pub fn documentIsComplete(self: *Page) void { fn _documentIsComplete(self: *Page) !void { self.document._ready_state = .complete; + self._session.browser.runMicrotasks(); + self._session.browser.runMessageLoop(); + // dispatch window.load event const event = try Event.init("load", .{}, self); // this event is weird, it's dispatched directly on the window, but From 907298c6b1933a1e5a52fc35749957b6e4cc9cb6 Mon Sep 17 00:00:00 2001 From: Muki Kiboigo Date: Mon, 8 Dec 2025 05:16:47 -0800 Subject: [PATCH 09/19] backport pageshow event --- src/browser/Page.zig | 9 +++ src/browser/js/bridge.zig | 1 + src/browser/webapi/Event.zig | 1 + src/browser/webapi/Window.zig | 14 +++++ .../webapi/event/PageTransitionEvent.zig | 63 +++++++++++++++++++ src/browser/webapi/navigation/Navigation.zig | 2 +- 6 files changed, 89 insertions(+), 1 deletion(-) create mode 100644 src/browser/webapi/event/PageTransitionEvent.zig diff --git a/src/browser/Page.zig b/src/browser/Page.zig index 2cdf63519..9da5f5d10 100644 --- a/src/browser/Page.zig +++ b/src/browser/Page.zig @@ -58,6 +58,7 @@ const MutationObserver = @import("webapi/MutationObserver.zig"); const IntersectionObserver = @import("webapi/IntersectionObserver.zig"); const CustomElementDefinition = @import("webapi/CustomElementDefinition.zig"); const storage = @import("webapi/storage/storage.zig"); +const PageTransitionEvent = @import("webapi/event/PageTransitionEvent.zig"); const timestamp = @import("../datetime.zig").timestamp; const milliTimestamp = @import("../datetime.zig").milliTimestamp; @@ -436,6 +437,14 @@ fn _documentIsComplete(self: *Page) !void { self.window._on_load, .{ .inject_target = false, .context = "page load" }, ); + + const pageshow_event = try PageTransitionEvent.init("pageshow", .{}, self); + try self._event_manager.dispatchWithFunction( + self.window.asEventTarget(), + pageshow_event.asEvent(), + self.window._on_pageshow, + .{ .context = "page show" }, + ); } fn pageHeaderDoneCallback(transfer: *Http.Transfer) !void { diff --git a/src/browser/js/bridge.zig b/src/browser/js/bridge.zig index 5a07e5a27..5fccd42c7 100644 --- a/src/browser/js/bridge.zig +++ b/src/browser/js/bridge.zig @@ -568,6 +568,7 @@ pub const JsApis = flattenTypes(&.{ @import("../webapi/event/MessageEvent.zig"), @import("../webapi/event/ProgressEvent.zig"), @import("../webapi/event/NavigationCurrentEntryChangeEvent.zig"), + @import("../webapi/event/PageTransitionEvent.zig"), @import("../webapi/MessageChannel.zig"), @import("../webapi/MessagePort.zig"), @import("../webapi/media/MediaError.zig"), diff --git a/src/browser/webapi/Event.zig b/src/browser/webapi/Event.zig index 5db5de858..11da60fdb 100644 --- a/src/browser/webapi/Event.zig +++ b/src/browser/webapi/Event.zig @@ -57,6 +57,7 @@ pub const Type = union(enum) { progress_event: *@import("event/ProgressEvent.zig"), composition_event: *@import("event/CompositionEvent.zig"), navigation_current_entry_change_event: *@import("event/NavigationCurrentEntryChangeEvent.zig"), + page_transition_event: *@import("event/PageTransitionEvent.zig"), }; const Options = struct { diff --git a/src/browser/webapi/Window.zig b/src/browser/webapi/Window.zig index ea7467c1d..d6b83bb40 100644 --- a/src/browser/webapi/Window.zig +++ b/src/browser/webapi/Window.zig @@ -55,6 +55,7 @@ _performance: Performance, _history: History, _storage_bucket: *storage.Bucket, _on_load: ?js.Function = null, +_on_pageshow: ?js.Function = null, _on_error: ?js.Function = null, // TODO: invoke on error? _on_unhandled_rejection: ?js.Function = null, // TODO: invoke on error _location: *Location, @@ -138,6 +139,18 @@ pub fn setOnLoad(self: *Window, cb_: ?js.Function) !void { } } +pub fn getOnPageShow(self: *const Window) ?js.Function { + return self._on_pageshow; +} + +pub fn setOnPageShow(self: *Window, cb_: ?js.Function) !void { + if (cb_) |cb| { + self._on_pageshow = cb; + } else { + self._on_pageshow = null; + } +} + pub fn getOnError(self: *const Window) ?js.Function { return self._on_error; } @@ -497,6 +510,7 @@ pub const JsApi = struct { pub const CSS = bridge.accessor(Window.getCSS, null, .{ .cache = "CSS" }); pub const customElements = bridge.accessor(Window.getCustomElements, null, .{ .cache = "customElements" }); pub const onload = bridge.accessor(Window.getOnLoad, Window.setOnLoad, .{}); + pub const onpageshow = bridge.accessor(Window.getOnPageShow, Window.setOnPageShow, .{}); pub const onerror = bridge.accessor(Window.getOnError, Window.getOnError, .{}); pub const onunhandledrejection = bridge.accessor(Window.getOnUnhandledRejection, Window.setOnUnhandledRejection, .{}); pub const fetch = bridge.function(Window.fetch, .{}); diff --git a/src/browser/webapi/event/PageTransitionEvent.zig b/src/browser/webapi/event/PageTransitionEvent.zig new file mode 100644 index 000000000..f4cebf549 --- /dev/null +++ b/src/browser/webapi/event/PageTransitionEvent.zig @@ -0,0 +1,63 @@ +// 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 log = @import("../../../log.zig"); +// const Window = @import("../html/window.zig").Window; +const Event = @import("../Event.zig"); +const js = @import("../../js/js.zig"); +const Page = @import("../../Page.zig"); + +// https://developer.mozilla.org/en-US/docs/Web/API/PageTransitionEvent +const PageTransitionEvent = @This(); + +const EventInit = struct { + persisted: ?bool = null, +}; + +_proto: *Event, +_persisted: bool, + +pub fn init(typ: []const u8, init_obj: EventInit, page: *Page) !*PageTransitionEvent { + return page._factory.event(typ, PageTransitionEvent{ + ._proto = undefined, + ._persisted = init_obj.persisted orelse false, + }); +} + +pub fn asEvent(self: *PageTransitionEvent) *Event { + return self._proto; +} + +pub fn getPersisted(self: *PageTransitionEvent) bool { + return self._persisted; +} + +const PageTransitionKind = enum { show, hide }; + +pub const JsApi = struct { + pub const bridge = js.Bridge(PageTransitionEvent); + + pub const Meta = struct { + pub const name = "PageTransitionEvent"; + pub const prototype_chain = bridge.prototypeChain(); + pub var class_id: bridge.ClassId = undefined; + }; + + pub const constructor = bridge.constructor(PageTransitionEvent.init, .{}); + pub const persisted = bridge.accessor(PageTransitionEvent.getPersisted, null, .{}); +}; diff --git a/src/browser/webapi/navigation/Navigation.zig b/src/browser/webapi/navigation/Navigation.zig index f6c26d801..e19e8b03f 100644 --- a/src/browser/webapi/navigation/Navigation.zig +++ b/src/browser/webapi/navigation/Navigation.zig @@ -329,7 +329,7 @@ pub fn reload(self: *Navigation, _opts: ?ReloadOptions, page: *Page) !Navigation const entry = self.getCurrentEntry(); if (opts.state) |state| { const previous = entry; - entry.state = .{ .source = .navigation, .value = state.toJson(arena) catch return error.DataClone }; + entry._state = .{ .source = .navigation, .value = state.toJson(arena) catch return error.DataClone }; const event = try NavigationCurrentEntryChangeEvent.init( "currententrychange", From 9d7b80c1ac2751f5915a7855c5a3430551f6f898 Mon Sep 17 00:00:00 2001 From: Muki Kiboigo Date: Mon, 8 Dec 2025 05:16:47 -0800 Subject: [PATCH 10/19] backport Location getHash --- src/browser/Page.zig | 7 ++++++- src/browser/tests/window/location.html | 18 ++++++++++++++++++ src/browser/webapi/Location.zig | 22 +++++++++++++++++++++- 3 files changed, 45 insertions(+), 2 deletions(-) diff --git a/src/browser/Page.zig b/src/browser/Page.zig index 9da5f5d10..ce3620fa0 100644 --- a/src/browser/Page.zig +++ b/src/browser/Page.zig @@ -287,8 +287,13 @@ pub fn navigate(self: *Page, request_url: [:0]const u8, opts: NavigateOpts) !voi if (!opts.force) { // If we are navigating within the same document, just change URL. if (URL.eqlDocument(self.url, resolved_url)) { + // update page url self.url = resolved_url; - // 3. change window.location + + // update location + self.window._location = try Location.init(self.url, self); + self.document._location = self.window._location; + try session.navigation.updateEntries("", .{ .push = null }, self, true); return; } diff --git a/src/browser/tests/window/location.html b/src/browser/tests/window/location.html index 01a4049db..b61808e0c 100644 --- a/src/browser/tests/window/location.html +++ b/src/browser/tests/window/location.html @@ -5,3 +5,21 @@ testing.expectEqual('http://127.0.0.1:9582/src/browser/tests/window/location.html', window.location.href); testing.expectEqual(document.location, window.location); + + diff --git a/src/browser/webapi/Location.zig b/src/browser/webapi/Location.zig index e7191a138..205d1732e 100644 --- a/src/browser/webapi/Location.zig +++ b/src/browser/webapi/Location.zig @@ -16,6 +16,7 @@ // 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 js = @import("../js/js.zig"); const URL = @import("URL.zig"); @@ -64,6 +65,25 @@ pub fn getHash(self: *const Location) []const u8 { return self._url.getHash(); } +pub fn setHash(_: *const Location, hash: []const u8, page: *Page) !void { + const normalized_hash = blk: { + if (hash.len == 0) { + const old_url = page.url; + + break :blk if (std.mem.indexOfScalar(u8, old_url, '#')) |index| + old_url[0..index] + else + old_url; + } else if (hash[0] == '#') + break :blk hash + else + break :blk try std.fmt.allocPrint(page.arena, "#{s}", .{hash}); + }; + + const duped_hash = try page.arena.dupeZ(u8, normalized_hash); + return page.navigate(duped_hash, .{ .reason = .script }); +} + pub fn toString(self: *const Location, page: *const Page) ![:0]const u8 { return self._url.toString(page); } @@ -80,7 +100,7 @@ pub const JsApi = struct { pub const toString = bridge.function(Location.toString, .{}); pub const href = bridge.accessor(Location.toString, null, .{}); pub const search = bridge.accessor(Location.getSearch, null, .{}); - pub const hash = bridge.accessor(Location.getHash, null, .{}); + pub const hash = bridge.accessor(Location.getHash, Location.setHash, .{}); pub const pathname = bridge.accessor(Location.getPathname, null, .{}); pub const hostname = bridge.accessor(Location.getHostname, null, .{}); pub const host = bridge.accessor(Location.getHost, null, .{}); From ee7852665e1fd50648696c09551d9674233c1d56 Mon Sep 17 00:00:00 2001 From: Muki Kiboigo Date: Mon, 8 Dec 2025 05:16:47 -0800 Subject: [PATCH 11/19] fix GPL headers --- .../NavigationCurrentEntryChangeEvent.zig | 18 ++++++++++++++++++ .../navigation/NavigationEventTarget.zig | 18 ++++++++++++++++++ .../navigation/NavigationHistoryEntry.zig | 18 ++++++++++++++++++ 3 files changed, 54 insertions(+) diff --git a/src/browser/webapi/event/NavigationCurrentEntryChangeEvent.zig b/src/browser/webapi/event/NavigationCurrentEntryChangeEvent.zig index 4d0166cfa..ead742e31 100644 --- a/src/browser/webapi/event/NavigationCurrentEntryChangeEvent.zig +++ b/src/browser/webapi/event/NavigationCurrentEntryChangeEvent.zig @@ -1,3 +1,21 @@ +// 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 Event = @import("../Event.zig"); const Page = @import("../../Page.zig"); diff --git a/src/browser/webapi/navigation/NavigationEventTarget.zig b/src/browser/webapi/navigation/NavigationEventTarget.zig index d262d3b49..1e9b0bd48 100644 --- a/src/browser/webapi/navigation/NavigationEventTarget.zig +++ b/src/browser/webapi/navigation/NavigationEventTarget.zig @@ -1,3 +1,21 @@ +// 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 EventTarget = @import("../EventTarget.zig"); const js = @import("../../js/js.zig"); diff --git a/src/browser/webapi/navigation/NavigationHistoryEntry.zig b/src/browser/webapi/navigation/NavigationHistoryEntry.zig index 3ef16bdce..86dc295c7 100644 --- a/src/browser/webapi/navigation/NavigationHistoryEntry.zig +++ b/src/browser/webapi/navigation/NavigationHistoryEntry.zig @@ -1,3 +1,21 @@ +// 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 URL = @import("../URL.zig"); const EventTarget = @import("../EventTarget.zig"); From 01d71323fcaf375e090d894499bb15b8cb73a7ea Mon Sep 17 00:00:00 2001 From: Muki Kiboigo Date: Mon, 8 Dec 2025 05:16:48 -0800 Subject: [PATCH 12/19] complete History impl backed by Navigation --- src/browser/Page.zig | 8 +- src/browser/Session.zig | 3 + src/browser/js/bridge.zig | 1 + src/browser/webapi/Event.zig | 1 + src/browser/webapi/History.zig | 92 ++++++++++++---------- src/browser/webapi/Window.zig | 21 ++++- src/browser/webapi/event/PopStateEvent.zig | 72 +++++++++++++++++ 7 files changed, 150 insertions(+), 48 deletions(-) create mode 100644 src/browser/webapi/event/PopStateEvent.zig diff --git a/src/browser/Page.zig b/src/browser/Page.zig index ce3620fa0..75b644135 100644 --- a/src/browser/Page.zig +++ b/src/browser/Page.zig @@ -34,7 +34,6 @@ const Mime = @import("Mime.zig"); const Factory = @import("Factory.zig"); const Session = @import("Session.zig"); const Scheduler = @import("Scheduler.zig"); -const History = @import("webapi/History.zig"); const EventManager = @import("EventManager.zig"); const ScriptManager = @import("ScriptManager.zig"); @@ -211,7 +210,6 @@ fn reset(self: *Page, comptime initializing: bool) !void { self.window = try self._factory.eventTarget(Window{ ._document = self.document, ._storage_bucket = storage_bucket, - ._history = History.init(self), ._performance = Performance.init(), ._proto = undefined, ._location = &default_location, @@ -1903,6 +1901,12 @@ const IdleNotification = union(enum) { } }; +pub fn isSameOrigin(self: *const Page, url: [:0]const u8) !bool { + const URLRaw = @import("URL.zig"); + const current_origin = (try URLRaw.getOrigin(self.arena, self.url)) orelse return false; + return std.mem.startsWith(u8, url, current_origin); +} + pub const NavigateReason = enum { anchor, address_bar, diff --git a/src/browser/Session.zig b/src/browser/Session.zig index d8b4f0d1b..5f25ae857 100644 --- a/src/browser/Session.zig +++ b/src/browser/Session.zig @@ -23,6 +23,7 @@ const log = @import("../log.zig"); const js = @import("js/js.zig"); const storage = @import("webapi/storage/storage.zig"); const Navigation = @import("webapi/navigation/Navigation.zig"); +const History = @import("webapi/History.zig"); const Page = @import("Page.zig"); const Browser = @import("Browser.zig"); @@ -55,6 +56,7 @@ executor: js.ExecutionWorld, cookie_jar: storage.Cookie.Jar, storage_shed: storage.Shed, +history: History, navigation: Navigation, page: ?*Page = null, @@ -80,6 +82,7 @@ pub fn init(self: *Session, browser: *Browser) !void { .arena = session_allocator, .cookie_jar = storage.Cookie.Jar.init(allocator), .navigation = Navigation.init(session_allocator), + .history = .{}, .transfer_arena = browser.transfer_arena.allocator(), }; } diff --git a/src/browser/js/bridge.zig b/src/browser/js/bridge.zig index 5fccd42c7..3358cc535 100644 --- a/src/browser/js/bridge.zig +++ b/src/browser/js/bridge.zig @@ -569,6 +569,7 @@ pub const JsApis = flattenTypes(&.{ @import("../webapi/event/ProgressEvent.zig"), @import("../webapi/event/NavigationCurrentEntryChangeEvent.zig"), @import("../webapi/event/PageTransitionEvent.zig"), + @import("../webapi/event/PopStateEvent.zig"), @import("../webapi/MessageChannel.zig"), @import("../webapi/MessagePort.zig"), @import("../webapi/media/MediaError.zig"), diff --git a/src/browser/webapi/Event.zig b/src/browser/webapi/Event.zig index 11da60fdb..0a49655b3 100644 --- a/src/browser/webapi/Event.zig +++ b/src/browser/webapi/Event.zig @@ -58,6 +58,7 @@ pub const Type = union(enum) { composition_event: *@import("event/CompositionEvent.zig"), navigation_current_entry_change_event: *@import("event/NavigationCurrentEntryChangeEvent.zig"), page_transition_event: *@import("event/PageTransitionEvent.zig"), + pop_state_event: *@import("event/PopStateEvent.zig"), }; const Options = struct { diff --git a/src/browser/webapi/History.zig b/src/browser/webapi/History.zig index d80fe3ba7..214f7230e 100644 --- a/src/browser/webapi/History.zig +++ b/src/browser/webapi/History.zig @@ -20,67 +20,75 @@ const std = @import("std"); const js = @import("../js/js.zig"); const Page = @import("../Page.zig"); +const PopStateEvent = @import("event/PopStateEvent.zig"); const History = @This(); -_page: *Page, -_length: u32 = 1, -_state: ?js.Object = null, - -pub fn init(page: *Page) History { - return .{ - ._page = page, - }; +pub fn getLength(_: *const History, page: *Page) u32 { + return @intCast(page._session.navigation._entries.items.len); } -pub fn deinit(self: *History) void { - if (self._state) |state| { - js.q.JS_FreeValue(self._page.js.ctx, state.value); - } +pub fn getState(_: *const History, page: *Page) !?js.Value { + if (page._session.navigation.getCurrentEntry()._state.value) |state| { + const value = try js.Value.fromJson(page.js, state); + return value; + } else return null; } -pub fn getLength(self: *const History) u32 { - return self._length; -} +pub fn pushState(_: *History, state: js.Object, _: []const u8, _url: ?[]const u8, page: *Page) !void { + const arena = page._session.arena; + const url = if (_url) |u| try arena.dupeZ(u8, u) else try arena.dupeZ(u8, page.url); -pub fn getState(self: *const History) ?js.Object { - return self._state; + const json = state.toJson(arena) catch return error.DateClone; + _ = try page._session.navigation.pushEntry(url, .{ .source = .history, .value = json }, page, true); } -pub fn pushState(self: *History, state: js.Object, _title: []const u8, url: ?[]const u8, page: *Page) !void { - _ = _title; // title is ignored in modern browsers - _ = url; // For minimal implementation, we don't actually navigate - _ = page; +pub fn replaceState(_: *History, state: js.Object, _: []const u8, _url: ?[]const u8, page: *Page) !void { + const arena = page._session.arena; + const url = if (_url) |u| try arena.dupeZ(u8, u) else try arena.dupeZ(u8, page.url); - self._state = try state.persist(); - self._length += 1; + const json = state.toJson(arena) catch return error.DateClone; + _ = try page._session.navigation.replaceEntry(url, .{ .source = .history, .value = json }, page, true); } -pub fn replaceState(self: *History, state: js.Object, _title: []const u8, url: ?[]const u8, page: *Page) !void { - _ = _title; - _ = url; - _ = page; - self._state = try state.persist(); - // Note: replaceState doesn't change length +fn goInner(delta: i32, page: *Page) !void { + // 0 behaves the same as no argument, both reloadig the page. + + 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 > page._session.navigation._entries.items.len - 1) { + return; + } + + const index = @as(usize, @intCast(index_s)); + const entry = page._session.navigation._entries.items[index]; + + if (entry._url) |url| { + if (try page.isSameOrigin(url)) { + const event = try PopStateEvent.init("popstate", .{ .state = entry._state.value }, page); + + try page._event_manager.dispatchWithFunction( + page.window.asEventTarget(), + event.asEvent(), + page.window._on_popstate, + .{ .context = "Pop State" }, + ); + } + } + + _ = try page._session.navigation.navigateInner(entry._url, .{ .traverse = index }, page); } -pub fn back(self: *History, page: *Page) void { - _ = self; - _ = page; - // Minimal implementation: no-op +pub fn back(_: *History, page: *Page) !void { + try goInner(-1, page); } -pub fn forward(self: *History, page: *Page) void { - _ = self; - _ = page; - // Minimal implementation: no-op +pub fn forward(_: *History, page: *Page) !void { + try goInner(1, page); } -pub fn go(self: *History, delta: i32, page: *Page) void { - _ = self; - _ = delta; - _ = page; - // Minimal implementation: no-op +pub fn go(_: *History, delta: ?i32, page: *Page) !void { + try goInner(delta orelse 0, page); } pub const JsApi = struct { diff --git a/src/browser/webapi/Window.zig b/src/browser/webapi/Window.zig index d6b83bb40..72db12651 100644 --- a/src/browser/webapi/Window.zig +++ b/src/browser/webapi/Window.zig @@ -52,10 +52,10 @@ _console: Console = .init, _navigator: Navigator = .init, _screen: Screen = .init, _performance: Performance, -_history: History, _storage_bucket: *storage.Bucket, _on_load: ?js.Function = null, _on_pageshow: ?js.Function = null, +_on_popstate: ?js.Function = null, _on_error: ?js.Function = null, // TODO: invoke on error? _on_unhandled_rejection: ?js.Function = null, // TODO: invoke on error _location: *Location, @@ -115,8 +115,8 @@ pub fn getLocation(self: *const Window) *Location { return self._location; } -pub fn getHistory(self: *Window) *History { - return &self._history; +pub fn getHistory(_: *Window, page: *Page) *History { + return &page._session.history; } pub fn getNavigation(_: *Window, page: *Page) *Navigation { @@ -151,6 +151,18 @@ pub fn setOnPageShow(self: *Window, cb_: ?js.Function) !void { } } +pub fn getOnPopState(self: *const Window) ?js.Function { + return self._on_popstate; +} + +pub fn setOnPopState(self: *Window, cb_: ?js.Function) !void { + if (cb_) |cb| { + self._on_popstate = cb; + } else { + self._on_popstate = null; + } +} + pub fn getOnError(self: *const Window) ?js.Function { return self._on_error; } @@ -504,13 +516,14 @@ pub const JsApi = struct { pub const sessionStorage = bridge.accessor(Window.getSessionStorage, null, .{ .cache = "sessionStorage" }); pub const document = bridge.accessor(Window.getDocument, null, .{ .cache = "document" }); pub const location = bridge.accessor(Window.getLocation, null, .{ .cache = "location" }); - pub const history = bridge.accessor(Window.getHistory, null, .{ .cache = "history" }); + pub const history = bridge.accessor(Window.getHistory, null, .{}); pub const navigation = bridge.accessor(Window.getNavigation, null, .{}); pub const crypto = bridge.accessor(Window.getCrypto, null, .{ .cache = "crypto" }); pub const CSS = bridge.accessor(Window.getCSS, null, .{ .cache = "CSS" }); pub const customElements = bridge.accessor(Window.getCustomElements, null, .{ .cache = "customElements" }); pub const onload = bridge.accessor(Window.getOnLoad, Window.setOnLoad, .{}); pub const onpageshow = bridge.accessor(Window.getOnPageShow, Window.setOnPageShow, .{}); + pub const onpopstate = bridge.accessor(Window.getOnPopState, Window.setOnPopState, .{}); pub const onerror = bridge.accessor(Window.getOnError, Window.getOnError, .{}); pub const onunhandledrejection = bridge.accessor(Window.getOnUnhandledRejection, Window.setOnUnhandledRejection, .{}); pub const fetch = bridge.function(Window.fetch, .{}); diff --git a/src/browser/webapi/event/PopStateEvent.zig b/src/browser/webapi/event/PopStateEvent.zig new file mode 100644 index 000000000..f6d7ce0f6 --- /dev/null +++ b/src/browser/webapi/event/PopStateEvent.zig @@ -0,0 +1,72 @@ +// 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 log = @import("../../../log.zig"); +// const Window = @import("../html/window.zig").Window; +const Event = @import("../Event.zig"); +const js = @import("../../js/js.zig"); +const Page = @import("../../Page.zig"); + +// https://developer.mozilla.org/en-US/docs/Web/API/PopStateEvent +const PopStateEvent = @This(); + +const EventOptions = struct { + state: ?[]const u8 = null, +}; + +_proto: *Event, +_state: ?[]const u8, + +pub fn init(typ: []const u8, _options: ?EventOptions, page: *Page) !*PopStateEvent { + const options = _options orelse EventOptions{}; + + return page._factory.event(typ, PopStateEvent{ + ._proto = undefined, + ._state = options.state, + }); +} + +pub fn asEvent(self: *PopStateEvent) *Event { + return self._proto; +} + +pub fn getState(self: *PopStateEvent, page: *Page) !?js.Value { + if (self._state == null) return null; + + const value = try js.Value.fromJson(page.js, self._state.?); + return value; +} + +pub fn getUAVisualTransition(_: *PopStateEvent) bool { + // Not currently supported so we always return false; + return false; +} + +pub const JsApi = struct { + pub const bridge = js.Bridge(PopStateEvent); + + pub const Meta = struct { + pub const name = "PopStateEvent"; + pub const prototype_chain = bridge.prototypeChain(); + pub var class_id: bridge.ClassId = undefined; + }; + + pub const constructor = bridge.constructor(PopStateEvent.init, .{}); + pub const state = bridge.accessor(PopStateEvent.getState, null, .{}); + pub const hasUAVisualTransition = bridge.accessor(PopStateEvent.getUAVisualTransition, null, .{}); +}; From ac85341cab9301d4260fd8246862976e1dcf0292 Mon Sep 17 00:00:00 2001 From: Muki Kiboigo Date: Mon, 8 Dec 2025 05:16:48 -0800 Subject: [PATCH 13/19] add NavigationKind to navigate --- src/browser/Page.zig | 7 +++-- src/browser/Session.zig | 6 +++- src/browser/webapi/Location.zig | 2 +- src/browser/webapi/navigation/Navigation.zig | 29 ++++++++++++-------- src/cdp/domains/page.zig | 2 +- src/cdp/domains/target.zig | 8 ++++-- src/cdp/testing.zig | 4 +-- src/lightpanda.zig | 2 +- src/main_legacy_test.zig | 2 +- src/main_wpt.zig | 2 +- src/testing.zig | 4 +-- 11 files changed, 42 insertions(+), 26 deletions(-) diff --git a/src/browser/Page.zig b/src/browser/Page.zig index 75b644135..0fd2c7508 100644 --- a/src/browser/Page.zig +++ b/src/browser/Page.zig @@ -58,6 +58,7 @@ const IntersectionObserver = @import("webapi/IntersectionObserver.zig"); const CustomElementDefinition = @import("webapi/CustomElementDefinition.zig"); const storage = @import("webapi/storage/storage.zig"); const PageTransitionEvent = @import("webapi/event/PageTransitionEvent.zig"); +const NavigationKind = @import("webapi/navigation/root.zig").NavigationKind; const timestamp = @import("../datetime.zig").timestamp; const milliTimestamp = @import("../datetime.zig").milliTimestamp; @@ -270,7 +271,7 @@ fn registerBackgroundTasks(self: *Page) !void { }.runMessageLoop, 250, .{ .name = "page.messageLoop" }); } -pub fn navigate(self: *Page, request_url: [:0]const u8, opts: NavigateOpts) !void { +pub fn navigate(self: *Page, request_url: [:0]const u8, opts: NavigateOpts, kind: NavigationKind) !void { const session = self._session; const resolved_url = try URL.resolve( @@ -292,7 +293,7 @@ pub fn navigate(self: *Page, request_url: [:0]const u8, opts: NavigateOpts) !voi self.window._location = try Location.init(self.url, self); self.document._location = self.window._location; - try session.navigation.updateEntries("", .{ .push = null }, self, true); + try session.navigation.updateEntries(resolved_url, kind, self, true); return; } } @@ -353,6 +354,8 @@ pub fn navigate(self: *Page, request_url: [:0]const u8, opts: NavigateOpts) !voi .timestamp = timestamp(.monotonic), }); + session.navigation._current_navigation_kind = kind; + http_client.request(.{ .ctx = self, .url = self.url, diff --git a/src/browser/Session.zig b/src/browser/Session.zig index 5f25ae857..3c599c393 100644 --- a/src/browser/Session.zig +++ b/src/browser/Session.zig @@ -191,7 +191,11 @@ fn processQueuedNavigation(self: *Session) !bool { return err; }; - page.navigate(qn.url, qn.opts) catch |err| { + page.navigate( + qn.url, + qn.opts, + self.navigation._current_navigation_kind orelse .{ .push = null }, + ) catch |err| { log.err(.browser, "queued navigation error", .{ .err = err, .url = qn.url }); return err; }; diff --git a/src/browser/webapi/Location.zig b/src/browser/webapi/Location.zig index 205d1732e..c2c2b1a8f 100644 --- a/src/browser/webapi/Location.zig +++ b/src/browser/webapi/Location.zig @@ -81,7 +81,7 @@ pub fn setHash(_: *const Location, hash: []const u8, page: *Page) !void { }; const duped_hash = try page.arena.dupeZ(u8, normalized_hash); - return page.navigate(duped_hash, .{ .reason = .script }); + return page.navigate(duped_hash, .{ .reason = .script }, .{ .replace = null }); } pub fn toString(self: *const Location, page: *const Page) ![:0]const u8 { diff --git a/src/browser/webapi/navigation/Navigation.zig b/src/browser/webapi/navigation/Navigation.zig index e19e8b03f..2f2b284fc 100644 --- a/src/browser/webapi/navigation/Navigation.zig +++ b/src/browser/webapi/navigation/Navigation.zig @@ -92,7 +92,6 @@ pub fn back(self: *Navigation, page: *Page) !NavigationReturn { const new_index = self._index - 1; const next_entry = self._entries.items[new_index]; - self._index = new_index; return self.navigateInner(next_entry._url, .{ .traverse = new_index }, page); } @@ -108,7 +107,6 @@ pub fn forward(self: *Navigation, page: *Page) !NavigationReturn { const new_index = self._index + 1; const next_entry = self._entries.items[new_index]; - self._index = new_index; return self.navigateInner(next_entry._url, .{ .traverse = new_index }, page); } @@ -132,7 +130,10 @@ pub fn updateEntries(self: *Navigation, url: [:0]const u8, kind: NavigationKind, // This is only really safe to run in the `pageDoneCallback` where we can guarantee that the URL and NavigationKind are correct. pub fn processNavigation(self: *Navigation, page: *Page) !void { const url = page.url; + const kind: NavigationKind = self._current_navigation_kind orelse .{ .push = null }; + defer self._current_navigation_kind = null; + try self.updateEntries(url, kind, page, false); } @@ -247,9 +248,11 @@ pub fn navigateInner( const committed = try page.js.createPromiseResolver(.page); const finished = try page.js.createPromiseResolver(.page); - const new_url = try URL.resolve(arena, url, page.url, .{}); + const new_url = try URL.resolve(arena, page.url, url, .{}); const is_same_document = URL.eqlDocument(new_url, page.url); + const previous = self.getCurrentEntry(); + switch (kind) { .push => |state| { if (is_same_document) { @@ -261,8 +264,7 @@ pub fn navigateInner( _ = try self.pushEntry(url, .{ .source = .navigation, .value = state }, page, true); } else { - // try page.navigate(url, .{ .reason = .navigation }, kind); - try page.navigate(url, .{ .reason = .navigation }); + try page.navigate(url, .{ .reason = .navigation }, kind); } }, .replace => |state| { @@ -275,8 +277,7 @@ pub fn navigateInner( _ = try self.replaceEntry(url, .{ .source = .navigation, .value = state }, page, true); } else { - // try page.navigate(url, .{ .reason = .navigation }, kind); - try page.navigate(url, .{ .reason = .navigation }); + try page.navigate(url, .{ .reason = .navigation }, kind); } }, .traverse => |index| { @@ -289,16 +290,22 @@ pub fn navigateInner( // todo: Fire navigate event finished.resolve("navigation traverse", {}); } else { - // try page.navigate(url, .{ .reason = .navigation }, kind); - try page.navigate(url, .{ .reason = .navigation }); + try page.navigate(url, .{ .reason = .navigation }, kind); } }, .reload => { - // try page.navigate(url, .{ .reason = .navigation }, kind); - try page.navigate(url, .{ .reason = .navigation }); + try page.navigate(url, .{ .reason = .navigation }, kind); }, } + // If we haven't navigated off, let us fire off an a currententrychange. + const event = try NavigationCurrentEntryChangeEvent.init( + "currententrychange", + .{ .from = previous, .navigationType = @tagName(kind) }, + page, + ); + try self._proto.dispatch(.{ .currententrychange = event }, page); + return .{ .committed = committed.promise(), .finished = finished.promise(), diff --git a/src/cdp/domains/page.zig b/src/cdp/domains/page.zig index bc961fe16..c27d3de78 100644 --- a/src/cdp/domains/page.zig +++ b/src/cdp/domains/page.zig @@ -183,7 +183,7 @@ fn navigate(cmd: anytype) !void { try page.navigate(params.url, .{ .reason = .address_bar, .cdp_id = cmd.input.id, - }); + }, .{ .push = null }); } pub fn pageNavigate(arena: Allocator, bc: anytype, event: *const Notification.PageNavigate) !void { diff --git a/src/cdp/domains/target.zig b/src/cdp/domains/target.zig index b59285afb..d6f672e29 100644 --- a/src/cdp/domains/target.zig +++ b/src/cdp/domains/target.zig @@ -179,9 +179,11 @@ fn createTarget(cmd: anytype) !void { try doAttachtoTarget(cmd, target_id); } - try page.navigate(params.url, .{ - .reason = .address_bar, - }); + try page.navigate( + params.url, + .{ .reason = .address_bar }, + .{ .push = null }, + ); try cmd.sendResult(.{ .targetId = target_id, diff --git a/src/cdp/testing.zig b/src/cdp/testing.zig index 8d459a9f4..b575d4f66 100644 --- a/src/cdp/testing.zig +++ b/src/cdp/testing.zig @@ -127,10 +127,10 @@ const TestContext = struct { const full_url = try std.fmt.allocPrintSentinel( self.arena.allocator(), "http://127.0.0.1:9582/src/browser/tests/{s}", - .{ url }, + .{url}, 0, ); - try page.navigate(full_url, .{}); + try page.navigate(full_url, .{}, .{ .push = null }); bc.session.fetchWait(2000); } return bc; diff --git a/src/lightpanda.zig b/src/lightpanda.zig index c7e714b1e..55ec5df7f 100644 --- a/src/lightpanda.zig +++ b/src/lightpanda.zig @@ -60,7 +60,7 @@ pub fn fetch(app: *App, url: [:0]const u8, opts: FetchOpts) !void { // } // } - _ = try page.navigate(url, .{}); + _ = try page.navigate(url, .{}, .{ .push = null }); _ = session.fetchWait(opts.wait_ms); const writer = opts.writer orelse return; diff --git a/src/main_legacy_test.zig b/src/main_legacy_test.zig index 0c409c357..5b1cf8628 100644 --- a/src/main_legacy_test.zig +++ b/src/main_legacy_test.zig @@ -85,7 +85,7 @@ pub fn run(allocator: Allocator, file: []const u8, session: *lp.Session) !void { try_catch.init(js_context); defer try_catch.deinit(); - try page.navigate(url, .{}); + try page.navigate(url, .{}, .{ .push = null }); session.fetchWait(2000); page._session.browser.runMicrotasks(); diff --git a/src/main_wpt.zig b/src/main_wpt.zig index ff512e408..5e334a224 100644 --- a/src/main_wpt.zig +++ b/src/main_wpt.zig @@ -114,7 +114,7 @@ fn run( defer session.removePage(); const url = try std.fmt.allocPrintSentinel(arena, "http://localhost:9582/{s}", .{test_file}, 0); - try page.navigate(url, .{}); + try page.navigate(url, .{}, .{ .push = null }); _ = page.wait(2000); diff --git a/src/testing.zig b/src/testing.zig index 452ce812d..f3c1aeac3 100644 --- a/src/testing.zig +++ b/src/testing.zig @@ -403,7 +403,7 @@ fn runWebApiTest(test_file: [:0]const u8) !void { try_catch.init(js_context); defer try_catch.deinit(); - try page.navigate(url, .{}); + try page.navigate(url, .{}, .{ .push = null }); test_session.fetchWait(2000); page._session.browser.runMicrotasks(); @@ -428,7 +428,7 @@ pub fn pageTest(comptime test_file: []const u8) !*Page { 0, ); - try page.navigate(url, .{}); + try page.navigate(url, .{}, .{ .push = null }); test_session.fetchWait(2000); return page; } From 395f93240d0d878ff12c472cf4783f750880b2ae Mon Sep 17 00:00:00 2001 From: Muki Kiboigo Date: Tue, 9 Dec 2025 15:10:18 -0800 Subject: [PATCH 14/19] minor Navigation style changes --- src/browser/webapi/Window.zig | 2 +- .../webapi/event/NavigationCurrentEntryChangeEvent.zig | 2 +- src/browser/webapi/event/PageTransitionEvent.zig | 4 +--- src/browser/webapi/event/PopStateEvent.zig | 6 +++--- src/browser/webapi/navigation/Navigation.zig | 4 ++-- src/browser/webapi/navigation/NavigationEventTarget.zig | 2 +- src/browser/webapi/navigation/NavigationHistoryEntry.zig | 2 +- src/browser/webapi/navigation/root.zig | 2 +- 8 files changed, 11 insertions(+), 13 deletions(-) diff --git a/src/browser/webapi/Window.zig b/src/browser/webapi/Window.zig index 72db12651..792ca8ddf 100644 --- a/src/browser/webapi/Window.zig +++ b/src/browser/webapi/Window.zig @@ -24,7 +24,7 @@ const log = @import("../../log.zig"); const Page = @import("../Page.zig"); const Console = @import("Console.zig"); const History = @import("History.zig"); -const Navigation = @import("../webapi/navigation/Navigation.zig"); +const Navigation = @import("navigation/Navigation.zig"); const Crypto = @import("Crypto.zig"); const CSS = @import("CSS.zig"); const Navigator = @import("Navigator.zig"); diff --git a/src/browser/webapi/event/NavigationCurrentEntryChangeEvent.zig b/src/browser/webapi/event/NavigationCurrentEntryChangeEvent.zig index ead742e31..f5d2a9358 100644 --- a/src/browser/webapi/event/NavigationCurrentEntryChangeEvent.zig +++ b/src/browser/webapi/event/NavigationCurrentEntryChangeEvent.zig @@ -1,4 +1,4 @@ -// Copyright (C) 2023-2024 Lightpanda (Selecy SAS) +// Copyright (C) 2023-2025 Lightpanda (Selecy SAS) // // Francis Bouvier // Pierre Tachoire diff --git a/src/browser/webapi/event/PageTransitionEvent.zig b/src/browser/webapi/event/PageTransitionEvent.zig index f4cebf549..44b2c9e71 100644 --- a/src/browser/webapi/event/PageTransitionEvent.zig +++ b/src/browser/webapi/event/PageTransitionEvent.zig @@ -1,4 +1,4 @@ -// Copyright (C) 2023-2024 Lightpanda (Selecy SAS) +// Copyright (C) 2023-2025 Lightpanda (Selecy SAS) // // Francis Bouvier // Pierre Tachoire @@ -47,8 +47,6 @@ pub fn getPersisted(self: *PageTransitionEvent) bool { return self._persisted; } -const PageTransitionKind = enum { show, hide }; - pub const JsApi = struct { pub const bridge = js.Bridge(PageTransitionEvent); diff --git a/src/browser/webapi/event/PopStateEvent.zig b/src/browser/webapi/event/PopStateEvent.zig index f6d7ce0f6..3ecffb997 100644 --- a/src/browser/webapi/event/PopStateEvent.zig +++ b/src/browser/webapi/event/PopStateEvent.zig @@ -1,4 +1,4 @@ -// Copyright (C) 2023-2024 Lightpanda (Selecy SAS) +// Copyright (C) 2023-2025 Lightpanda (Selecy SAS) // // Francis Bouvier // Pierre Tachoire @@ -52,7 +52,7 @@ pub fn getState(self: *PopStateEvent, page: *Page) !?js.Value { return value; } -pub fn getUAVisualTransition(_: *PopStateEvent) bool { +pub fn hasUAVisualTransition(_: *PopStateEvent) bool { // Not currently supported so we always return false; return false; } @@ -68,5 +68,5 @@ pub const JsApi = struct { pub const constructor = bridge.constructor(PopStateEvent.init, .{}); pub const state = bridge.accessor(PopStateEvent.getState, null, .{}); - pub const hasUAVisualTransition = bridge.accessor(PopStateEvent.getUAVisualTransition, null, .{}); + pub const hasUAVisualTransition = bridge.accessor(PopStateEvent.hasUAVisualTransition, null, .{}); }; diff --git a/src/browser/webapi/navigation/Navigation.zig b/src/browser/webapi/navigation/Navigation.zig index 2f2b284fc..28016bd38 100644 --- a/src/browser/webapi/navigation/Navigation.zig +++ b/src/browser/webapi/navigation/Navigation.zig @@ -1,4 +1,4 @@ -// Copyright (C) 2023-2024 Lightpanda (Selecy SAS) +// Copyright (C) 2023-2025 Lightpanda (Selecy SAS) // // Francis Bouvier // Pierre Tachoire @@ -42,7 +42,7 @@ _current_navigation_kind: ?NavigationKind = null, _index: usize = 0, // Need to be stable pointers, because Events can reference entries. -_entries: std.ArrayListUnmanaged(*NavigationHistoryEntry) = .empty, +_entries: std.ArrayList(*NavigationHistoryEntry) = .empty, _next_entry_id: usize = 0, pub fn init(arena: std.mem.Allocator) Navigation { diff --git a/src/browser/webapi/navigation/NavigationEventTarget.zig b/src/browser/webapi/navigation/NavigationEventTarget.zig index 1e9b0bd48..0872d7fb5 100644 --- a/src/browser/webapi/navigation/NavigationEventTarget.zig +++ b/src/browser/webapi/navigation/NavigationEventTarget.zig @@ -1,4 +1,4 @@ -// Copyright (C) 2023-2024 Lightpanda (Selecy SAS) +// Copyright (C) 2023-2025 Lightpanda (Selecy SAS) // // Francis Bouvier // Pierre Tachoire diff --git a/src/browser/webapi/navigation/NavigationHistoryEntry.zig b/src/browser/webapi/navigation/NavigationHistoryEntry.zig index 86dc295c7..0f3289b27 100644 --- a/src/browser/webapi/navigation/NavigationHistoryEntry.zig +++ b/src/browser/webapi/navigation/NavigationHistoryEntry.zig @@ -1,4 +1,4 @@ -// Copyright (C) 2023-2024 Lightpanda (Selecy SAS) +// Copyright (C) 2023-2025 Lightpanda (Selecy SAS) // // Francis Bouvier // Pierre Tachoire diff --git a/src/browser/webapi/navigation/root.zig b/src/browser/webapi/navigation/root.zig index e2a5c1733..d611cf9fc 100644 --- a/src/browser/webapi/navigation/root.zig +++ b/src/browser/webapi/navigation/root.zig @@ -1,4 +1,4 @@ -// Copyright (C) 2023-2024 Lightpanda (Selecy SAS) +// Copyright (C) 2023-2025 Lightpanda (Selecy SAS) // // Francis Bouvier // Pierre Tachoire From 6534dc4c4ff4a7ec2600f5bdaf8f97c0834b3473 Mon Sep 17 00:00:00 2001 From: Muki Kiboigo Date: Tue, 9 Dec 2025 15:11:15 -0800 Subject: [PATCH 15/19] use Navigation ptr instead of fat copy --- src/browser/webapi/navigation/NavigationHistoryEntry.zig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/browser/webapi/navigation/NavigationHistoryEntry.zig b/src/browser/webapi/navigation/NavigationHistoryEntry.zig index 0f3289b27..2411a7418 100644 --- a/src/browser/webapi/navigation/NavigationHistoryEntry.zig +++ b/src/browser/webapi/navigation/NavigationHistoryEntry.zig @@ -52,7 +52,7 @@ pub fn id(self: *const NavigationHistoryEntry) []const u8 { } pub fn index(self: *const NavigationHistoryEntry, page: *Page) i32 { - const navigation = page._session.navigation; + const navigation = &page._session.navigation; for (navigation._entries.items, 0..) |entry, i| { if (std.mem.eql(u8, entry._id, self._id)) { From 3662d1681ec2da68841df159bf1ca7474d76121a Mon Sep 17 00:00:00 2001 From: Muki Kiboigo Date: Tue, 9 Dec 2025 15:13:17 -0800 Subject: [PATCH 16/19] no need to run microtasks before onload --- src/browser/Page.zig | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/browser/Page.zig b/src/browser/Page.zig index 0fd2c7508..904f03b70 100644 --- a/src/browser/Page.zig +++ b/src/browser/Page.zig @@ -429,9 +429,6 @@ pub fn documentIsComplete(self: *Page) void { fn _documentIsComplete(self: *Page) !void { self.document._ready_state = .complete; - self._session.browser.runMicrotasks(); - self._session.browser.runMessageLoop(); - // dispatch window.load event const event = try Event.init("load", .{}, self); // this event is weird, it's dispatched directly on the window, but From ddb83cf9c52fc60168c63a61710a8668a644fefe Mon Sep 17 00:00:00 2001 From: Muki Kiboigo Date: Tue, 9 Dec 2025 15:17:54 -0800 Subject: [PATCH 17/19] add assert and note on getCurrentEntry --- src/browser/webapi/navigation/Navigation.zig | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/browser/webapi/navigation/Navigation.zig b/src/browser/webapi/navigation/Navigation.zig index 28016bd38..49de232e8 100644 --- a/src/browser/webapi/navigation/Navigation.zig +++ b/src/browser/webapi/navigation/Navigation.zig @@ -72,6 +72,10 @@ pub fn getCanGoForward(self: *const Navigation) bool { } pub fn getCurrentEntry(self: *Navigation) *NavigationHistoryEntry { + // This should never fail. An entry should always be created before + // we run the scripts on the page we are loading. + std.debug.assert(self._entries.items.len > 0); + return self._entries.items[self._index]; } From 7c9d7259e6cf6089eac1dfd4b181e7f86d09c64a Mon Sep 17 00:00:00 2001 From: Muki Kiboigo Date: Tue, 9 Dec 2025 15:47:09 -0800 Subject: [PATCH 18/19] add NavigationActivation --- src/browser/Page.zig | 5 +- src/browser/js/bridge.zig | 1 + src/browser/webapi/navigation/Navigation.zig | 34 +++++++++-- .../navigation/NavigationActivation.zig | 56 +++++++++++++++++++ src/browser/webapi/navigation/root.zig | 23 ++------ 5 files changed, 90 insertions(+), 29 deletions(-) create mode 100644 src/browser/webapi/navigation/NavigationActivation.zig diff --git a/src/browser/Page.zig b/src/browser/Page.zig index 904f03b70..2b81cf970 100644 --- a/src/browser/Page.zig +++ b/src/browser/Page.zig @@ -530,7 +530,7 @@ fn pageDoneCallback(ctx: *anyopaque) !void { self.clearTransferArena(); //We need to handle different navigation types differently. - try self._session.navigation.processNavigation(self); + try self._session.navigation.commitNavigation(self); defer if (comptime IS_DEBUG) { log.debug(.page, "page.load.complete", .{ .url = self.url }); @@ -567,9 +567,6 @@ fn pageDoneCallback(ctx: *anyopaque) !void { }, else => unreachable, } - // We need to handle different navigation types differently. - // @ZIGDOM - // try self._session.navigation.processNavigation(self); } fn pageErrorCallback(ctx: *anyopaque, err: anyerror) void { diff --git a/src/browser/js/bridge.zig b/src/browser/js/bridge.zig index 3358cc535..b79622d06 100644 --- a/src/browser/js/bridge.zig +++ b/src/browser/js/bridge.zig @@ -605,4 +605,5 @@ pub const JsApis = flattenTypes(&.{ @import("../webapi/navigation/Navigation.zig"), @import("../webapi/navigation/NavigationEventTarget.zig"), @import("../webapi/navigation/NavigationHistoryEntry.zig"), + @import("../webapi/navigation/NavigationActivation.zig"), }); diff --git a/src/browser/webapi/navigation/Navigation.zig b/src/browser/webapi/navigation/Navigation.zig index 49de232e8..424c8d784 100644 --- a/src/browser/webapi/navigation/Navigation.zig +++ b/src/browser/webapi/navigation/Navigation.zig @@ -29,10 +29,11 @@ const EventTarget = @import("../EventTarget.zig"); const Navigation = @This(); const NavigationKind = @import("root.zig").NavigationKind; -const NavigationHistoryEntry = @import("NavigationHistoryEntry.zig"); +const NavigationActivation = @import("NavigationActivation.zig"); const NavigationTransition = @import("root.zig").NavigationTransition; const NavigationState = @import("root.zig").NavigationState; +const NavigationHistoryEntry = @import("NavigationHistoryEntry.zig"); const NavigationCurrentEntryChangeEvent = @import("../event/NavigationCurrentEntryChangeEvent.zig"); const NavigationEventTarget = @import("NavigationEventTarget.zig"); @@ -44,6 +45,7 @@ _index: usize = 0, // Need to be stable pointers, because Events can reference entries. _entries: std.ArrayList(*NavigationHistoryEntry) = .empty, _next_entry_id: usize = 0, +_activation: ?NavigationActivation = null, pub fn init(arena: std.mem.Allocator) Navigation { return Navigation{ ._arena = arena }; @@ -63,6 +65,10 @@ pub fn onNewPage(self: *Navigation, page: *Page) !void { ); } +pub fn getActivation(self: *const Navigation) ?NavigationActivation { + return self._activation; +} + pub fn getCanGoBack(self: *const Navigation) bool { return self._index > 0; } @@ -71,12 +77,18 @@ pub fn getCanGoForward(self: *const Navigation) bool { return self._entries.items.len > self._index + 1; } +pub fn getCurrentEntryOrNull(self: *Navigation) ?*NavigationHistoryEntry { + if (self._entries.items.len > self._index) { + return self._entries.items[self._index]; + } else return null; +} + pub fn getCurrentEntry(self: *Navigation) *NavigationHistoryEntry { // This should never fail. An entry should always be created before // we run the scripts on the page we are loading. std.debug.assert(self._entries.items.len > 0); - return self._entries.items[self._index]; + return self.getCurrentEntryOrNull().?; } pub fn getTransition(_: *const Navigation) ?NavigationTransition { @@ -117,8 +129,8 @@ pub fn forward(self: *Navigation, page: *Page) !NavigationReturn { pub fn updateEntries(self: *Navigation, url: [:0]const u8, kind: NavigationKind, page: *Page, dispatch: bool) !void { switch (kind) { - .replace => { - _ = try self.replaceEntry(url, .{ .source = .navigation, .value = null }, page, dispatch); + .replace => |state| { + _ = try self.replaceEntry(url, .{ .source = .navigation, .value = state }, page, dispatch); }, .push => |state| { _ = try self.pushEntry(url, .{ .source = .navigation, .value = state }, page, dispatch); @@ -131,14 +143,23 @@ pub fn updateEntries(self: *Navigation, url: [:0]const u8, kind: NavigationKind, } // This is for after true navigation processing, where we need to ensure that our entries are up to date. -// This is only really safe to run in the `pageDoneCallback` where we can guarantee that the URL and NavigationKind are correct. -pub fn processNavigation(self: *Navigation, page: *Page) !void { +// +// This is only really safe to run in the `pageDoneCallback` +// where we can guarantee that the URL and NavigationKind are correct. +pub fn commitNavigation(self: *Navigation, page: *Page) !void { const url = page.url; const kind: NavigationKind = self._current_navigation_kind orelse .{ .push = null }; defer self._current_navigation_kind = null; + const from_entry = self.getCurrentEntryOrNull(); try self.updateEntries(url, kind, page, false); + + self._activation = NavigationActivation{ + ._from = from_entry, + ._entry = self.getCurrentEntry(), + ._type = kind.toNavigationType(), + }; } /// Pushes an entry into the Navigation stack WITHOUT actually navigating to it. @@ -401,6 +422,7 @@ pub const JsApi = struct { pub var class_id: bridge.ClassId = undefined; }; + pub const activation = bridge.accessor(Navigation.getActivation, null, .{}); pub const canGoBack = bridge.accessor(Navigation.getCanGoBack, null, .{}); pub const canGoForward = bridge.accessor(Navigation.getCanGoForward, null, .{}); pub const currentEntry = bridge.accessor(Navigation.getCurrentEntry, null, .{}); diff --git a/src/browser/webapi/navigation/NavigationActivation.zig b/src/browser/webapi/navigation/NavigationActivation.zig new file mode 100644 index 000000000..3c161f9bf --- /dev/null +++ b/src/browser/webapi/navigation/NavigationActivation.zig @@ -0,0 +1,56 @@ +// Copyright (C) 2023-2025 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 js = @import("../../js/js.zig"); + +const NavigationType = @import("root.zig").NavigationType; +const NavigationHistoryEntry = @import("NavigationHistoryEntry.zig"); + +// https://developer.mozilla.org/en-US/docs/Web/API/NavigationActivation +const NavigationActivation = @This(); + +_entry: *NavigationHistoryEntry, +_from: ?*NavigationHistoryEntry = null, +_type: NavigationType, + +pub fn getEntry(self: *const NavigationActivation) *NavigationHistoryEntry { + return self._entry; +} + +pub fn getFrom(self: *const NavigationActivation) ?*NavigationHistoryEntry { + return self._from; +} + +pub fn getNavigationType(self: *const NavigationActivation) []const u8 { + return @tagName(self._type); +} + +pub const JsApi = struct { + pub const bridge = js.Bridge(NavigationActivation); + + pub const Meta = struct { + pub const name = "NavigationActivation"; + pub const prototype_chain = bridge.prototypeChain(); + pub var class_id: bridge.ClassId = undefined; + }; + + pub const entry = bridge.accessor(NavigationActivation.getEntry, null, .{}); + pub const from = bridge.accessor(NavigationActivation.getFrom, null, .{}); + pub const navigationType = bridge.accessor(NavigationActivation.getNavigationType, null, .{}); +}; diff --git a/src/browser/webapi/navigation/root.zig b/src/browser/webapi/navigation/root.zig index d611cf9fc..ef8b20e2c 100644 --- a/src/browser/webapi/navigation/root.zig +++ b/src/browser/webapi/navigation/root.zig @@ -38,6 +38,10 @@ pub const NavigationKind = union(NavigationType) { replace: ?[]const u8, traverse: usize, reload, + + pub fn toNavigationType(self: NavigationKind) NavigationType { + return std.meta.activeTag(self); + } }; pub const NavigationState = struct { @@ -45,25 +49,6 @@ pub const NavigationState = struct { value: ?[]const u8, }; -// https://developer.mozilla.org/en-US/docs/Web/API/NavigationActivation -pub const NavigationActivation = struct { - entry: NavigationHistoryEntry, - from: ?NavigationHistoryEntry = null, - type: NavigationType, - - 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) NavigationType { - return self.type; - } -}; - // https://developer.mozilla.org/en-US/docs/Web/API/NavigationTransition pub const NavigationTransition = struct { finished: js.Promise, From 02a0727870cfece2c75c294bc16d2fa28cd63922 Mon Sep 17 00:00:00 2001 From: Muki Kiboigo Date: Tue, 9 Dec 2025 15:51:06 -0800 Subject: [PATCH 19/19] eqlDocument slicing at hash --- src/browser/URL.zig | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/src/browser/URL.zig b/src/browser/URL.zig index 11739b070..4a399a3b1 100644 --- a/src/browser/URL.zig +++ b/src/browser/URL.zig @@ -272,14 +272,10 @@ pub fn getHost(raw: [:0]const u8) []const u8 { // Returns true if these two URLs point to the same document. pub fn eqlDocument(first: [:0]const u8, second: [:0]const u8) bool { - if (!std.mem.eql(u8, getProtocol(first), getProtocol(second))) return false; - if (!std.mem.eql(u8, getHost(first), getHost(second))) return false; - if (!std.mem.eql(u8, getPort(first), getPort(second))) return false; - if (!std.mem.eql(u8, getPathname(first), getPathname(second))) return false; - if (!std.mem.eql(u8, getSearch(first), getSearch(second))) return false; - // hashes are allowed to be different. - - return true; + // First '#' signifies the start of the fragment. + const first_hash_index = std.mem.indexOfScalar(u8, first, '#') orelse first.len; + const second_hash_index = std.mem.indexOfScalar(u8, second, '#') orelse second.len; + return std.mem.eql(u8, first[0..first_hash_index], second[0..second_hash_index]); } const KnownProtocol = enum {