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:
Rohit
2026-07-15 00:40:32 +05:30
parent 9965e59672
commit 2f4b7ec9ce
5 changed files with 88 additions and 0 deletions

View File

@@ -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

View File

@@ -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;

View File

@@ -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 {

View File

@@ -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);

View File

@@ -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",
}, .{});
}