diff --git a/.changeset/nice-socks-bow.md b/.changeset/nice-socks-bow.md new file mode 100644 index 000000000..3c3670c32 --- /dev/null +++ b/.changeset/nice-socks-bow.md @@ -0,0 +1,5 @@ +--- +"@browserbasehq/stagehand": patch +--- + +fix: waitForDomNetworkQuiet() causing `act()` to hang indefinitely diff --git a/packages/core/lib/v3/handlers/handlerUtils/actHandlerUtils.ts b/packages/core/lib/v3/handlers/handlerUtils/actHandlerUtils.ts index 7a8ffb9a3..a5f2bc155 100644 --- a/packages/core/lib/v3/handlers/handlerUtils/actHandlerUtils.ts +++ b/packages/core/lib/v3/handlers/handlerUtils/actHandlerUtils.ts @@ -528,8 +528,12 @@ export async function waitForDomNetworkQuiet( frame: Frame, timeoutMs?: number, ): Promise { - 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; @@ -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(() => {}); @@ -653,7 +665,7 @@ export async function waitForDomNetworkQuiet( }); } resolveDone(); - }, timeout); + }, remainingBudget); const resolveDone = () => { client.off("Network.requestWillBeSent", onRequest); diff --git a/packages/core/lib/v3/understudy/frame.ts b/packages/core/lib/v3/understudy/frame.ts index c9e26bf96..9735bf82a 100644 --- a/packages/core/lib/v3/understudy/frame.ts +++ b/packages/core/lib/v3/understudy/frame.ts @@ -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 { await this.session.send("Page.enable"); - await new Promise((resolve) => { + const targetState = state.toLowerCase(); + const timeout = Math.max(0, timeoutMs); + await new Promise((resolve, reject) => { + let done = false; + let timer: ReturnType | 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); }); }