diff --git a/.changeset/small-jobs-drum.md b/.changeset/small-jobs-drum.md
new file mode 100644
index 000000000..a16645b38
--- /dev/null
+++ b/.changeset/small-jobs-drum.md
@@ -0,0 +1,5 @@
+---
+"@browserbasehq/stagehand": patch
+---
+
+fix issue where scripts added via context.addInitScripts() were not being injected into new pages that were opened via popups (eg, clicking a link that opens a new page) and/or calling context.newPage(url)
diff --git a/packages/core/lib/v3/tests/context-addInitScript.spec.ts b/packages/core/lib/v3/tests/context-addInitScript.spec.ts
index 8ed86f2e2..dcdcac511 100644
--- a/packages/core/lib/v3/tests/context-addInitScript.spec.ts
+++ b/packages/core/lib/v3/tests/context-addInitScript.spec.ts
@@ -114,6 +114,87 @@ test.describe("context.addInitScript", () => {
expect(observed).toEqual(payload);
});
+ test("applies script to newPage(url) on initial document", async () => {
+ const payload = { marker: "newPageUrl" };
+
+ await ctx.addInitScript((arg) => {
+ function setPayload(): void {
+ const root = document.documentElement;
+ if (!root) return;
+ root.dataset.initPayload = JSON.stringify(arg);
+ }
+ if (document.readyState === "loading") {
+ document.addEventListener("DOMContentLoaded", setPayload, {
+ once: true,
+ });
+ } else {
+ setPayload();
+ }
+ }, payload);
+
+ const newPage = await ctx.newPage(
+ toDataUrl("
new page"),
+ );
+ await newPage.waitForLoadState("load");
+
+ const observed = await newPage.evaluate(() => {
+ const raw = document.documentElement.dataset.initPayload;
+ return raw ? JSON.parse(raw) : undefined;
+ });
+ expect(observed).toEqual(payload);
+ });
+
+ test("applies script to pages opened via link clicks", async () => {
+ const payload = { marker: "linkClick" };
+
+ await ctx.addInitScript((arg) => {
+ function setPayload(): void {
+ const root = document.documentElement;
+ if (!root) return;
+ root.dataset.initPayload = JSON.stringify(arg);
+ }
+ if (document.readyState === "loading") {
+ document.addEventListener("DOMContentLoaded", setPayload, {
+ once: true,
+ });
+ } else {
+ setPayload();
+ }
+ }, payload);
+
+ const popupUrl = toDataUrl("popup");
+ const openerHtml =
+ "" +
+ "" +
+ 'open' +
+ "";
+
+ const opener = await ctx.awaitActivePage();
+ await opener.goto(toDataUrl(openerHtml), { waitUntil: "load" });
+ await opener.locator("#open").click();
+
+ const openerId = opener.targetId();
+ const deadline = Date.now() + 2000;
+ let popup = ctx.pages().find((p) => p.targetId() !== openerId);
+ while (!popup && Date.now() < deadline) {
+ await opener.waitForTimeout(25);
+ popup = ctx.pages().find((p) => p.targetId() !== openerId);
+ }
+ if (!popup) {
+ throw new Error("Popup page was not created");
+ }
+
+ await popup.waitForLoadState("load");
+
+ const observed = await popup.evaluate(() => {
+ const raw = document.documentElement.dataset.initPayload;
+ return raw ? JSON.parse(raw) : undefined;
+ });
+ expect(observed).toEqual(payload);
+ });
+
test("context.addInitScript installs a function callable from page.evaluate", async () => {
const page = await ctx.awaitActivePage();
diff --git a/packages/core/lib/v3/understudy/cdp.ts b/packages/core/lib/v3/understudy/cdp.ts
index 9ab81f665..cb4055b03 100644
--- a/packages/core/lib/v3/understudy/cdp.ts
+++ b/packages/core/lib/v3/understudy/cdp.ts
@@ -109,12 +109,7 @@ export class CdpConnection implements CDPSessionLike {
await this.send("Target.setAutoAttach", {
autoAttach: true,
flatten: true,
- waitForDebuggerOnStart: false,
- filter: [
- { type: "worker", exclude: true },
- { type: "shared_worker", exclude: true },
- { type: "service_worker", exclude: true },
- ],
+ waitForDebuggerOnStart: true,
});
await this.send("Target.setDiscoverTargets", { discover: true });
}
diff --git a/packages/core/lib/v3/understudy/context.ts b/packages/core/lib/v3/understudy/context.ts
index 3d2bec33e..b0ac91f0d 100644
--- a/packages/core/lib/v3/understudy/context.ts
+++ b/packages/core/lib/v3/understudy/context.ts
@@ -59,6 +59,20 @@ export class V3Context {
private pendingCreatedTargetUrl = new Map();
private readonly initScripts: string[] = [];
+ private installInitScriptsOnSession(
+ session: CDPSessionLike,
+ ): Array> {
+ if (!this.initScripts.length) return [];
+ const promises: Array> = [];
+ promises.push(session.send("Page.enable"));
+ for (const source of this.initScripts) {
+ promises.push(
+ session.send("Page.addScriptToEvaluateOnNewDocument", { source }),
+ );
+ }
+ return promises;
+ }
+
/**
* Create a Context for a given CDP websocket URL and bootstrap target wiring.
*/
@@ -214,6 +228,7 @@ export class V3Context {
arg?: Arg,
): Promise {
const source = await normalizeInitScriptSource(script, arg);
+ if (this.initScripts.includes(source)) return;
this.initScripts.push(source);
const pages = this.pages();
await Promise.all(pages.map((page) => page.registerInitScript(source)));
@@ -233,7 +248,16 @@ export class V3Context {
return rows.map((r) => r.page);
}
- private async applyInitScriptsToPage(page: Page): Promise {
+ private async applyInitScriptsToPage(
+ page: Page,
+ opts?: { seedOnly?: boolean },
+ ): Promise {
+ if (opts?.seedOnly) {
+ for (const source of this.initScripts) {
+ page.seedInitScript(source);
+ }
+ return;
+ }
for (const source of this.initScripts) {
await page.registerInitScript(source);
}
@@ -264,18 +288,31 @@ export class V3Context {
* Waits until the target is attached and registered.
*/
public async newPage(url = "about:blank"): Promise {
+ const targetUrl = String(url ?? "about:blank");
const { targetId } = await this.conn.send<{ targetId: string }>(
"Target.createTarget",
- { url },
+ // Create at about:blank so init scripts can install before first real navigation.
+ { url: "about:blank" },
);
- this.pendingCreatedTargetUrl.set(targetId, url);
+ this.pendingCreatedTargetUrl.set(targetId, "about:blank");
// Best-effort bring-to-front
await this.conn.send("Target.activateTarget", { targetId }).catch(() => {});
const deadline = Date.now() + 5000;
while (Date.now() < deadline) {
const page = this.pagesByTarget.get(targetId);
- if (page) return page;
+ if (page) {
+ // we created at about:blank; navigate only after attach so init scripts run
+ // on the first real document. Fire-and-forget so newPage() resolves on attach.
+ if (targetUrl !== "about:blank") {
+ // Seed requested URL into the page cache before navigation events arrive.
+ page.seedCurrentUrl(targetUrl);
+ void page
+ .sendCDP("Page.navigate", { url: targetUrl })
+ .catch(() => {});
+ }
+ return page;
+ }
await new Promise((r) => setTimeout(r, 25));
}
throw new TimeoutError(`newPage: target not attached (${targetId})`, 5000);
@@ -346,6 +383,9 @@ export class V3Context {
if (info.type === "page" && (ti?.openerId || ti?.openerFrameId)) {
this._notePopupSignal();
}
+ // Let auto-attach handle top-level pages so waitForDebuggerOnStart can pause them.
+ if (isTopLevelPage(info)) return;
+
try {
await this.conn.attachToTarget(info.targetId);
} catch {
@@ -385,93 +425,143 @@ export class V3Context {
info: Protocol.Target.TargetInfo,
sessionId: SessionId,
): Promise {
+ // Workers are ignored by Stagehand, but with waitForDebuggerOnStart enabled
+ // they still need to be resumed so we don't leave them paused.
+ if (
+ info.type === "worker" ||
+ info.type === "service_worker" ||
+ info.type === "shared_worker"
+ ) {
+ const session = this.conn.getSession(sessionId);
+ if (session) {
+ await session.send("Runtime.runIfWaitingForDebugger").catch(() => {});
+ }
+ return;
+ }
+
const session = this.conn.getSession(sessionId);
if (!session) return;
// Init guard
if (this._sessionInit.has(sessionId)) return;
this._sessionInit.add(sessionId);
- await session.send("Runtime.runIfWaitingForDebugger").catch(() => {});
// Register for Runtime events before enabling it so we don't miss initial contexts.
executionContexts.attachSession(session);
- const piercerReady = await this.ensurePiercer(session);
- if (!piercerReady) return;
-
- await session
- .send("Page.setLifecycleEventsEnabled", { enabled: true })
- .catch(() => {});
-
- // Top-level handling
- if (isTopLevelPage(info)) {
- const page = await Page.create(
- this.conn,
- session,
- info.targetId,
- this.apiClient,
- this.localBrowserLaunchOptions,
- this.env === "BROWSERBASE",
- );
- this.wireSessionToOwnerPage(sessionId, page);
- this.pagesByTarget.set(info.targetId, page);
- this.mainFrameToTarget.set(page.mainFrameId(), info.targetId);
- this.sessionOwnerPage.set(sessionId, page);
- this.frameOwnerPage.set(page.mainFrameId(), page);
- this.typeByTarget.set(info.targetId, "page");
- if (!this.createdAtByTarget.has(info.targetId)) {
- this.createdAtByTarget.set(info.targetId, Date.now());
+ // Ensure we only resume once even if multiple code paths hit finally.
+ let resumed = false;
+ const resume = async (): Promise => {
+ if (resumed) return;
+ resumed = true;
+ // waitForDebuggerOnStart pauses new targets; resume once we've done
+ // any "must happen before first document" work.
+ await session.send("Runtime.runIfWaitingForDebugger").catch(() => {});
+ };
+
+ // Install any context-level init scripts as early as possible on this session.
+ // If this throws, we still resume the target but avoid re-installing later.
+ let scriptsInstalled = true;
+ let installPromises: Array> = [];
+ try {
+ installPromises = this.installInitScriptsOnSession(session);
+ } catch {
+ scriptsInstalled = false;
+ }
+ // Resume immediately so we never deadlock on paused targets; we then wait
+ // for the init-script sends to settle in the background.
+ await resume();
+ if (installPromises.length) {
+ const results = await Promise.allSettled(installPromises);
+ if (results.some((r) => r.status === "rejected")) {
+ scriptsInstalled = false;
}
- const pendingSeedUrl = this.pendingCreatedTargetUrl.get(info.targetId);
- this.pendingCreatedTargetUrl.delete(info.targetId);
- page.seedCurrentUrl(pendingSeedUrl ?? info.url ?? "");
- this._pushActive(info.targetId);
- this.installFrameEventBridges(sessionId, page);
- await this.applyInitScriptsToPage(page);
-
- return;
}
- // Child (iframe / OOPIF)
try {
- const { frameTree } =
- await session.send(
- "Page.getFrameTree",
+ const piercerReady = await this.ensurePiercer(session);
+ if (!piercerReady) return;
+
+ await session
+ .send("Page.setLifecycleEventsEnabled", { enabled: true })
+ .catch(() => {});
+
+ // Top-level handling
+ if (isTopLevelPage(info)) {
+ const page = await Page.create(
+ this.conn,
+ session,
+ info.targetId,
+ this.apiClient,
+ this.localBrowserLaunchOptions,
+ this.env === "BROWSERBASE",
);
- const childMainId = frameTree.frame.id;
-
- // Try to find owner Page now (it may already have the node in its tree)
- let owner = this.frameOwnerPage.get(childMainId);
- if (!owner) {
- for (const p of this.pagesByTarget.values()) {
- const tree = p.asProtocolFrameTree(p.mainFrameId());
- const has = (function find(n: Protocol.Page.FrameTree): boolean {
- if (n.frame.id === childMainId) return true;
- for (const c of n.childFrames ?? []) if (find(c)) return true;
- return false;
- })(tree);
- if (has) {
- owner = p;
- break;
- }
+ this.wireSessionToOwnerPage(sessionId, page);
+ this.pagesByTarget.set(info.targetId, page);
+ this.mainFrameToTarget.set(page.mainFrameId(), info.targetId);
+ this.sessionOwnerPage.set(sessionId, page);
+ this.frameOwnerPage.set(page.mainFrameId(), page);
+ this.typeByTarget.set(info.targetId, "page");
+ if (!this.createdAtByTarget.has(info.targetId)) {
+ this.createdAtByTarget.set(info.targetId, Date.now());
}
+ const pendingSeedUrl = this.pendingCreatedTargetUrl.get(info.targetId);
+ this.pendingCreatedTargetUrl.delete(info.targetId);
+ page.seedCurrentUrl(pendingSeedUrl ?? info.url ?? "");
+ this._pushActive(info.targetId);
+ this.installFrameEventBridges(sessionId, page);
+ // If we already installed scripts at the session level, only seed the
+ // Page's registry to avoid double-installing DOMContentLoaded handlers.
+ await this.applyInitScriptsToPage(page, {
+ seedOnly: scriptsInstalled,
+ });
+
+ return;
}
- if (owner) {
- owner.adoptOopifSession(session, childMainId);
- this.sessionOwnerPage.set(sessionId, owner);
- this.installFrameEventBridges(sessionId, owner);
- // Prime the execution-context registry so later lookups succeed even if
- // the frame navigates before we issue a command.
- void executionContexts
- .waitForMainWorld(session, childMainId)
- .catch(() => {});
- } else {
- this.pendingOopifByMainFrame.set(childMainId, sessionId);
+ // Child (iframe / OOPIF)
+ try {
+ const { frameTree } =
+ await session.send(
+ "Page.getFrameTree",
+ );
+ const childMainId = frameTree.frame.id;
+
+ // Try to find owner Page now (it may already have the node in its tree)
+ let owner = this.frameOwnerPage.get(childMainId);
+ if (!owner) {
+ for (const p of this.pagesByTarget.values()) {
+ const tree = p.asProtocolFrameTree(p.mainFrameId());
+ const has = (function find(n: Protocol.Page.FrameTree): boolean {
+ if (n.frame.id === childMainId) return true;
+ for (const c of n.childFrames ?? []) if (find(c)) return true;
+ return false;
+ })(tree);
+ if (has) {
+ owner = p;
+ break;
+ }
+ }
+ }
+
+ if (owner) {
+ owner.adoptOopifSession(session, childMainId);
+ this.sessionOwnerPage.set(sessionId, owner);
+ this.installFrameEventBridges(sessionId, owner);
+ // Prime the execution-context registry so later lookups succeed even if
+ // the frame navigates before we issue a command.
+ void executionContexts
+ .waitForMainWorld(session, childMainId)
+ .catch(() => {});
+ } else {
+ this.pendingOopifByMainFrame.set(childMainId, sessionId);
+ }
+ } catch {
+ // page.getFrameTree failed. Most likely was an ad iframe
+ // that opened & closed before we could attach. ignore
}
- } catch {
- // page.getFrameTree failed. Most likely was an ad iframe
- // that opened & closed before we could attach. ignore
+ } finally {
+ await resume();
}
}
diff --git a/packages/core/lib/v3/understudy/page.ts b/packages/core/lib/v3/understudy/page.ts
index e8f636629..0dc141f3c 100644
--- a/packages/core/lib/v3/understudy/page.ts
+++ b/packages/core/lib/v3/understudy/page.ts
@@ -176,6 +176,12 @@ export class Page {
await Promise.all(installs);
}
+ // Seed an init script without re-installing it on the current sessions.
+ public seedInitScript(source: string): void {
+ if (this.initScripts.includes(source)) return;
+ this.initScripts.push(source);
+ }
+
// --- Optional visual cursor overlay management ---
private cursorEnabled = false;
private async ensureCursorScript(): Promise {