Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/nice-socks-bow.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@browserbasehq/stagehand": patch
---

fix: waitForDomNetworkQuiet() causing `act()` to hang indefinitely
20 changes: 16 additions & 4 deletions packages/core/lib/v3/handlers/handlerUtils/actHandlerUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -528,8 +528,12 @@ export async function waitForDomNetworkQuiet(
frame: Frame,
timeoutMs?: number,
): Promise<void> {
const timeout = typeof timeoutMs === "number" ? timeoutMs : 5_000;
const overallTimeout =
typeof timeoutMs === "number" && Number.isFinite(timeoutMs)
? Math.max(0, timeoutMs)
: 5_000;
const client = frame.session;
const settleStart = Date.now();

// Ensure a document exists; if not, wait for DOMContentLoaded on this frame.
let hasDoc: boolean;
Expand All @@ -539,8 +543,16 @@ export async function waitForDomNetworkQuiet(
} catch {
hasDoc = false;
}
if (!hasDoc) {
await frame.waitForLoadState("domcontentloaded").catch(() => {});
if (!hasDoc && overallTimeout > 0) {
await frame
.waitForLoadState("domcontentloaded", overallTimeout)
.catch(() => {});
}

const elapsed = Date.now() - settleStart;
const remainingBudget = Math.max(0, overallTimeout - elapsed);
if (remainingBudget === 0) {
return;
}

await client.send("Network.enable").catch(() => {});
Expand Down Expand Up @@ -653,7 +665,7 @@ export async function waitForDomNetworkQuiet(
});
}
resolveDone();
}, timeout);
}, remainingBudget);

const resolveDone = () => {
client.off("Network.requestWillBeSent", onRequest);
Expand Down
37 changes: 33 additions & 4 deletions packages/core/lib/v3/understudy/frame.ts
Original file line number Diff line number Diff line change
Expand Up @@ -243,16 +243,45 @@ export class Frame implements FrameManager {
/** Wait for a lifecycle state (load/domcontentloaded/networkidle) */
async waitForLoadState(
state: "load" | "domcontentloaded" | "networkidle" = "load",
timeoutMs: number = 15_000,
): Promise<void> {
await this.session.send("Page.enable");
await new Promise<void>((resolve) => {
const targetState = state.toLowerCase();
const timeout = Math.max(0, timeoutMs);
await new Promise<void>((resolve, reject) => {
let done = false;
let timer: ReturnType<typeof setTimeout> | null = null;
const finish = () => {
if (done) return;
done = true;
this.session.off("Page.lifecycleEvent", handler);
if (timer) {
clearTimeout(timer);
timer = null;
}
resolve();
};
const handler = (evt: Protocol.Page.LifecycleEventEvent) => {
if (evt.frameId === this.frameId && evt.name === state) {
this.session.off("Page.lifecycleEvent", handler);
resolve();
const sameFrame = evt.frameId === this.frameId;
// need to normalize here because CDP lifecycle names look like 'DOMContentLoaded'
// but we accept 'domcontentloaded'
const lifecycleName = String(evt.name ?? "").toLowerCase();
if (sameFrame && lifecycleName === targetState) {
finish();
}
};
this.session.on("Page.lifecycleEvent", handler);

timer = setTimeout(() => {
if (done) return;
done = true;
this.session.off("Page.lifecycleEvent", handler);
reject(
new Error(
`waitForLoadState(${state}) timed out after ${timeout}ms for frame ${this.frameId}`,
),
);
}, timeout);
});
}

Expand Down
Loading