Skip to content

Commit 58b06eb

Browse files
wait for dom settle with CDP network events (#768)
* wait for dom settle with CDP network events * rm debug logs * update package.json * changeset * update log level * make devtools-protocol regular dep
1 parent 64d331d commit 58b06eb

File tree

7 files changed

+167
-82
lines changed

7 files changed

+167
-82
lines changed

.changeset/moody-monkeys-mate.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@browserbasehq/stagehand-lib": patch
3+
---
4+
5+
fix: page.evaluate: Execution context was destroyed, most likely because of a navigation

lib/StagehandPage.ts

Lines changed: 151 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import {
3030
} from "../types/stagehandErrors";
3131
import { StagehandAPIError } from "@/types/stagehandApiErrors";
3232
import { scriptContent } from "@/lib/dom/build/scriptContent";
33+
import type { Protocol } from "devtools-protocol";
3334

3435
export class StagehandPage {
3536
private stagehand: Stagehand;
@@ -427,71 +428,165 @@ ${scriptContent} \
427428
return this.intContext.context;
428429
}
429430

430-
// We can make methods public because StagehandPage is private to the Stagehand class.
431-
// When a user gets stagehand.page, they are getting a proxy to the Playwright page.
432-
// We can override the methods on the proxy to add our own behavior
433-
public async _waitForSettledDom(timeoutMs?: number) {
434-
try {
435-
const timeout = timeoutMs ?? this.stagehand.domSettleTimeoutMs;
436-
let timeoutHandle: NodeJS.Timeout;
431+
/**
432+
* `_waitForSettledDom` waits until the DOM is settled, and therefore is
433+
* ready for actions to be taken.
434+
*
435+
* **Definition of “settled”**
436+
* • No in-flight network requests (except WebSocket / Server-Sent-Events).
437+
* • That idle state lasts for at least **500 ms** (the “quiet-window”).
438+
*
439+
* **How it works**
440+
* 1. Subscribes to CDP Network and Page events for the main target and all
441+
* out-of-process iframes (via `Target.setAutoAttach { flatten:true }`).
442+
* 2. Every time `Network.requestWillBeSent` fires, the request ID is added
443+
* to an **`inflight`** `Set`.
444+
* 3. When the request finishes—`loadingFinished`, `loadingFailed`,
445+
* `requestServedFromCache`, or a *data:* response—the request ID is
446+
* removed.
447+
* 4. *Document* requests are also mapped **frameId → requestId**; when
448+
* `Page.frameStoppedLoading` fires the corresponding Document request is
449+
* removed immediately (covers iframes whose network events never close).
450+
* 5. A **stalled-request sweep timer** runs every 500 ms. If a *Document*
451+
* request has been open for ≥ 2 s it is forcibly removed; this prevents
452+
* ad/analytics iframes from blocking the wait forever.
453+
* 6. When `inflight` becomes empty the helper starts a 500 ms timer.
454+
* If no new request appears before the timer fires, the promise
455+
* resolves → **DOM is considered settled**.
456+
* 7. A global guard (`timeoutMs` or `stagehand.domSettleTimeoutMs`,
457+
* default ≈ 30 s) ensures we always resolve; if it fires we log how many
458+
* requests were still outstanding.
459+
*
460+
* @param timeoutMs – Optional hard cap (ms). Defaults to
461+
* `this.stagehand.domSettleTimeoutMs`.
462+
*/
463+
public async _waitForSettledDom(timeoutMs?: number): Promise<void> {
464+
const timeout = timeoutMs ?? this.stagehand.domSettleTimeoutMs;
465+
const client = await this.getCDPClient();
437466

438-
await this.page.waitForLoadState("domcontentloaded");
467+
const hasDoc = !!(await this.page.title().catch(() => false));
468+
if (!hasDoc) await this.page.waitForLoadState("domcontentloaded");
469+
470+
await client.send("Network.enable");
471+
await client.send("Page.enable");
472+
await client.send("Target.setAutoAttach", {
473+
autoAttach: true,
474+
waitForDebuggerOnStart: false,
475+
flatten: true,
476+
});
477+
478+
return new Promise<void>((resolve) => {
479+
const inflight = new Set<string>();
480+
const meta = new Map<string, { url: string; start: number }>();
481+
const docByFrame = new Map<string, string>();
482+
483+
let quietTimer: NodeJS.Timeout | null = null;
484+
let stalledRequestSweepTimer: NodeJS.Timeout | null = null;
485+
486+
const clearQuiet = () => {
487+
if (quietTimer) {
488+
clearTimeout(quietTimer);
489+
quietTimer = null;
490+
}
491+
};
439492

440-
const timeoutPromise = new Promise<void>((resolve) => {
441-
timeoutHandle = setTimeout(() => {
493+
const maybeQuiet = () => {
494+
if (inflight.size === 0 && !quietTimer)
495+
quietTimer = setTimeout(() => resolveDone(), 500);
496+
};
497+
498+
const finishReq = (id: string) => {
499+
if (!inflight.delete(id)) return;
500+
meta.delete(id);
501+
for (const [fid, rid] of docByFrame)
502+
if (rid === id) docByFrame.delete(fid);
503+
clearQuiet();
504+
maybeQuiet();
505+
};
506+
507+
const onRequest = (p: Protocol.Network.RequestWillBeSentEvent) => {
508+
if (p.type === "WebSocket" || p.type === "EventSource") return;
509+
510+
inflight.add(p.requestId);
511+
meta.set(p.requestId, { url: p.request.url, start: Date.now() });
512+
513+
if (p.type === "Document" && p.frameId)
514+
docByFrame.set(p.frameId, p.requestId);
515+
516+
clearQuiet();
517+
};
518+
519+
const onFinish = (p: { requestId: string }) => finishReq(p.requestId);
520+
const onCached = (p: { requestId: string }) => finishReq(p.requestId);
521+
const onDataUrl = (p: Protocol.Network.ResponseReceivedEvent) =>
522+
p.response.url.startsWith("data:") && finishReq(p.requestId);
523+
524+
const onFrameStop = (f: Protocol.Page.FrameStoppedLoadingEvent) => {
525+
const id = docByFrame.get(f.frameId);
526+
if (id) finishReq(id);
527+
};
528+
529+
client.on("Network.requestWillBeSent", onRequest);
530+
client.on("Network.loadingFinished", onFinish);
531+
client.on("Network.loadingFailed", onFinish);
532+
client.on("Network.requestServedFromCache", onCached);
533+
client.on("Network.responseReceived", onDataUrl);
534+
client.on("Page.frameStoppedLoading", onFrameStop);
535+
536+
stalledRequestSweepTimer = setInterval(() => {
537+
const now = Date.now();
538+
for (const [id, m] of meta) {
539+
if (now - m.start > 2_000) {
540+
inflight.delete(id);
541+
meta.delete(id);
542+
this.stagehand.log({
543+
category: "dom",
544+
message: "⏳ forcing completion of stalled iframe document",
545+
level: 2,
546+
auxiliary: {
547+
url: {
548+
value: m.url.slice(0, 120),
549+
type: "string",
550+
},
551+
},
552+
});
553+
}
554+
}
555+
maybeQuiet();
556+
}, 500);
557+
558+
maybeQuiet();
559+
560+
const guard = setTimeout(() => {
561+
if (inflight.size)
442562
this.stagehand.log({
443563
category: "dom",
444-
message: "DOM settle timeout exceeded, continuing anyway",
445-
level: 1,
564+
message:
565+
"⚠️ DOM-settle timeout reached – network requests still pending",
566+
level: 2,
446567
auxiliary: {
447-
timeout_ms: {
448-
value: timeout.toString(),
568+
count: {
569+
value: inflight.size.toString(),
449570
type: "integer",
450571
},
451572
},
452573
});
453-
resolve();
454-
}, timeout);
455-
});
456-
457-
try {
458-
await Promise.race([
459-
this.page.evaluate(() => {
460-
return new Promise<void>((resolve) => {
461-
if (typeof window.waitForDomSettle === "function") {
462-
window.waitForDomSettle().then(resolve);
463-
} else {
464-
console.warn(
465-
"waitForDomSettle is not defined, considering DOM as settled",
466-
);
467-
resolve();
468-
}
469-
});
470-
}),
471-
this.page.waitForLoadState("domcontentloaded"),
472-
this.page.waitForSelector("body"),
473-
timeoutPromise,
474-
]);
475-
} finally {
476-
clearTimeout(timeoutHandle!);
477-
}
478-
} catch (e) {
479-
this.stagehand.log({
480-
category: "dom",
481-
message: "Error in waitForSettledDom",
482-
level: 1,
483-
auxiliary: {
484-
error: {
485-
value: e.message,
486-
type: "string",
487-
},
488-
trace: {
489-
value: e.stack,
490-
type: "string",
491-
},
492-
},
493-
});
494-
}
574+
resolveDone();
575+
}, timeout);
576+
577+
const resolveDone = () => {
578+
client.off("Network.requestWillBeSent", onRequest);
579+
client.off("Network.loadingFinished", onFinish);
580+
client.off("Network.loadingFailed", onFinish);
581+
client.off("Network.requestServedFromCache", onCached);
582+
client.off("Network.responseReceived", onDataUrl);
583+
client.off("Page.frameStoppedLoading", onFrameStop);
584+
if (quietTimer) clearTimeout(quietTimer);
585+
if (stalledRequestSweepTimer) clearInterval(stalledRequestSweepTimer);
586+
clearTimeout(guard);
587+
resolve();
588+
};
589+
});
495590
}
496591

497592
async act(

lib/dom/global.d.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@ export {};
22
declare global {
33
interface Window {
44
__stagehandInjected?: boolean;
5-
waitForDomSettle: () => Promise<void>;
65
__playwright?: unknown;
76
__pw_manual?: unknown;
87
__PW_inspect?: unknown;

lib/dom/process.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@ import { generateXPathsForElement as generateXPaths } from "./xpathUtils";
22
import {
33
canElementScroll,
44
getNodeFromXpath,
5-
waitForDomSettle,
65
waitForElementScrollEnd,
76
} from "./utils";
87

@@ -74,7 +73,6 @@ export async function getScrollableElementXpaths(
7473
return xpaths;
7574
}
7675

77-
window.waitForDomSettle = waitForDomSettle;
7876
window.getScrollableElementXpaths = getScrollableElementXpaths;
7977
window.getNodeFromXpath = getNodeFromXpath;
8078
window.waitForElementScrollEnd = waitForElementScrollEnd;

lib/dom/utils.ts

Lines changed: 0 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,5 @@
11
import { StagehandDomProcessError } from "@/types/stagehandErrors";
22

3-
export async function waitForDomSettle() {
4-
return new Promise<void>((resolve) => {
5-
const createTimeout = () => {
6-
return setTimeout(() => {
7-
resolve();
8-
}, 2000);
9-
};
10-
let timeout = createTimeout();
11-
const observer = new MutationObserver(() => {
12-
clearTimeout(timeout);
13-
timeout = createTimeout();
14-
});
15-
observer.observe(window.document.body, { childList: true, subtree: true });
16-
});
17-
}
18-
193
/**
204
* Tests if the element actually responds to .scrollTo(...)
215
* and that scrollTop changes as expected.

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
"docs": "pnpm --filter @browserbasehq/stagehand-docs run dev",
1616
"evals": "pnpm run build && tsx evals/index.eval.ts",
1717
"e2e": "pnpm run build && cd evals/deterministic && playwright test --config=e2e.playwright.config.ts",
18-
"e2e:bb": "pnpm run build && cd evals/deterministic && playwright test --config=bb.playwright.config.ts",
18+
"e2e:bb": "pnpm run build && cd evals/deterministic && playwright test --config=bb.playwright.config.ts",
1919
"e2e:local": "pnpm run build && cd evals/deterministic && playwright test --config=local.playwright.config.ts",
2020
"build-dom-scripts": "tsx lib/dom/genDomScripts.ts",
2121
"build-types": "tsc --emitDeclarationOnly --outDir dist",
@@ -72,6 +72,7 @@
7272
"@browserbasehq/sdk": "^2.4.0",
7373
"@google/genai": "^0.8.0",
7474
"ai": "^4.3.9",
75+
"devtools-protocol": "^0.0.1464554",
7576
"openai": "^4.87.1",
7677
"pino": "^9.6.0",
7778
"pino-pretty": "^13.0.0",

pnpm-lock.yaml

Lines changed: 9 additions & 6 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)