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 : '