From 2f4b7ec9cec64978b084134d28c083c061a71d93 Mon Sep 17 00:00:00 2001 From: Rohit <71192000+rohitsux@users.noreply.github.com> Date: Wed, 15 Jul 2026 00:40:32 +0530 Subject: [PATCH] feat(cdp): emit Page.navigatedWithinDocument for same-document navigations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/Notification.zig | 20 +++++++++++++++++ src/browser/Frame.zig | 17 ++++++++++++++ src/browser/webapi/History.zig | 4 ++++ src/cdp/CDP.zig | 6 +++++ src/cdp/domains/page.zig | 41 ++++++++++++++++++++++++++++++++++ 5 files changed, 88 insertions(+) diff --git a/src/Notification.zig b/src/Notification.zig index c28a74c85..068260f88 100644 --- a/src/Notification.zig +++ b/src/Notification.zig @@ -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 diff --git a/src/browser/Frame.zig b/src/browser/Frame.zig index b84fca6f7..23fcfc6cb 100644 --- a/src/browser/Frame.zig +++ b/src/browser/Frame.zig @@ -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; diff --git a/src/browser/webapi/History.zig b/src/browser/webapi/History.zig index 5f255385c..f4a8f4546 100644 --- a/src/browser/webapi/History.zig +++ b/src/browser/webapi/History.zig @@ -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 { diff --git a/src/cdp/CDP.zig b/src/cdp/CDP.zig index 1b2884b70..19fa87299 100644 --- a/src/cdp/CDP.zig +++ b/src/cdp/CDP.zig @@ -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); diff --git a/src/cdp/domains/page.zig b/src/cdp/domains/page.zig index cfeddd0a5..a9cdaad49 100644 --- a/src/cdp/domains/page.zig +++ b/src/cdp/domains/page.zig @@ -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", + }, .{}); +}