From 681946fc5609fb32ad0ae4358c5789330a91ff60 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A0=20Arrufat?= Date: Fri, 18 Sep 2026 10:40:11 +0200 Subject: [PATCH 1/2] fix(extension): re-issue waitForSelector when a navigation destroys its context The wait is a single Runtime.evaluate pinned to the execution context that was current when it was issued, typically the document a click is about to navigate away from. When the commit lands after that, Chrome rejects the pending evaluate with "Inspected target navigated or closed" (V8 words it "Execution context was destroyed") and the wait failed, since only "Cannot find context with specified id" was retried. The wait has no side effects, so Page.waitForSelector now re-resolves its target frame and re-issues the wait against the new document with the time left. Other evaluate failures and an exhausted budget still throw. Fixes #2982 --- .../wait-for-selector-across-navigation.md | 6 + .../page-wait-for-selector-navigation.test.ts | 138 ++++++++++++++++++ packages/extension/understudy/page.ts | 61 ++++++-- 3 files changed, 189 insertions(+), 16 deletions(-) create mode 100644 .changeset/wait-for-selector-across-navigation.md create mode 100644 packages/extension/tests/page-wait-for-selector-navigation.test.ts diff --git a/.changeset/wait-for-selector-across-navigation.md b/.changeset/wait-for-selector-across-navigation.md new file mode 100644 index 0000000000..e20cd73eed --- /dev/null +++ b/.changeset/wait-for-selector-across-navigation.md @@ -0,0 +1,6 @@ +--- +"@browserbasehq/stagehand-extension": patch +"@browserbasehq/stagehand": patch +--- + +`page.waitForSelector()` survives the navigation it is waiting through: when the commit rejects the pending evaluate with "Inspected target navigated or closed" (or "Execution context was destroyed"), the wait is re-issued against the new document with the remaining timeout instead of failing. diff --git a/packages/extension/tests/page-wait-for-selector-navigation.test.ts b/packages/extension/tests/page-wait-for-selector-navigation.test.ts new file mode 100644 index 0000000000..ddda846c1d --- /dev/null +++ b/packages/extension/tests/page-wait-for-selector-navigation.test.ts @@ -0,0 +1,138 @@ +import type { Protocol } from "devtools-protocol"; +import { describe, expect, it } from "vitest"; +import type { StagehandLogger } from "../logger.js"; +import type { CDPSessionLike, CdpConnection } from "../understudy/cdp.js"; +import { executionContexts } from "../understudy/executionContextRegistry.js"; +import { Page } from "../understudy/page.js"; + +const FRAME_ID = "frame-main"; + +/** + * A session whose Runtime.evaluate answers are scripted per locator-world + * context id: the outgoing document's context rejects the way Chrome rejects a + * pending evaluate once the navigation commits, the new one resolves. + */ +class FakeSession implements CDPSessionLike { + readonly id = "session-main"; + readonly handlers = new Map void>>(); + readonly readyContexts = new Set(); + readonly evaluateCalls: Array<{ contextId?: number; expression: string }> = []; + readonly answers = new Map Promise>(); + + async send(method: string, params?: object): Promise { + if (method !== "Runtime.evaluate") return {} as R; + const { contextId, expression } = params as { contextId?: number; expression: string }; + if (expression.includes("__stagehandExtensionWorld")) { + const ready = contextId !== undefined && this.readyContexts.has(contextId); + return { result: { value: { ready, marker: ready, domApi: "function" } } } as R; + } + this.evaluateCalls.push({ contextId, expression }); + const answer = contextId !== undefined ? this.answers.get(contextId) : undefined; + if (!answer) throw new Error(`unexpected evaluate in context ${contextId}`); + return (await answer()) as R; + } + + on

(event: string, handler: (params: P) => void): void { + const handlers = this.handlers.get(event) ?? new Set<(params: unknown) => void>(); + handlers.add(handler as (params: unknown) => void); + this.handlers.set(event, handlers); + } + + off

