diff --git a/src/browser/html/History.zig b/src/browser/html/History.zig index d67da3f5..5ef5ac3a 100644 --- a/src/browser/html/History.zig +++ b/src/browser/html/History.zig @@ -81,7 +81,9 @@ fn _dispatchPopStateEvent(state: ?[]const u8, page: *Page) !void { pub fn _pushState(_: *const History, state: js.Object, _: ?[]const u8, _url: ?[]const u8, page: *Page) !void { const arena = page.session.arena; const url = if (_url) |u| try arena.dupe(u8, u) else try arena.dupe(u8, page.url.raw); - _ = try page.session.navigation.pushEntry(url, .{ .state = state }, page); + + const json = state.toJson(arena) catch return error.DataClone; + _ = try page.session.navigation.pushEntry(url, json, page); } pub fn _replaceState(_: *const History, state: js.Object, _: ?[]const u8, _url: ?[]const u8, page: *Page) !void { @@ -113,7 +115,7 @@ pub fn go(_: *const History, delta: i32, page: *Page) !void { } } - _ = try entry.navigate(page, .force); + _ = try page.session.navigation.navigate(entry.url, .{ .traverse = index }, page); } pub fn _go(self: *History, _delta: ?i32, page: *Page) !void { diff --git a/src/browser/html/Navigation.zig b/src/browser/html/Navigation.zig index 7968d821..c7eb9276 100644 --- a/src/browser/html/Navigation.zig +++ b/src/browser/html/Navigation.zig @@ -34,16 +34,24 @@ const Navigation = @This(); pub const Interfaces = .{ Navigation, NavigationActivation, + NavigationTransition, NavigationHistoryEntry, }; +pub const NavigationKind = union(enum) { + initial, + push: ?[]const u8, + replace, + traverse: usize, + reload, +}; + pub const prototype = *EventTarget; base: parser.EventTargetTBase = parser.EventTargetTBase{ .internal_target_type = .plain }, index: usize = 0, entries: std.ArrayListUnmanaged(NavigationHistoryEntry) = .empty, next_entry_id: usize = 0, -// TODO: key->index mapping // https://developer.mozilla.org/en-US/docs/Web/API/NavigationHistoryEntry const NavigationHistoryEntry = struct { @@ -91,49 +99,17 @@ const NavigationHistoryEntry = struct { return null; } } - - pub fn navigate(entry: NavigationHistoryEntry, reload: enum { none, force }, page: *Page) !NavigationReturn { - const arena = page.session.arena; - const url = entry.url orelse return error.MissingURL; - - // https://github.com/WICG/navigation-api/issues/95 - // - // These will only settle on same-origin navigation (mostly intended for SPAs). - // It is fine (and expected) for these to not settle on cross-origin requests :) - const committed = try page.js.createPromiseResolver(.page); - const finished = try page.js.createPromiseResolver(.page); - - const new_url = try URL.parse(url, null); - if (try page.url.eqlDocument(&new_url, arena) or reload == .force) { - page.url = new_url; - try committed.resolve({}); - - // todo: Fire navigate event - - try finished.resolve({}); - } else { - // TODO: Change to history - try page.navigateFromWebAPI(url, .{ .reason = .history }); - } - - return .{ - .committed = committed.promise(), - .finished = finished.promise(), - }; - } }; // https://developer.mozilla.org/en-US/docs/Web/API/NavigationActivation const NavigationActivation = struct { const NavigationActivationType = enum { + pub const ENUM_JS_USE_TAG = true; + push, reload, replace, traverse, - - pub fn toString(self: NavigationActivationType) []const u8 { - return @tagName(self); - } }; entry: NavigationHistoryEntry, @@ -153,6 +129,13 @@ const NavigationActivation = struct { } }; +// https://developer.mozilla.org/en-US/docs/Web/API/NavigationTransition +const NavigationTransition = struct { + finished: js.Promise, + from: NavigationHistoryEntry, + navigation_type: NavigationActivation.NavigationActivationType, +}; + pub fn get_canGoBack(self: *const Navigation) bool { return self.index > 0; } @@ -169,6 +152,11 @@ pub fn get_currentEntry(self: *const Navigation) NavigationHistoryEntry { return self.entries.items[self.index]; } +pub fn get_transition(_: *const Navigation) ?NavigationTransition { + // For now, all transitions are just considered complete. + return null; +} + const NavigationReturn = struct { committed: js.Promise, finished: js.Promise, @@ -183,7 +171,7 @@ pub fn _back(self: *Navigation, page: *Page) !NavigationReturn { const next_entry = self.entries.items[new_index]; self.index = new_index; - return next_entry.navigate(.none, page); + return self.navigate(next_entry.url, .{ .traverse = new_index }, page); } pub fn _entries(self: *const Navigation) []NavigationHistoryEntry { @@ -199,15 +187,33 @@ pub fn _forward(self: *Navigation, page: *Page) !NavigationReturn { const next_entry = self.entries.items[new_index]; self.index = new_index; - return next_entry.navigate(.none, page); + return self.navigate(next_entry.url, .{ .traverse = new_index }, page); +} + +// This is for after true navigation processing, where we need to ensure that our entries are up to date. +pub fn processNavigation(self: *Navigation, url: []const u8, kind: NavigationKind, page: *Page) !void { + switch (kind) { + .initial => { + _ = try self.pushEntry(url, null, page); + }, + .replace => { + // When replacing, we just update the URL but the state is nullified. + const entry = self.currentEntry(); + entry.url = url; + entry.state = null; + }, + .push => |state| { + _ = try self.pushEntry(url, state, page); + }, + .traverse, .reload => {}, + } } /// Pushes an entry into the Navigation stack WITHOUT actually navigating to it. /// For that, use `navigate`. -pub fn pushEntry(self: *Navigation, _url: ?[]const u8, _opts: ?NavigateOptions, page: *Page) !NavigationHistoryEntry { +pub fn pushEntry(self: *Navigation, _url: ?[]const u8, state: ?[]const u8, page: *Page) !NavigationHistoryEntry { const arena = page.session.arena; - const options = _opts orelse NavigateOptions{}; const url = if (_url) |u| try arena.dupe(u8, u) else null; // truncates our history here. @@ -221,14 +227,6 @@ pub fn pushEntry(self: *Navigation, _url: ?[]const u8, _opts: ?NavigateOptions, const id_str = try std.fmt.allocPrint(arena, "{d}", .{id}); - const state: ?[]const u8 = blk: { - if (options.state) |s| { - break :blk s.toJson(arena) catch return error.DataClone; - } else { - break :blk null; - } - }; - const entry = NavigationHistoryEntry{ .id = id_str, .key = id_str, @@ -237,7 +235,6 @@ pub fn pushEntry(self: *Navigation, _url: ?[]const u8, _opts: ?NavigateOptions, }; try self.entries.append(arena, entry); - return entry; } @@ -255,9 +252,67 @@ const NavigateOptions = struct { history: NavigateOptionsHistory = .auto, }; +pub fn navigate( + self: *Navigation, + _url: ?[]const u8, + kind: NavigationKind, + page: *Page, +) !NavigationReturn { + const arena = page.session.arena; + const url = _url orelse return error.MissingURL; + + // https://github.com/WICG/navigation-api/issues/95 + // + // These will only settle on same-origin navigation (mostly intended for SPAs). + // It is fine (and expected) for these to not settle on cross-origin requests :) + const committed = try page.js.createPromiseResolver(.page); + const finished = try page.js.createPromiseResolver(.page); + + const new_url = try URL.parse(url, null); + const is_same_document = try page.url.eqlDocument(&new_url, arena); + + switch (kind) { + .push => |state| { + if (is_same_document) { + page.url = new_url; + try committed.resolve({}); + // todo: Fire navigate event + try finished.resolve({}); + + _ = try self.pushEntry(url, state, page); + } else { + try page.navigateFromWebAPI(url, .{ .reason = .navigation }, kind); + } + }, + .traverse => |index| { + self.index = index; + + if (is_same_document) { + page.url = new_url; + + try committed.resolve({}); + // todo: Fire navigate event + try finished.resolve({}); + } else { + try page.navigateFromWebAPI(url, .{ .reason = .navigation }, kind); + } + }, + .reload => { + try page.navigateFromWebAPI(url, .{ .reason = .navigation }, kind); + }, + else => unreachable, + } + + return .{ + .committed = committed.promise(), + .finished = finished.promise(), + }; +} + pub fn _navigate(self: *Navigation, _url: []const u8, _opts: ?NavigateOptions, page: *Page) !NavigationReturn { - const entry = try self.pushEntry(_url, _opts, page); - return entry.navigate(.none, page); + const opts = _opts orelse NavigateOptions{}; + const json = if (opts.state) |state| state.toJson(page.session.arena) catch return error.DataClone else null; + return try self.navigate(_url, .{ .push = json }, page); } pub const ReloadOptions = struct { @@ -274,15 +329,23 @@ pub fn _reload(self: *Navigation, _opts: ?ReloadOptions, page: *Page) !Navigatio entry.state = state.toJson(arena) catch return error.DataClone; } - return entry.navigate(.force, page); + return self.navigate(entry.url, .reload, page); } -pub fn _transition(_: *const Navigation) !NavigationReturn { - unreachable; -} +pub const TraverseToOptions = struct { + info: ?js.Object = null, +}; -pub fn _traverseTo(_: *const Navigation, _: []const u8) !NavigationReturn { - unreachable; +pub fn _traverseTo(self: *Navigation, key: []const u8, _: ?TraverseToOptions, page: *Page) !NavigationReturn { + // const opts = _opts orelse TraverseToOptions{}; + + for (self.entries.items, 0..) |entry, i| { + if (std.mem.eql(u8, key, entry.key)) { + return try self.navigate(entry.url, .{ .traverse = i }, page); + } + } + + return error.InvalidStateError; } pub const UpdateCurrentEntryOptions = struct { diff --git a/src/browser/html/document.zig b/src/browser/html/document.zig index 4020b498..d0fee6d2 100644 --- a/src/browser/html/document.zig +++ b/src/browser/html/document.zig @@ -195,7 +195,7 @@ pub const HTMLDocument = struct { } pub fn set_location(_: *const parser.DocumentHTML, url: []const u8, page: *Page) !void { - return page.navigateFromWebAPI(url, .{ .reason = .script }); + return page.navigateFromWebAPI(url, .{ .reason = .script }, .{ .push = null }); } pub fn get_designMode(_: *parser.DocumentHTML) []const u8 { diff --git a/src/browser/html/location.zig b/src/browser/html/location.zig index 3e3e593b..220b4974 100644 --- a/src/browser/html/location.zig +++ b/src/browser/html/location.zig @@ -74,15 +74,15 @@ pub const Location = struct { } pub fn _assign(_: *const Location, url: []const u8, page: *Page) !void { - return page.navigateFromWebAPI(url, .{ .reason = .script }); + return page.navigateFromWebAPI(url, .{ .reason = .script }, .{ .push = null }); } pub fn _replace(_: *const Location, url: []const u8, page: *Page) !void { - return page.navigateFromWebAPI(url, .{ .reason = .script }); + return page.navigateFromWebAPI(url, .{ .reason = .script }, .replace); } pub fn _reload(_: *const Location, page: *Page) !void { - return page.navigateFromWebAPI(page.url.raw, .{ .reason = .script }); + return page.navigateFromWebAPI(page.url.raw, .{ .reason = .script }, .reload); } pub fn _toString(self: *Location, page: *Page) ![]const u8 { diff --git a/src/browser/html/window.zig b/src/browser/html/window.zig index e6ea1963..3947c526 100644 --- a/src/browser/html/window.zig +++ b/src/browser/html/window.zig @@ -143,7 +143,7 @@ pub const Window = struct { } pub fn set_location(_: *const Window, url: []const u8, page: *Page) !void { - return page.navigateFromWebAPI(url, .{ .reason = .script }); + return page.navigateFromWebAPI(url, .{ .reason = .script }, .{ .push = null }); } // frames return the window itself, but accessing it via a pseudo diff --git a/src/browser/page.zig b/src/browser/page.zig index f6283f07..15caacad 100644 --- a/src/browser/page.zig +++ b/src/browser/page.zig @@ -34,6 +34,7 @@ const Http = @import("../http/Http.zig"); const ScriptManager = @import("ScriptManager.zig"); const SlotChangeMonitor = @import("SlotChangeMonitor.zig"); const HTMLDocument = @import("html/document.zig").HTMLDocument; +const NavigationKind = @import("html/Navigation.zig").NavigationKind; const js = @import("js/js.zig"); const URL = @import("../url.zig").URL; @@ -815,8 +816,8 @@ pub const Page = struct { }, } - // Push the navigation after a successful load. - _ = try self.session.navigation.pushEntry(self.url.raw, null, self); + // We need to handle different navigation types differently. + try self.session.navigation.processNavigation(self.url.raw, self.session.navigation_kind, self); } fn pageErrorCallback(ctx: *anyopaque, err: anyerror) void { @@ -906,7 +907,7 @@ pub const Page = struct { .a => { const element: *parser.Element = @ptrCast(node); const href = (try parser.elementGetAttribute(element, "href")) orelse return; - try self.navigateFromWebAPI(href, .{}); + try self.navigateFromWebAPI(href, .{}, .{ .push = null }); }, .input => { const element: *parser.Element = @ptrCast(node); @@ -1043,7 +1044,7 @@ pub const Page = struct { // As such we schedule the function to be called as soon as possible. // The page.arena is safe to use here, but the transfer_arena exists // specifically for this type of lifetime. - pub fn navigateFromWebAPI(self: *Page, url: []const u8, opts: NavigateOpts) !void { + pub fn navigateFromWebAPI(self: *Page, url: []const u8, opts: NavigateOpts, kind: NavigationKind) !void { const session = self.session; if (session.queued_navigation != null) { // It might seem like this should never happen. And it might not, @@ -1070,6 +1071,8 @@ pub const Page = struct { .url = try URL.stitch(session.transfer_arena, url, self.url.raw, .{ .alloc = .always }), }; + session.navigation_kind = kind; + self.http_client.abort(); // In v8, this throws an exception which JS code cannot catch. @@ -1120,7 +1123,7 @@ pub const Page = struct { } else { action = try URL.concatQueryString(transfer_arena, action, buf.items); } - try self.navigateFromWebAPI(action, opts); + try self.navigateFromWebAPI(action, opts, .{ .push = null }); } pub fn isNodeAttached(self: *const Page, node: *parser.Node) bool { @@ -1178,6 +1181,7 @@ pub const NavigateReason = enum { form, script, history, + navigation, }; pub const NavigateOpts = struct { diff --git a/src/browser/session.zig b/src/browser/session.zig index 3456f159..d491cf27 100644 --- a/src/browser/session.zig +++ b/src/browser/session.zig @@ -22,6 +22,7 @@ const Allocator = std.mem.Allocator; const js = @import("js/js.zig"); const Page = @import("page.zig").Page; +const NavigationKind = @import("html/Navigation.zig").NavigationKind; const Browser = @import("browser.zig").Browser; const NavigateOpts = @import("page.zig").NavigateOpts; const History = @import("html/History.zig"); @@ -59,6 +60,7 @@ pub const Session = struct { // https://developer.mozilla.org/en-US/docs/Web/API/History history: History = .{}, navigation: Navigation = .{}, + navigation_kind: NavigationKind = .initial, page: ?Page = null, diff --git a/src/cdp/domains/page.zig b/src/cdp/domains/page.zig index 1f6b720a..c7bfcfb9 100644 --- a/src/cdp/domains/page.zig +++ b/src/cdp/domains/page.zig @@ -174,7 +174,7 @@ pub fn pageNavigate(arena: Allocator, bc: anytype, event: *const Notification.Pa var cdp = bc.cdp; const reason_: ?[]const u8 = switch (event.opts.reason) { .anchor => "anchorClick", - .script, .history => "scriptInitiated", + .script, .history, .navigation => "scriptInitiated", .form => switch (event.opts.method) { .GET => "formSubmissionGet", .POST => "formSubmissionPost", diff --git a/src/testing.zig b/src/testing.zig index 93c1abad..92ae34f0 100644 --- a/src/testing.zig +++ b/src/testing.zig @@ -402,19 +402,13 @@ pub fn htmlRunner(file: []const u8) !void { const url = try std.fmt.allocPrint(arena_allocator, "http://localhost:9582/src/tests/{s}", .{file}); try page.navigate(url, .{}); - _ = page.wait(2000); + test_session.fetchWait(2000); + // page exits more aggressively in tests. We want to make sure this is called // at lease once. page.session.browser.runMicrotasks(); page.session.browser.runMessageLoop(); - const needs_second_wait = try js_context.exec("testing._onPageWait.length > 0", "check_onPageWait"); - if (needs_second_wait.value.toBool(page.js.isolate)) { - // sets the isSecondWait flag in testing. - _ = js_context.exec("testing._isSecondWait = true", "set_second_wait_flag") catch {}; - _ = page.wait(2000); - } - @import("root").js_runner_duration += std.time.Instant.since(try std.time.Instant.now(), start); const value = js_context.exec("testing.getStatus()", "testing.getStatus()") catch |err| { diff --git a/src/tests/html/history.html b/src/tests/html/history.html index 0f4ff95f..60b54b52 100644 --- a/src/tests/html/history.html +++ b/src/tests/html/history.html @@ -11,10 +11,12 @@ testing.expectEqual('auto', history.scrollRestoration); testing.expectEqual(null, history.state) - history.pushState({ testInProgress: true }, null, 'http://127.0.0.1:9582/xhr/json'); + history.pushState({ testInProgress: true }, null, 'http://127.0.0.1:9582/src/tests/html/history2.html'); testing.expectEqual({ testInProgress: true }, history.state); + history.pushState({ testInProgress: false }, null, 'http://127.0.0.1:9582/xhr/json'); history.replaceState({ "new": "field", testComplete: true }, null); + let state = { "new": "field", testComplete: true }; testing.expectEqual(state, history.state); @@ -31,10 +33,5 @@ testing.expectEqual(state, popstateEventState); }) - testing.onPageWait(() => { - testing.expectEqual(true, history.state && history.state.testComplete); - testing.expectEqual(state, history.state); - }); - - testing.expectEqual(undefined, history.go()); + history.back(); diff --git a/src/tests/html/history2.html b/src/tests/html/history2.html new file mode 100644 index 00000000..735c71e9 --- /dev/null +++ b/src/tests/html/history2.html @@ -0,0 +1,6 @@ + + + + diff --git a/src/tests/html/navigation.html b/src/tests/html/navigation.html index f1ff61fb..e3744f55 100644 --- a/src/tests/html/navigation.html +++ b/src/tests/html/navigation.html @@ -1,5 +1,6 @@ + diff --git a/src/tests/html/navigation2.html b/src/tests/html/navigation2.html new file mode 100644 index 00000000..3b8ad282 --- /dev/null +++ b/src/tests/html/navigation2.html @@ -0,0 +1,8 @@ + + + + diff --git a/src/tests/testing.js b/src/tests/testing.js index 779cca8c..e83744ee 100644 --- a/src/tests/testing.js +++ b/src/tests/testing.js @@ -51,14 +51,6 @@ // if we're already in a fail state, return fail, nothing can recover this if (testing._status === 'fail') return 'fail'; - if (testing._isSecondWait) { - for (const pw of (testing._onPageWait)) { - testing._captured = pw[1]; - pw[0](); - testing._captured = null; - } - } - // run any eventually's that we've captured for (const ev of testing._eventually) { testing._captured = ev[1]; @@ -101,18 +93,6 @@ _registerErrorCallback(); } - // Set expectations to happen on the next time that `page.wait` is executed. - // - // History specifically uses this as it queues navigation that needs to be checked - // when the next page is loaded. - function onPageWait(fn) { - // Store callbacks to run when page.wait() happens - testing._onPageWait.push([fn, { - script_id: document.currentScript.id, - stack: new Error().stack, - }]); - } - async function async(promise, cb) { const script_id = document.currentScript ? document.currentScript.id : '