mirror of
https://github.com/lightpanda-io/browser.git
synced 2026-08-02 10:47:15 -04:00
feat(cdp): emit Page.navigatedWithinDocument for same-document navigations
CDP clients (Playwright/Puppeteer) never saw same-document navigations — history.pushState / replaceState changed frame.url but fired no CDP event, so frame.url() went stale and waitForNavigation missed SPA route changes. Add a frame_navigated_within_document notification, dispatched from History.pushState/replaceState, and emit Page.navigatedWithinDocument from the Page domain with frameId, url, and navigationType. Unlike frameNavigated, this deliberately does NOT send Runtime.executionContextsCleared or DOM.documentUpdated — the document and its execution context are unchanged, and clearing them would invalidate the client's live context ids and break frame.evaluate(). Adds a cdp.frame test. Scoped to the History-API path; fragment and Navigation-API paths are left for follow-ups.
This commit is contained in:
@@ -75,6 +75,7 @@ const EventListeners = struct {
|
||||
frame_created: List = .{},
|
||||
frame_navigate: List = .{},
|
||||
frame_navigated: List = .{},
|
||||
frame_navigated_within_document: List = .{},
|
||||
frame_navigate_failed: List = .{},
|
||||
frame_network_idle: List = .{},
|
||||
frame_network_almost_idle: List = .{},
|
||||
@@ -104,6 +105,7 @@ const Events = union(enum) {
|
||||
frame_created: *Frame,
|
||||
frame_navigate: *const FrameNavigate,
|
||||
frame_navigated: *const FrameNavigated,
|
||||
frame_navigated_within_document: *const FrameNavigatedWithinDocument,
|
||||
frame_navigate_failed: *const FrameNavigateFailed,
|
||||
frame_network_idle: *const FrameNetworkIdle,
|
||||
frame_network_almost_idle: *const FrameNetworkAlmostIdle,
|
||||
@@ -154,6 +156,24 @@ pub const FrameNavigated = struct {
|
||||
opts: Frame.NavigatedOpts,
|
||||
};
|
||||
|
||||
// A same-document navigation (History pushState/replaceState, fragment
|
||||
// changes, or history traversal). The document and its execution context
|
||||
// stay alive — only the URL changes — so CDP must emit
|
||||
// Page.navigatedWithinDocument WITHOUT clearing the execution context or
|
||||
// sending DOM.documentUpdated.
|
||||
pub const FrameNavigatedWithinDocument = struct {
|
||||
frame_id: u32,
|
||||
timestamp: u64,
|
||||
url: [:0]const u8,
|
||||
navigation_type: NavigationType = .other,
|
||||
|
||||
pub const NavigationType = enum {
|
||||
fragment,
|
||||
historyApi,
|
||||
other,
|
||||
};
|
||||
};
|
||||
|
||||
// A root navigation that failed before commit (DNS failure, connection
|
||||
// refused, malformed response, ...). Dispatched so CDP can answer the
|
||||
// pending Page.navigate command with an errorText instead of leaving the
|
||||
|
||||
@@ -33,6 +33,7 @@ const Parser = @import("parser/Parser.zig");
|
||||
const h5e = @import("parser/html5ever.zig");
|
||||
|
||||
const CustomElementReactions = @import("CustomElementReactions.zig");
|
||||
const Notification = @import("../Notification.zig");
|
||||
|
||||
const URL = @import("URL.zig");
|
||||
const Blob = @import("webapi/Blob.zig");
|
||||
@@ -599,6 +600,22 @@ pub fn isSameOrigin(self: *const Frame, url: [:0]const u8) bool {
|
||||
return std.mem.eql(u8, URL.getHost(url), URL.getHost(current_origin));
|
||||
}
|
||||
|
||||
// Notify observers of a same-document navigation (History pushState /
|
||||
// replaceState, fragment change, or history traversal). Unlike navigate(),
|
||||
// the document and its execution context are unchanged — only the URL moves.
|
||||
pub fn notifyNavigatedWithinDocument(
|
||||
self: *Frame,
|
||||
url: [:0]const u8,
|
||||
navigation_type: Notification.FrameNavigatedWithinDocument.NavigationType,
|
||||
) void {
|
||||
self._session.notification.dispatch(.frame_navigated_within_document, &.{
|
||||
.frame_id = self._frame_id,
|
||||
.url = url,
|
||||
.timestamp = timestamp(.monotonic),
|
||||
.navigation_type = navigation_type,
|
||||
});
|
||||
}
|
||||
|
||||
pub fn navigate(self: *Frame, request_url: [:0]const u8, opts: NavigateOpts) !void {
|
||||
lp.assert(self._load_state == .waiting, "frame.renavigate", .{});
|
||||
const session = self._session;
|
||||
|
||||
@@ -62,6 +62,8 @@ pub fn pushState(_: *History, state: js.Value, _: ?[]const u8, _url: ?[]const u8
|
||||
frame.url = url;
|
||||
// setHref == reinitializing.
|
||||
try frame.window._location._url.setHref(url, &frame.js.execution);
|
||||
|
||||
frame.notifyNavigatedWithinDocument(url, .historyApi);
|
||||
}
|
||||
|
||||
pub fn replaceState(_: *History, state: js.Value, _: ?[]const u8, _url: ?[]const u8, frame: *Frame) !void {
|
||||
@@ -77,6 +79,8 @@ pub fn replaceState(_: *History, state: js.Value, _: ?[]const u8, _url: ?[]const
|
||||
frame.url = url;
|
||||
// setHref == reinitializing.
|
||||
try frame.window._location._url.setHref(url, &frame.js.execution);
|
||||
|
||||
frame.notifyNavigatedWithinDocument(url, .historyApi);
|
||||
}
|
||||
|
||||
fn goInner(delta: i32, frame: *Frame) !void {
|
||||
|
||||
@@ -629,6 +629,7 @@ pub const BrowserContext = struct {
|
||||
try notification.register(.frame_created, self, onFrameCreated);
|
||||
try notification.register(.frame_navigate, self, onFrameNavigate);
|
||||
try notification.register(.frame_navigated, self, onFrameNavigated);
|
||||
try notification.register(.frame_navigated_within_document, self, onFrameNavigatedWithinDocument);
|
||||
try notification.register(.frame_navigate_failed, self, onFrameNavigateFailed);
|
||||
try notification.register(.frame_child_frame_created, self, onFrameChildFrameCreated);
|
||||
try notification.register(.frame_dom_content_loaded, self, onFrameDOMContentLoaded);
|
||||
@@ -922,6 +923,11 @@ pub const BrowserContext = struct {
|
||||
return @import("domains/page.zig").frameNavigateFailed(self, msg);
|
||||
}
|
||||
|
||||
pub fn onFrameNavigatedWithinDocument(ctx: *anyopaque, msg: *const Notification.FrameNavigatedWithinDocument) !void {
|
||||
const self: *BrowserContext = @ptrCast(@alignCast(ctx));
|
||||
return @import("domains/page.zig").frameNavigatedWithinDocument(self, msg);
|
||||
}
|
||||
|
||||
pub fn onFrameChildFrameCreated(ctx: *anyopaque, msg: *const Notification.FrameChildFrameCreated) !void {
|
||||
const self: *BrowserContext = @ptrCast(@alignCast(ctx));
|
||||
return @import("domains/page.zig").frameChildFrameCreated(self, msg);
|
||||
|
||||
@@ -755,6 +755,22 @@ pub fn frameNavigated(arena: Allocator, bc: *CDP.BrowserContext, event: *const N
|
||||
try cdp.sendEvent("DOM.documentUpdated", null, .{ .session_id = session_id });
|
||||
}
|
||||
|
||||
pub fn frameNavigatedWithinDocument(bc: anytype, event: *const Notification.FrameNavigatedWithinDocument) !void {
|
||||
const session_id = bc.session_id orelse return;
|
||||
const frame_id = &id.toFrameId(event.frame_id);
|
||||
|
||||
// Same-document navigation (pushState / replaceState / fragment). The
|
||||
// document and its execution context are unchanged, so — unlike
|
||||
// frameNavigated — we deliberately do NOT send Runtime.executionContextsCleared
|
||||
// or DOM.documentUpdated; doing so would invalidate the client's live
|
||||
// execution context ids and break Puppeteer's frame.evaluate().
|
||||
try bc.cdp.sendEvent("Page.navigatedWithinDocument", .{
|
||||
.frameId = frame_id,
|
||||
.url = event.url,
|
||||
.navigationType = @tagName(event.navigation_type),
|
||||
}, .{ .session_id = session_id });
|
||||
}
|
||||
|
||||
pub fn frameDOMContentLoaded(bc: anytype, event: *const Notification.FrameDOMContentLoaded) !void {
|
||||
const session_id = bc.session_id orelse return;
|
||||
const timestamp = event.timestamp;
|
||||
@@ -1693,3 +1709,28 @@ test "cdp.frame: getNavigationHistory + navigateToHistoryEntry" {
|
||||
try ctx.expectSentError(-31998, "InvalidParams", .{ .id = 42 });
|
||||
}
|
||||
}
|
||||
|
||||
test "cdp.frame: history.pushState emits Page.navigatedWithinDocument" {
|
||||
var ctx = try testing.context();
|
||||
defer ctx.deinit();
|
||||
|
||||
var bc = try ctx.loadBrowserContext(.{ .id = "BID-9", .url = "hi.html", .target_id = "FID-000000000X".* });
|
||||
|
||||
const frame = bc.mainFrame() orelse unreachable;
|
||||
|
||||
{
|
||||
var ls: js.Local.Scope = undefined;
|
||||
frame.js.localScope(&ls);
|
||||
defer ls.deinit();
|
||||
_ = try ls.local.exec("history.pushState({}, '', '/next')", null);
|
||||
}
|
||||
|
||||
// Same-document navigation must surface as Page.navigatedWithinDocument
|
||||
// (not a full Page.frameNavigated), carrying the new URL and the
|
||||
// history-API navigation type. The main frame is assigned the internal
|
||||
// id FID-0000000001.
|
||||
try ctx.expectSentEvent("Page.navigatedWithinDocument", .{
|
||||
.frameId = "FID-0000000001",
|
||||
.navigationType = "historyApi",
|
||||
}, .{});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user