(event: string, handler: (params: P) => void): void { + this.handlers.get(event)?.delete(handler as (params: unknown) => void); + } + + async close(): Promise {} + + emit(event: string, params: unknown): void { + for (const handler of this.handlers.get(event) ?? []) handler(params); + } +} + +const contextCreated = ( + id: number, + isDefault: boolean, +): Protocol.Runtime.ExecutionContextCreatedEvent => + ({ + context: { + id, + origin: isDefault ? "http://example.test" : "chrome-extension://stagehand", + name: isDefault ? "" : "Stagehand", + uniqueId: `context-${id}`, + auxData: { frameId: FRAME_ID, isDefault }, + }, + }) as Protocol.Runtime.ExecutionContextCreatedEvent; + +function createPage(session: FakeSession): Page { + executionContexts.attachSession(session); + const connection = { + targetIdForSession: (sessionId: string) => `target-${sessionId}`, + } as CdpConnection; + return new Page(connection, session, "target-main", FRAME_ID, {} as StagehandLogger); +} + +function documentReady(session: FakeSession, mainContextId: number, worldContextId: number): void { + session.emit("Runtime.executionContextCreated", contextCreated(mainContextId, true)); + session.emit("Runtime.executionContextCreated", contextCreated(worldContextId, false)); + session.readyContexts.add(worldContextId); +} + +describe("Page.waitForSelector across a navigation", () => { + it("re-issues the wait in the new document when the commit kills the pending evaluate", async () => { + const session = new FakeSession(); + const page = createPage(session); + documentReady(session, 1, 2); + + // The wait is pending in the outgoing document's world when the navigation + // that the caller is waiting for commits. + session.answers.set(2, async () => { + session.emit("Runtime.executionContextsCleared", {}); + documentReady(session, 3, 4); + throw new Error("-32000 Inspected target navigated or closed"); + }); + session.answers.set(4, async () => ({ result: { value: true } })); + + await expect(page.waitForSelector("#query", { timeout: 1000 })).resolves.toBe(true); + expect(session.evaluateCalls.map((call) => call.contextId)).toStrictEqual([2, 4]); + // The retry carries the time left, not the original budget. + const retryTimeout = Number( + /\["waitForSelector"\]\("#query", "visible", (\d+), /.exec( + session.evaluateCalls[1]!.expression, + )?.[1], + ); + expect(retryTimeout).toBeGreaterThan(0); + expect(retryTimeout).toBeLessThanOrEqual(1000); + }); + + it("gives up once the budget is spent", async () => { + const session = new FakeSession(); + const page = createPage(session); + documentReady(session, 1, 2); + session.answers.set(2, async () => { + await new Promise((resolve) => setTimeout(resolve, 30)); + session.emit("Runtime.executionContextsCleared", {}); + documentReady(session, 3, 4); + throw new Error("Execution context was destroyed."); + }); + session.answers.set(4, async () => ({ result: { value: true } })); + + await expect(page.waitForSelector("#query", { timeout: 20 })).rejects.toThrow( + "Execution context was destroyed", + ); + expect(session.evaluateCalls).toHaveLength(1); + }); + + it("does not retry other evaluate failures", async () => { + const session = new FakeSession(); + const page = createPage(session); + documentReady(session, 1, 2); + session.answers.set(2, async () => { + throw new Error("Some other CDP error"); + }); + + await expect(page.waitForSelector("#query", { timeout: 1000 })).rejects.toThrow( + "Some other CDP error", + ); + expect(session.evaluateCalls).toHaveLength(1); + }); +}); diff --git a/packages/extension/understudy/page.ts b/packages/extension/understudy/page.ts index 8591e86a2d..bfcdb485a7 100644 --- a/packages/extension/understudy/page.ts +++ b/packages/extension/understudy/page.ts @@ -156,6 +156,22 @@ function webMCPToolResponse(event: Protocol.WebMCP.ToolRespondedEvent): WebMCPTo }); } +/** + * Messages a pending `Runtime.evaluate` is rejected with when the navigation it + * outlives destroys its execution context (Chrome and V8 wording, plus the + * stale-id case a lookup can race into). + */ +const NAVIGATION_TEARDOWN_MESSAGES = [ + "Inspected target navigated or closed", + "Execution context was destroyed", + "Cannot find context with specified id", +]; + +function isNavigationTeardownError(error: unknown): boolean { + const message = error instanceof Error ? error.message : String(error); + return NAVIGATION_TEARDOWN_MESSAGES.some((needle) => message.includes(needle)); +} + export class Page { /** Every CDP child session this page owns (top-level + adopted OOPIF sessions). */ readonly sessions = new Map(); // sessionId -> session @@ -1436,22 +1452,35 @@ export class Page { const state = options?.state ?? "visible"; const pierceShadow = options?.pierceShadow ?? true; const startTime = Date.now(); - const root = this.mainFrameWrapper; - const { frame: targetFrame, selector: finalSelector } = await resolveLocatorTarget( - this, - root, - selector, - ); - const elapsed = Date.now() - startTime; - const remainingTimeout = Math.max(0, timeout - elapsed); - - const expression = buildLocatorInvocation("waitForSelector", [ - JSON.stringify(finalSelector), - JSON.stringify(state), - String(remainingTimeout), - String(pierceShadow), - ]); - return targetFrame.evaluateInLocatorWorld(expression); + for (;;) { + const root = this.mainFrameWrapper; + const { frame: targetFrame, selector: finalSelector } = await resolveLocatorTarget( + this, + root, + selector, + ); + const elapsed = Date.now() - startTime; + const remainingTimeout = Math.max(0, timeout - elapsed); + + const expression = buildLocatorInvocation("waitForSelector", [ + JSON.stringify(finalSelector), + JSON.stringify(state), + String(remainingTimeout), + String(pierceShadow), + ]); + try { + return await targetFrame.evaluateInLocatorWorld(expression); + } catch (error) { + // The wait is typically issued right after the click that triggers a + // navigation, pinned to the context of the outgoing document. When the + // commit tears that context down, the pending evaluate is rejected; + // the wait has no side effects, so re-issue it against the new + // document with the time left. + if (!isNavigationTeardownError(error) || Date.now() - startTime >= timeout) { + throw error; + } + } + } } /** Internal batch evaluation; page.evaluate continues to use the main world unchanged. */ From a0d54c68bdc6424b269e8e0b67da25aded99a642 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A0=20Arrufat?= Date: Tue, 29 Sep 2026 10:19:26 +0200 Subject: [PATCH 2/2] test(extension): cover a waitForSelector retry that lands on a torn-down context A navigation can go through more than one context transition while the wait is pending, so the first re-issued wait can itself be rejected with "Cannot find context with specified id". The loop keeps re-issuing until the caller's timeout; assert that it resolves on the third attempt. --- .../page-wait-for-selector-navigation.test.ts | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/packages/extension/tests/page-wait-for-selector-navigation.test.ts b/packages/extension/tests/page-wait-for-selector-navigation.test.ts index ddda846c1d..10ce008d4f 100644 --- a/packages/extension/tests/page-wait-for-selector-navigation.test.ts +++ b/packages/extension/tests/page-wait-for-selector-navigation.test.ts @@ -104,6 +104,29 @@ describe("Page.waitForSelector across a navigation", () => { expect(retryTimeout).toBeLessThanOrEqual(1000); }); + it("keeps re-issuing when the first retry lands on a context that is also torn down", async () => { + const session = new FakeSession(); + const page = createPage(session); + documentReady(session, 1, 2); + + // The commit first kills the outgoing world, then the world the retry + // picked up goes away too before the final document settles. + session.answers.set(2, async () => { + session.emit("Runtime.executionContextsCleared", {}); + documentReady(session, 3, 4); + throw new Error("-32000 Inspected target navigated or closed"); + }); + session.answers.set(4, async () => { + session.emit("Runtime.executionContextsCleared", {}); + documentReady(session, 5, 6); + throw new Error("-32000 Cannot find context with specified id"); + }); + session.answers.set(6, async () => ({ result: { value: true } })); + + await expect(page.waitForSelector("#query", { timeout: 1000 })).resolves.toBe(true); + expect(session.evaluateCalls.map((call) => call.contextId)).toStrictEqual([2, 4, 6]); + }); + it("gives up once the budget is spent", async () => { const session = new FakeSession(); const page = createPage(session);