diff --git a/src/browser/dom/event_target.zig b/src/browser/dom/event_target.zig index 5ee77751..32d7a14f 100644 --- a/src/browser/dom/event_target.zig +++ b/src/browser/dom/event_target.zig @@ -34,6 +34,7 @@ pub const Union = union(enum) { screen_orientation: *@import("../html/screen.zig").ScreenOrientation, performance: *@import("performance.zig").Performance, media_query_list: *@import("../html/media_query_list.zig").MediaQueryList, + navigation: *@import("../navigation/Navigation.zig"), }; // EventTarget implementation @@ -82,6 +83,11 @@ pub const EventTarget = struct { .media_query_list => { return .{ .media_query_list = @fieldParentPtr("base", @as(*parser.EventTargetTBase, @ptrCast(et))) }; }, + .navigation => { + const NavigationEventTarget = @import("../navigation/NavigationEventTarget.zig"); + const base: *NavigationEventTarget = @fieldParentPtr("base", @as(*parser.EventTargetTBase, @ptrCast(et))); + return .{ .navigation = @fieldParentPtr("proto", base) }; + }, } } diff --git a/src/browser/events/event.zig b/src/browser/events/event.zig index 7c98b580..4be76295 100644 --- a/src/browser/events/event.zig +++ b/src/browser/events/event.zig @@ -17,6 +17,7 @@ // along with this program. If not, see . const std = @import("std"); +const js = @import("../js/js.zig"); const Allocator = std.mem.Allocator; const log = @import("../../log.zig"); @@ -38,6 +39,7 @@ const ErrorEvent = @import("../html/error_event.zig").ErrorEvent; const MessageEvent = @import("../dom/MessageChannel.zig").MessageEvent; const PopStateEvent = @import("../html/History.zig").PopStateEvent; const CompositionEvent = @import("composition_event.zig").CompositionEvent; +const NavigationCurrentEntryChangeEvent = @import("../navigation/navigation.zig").NavigationCurrentEntryChangeEvent; // Event interfaces pub const Interfaces = .{ @@ -50,6 +52,7 @@ pub const Interfaces = .{ MessageEvent, PopStateEvent, CompositionEvent, + NavigationCurrentEntryChangeEvent, }; pub const Union = generate.Union(Interfaces); @@ -79,6 +82,9 @@ pub const Event = struct { .keyboard_event => .{ .KeyboardEvent = @as(*parser.KeyboardEvent, @ptrCast(evt)) }, .pop_state => .{ .PopStateEvent = @as(*PopStateEvent, @ptrCast(evt)).* }, .composition_event => .{ .CompositionEvent = (@as(*CompositionEvent, @fieldParentPtr("proto", evt))).* }, + .navigation_current_entry_change_event => .{ + .NavigationCurrentEntryChangeEvent = @as(*NavigationCurrentEntryChangeEvent, @ptrCast(evt)).*, + }, }; } @@ -226,8 +232,6 @@ pub const EventHandler = struct { node: parser.EventNode, listener: *parser.EventListener, - const js = @import("../js/js.zig"); - pub const Listener = union(enum) { function: js.Function, object: js.Object, @@ -399,6 +403,40 @@ const SignalCallback = struct { } }; +pub fn DirectEventHandler( + comptime TargetT: type, + target: *TargetT, + event_type: []const u8, + maybe_listener: ?EventHandler.Listener, + cb: *?js.Function, + page_arena: std.mem.Allocator, +) !void { + const event_target = parser.toEventTarget(TargetT, target); + + // Check if we have a listener set. + if (cb.*) |callback| { + const listener = try parser.eventTargetHasListener(event_target, event_type, false, callback.id); + std.debug.assert(listener != null); + try parser.eventTargetRemoveEventListener(event_target, event_type, listener.?, false); + } + + if (maybe_listener) |listener| { + switch (listener) { + // If an object is given as listener, do nothing. + .object => {}, + .function => |callback| { + _ = try EventHandler.register(page_arena, event_target, event_type, listener, null) orelse unreachable; + cb.* = callback; + + return; + }, + } + } + + // Just unset the listener. + cb.* = null; +} + const testing = @import("../../testing.zig"); test "Browser: Event" { try testing.htmlRunner("events/event.html"); diff --git a/src/browser/html/History.zig b/src/browser/html/History.zig index f8be6bb3..aa491518 100644 --- a/src/browser/html/History.zig +++ b/src/browser/html/History.zig @@ -21,140 +21,79 @@ const log = @import("../../log.zig"); const js = @import("../js/js.zig"); const Page = @import("../page.zig").Page; +const Window = @import("window.zig").Window; // https://html.spec.whatwg.org/multipage/nav-history-apis.html#the-history-interface const History = @This(); -const HistoryEntry = struct { - url: []const u8, - // This is serialized as JSON because - // History must survive a JsContext. - state: ?[]u8, -}; - const ScrollRestorationMode = enum { + pub const ENUM_JS_USE_TAG = true; + auto, manual, - - pub fn fromString(str: []const u8) ?ScrollRestorationMode { - for (std.enums.values(ScrollRestorationMode)) |mode| { - if (std.ascii.eqlIgnoreCase(str, @tagName(mode))) { - return mode; - } - } else { - return null; - } - } - - pub fn toString(self: ScrollRestorationMode) []const u8 { - return @tagName(self); - } }; scroll_restoration: ScrollRestorationMode = .auto, -stack: std.ArrayListUnmanaged(HistoryEntry) = .empty, -current: ?usize = null, -pub fn get_length(self: *History) u32 { - return @intCast(self.stack.items.len); +pub fn get_length(_: *History, page: *Page) u32 { + return @intCast(page.session.navigation.entries.items.len); } pub fn get_scrollRestoration(self: *History) ScrollRestorationMode { return self.scroll_restoration; } -pub fn set_scrollRestoration(self: *History, mode: []const u8) void { - self.scroll_restoration = ScrollRestorationMode.fromString(mode) orelse self.scroll_restoration; +pub fn set_scrollRestoration(self: *History, mode: ScrollRestorationMode) void { + self.scroll_restoration = mode; } -pub fn get_state(self: *History, page: *Page) !?js.Value { - if (self.current) |curr| { - const entry = self.stack.items[curr]; - if (entry.state) |state| { - const value = try js.Value.fromJson(page.js, state); - return value; - } else { - return null; - } +pub fn get_state(_: *History, page: *Page) !?js.Value { + if (page.session.navigation.currentEntry().state) |state| { + const value = try js.Value.fromJson(page.js, state); + return value; } else { return null; } } -pub fn pushNavigation(self: *History, _url: []const u8, page: *Page) !void { +pub fn _pushState(_: *const History, state: js.Object, _: ?[]const u8, _url: ?[]const u8, page: *Page) !void { const arena = page.session.arena; - const url = try arena.dupe(u8, _url); + const url = if (_url) |u| try arena.dupe(u8, u) else try arena.dupe(u8, page.url.raw); - const entry = HistoryEntry{ .state = null, .url = url }; - try self.stack.append(arena, entry); - self.current = self.stack.items.len - 1; + const json = state.toJson(arena) catch return error.DataClone; + _ = try page.session.navigation.pushEntry(url, json, page, true); } -pub fn dispatchPopStateEvent(state: ?[]const u8, page: *Page) void { - log.debug(.script_event, "dispatch popstate event", .{ - .type = "popstate", - .source = "history", - }); - History._dispatchPopStateEvent(state, page) catch |err| { - log.err(.app, "dispatch popstate event error", .{ - .err = err, - .type = "popstate", - .source = "history", - }); - }; -} - -fn _dispatchPopStateEvent(state: ?[]const u8, page: *Page) !void { - var evt = try PopStateEvent.constructor("popstate", .{ .state = state }); - - _ = try parser.eventTargetDispatchEvent( - @as(*parser.EventTarget, @ptrCast(&page.window)), - &evt.proto, - ); -} - -pub fn _pushState(self: *History, state: js.Object, _: ?[]const u8, _url: ?[]const u8, page: *Page) !void { +pub fn _replaceState(_: *const History, state: js.Object, _: ?[]const u8, _url: ?[]const u8, page: *Page) !void { const arena = page.session.arena; + const entry = page.session.navigation.currentEntry(); const json = try state.toJson(arena); const url = if (_url) |u| try arena.dupe(u8, u) else try arena.dupe(u8, page.url.raw); - const entry = HistoryEntry{ .state = json, .url = url }; - try self.stack.append(arena, entry); - self.current = self.stack.items.len - 1; + + entry.state = json; + entry.url = url; } -pub fn _replaceState(self: *History, state: js.Object, _: ?[]const u8, _url: ?[]const u8, page: *Page) !void { - const arena = page.session.arena; - - if (self.current) |curr| { - const entry = &self.stack.items[curr]; - const json = try state.toJson(arena); - const url = if (_url) |u| try arena.dupe(u8, u) else try arena.dupe(u8, page.url.raw); - entry.* = HistoryEntry{ .state = json, .url = url }; - } else { - try self._pushState(state, "", _url, page); - } -} - -pub fn go(self: *History, delta: i32, page: *Page) !void { +pub fn go(_: *const History, delta: i32, page: *Page) !void { // 0 behaves the same as no argument, both reloading the page. - // If this is getting called, there SHOULD be an entry, atleast from pushNavigation. - const current = self.current.?; + const current = page.session.navigation.index; const index_s: i64 = @intCast(@as(i64, @intCast(current)) + @as(i64, @intCast(delta))); - if (index_s < 0 or index_s > self.stack.items.len - 1) { + if (index_s < 0 or index_s > page.session.navigation.entries.items.len - 1) { return; } const index = @as(usize, @intCast(index_s)); - const entry = self.stack.items[index]; - self.current = index; + const entry = page.session.navigation.entries.items[index]; - if (try page.isSameOrigin(entry.url)) { - History.dispatchPopStateEvent(entry.state, page); + if (entry.url) |url| { + if (try page.isSameOrigin(url)) { + PopStateEvent.dispatch(entry.state, page); + } } - try page.navigateFromWebAPI(entry.url, .{ .reason = .history }); + _ = try page.session.navigation.navigate(entry.url, .{ .traverse = index }, page); } pub fn _go(self: *History, _delta: ?i32, page: *Page) !void { @@ -207,9 +146,38 @@ pub const PopStateEvent = struct { return null; } } + + pub fn dispatch(state: ?[]const u8, page: *Page) void { + log.debug(.script_event, "dispatch popstate event", .{ + .type = "popstate", + .source = "history", + }); + + var evt = PopStateEvent.constructor("popstate", .{ .state = state }) catch |err| { + log.err(.app, "event constructor error", .{ + .err = err, + .type = "popstate", + .source = "history", + }); + + return; + }; + + _ = parser.eventTargetDispatchEvent( + parser.toEventTarget(Window, &page.window), + &evt.proto, + ) catch |err| { + log.err(.app, "dispatch popstate event error", .{ + .err = err, + .type = "popstate", + .source = "history", + }); + }; + } }; const testing = @import("../../testing.zig"); test "Browser: HTML.History" { - try testing.htmlRunner("html/history.html"); + try testing.htmlRunner("html/history/history.html"); + try testing.htmlRunner("html/history/history2.html"); } diff --git a/src/browser/html/document.zig b/src/browser/html/document.zig index 0516e76d..98465014 100644 --- a/src/browser/html/document.zig +++ b/src/browser/html/document.zig @@ -195,7 +195,7 @@ pub const HTMLDocument = struct { } pub fn set_location(_: *const parser.DocumentHTML, url: []const u8, page: *Page) !void { - return page.navigateFromWebAPI(url, .{ .reason = .script }); + return page.navigateFromWebAPI(url, .{ .reason = .script }, .{ .push = null }); } pub fn get_designMode(_: *parser.DocumentHTML) []const u8 { diff --git a/src/browser/html/location.zig b/src/browser/html/location.zig index 1f8d134c..649883de 100644 --- a/src/browser/html/location.zig +++ b/src/browser/html/location.zig @@ -39,7 +39,7 @@ pub const Location = struct { } pub fn set_href(_: *const Location, href: []const u8, page: *Page) !void { - return page.navigateFromWebAPI(href, .{ .reason = .script }); + return page.navigateFromWebAPI(href, .{ .reason = .script }, .{ .push = null }); } pub fn get_protocol(self: *Location) []const u8 { @@ -75,15 +75,15 @@ pub const Location = struct { } pub fn _assign(_: *const Location, url: []const u8, page: *Page) !void { - return page.navigateFromWebAPI(url, .{ .reason = .script }); + return page.navigateFromWebAPI(url, .{ .reason = .script }, .{ .push = null }); } pub fn _replace(_: *const Location, url: []const u8, page: *Page) !void { - return page.navigateFromWebAPI(url, .{ .reason = .script }); + return page.navigateFromWebAPI(url, .{ .reason = .script }, .replace); } pub fn _reload(_: *const Location, page: *Page) !void { - return page.navigateFromWebAPI(page.url.raw, .{ .reason = .script }); + return page.navigateFromWebAPI(page.url.raw, .{ .reason = .script }, .reload); } pub fn _toString(self: *Location, page: *Page) ![]const u8 { diff --git a/src/browser/html/window.zig b/src/browser/html/window.zig index 97aa381f..d9d86607 100644 --- a/src/browser/html/window.zig +++ b/src/browser/html/window.zig @@ -25,6 +25,7 @@ const Page = @import("../page.zig").Page; const Navigator = @import("navigator.zig").Navigator; const History = @import("History.zig"); +const Navigation = @import("../navigation/Navigation.zig"); const Location = @import("location.zig").Location; const Crypto = @import("../crypto/crypto.zig").Crypto; const Console = @import("../console/console.zig").Console; @@ -43,6 +44,8 @@ const fetchFn = @import("../fetch/fetch.zig").fetch; const storage = @import("../storage/storage.zig"); const ErrorEvent = @import("error_event.zig").ErrorEvent; +const DirectEventHandler = @import("../events/event.zig").DirectEventHandler; + // https://dom.spec.whatwg.org/#interface-window-extensions // https://html.spec.whatwg.org/multipage/nav-history-apis.html#window pub const Window = struct { @@ -69,6 +72,7 @@ pub const Window = struct { scroll_x: u32 = 0, scroll_y: u32 = 0, onload_callback: ?js.Function = null, + onpopstate_callback: ?js.Function = null, pub fn create(target: ?[]const u8, navigator: ?Navigator) !Window { var fbs = std.io.fixedBufferStream(""); @@ -115,31 +119,17 @@ pub const Window = struct { /// Sets `onload_callback`. pub fn set_onload(self: *Window, maybe_listener: ?EventHandler.Listener, page: *Page) !void { - const event_target = parser.toEventTarget(Window, self); - const event_type = "load"; + try DirectEventHandler(Window, self, "load", maybe_listener, &self.onload_callback, page.arena); + } - // Check if we have a listener set. - if (self.onload_callback) |callback| { - const listener = try parser.eventTargetHasListener(event_target, event_type, false, callback.id); - std.debug.assert(listener != null); - try parser.eventTargetRemoveEventListener(event_target, event_type, listener.?, false); - } + /// Returns `onpopstate_callback`. + pub fn get_onpopstate(self: *const Window) ?js.Function { + return self.onpopstate_callback; + } - if (maybe_listener) |listener| { - switch (listener) { - // If an object is given as listener, do nothing. - .object => {}, - .function => |callback| { - _ = try EventHandler.register(page.arena, event_target, event_type, listener, null) orelse unreachable; - self.onload_callback = callback; - - return; - }, - } - } - - // Just unset the listener. - self.onload_callback = null; + /// Sets `onpopstate_callback`. + pub fn set_onpopstate(self: *Window, maybe_listener: ?EventHandler.Listener, page: *Page) !void { + try DirectEventHandler(Window, self, "popstate", maybe_listener, &self.onpopstate_callback, page.arena); } pub fn get_location(self: *Window) *Location { @@ -147,7 +137,7 @@ pub const Window = struct { } pub fn set_location(_: *const Window, url: []const u8, page: *Page) !void { - return page.navigateFromWebAPI(url, .{ .reason = .script }); + return page.navigateFromWebAPI(url, .{ .reason = .script }, .{ .push = null }); } // frames return the window itself, but accessing it via a pseudo @@ -195,6 +185,10 @@ pub const Window = struct { return &page.session.history; } + pub fn get_navigation(_: *Window, page: *Page) *Navigation { + return &page.session.navigation; + } + // The interior height of the window in pixels, including the height of the horizontal scroll bar, if present. pub fn get_innerHeight(_: *Window, page: *Page) u32 { // We do not have scrollbars or padding so this is the same as Element.clientHeight diff --git a/src/browser/js/Context.zig b/src/browser/js/Context.zig index d4ccb887..be7cf7c4 100644 --- a/src/browser/js/Context.zig +++ b/src/browser/js/Context.zig @@ -750,9 +750,16 @@ pub fn jsValueToZig(self: *Context, comptime named_function: NamedFunction, comp unreachable; }, .@"enum" => |e| { - switch (@typeInfo(e.tag_type)) { - .int => return std.meta.intToEnum(T, try jsIntToZig(e.tag_type, js_value, self.v8_context)), - else => @compileError(named_function.full_name ++ " has an unsupported enum parameter type: " ++ @typeName(T)), + if (@hasDecl(T, "ENUM_JS_USE_TAG")) { + const str = try self.jsValueToZig(named_function, []const u8, js_value); + return std.meta.stringToEnum(T, str) orelse return error.InvalidEnumValue; + } else { + switch (@typeInfo(e.tag_type)) { + .int => return std.meta.intToEnum(T, try jsIntToZig(e.tag_type, js_value, self.v8_context)), + else => { + @compileError(named_function.full_name ++ " has an unsupported enum parameter type: " ++ @typeName(T)); + }, + } } }, else => {}, diff --git a/src/browser/js/js.zig b/src/browser/js/js.zig index 31cf5442..aeff6540 100644 --- a/src/browser/js/js.zig +++ b/src/browser/js/js.zig @@ -378,8 +378,13 @@ pub fn simpleZigValueToJs(isolate: v8.Isolate, value: anytype, comptime fail: bo .@"enum" => { const T = @TypeOf(value); if (@hasDecl(T, "toString")) { + // This should be deprecated in favor of the ENUM_JS_USE_TAG. return simpleZigValueToJs(isolate, value.toString(), fail); } + + if (@hasDecl(T, "ENUM_JS_USE_TAG")) { + return simpleZigValueToJs(isolate, @tagName(value), fail); + } }, else => {}, } diff --git a/src/browser/js/types.zig b/src/browser/js/types.zig index e0c192df..89322984 100644 --- a/src/browser/js/types.zig +++ b/src/browser/js/types.zig @@ -16,6 +16,7 @@ const Interfaces = generate.Tuple(.{ @import("../storage/storage.zig").Interfaces, @import("../url/url.zig").Interfaces, @import("../xhr/xhr.zig").Interfaces, + @import("../navigation/navigation.zig").Interfaces, @import("../xhr/form_data.zig").Interfaces, @import("../xhr/File.zig"), @import("../xmlserializer/xmlserializer.zig").Interfaces, diff --git a/src/browser/navigation/Navigation.zig b/src/browser/navigation/Navigation.zig new file mode 100644 index 00000000..8a35aeaa --- /dev/null +++ b/src/browser/navigation/Navigation.zig @@ -0,0 +1,294 @@ +// Copyright (C) 2023-2024 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +const std = @import("std"); +const log = @import("../../log.zig"); +const URL = @import("../../url.zig").URL; + +const js = @import("../js/js.zig"); +const Page = @import("../page.zig").Page; + +const DirectEventHandler = @import("../events/event.zig").DirectEventHandler; +const EventTarget = @import("../dom/event_target.zig").EventTarget; +const EventHandler = @import("../events/event.zig").EventHandler; + +const parser = @import("../netsurf.zig"); + +// https://developer.mozilla.org/en-US/docs/Web/API/Navigation +const Navigation = @This(); + +const NavigationKind = @import("navigation.zig").NavigationKind; +const NavigationHistoryEntry = @import("navigation.zig").NavigationHistoryEntry; +const NavigationTransition = @import("navigation.zig").NavigationTransition; +const NavigationEventTarget = @import("NavigationEventTarget.zig"); + +const NavigationCurrentEntryChangeEvent = @import("navigation.zig").NavigationCurrentEntryChangeEvent; + +pub const prototype = *NavigationEventTarget; +proto: NavigationEventTarget = NavigationEventTarget{}, + +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 get_canGoBack(self: *const Navigation) bool { + return self.index > 0; +} + +pub fn get_canGoForward(self: *const Navigation) bool { + return self.entries.items.len > self.index + 1; +} + +pub fn currentEntry(self: *Navigation) *NavigationHistoryEntry { + return self.entries.items[self.index]; +} + +pub fn get_currentEntry(self: *Navigation) *NavigationHistoryEntry { + return self.currentEntry(); +} + +pub fn get_transition(_: *const Navigation) ?NavigationTransition { + // For now, all transitions are just considered complete. + return null; +} + +const NavigationReturn = struct { + committed: js.Promise, + finished: js.Promise, +}; + +pub fn _back(self: *Navigation, page: *Page) !NavigationReturn { + if (!self.get_canGoBack()) { + return error.InvalidStateError; + } + + const new_index = self.index - 1; + const next_entry = self.entries.items[new_index]; + self.index = new_index; + + return self.navigate(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.get_canGoForward()) { + return error.InvalidStateError; + } + + const new_index = self.index + 1; + const next_entry = self.entries.items[new_index]; + self.index = new_index; + + return self.navigate(next_entry.url, .{ .traverse = new_index }, page); +} + +// This is for after true navigation processing, where we need to ensure that our entries are up to date. +// 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.raw; + const kind = page.session.navigation_kind; + + if (kind) |k| { + switch (k) { + .replace => { + // When replacing, we just update the URL but the state is nullified. + const entry = self.currentEntry(); + entry.url = url; + entry.state = null; + }, + .push => |state| { + _ = try self.pushEntry(url, state, page, false); + }, + .traverse, .reload => {}, + } + } else { + _ = try self.pushEntry(url, null, page, false); + } +} + +/// Pushes an entry into the Navigation stack WITHOUT actually navigating to it. +/// For that, use `navigate`. +pub fn pushEntry(self: *Navigation, _url: []const u8, state: ?[]const u8, page: *Page, dispatch: bool) !*NavigationHistoryEntry { + const arena = page.session.arena; + + const url = try arena.dupe(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.currentEntry() else null; + try self.entries.append(arena, entry); + if (previous) |prev| { + if (dispatch) { + NavigationCurrentEntryChangeEvent.dispatch(self, prev, .push); + } + } + + self.index = index; + + return entry; +} + +const NavigateOptions = struct { + const NavigateOptionsHistory = enum { + pub const ENUM_JS_USE_TAG = true; + + auto, + push, + replace, + }; + + state: ?js.Object = null, + info: ?js.Object = null, + history: NavigateOptionsHistory = .auto, +}; + +pub fn navigate( + self: *Navigation, + _url: ?[]const u8, + kind: NavigationKind, + page: *Page, +) !NavigationReturn { + const arena = page.session.arena; + const url = _url orelse return error.MissingURL; + + // https://github.com/WICG/navigation-api/issues/95 + // + // These will only settle on same-origin navigation (mostly intended for SPAs). + // It is fine (and expected) for these to not settle on cross-origin requests :) + const committed = try page.js.createPromiseResolver(.page); + const finished = try page.js.createPromiseResolver(.page); + + const new_url = try URL.parse(url, null); + const is_same_document = try page.url.eqlDocument(&new_url, arena); + + switch (kind) { + .push => |state| { + if (is_same_document) { + page.url = new_url; + + try committed.resolve({}); + // todo: Fire navigate event + try finished.resolve({}); + + _ = try self.pushEntry(url, state, page, true); + } else { + try page.navigateFromWebAPI(url, .{ .reason = .navigation }, kind); + } + }, + .traverse => |index| { + self.index = index; + + if (is_same_document) { + page.url = new_url; + + try committed.resolve({}); + // todo: Fire navigate event + try finished.resolve({}); + } else { + try page.navigateFromWebAPI(url, .{ .reason = .navigation }, kind); + } + }, + .reload => { + try page.navigateFromWebAPI(url, .{ .reason = .navigation }, kind); + }, + else => unreachable, + } + + return .{ + .committed = committed.promise(), + .finished = finished.promise(), + }; +} + +pub fn _navigate(self: *Navigation, _url: []const u8, _opts: ?NavigateOptions, page: *Page) !NavigationReturn { + const opts = _opts orelse NavigateOptions{}; + const json = if (opts.state) |state| state.toJson(page.session.arena) catch return error.DataClone else null; + return try self.navigate(_url, .{ .push = json }, page); +} + +pub const ReloadOptions = struct { + state: ?js.Object = null, + info: ?js.Object = null, +}; + +pub fn _reload(self: *Navigation, _opts: ?ReloadOptions, page: *Page) !NavigationReturn { + const arena = page.session.arena; + + const opts = _opts orelse ReloadOptions{}; + const entry = self.currentEntry(); + if (opts.state) |state| { + const previous = entry; + entry.state = state.toJson(arena) catch return error.DataClone; + NavigationCurrentEntryChangeEvent.dispatch(self, previous, .reload); + } + + return self.navigate(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.navigate(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 = page.session.arena; + + const previous = self.currentEntry(); + self.currentEntry().state = options.state.toJson(arena) catch return error.DataClone; + NavigationCurrentEntryChangeEvent.dispatch(self, previous, null); +} diff --git a/src/browser/navigation/NavigationEventTarget.zig b/src/browser/navigation/NavigationEventTarget.zig new file mode 100644 index 00000000..e3358a1f --- /dev/null +++ b/src/browser/navigation/NavigationEventTarget.zig @@ -0,0 +1,58 @@ +const std = @import("std"); + +const js = @import("../js/js.zig"); +const Page = @import("../page.zig").Page; + +const EventTarget = @import("../dom/event_target.zig").EventTarget; +const EventHandler = @import("../events/event.zig").EventHandler; + +const parser = @import("../netsurf.zig"); + +pub const NavigationEventTarget = @This(); + +pub const prototype = *EventTarget; +// Extend libdom event target for pure zig struct. +base: parser.EventTargetTBase = parser.EventTargetTBase{ .internal_target_type = .navigation }, + +oncurrententrychange_cbk: ?js.Function = null, + +fn register( + self: *NavigationEventTarget, + alloc: std.mem.Allocator, + typ: []const u8, + listener: EventHandler.Listener, +) !?js.Function { + const target = parser.toEventTarget(NavigationEventTarget, self); + + // The only time this can return null if the listener is already + // registered. But before calling `register`, all of our functions + // remove any existing listener, so it should be impossible to get null + // from this function call. + const eh = (try EventHandler.register(alloc, target, typ, listener, null)) orelse unreachable; + return eh.callback; +} + +fn unregister(self: *NavigationEventTarget, typ: []const u8, cbk_id: usize) !void { + const et = parser.toEventTarget(NavigationEventTarget, self); + // check if event target has already this listener + const lst = try parser.eventTargetHasListener(et, typ, false, cbk_id); + if (lst == null) { + return; + } + + // remove listener + try parser.eventTargetRemoveEventListener(et, typ, lst.?, false); +} + +pub fn get_oncurrententrychange(self: *NavigationEventTarget) ?js.Function { + return self.oncurrententrychange_cbk; +} + +pub fn set_oncurrententrychange(self: *NavigationEventTarget, listener: ?EventHandler.Listener, page: *Page) !void { + if (self.oncurrententrychange_cbk) |cbk| try self.unregister("currententrychange", cbk.id); + if (listener) |listen| { + self.oncurrententrychange_cbk = try self.register(page.arena, "currententrychange", listen); + } else { + self.oncurrententrychange_cbk = null; + } +} diff --git a/src/browser/navigation/navigation.zig b/src/browser/navigation/navigation.zig new file mode 100644 index 00000000..bfe5830f --- /dev/null +++ b/src/browser/navigation/navigation.zig @@ -0,0 +1,215 @@ +// Copyright (C) 2023-2024 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +const std = @import("std"); +const log = @import("../../log.zig"); +const URL = @import("../../url.zig").URL; + +const js = @import("../js/js.zig"); +const Page = @import("../page.zig").Page; + +const DirectEventHandler = @import("../events/event.zig").DirectEventHandler; +const EventTarget = @import("../dom/event_target.zig").EventTarget; +const EventHandler = @import("../events/event.zig").EventHandler; + +const parser = @import("../netsurf.zig"); + +const Navigation = @import("Navigation.zig"); +const NavigationEventTarget = @import("NavigationEventTarget.zig"); + +pub const Interfaces = .{ + Navigation, + NavigationEventTarget, + NavigationActivation, + NavigationTransition, + NavigationHistoryEntry, +}; + +pub const NavigationType = enum { + pub const ENUM_JS_USE_TAG = true; + + push, + replace, + traverse, + reload, +}; + +pub const NavigationKind = union(NavigationType) { + push: ?[]const u8, + replace, + traverse: usize, + reload, +}; + +// https://developer.mozilla.org/en-US/docs/Web/API/NavigationHistoryEntry +pub const NavigationHistoryEntry = struct { + pub const prototype = *EventTarget; + base: parser.EventTargetTBase = parser.EventTargetTBase{ .internal_target_type = .plain }, + + id: []const u8, + key: []const u8, + url: ?[]const u8, + state: ?[]const u8, + + pub fn get_id(self: *const NavigationHistoryEntry) []const u8 { + return self.id; + } + + pub fn get_index(self: *const NavigationHistoryEntry, page: *Page) i32 { + const navigation = page.session.navigation; + for (navigation.entries.items, 0..) |entry, i| { + if (std.mem.eql(u8, entry.id, self.id)) { + return @intCast(i); + } + } + + return -1; + } + + pub fn get_key(self: *const NavigationHistoryEntry) []const u8 { + return self.key; + } + + pub fn get_sameDocument(self: *const NavigationHistoryEntry, page: *Page) !bool { + const _url = self.url orelse return false; + const url = try URL.parse(_url, null); + return page.url.eqlDocument(&url, page.call_arena); + } + + pub fn get_url(self: *const NavigationHistoryEntry) ?[]const u8 { + return self.url; + } + + pub fn _getState(self: *const NavigationHistoryEntry, page: *Page) !?js.Value { + if (self.state) |state| { + return try js.Value.fromJson(page.js, state); + } else { + return null; + } + } +}; + +// https://developer.mozilla.org/en-US/docs/Web/API/NavigationActivation +pub const NavigationActivation = struct { + const NavigationActivationType = enum { + pub const ENUM_JS_USE_TAG = true; + + push, + reload, + replace, + traverse, + }; + + entry: NavigationHistoryEntry, + from: ?NavigationHistoryEntry = null, + type: NavigationActivationType, + + pub fn get_entry(self: *const NavigationActivation) NavigationHistoryEntry { + return self.entry; + } + + pub fn get_from(self: *const NavigationActivation) ?NavigationHistoryEntry { + return self.from; + } + + pub fn get_navigationType(self: *const NavigationActivation) NavigationActivationType { + return self.type; + } +}; + +// https://developer.mozilla.org/en-US/docs/Web/API/NavigationTransition +pub const NavigationTransition = struct { + finished: js.Promise, + from: NavigationHistoryEntry, + navigation_type: NavigationActivation.NavigationActivationType, +}; + +const Event = @import("../events/event.zig").Event; + +pub const NavigationCurrentEntryChangeEvent = struct { + pub const prototype = *Event; + pub const union_make_copy = true; + + pub const EventInit = struct { + from: *NavigationHistoryEntry, + navigationType: ?NavigationType = null, + }; + + proto: parser.Event, + from: *NavigationHistoryEntry, + navigation_type: ?NavigationType, + + pub fn constructor(event_type: []const u8, opts: EventInit) !NavigationCurrentEntryChangeEvent { + const event = try parser.eventCreate(); + defer parser.eventDestroy(event); + + try parser.eventInit(event, event_type, .{}); + parser.eventSetInternalType(event, .navigation_current_entry_change_event); + + return .{ + .proto = event.*, + .from = opts.from, + .navigation_type = opts.navigationType, + }; + } + + pub fn get_from(self: *NavigationCurrentEntryChangeEvent) *NavigationHistoryEntry { + return self.from; + } + + pub fn get_navigationType(self: *const NavigationCurrentEntryChangeEvent) ?NavigationType { + return self.navigation_type; + } + + pub fn dispatch(navigation: *Navigation, from: *NavigationHistoryEntry, typ: ?NavigationType) void { + log.debug(.script_event, "dispatch event", .{ + .type = "currententrychange", + .source = "navigation", + }); + + var evt = NavigationCurrentEntryChangeEvent.constructor( + "currententrychange", + .{ .from = from, .navigationType = typ }, + ) catch |err| { + log.err(.app, "event constructor error", .{ + .err = err, + .type = "currententrychange", + .source = "navigation", + }); + + return; + }; + + _ = parser.eventTargetDispatchEvent( + @as(*parser.EventTarget, @ptrCast(navigation)), + &evt.proto, + ) catch |err| { + log.err(.app, "dispatch event error", .{ + .err = err, + .type = "currententrychange", + .source = "navigation", + }); + }; + } +}; + +const testing = @import("../../testing.zig"); +test "Browser: Navigation" { + try testing.htmlRunner("html/navigation/navigation.html"); + try testing.htmlRunner("html/navigation/navigation_currententrychange.html"); +} diff --git a/src/browser/netsurf.zig b/src/browser/netsurf.zig index 38f13dd6..a08a6996 100644 --- a/src/browser/netsurf.zig +++ b/src/browser/netsurf.zig @@ -560,6 +560,7 @@ pub const EventType = enum(u8) { keyboard_event = 8, pop_state = 9, composition_event = 10, + navigation_current_entry_change_event = 11, }; pub const MutationEvent = c.dom_mutation_event; @@ -831,6 +832,7 @@ pub const EventTargetTBase = extern struct { message_port = 7, screen = 8, screen_orientation = 9, + navigation = 10, }; vtable: ?*const c.struct_dom_event_target_vtable = &c.struct_dom_event_target_vtable{ diff --git a/src/browser/page.zig b/src/browser/page.zig index ddd5fc89..f1f6553a 100644 --- a/src/browser/page.zig +++ b/src/browser/page.zig @@ -35,6 +35,9 @@ const ScriptManager = @import("ScriptManager.zig"); const SlotChangeMonitor = @import("SlotChangeMonitor.zig"); const HTMLDocument = @import("html/document.zig").HTMLDocument; +const NavigationKind = @import("navigation/navigation.zig").NavigationKind; +const NavigationCurrentEntryChangeEvent = @import("navigation/navigation.zig").NavigationCurrentEntryChangeEvent; + const js = @import("js/js.zig"); const URL = @import("../url.zig").URL; @@ -832,8 +835,8 @@ pub const Page = struct { }, } - // Push the navigation after a successful load. - try self.session.history.pushNavigation(self.url.raw, self); + // We need to handle different navigation types differently. + try self.session.navigation.processNavigation(self); } fn pageErrorCallback(ctx: *anyopaque, err: anyerror) void { @@ -923,7 +926,7 @@ pub const Page = struct { .a => { const element: *parser.Element = @ptrCast(node); const href = (try parser.elementGetAttribute(element, "href")) orelse return; - try self.navigateFromWebAPI(href, .{}); + try self.navigateFromWebAPI(href, .{}, .{ .push = null }); }, .input => { const element: *parser.Element = @ptrCast(node); @@ -1060,8 +1063,25 @@ pub const Page = struct { // As such we schedule the function to be called as soon as possible. // The page.arena is safe to use here, but the transfer_arena exists // specifically for this type of lifetime. - pub fn navigateFromWebAPI(self: *Page, url: []const u8, opts: NavigateOpts) !void { + pub fn navigateFromWebAPI(self: *Page, url: []const u8, opts: NavigateOpts, kind: NavigationKind) !void { const session = self.session; + const stitched_url = try URL.stitch(session.transfer_arena, url, self.url.raw, .{ .alloc = .always }); + + // Force will force a page load. + // Otherwise, we need to check if this is a true navigation. + if (!opts.force) { + // If we are navigating within the same document, just change URL. + const new_url = try URL.parse(stitched_url, null); + + if (try self.url.eqlDocument(&new_url, session.transfer_arena)) { + self.url = new_url; + + const prev = session.navigation.currentEntry(); + NavigationCurrentEntryChangeEvent.dispatch(&self.session.navigation, prev, kind); + return; + } + } + if (session.queued_navigation != null) { // It might seem like this should never happen. And it might not, // BUT..consider the case where we have script like: @@ -1084,9 +1104,11 @@ pub const Page = struct { session.queued_navigation = .{ .opts = opts, - .url = try URL.stitch(session.transfer_arena, url, self.url.raw, .{ .alloc = .always }), + .url = stitched_url, }; + session.navigation_kind = kind; + self.http_client.abort(); // In v8, this throws an exception which JS code cannot catch. @@ -1137,7 +1159,7 @@ pub const Page = struct { } else { action = try URL.concatQueryString(transfer_arena, action, buf.items); } - try self.navigateFromWebAPI(action, opts); + try self.navigateFromWebAPI(action, opts, .{ .push = null }); } pub fn isNodeAttached(self: *const Page, node: *parser.Node) bool { @@ -1195,6 +1217,7 @@ pub const NavigateReason = enum { form, script, history, + navigation, }; pub const NavigateOpts = struct { @@ -1203,6 +1226,7 @@ pub const NavigateOpts = struct { method: Http.Method = .GET, body: ?[]const u8 = null, header: ?[:0]const u8 = null, + force: bool = false, }; const IdleNotification = union(enum) { diff --git a/src/browser/session.zig b/src/browser/session.zig index a5651d20..a4ed61c9 100644 --- a/src/browser/session.zig +++ b/src/browser/session.zig @@ -22,9 +22,11 @@ const Allocator = std.mem.Allocator; const js = @import("js/js.zig"); const Page = @import("page.zig").Page; +const NavigationKind = @import("navigation/navigation.zig").NavigationKind; const Browser = @import("browser.zig").Browser; const NavigateOpts = @import("page.zig").NavigateOpts; const History = @import("html/History.zig"); +const Navigation = @import("navigation/Navigation.zig"); const log = @import("../log.zig"); const parser = @import("netsurf.zig"); @@ -57,6 +59,8 @@ pub const Session = struct { // History is persistent across the "tab". // https://developer.mozilla.org/en-US/docs/Web/API/History history: History = .{}, + navigation: Navigation = .{}, + navigation_kind: ?NavigationKind = null, page: ?Page = null, diff --git a/src/cdp/domains/page.zig b/src/cdp/domains/page.zig index 91656103..f1aa83e9 100644 --- a/src/cdp/domains/page.zig +++ b/src/cdp/domains/page.zig @@ -197,7 +197,7 @@ pub fn pageNavigate(arena: Allocator, bc: anytype, event: *const Notification.Pa var cdp = bc.cdp; const reason_: ?[]const u8 = switch (event.opts.reason) { .anchor => "anchorClick", - .script, .history => "scriptInitiated", + .script, .history, .navigation => "scriptInitiated", .form => switch (event.opts.method) { .GET => "formSubmissionGet", .POST => "formSubmissionPost", diff --git a/src/testing.zig b/src/testing.zig index 93c1abad..92ae34f0 100644 --- a/src/testing.zig +++ b/src/testing.zig @@ -402,19 +402,13 @@ pub fn htmlRunner(file: []const u8) !void { const url = try std.fmt.allocPrint(arena_allocator, "http://localhost:9582/src/tests/{s}", .{file}); try page.navigate(url, .{}); - _ = page.wait(2000); + test_session.fetchWait(2000); + // page exits more aggressively in tests. We want to make sure this is called // at lease once. page.session.browser.runMicrotasks(); page.session.browser.runMessageLoop(); - const needs_second_wait = try js_context.exec("testing._onPageWait.length > 0", "check_onPageWait"); - if (needs_second_wait.value.toBool(page.js.isolate)) { - // sets the isSecondWait flag in testing. - _ = js_context.exec("testing._isSecondWait = true", "set_second_wait_flag") catch {}; - _ = page.wait(2000); - } - @import("root").js_runner_duration += std.time.Instant.since(try std.time.Instant.now(), start); const value = js_context.exec("testing.getStatus()", "testing.getStatus()") catch |err| { diff --git a/src/tests/html/history.html b/src/tests/html/history/history.html similarity index 76% rename from src/tests/html/history.html rename to src/tests/html/history/history.html index 2c5591a4..fbb7dd95 100644 --- a/src/tests/html/history.html +++ b/src/tests/html/history/history.html @@ -1,21 +1,22 @@ - + diff --git a/src/tests/html/history/history2.html b/src/tests/html/history/history2.html new file mode 100644 index 00000000..83dd809a --- /dev/null +++ b/src/tests/html/history/history2.html @@ -0,0 +1,26 @@ + + + + diff --git a/src/tests/html/history/history_after_nav.html b/src/tests/html/history/history_after_nav.html new file mode 100644 index 00000000..d9e4e66d --- /dev/null +++ b/src/tests/html/history/history_after_nav.html @@ -0,0 +1,6 @@ + + + + diff --git a/src/tests/html/navigation/navigation.html b/src/tests/html/navigation/navigation.html new file mode 100644 index 00000000..24efe6c7 --- /dev/null +++ b/src/tests/html/navigation/navigation.html @@ -0,0 +1,18 @@ + + + + diff --git a/src/tests/html/navigation/navigation2.html b/src/tests/html/navigation/navigation2.html new file mode 100644 index 00000000..b16fa917 --- /dev/null +++ b/src/tests/html/navigation/navigation2.html @@ -0,0 +1,8 @@ + + + + diff --git a/src/tests/html/navigation/navigation_currententrychange.html b/src/tests/html/navigation/navigation_currententrychange.html new file mode 100644 index 00000000..c84bcbad --- /dev/null +++ b/src/tests/html/navigation/navigation_currententrychange.html @@ -0,0 +1,15 @@ + + + + diff --git a/src/tests/testing.js b/src/tests/testing.js index 779cca8c..e83744ee 100644 --- a/src/tests/testing.js +++ b/src/tests/testing.js @@ -51,14 +51,6 @@ // if we're already in a fail state, return fail, nothing can recover this if (testing._status === 'fail') return 'fail'; - if (testing._isSecondWait) { - for (const pw of (testing._onPageWait)) { - testing._captured = pw[1]; - pw[0](); - testing._captured = null; - } - } - // run any eventually's that we've captured for (const ev of testing._eventually) { testing._captured = ev[1]; @@ -101,18 +93,6 @@ _registerErrorCallback(); } - // Set expectations to happen on the next time that `page.wait` is executed. - // - // History specifically uses this as it queues navigation that needs to be checked - // when the next page is loaded. - function onPageWait(fn) { - // Store callbacks to run when page.wait() happens - testing._onPageWait.push([fn, { - script_id: document.currentScript.id, - stack: new Error().stack, - }]); - } - async function async(promise, cb) { const script_id = document.currentScript ? document.currentScript.id : '