Skip to content

Commit 28c8068

Browse files
fix(browser): resolve correct targetId in navigate response after renderer swap (openclaw#25326)
* fix(browser): resolve correct targetId in navigate response after renderer swap When `navigateViaPlaywright` triggers a Chrome renderer-process swap (e.g. navigating from chrome-extension:// to https://), the old `tab.targetId` captured before navigation becomes stale. The `/navigate` route previously returned this stale targetId in its response. After navigation, re-resolve the current tab by matching against the final URL via `profileCtx.listTabs()`. If the old target is already gone but the new one is not yet visible (extension re-attach in progress), retry after 800ms. Follow-up to openclaw#19744 (67bac62) which fixed the extension-side stale session cleanup. * fix(browser): prefer non-stale targetId when multiple tabs share the same URL When multiple tabs have the same URL after navigation, find() could pick a pre-existing tab instead of the newly created one. Now only re-resolve when the old target is gone (renderer swap detected), and prefer the tab whose targetId differs from the old one. * fix(browser): encapsulate targetId resolution logic after navigation Introduced a new function `resolveTargetIdAfterNavigate` to handle the resolution of the correct targetId after a navigation event that may trigger a renderer swap. This refactor improves code clarity and reuses the logic for determining the current targetId, ensuring that the correct tab is identified even when multiple tabs share the same URL. * refactor(tests): simplify listTabs initialization in agent snapshot tests Updated the initialization of listTabs in the agent snapshot tests for better readability by removing unnecessary line breaks. This change enhances code clarity without altering the test logic. * fix(ui): widen Set type to accept string tokens in external-link helper * chore: retrigger CI (unrelated Windows flaky test) Co-authored-by: Cursor <[email protected]> --------- Co-authored-by: Cursor <[email protected]>
1 parent 26db298 commit 28c8068

File tree

2 files changed

+159
-2
lines changed

2 files changed

+159
-2
lines changed
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
import { describe, expect, it, vi } from "vitest";
2+
import { resolveTargetIdAfterNavigate } from "./agent.snapshot.js";
3+
4+
type Tab = { targetId: string; url: string };
5+
6+
function staticListTabs(tabs: Tab[]): () => Promise<Tab[]> {
7+
return async () => tabs;
8+
}
9+
10+
describe("resolveTargetIdAfterNavigate", () => {
11+
it("returns original targetId when old target still exists (no swap)", async () => {
12+
const result = await resolveTargetIdAfterNavigate({
13+
oldTargetId: "old-123",
14+
navigatedUrl: "https://example.com",
15+
listTabs: staticListTabs([
16+
{ targetId: "old-123", url: "https://example.com" },
17+
{ targetId: "other-456", url: "https://other.com" },
18+
]),
19+
});
20+
expect(result).toBe("old-123");
21+
});
22+
23+
it("resolves new targetId when old target is gone (renderer swap)", async () => {
24+
const result = await resolveTargetIdAfterNavigate({
25+
oldTargetId: "old-123",
26+
navigatedUrl: "https://example.com",
27+
listTabs: staticListTabs([{ targetId: "new-456", url: "https://example.com" }]),
28+
});
29+
expect(result).toBe("new-456");
30+
});
31+
32+
it("prefers non-stale targetId when multiple tabs share the URL", async () => {
33+
const result = await resolveTargetIdAfterNavigate({
34+
oldTargetId: "old-123",
35+
navigatedUrl: "https://example.com",
36+
listTabs: staticListTabs([
37+
{ targetId: "preexisting-000", url: "https://example.com" },
38+
{ targetId: "fresh-777", url: "https://example.com" },
39+
]),
40+
});
41+
// Both differ from old targetId; the first non-stale match wins.
42+
expect(result).toBe("preexisting-000");
43+
});
44+
45+
it("retries and resolves targetId when first listTabs has no URL match", async () => {
46+
vi.useFakeTimers();
47+
let calls = 0;
48+
49+
const result$ = resolveTargetIdAfterNavigate({
50+
oldTargetId: "old-123",
51+
navigatedUrl: "https://delayed.com",
52+
listTabs: async () => {
53+
calls++;
54+
if (calls === 1) {
55+
return [{ targetId: "unrelated-1", url: "https://unrelated.com" }];
56+
}
57+
return [{ targetId: "delayed-999", url: "https://delayed.com" }];
58+
},
59+
});
60+
61+
await vi.advanceTimersByTimeAsync(800);
62+
const result = await result$;
63+
64+
expect(result).toBe("delayed-999");
65+
expect(calls).toBe(2);
66+
67+
vi.useRealTimers();
68+
});
69+
70+
it("falls back to original targetId when no match found after retry", async () => {
71+
vi.useFakeTimers();
72+
73+
const result$ = resolveTargetIdAfterNavigate({
74+
oldTargetId: "old-123",
75+
navigatedUrl: "https://no-match.com",
76+
listTabs: staticListTabs([
77+
{ targetId: "unrelated-1", url: "https://unrelated.com" },
78+
{ targetId: "unrelated-2", url: "https://unrelated2.com" },
79+
]),
80+
});
81+
82+
await vi.advanceTimersByTimeAsync(800);
83+
const result = await result$;
84+
85+
expect(result).toBe("old-123");
86+
87+
vi.useRealTimers();
88+
});
89+
90+
it("falls back to single remaining tab when no URL match after retry", async () => {
91+
vi.useFakeTimers();
92+
93+
const result$ = resolveTargetIdAfterNavigate({
94+
oldTargetId: "old-123",
95+
navigatedUrl: "https://single-tab.com",
96+
listTabs: staticListTabs([{ targetId: "only-tab", url: "https://some-other.com" }]),
97+
});
98+
99+
await vi.advanceTimersByTimeAsync(800);
100+
const result = await result$;
101+
102+
expect(result).toBe("only-tab");
103+
104+
vi.useRealTimers();
105+
});
106+
107+
it("falls back to original targetId when listTabs throws", async () => {
108+
const result = await resolveTargetIdAfterNavigate({
109+
oldTargetId: "old-123",
110+
navigatedUrl: "https://error.com",
111+
listTabs: async () => {
112+
throw new Error("CDP connection lost");
113+
},
114+
});
115+
expect(result).toBe("old-123");
116+
});
117+
});

src/browser/routes/agent.snapshot.ts

Lines changed: 42 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,41 @@ async function saveBrowserMediaResponse(params: {
4848
});
4949
}
5050

51+
/** Resolve the correct targetId after a navigation that may trigger a renderer swap. */
52+
export async function resolveTargetIdAfterNavigate(opts: {
53+
oldTargetId: string;
54+
navigatedUrl: string;
55+
listTabs: () => Promise<Array<{ targetId: string; url: string }>>;
56+
}): Promise<string> {
57+
let currentTargetId = opts.oldTargetId;
58+
try {
59+
const refreshed = await opts.listTabs();
60+
if (!refreshed.some((t) => t.targetId === opts.oldTargetId)) {
61+
// Renderer swap: old target gone, resolve the replacement.
62+
// Prefer a URL match whose targetId differs from the old one
63+
// to avoid picking a pre-existing tab when multiple share the URL.
64+
const byUrl = refreshed.filter((t) => t.url === opts.navigatedUrl);
65+
const replaced = byUrl.find((t) => t.targetId !== opts.oldTargetId) ?? byUrl[0];
66+
if (replaced) {
67+
currentTargetId = replaced.targetId;
68+
} else {
69+
await new Promise((r) => setTimeout(r, 800));
70+
const retried = await opts.listTabs();
71+
const match =
72+
retried.find((t) => t.url === opts.navigatedUrl && t.targetId !== opts.oldTargetId) ??
73+
retried.find((t) => t.url === opts.navigatedUrl) ??
74+
(retried.length === 1 ? retried[0] : null);
75+
if (match) {
76+
currentTargetId = match.targetId;
77+
}
78+
}
79+
}
80+
} catch {
81+
// Best-effort: fall back to pre-navigation targetId
82+
}
83+
return currentTargetId;
84+
}
85+
5186
export function registerBrowserAgentSnapshotRoutes(
5287
app: BrowserRouteRegistrar,
5388
ctx: BrowserRouteContext,
@@ -65,14 +100,19 @@ export function registerBrowserAgentSnapshotRoutes(
65100
ctx,
66101
targetId,
67102
feature: "navigate",
68-
run: async ({ cdpUrl, tab, pw }) => {
103+
run: async ({ cdpUrl, tab, pw, profileCtx }) => {
69104
const result = await pw.navigateViaPlaywright({
70105
cdpUrl,
71106
targetId: tab.targetId,
72107
url,
73108
...withBrowserNavigationPolicy(ctx.state().resolved.ssrfPolicy),
74109
});
75-
res.json({ ok: true, targetId: tab.targetId, ...result });
110+
const currentTargetId = await resolveTargetIdAfterNavigate({
111+
oldTargetId: tab.targetId,
112+
navigatedUrl: result.url,
113+
listTabs: () => profileCtx.listTabs(),
114+
});
115+
res.json({ ok: true, targetId: currentTargetId, ...result });
76116
},
77117
});
78118
});

0 commit comments

Comments
 (0)