PW Multi-Tab Handler
You write race-free tab/popup-handling code the engineer must verify
against the real flow — never assume a selector, OAuth field, or redirect
URL exists. The one rule underneath every pattern here: a new page is an
event to subscribe to before the click, never something to poll for
afterward.
When to use
- A click opens a new tab/window (
target="_blank", window.open, a
payment or SSO redirect) and the test needs to interact with it.
- Several tabs are open at once and the test needs to switch between them
reliably.
- Someone reports a popup test that hangs, times out, or is flaky.
When not to use
- Generating the rest of the test around this flow →
pw-test-generator.
- The flakiness isn't tab-related (timing, shared state, missing await
elsewhere) →
pw-flaky-debugger.
- The tab-opening element itself has a brittle locator →
pw-locator-fixer.
Workflow
- Subscribe before you act. Register
context.waitForEvent('page') or
page.waitForEvent('popup') before the triggering action, and await
both together with Promise.all([...]). Subscribing after the click is
a race — the event may already have fired.
- Prefer
page.waitForEvent('popup') over context.waitForEvent('page')
when one specific element triggers the new window (e.g. window.open) —
it scopes the wait to the exact opener rather than any new page in the
context.
- Always
await newPage.waitForLoadState() before any locator or URL
assertion — the page object resolves the instant the tab exists, not
when it has content.
- Hold a reference, never index by position.
context.pages()[1] is
not portable across Chromium/Firefox/WebKit; capture and use the
returned Page object.
- For an OAuth/SSO popup, drive the provider's screen inside the
popup, then wait on
popup.waitForEvent('close') as the signal auth
finished before asserting on the main page — with a generous timeout,
since provider redirects are slow. Prefer replaying a saved
storageState from one login in global setup over driving the popup in
every test.
- Close popups you opened (or rely on context teardown) — leaked tabs
slow the suite and can steal modal focus.
- All tabs in one
BrowserContext share cookies/storage — a
cross-origin OAuth popup still arrives as a page event; it never
needs a new context, which would throw away the session.
Output shape
import { test, expect } from '@playwright/test';
test('opens docs in a new tab', async ({ context, page }) => {
await page.goto('/app');
// Subscribe BEFORE the click, await both together.
const [newPage] = await Promise.all([
context.waitForEvent('page'),
page.getByRole('link', { name: 'Open docs' }).click(),
]);
await newPage.waitForLoadState('domcontentloaded'); // page exists immediately; content doesn't
await expect(newPage).toHaveURL(/\/docs/);
await newPage.close();
});
test('logs in via an OAuth popup', async ({ page }) => {
const popupPromise = page.waitForEvent('popup');
await page.getByRole('button', { name: 'Continue with Google' }).click();
const oauth = await popupPromise;
await oauth.waitForLoadState('domcontentloaded');
// ... drive the provider's consent screen inside `oauth` ...
await oauth.waitForEvent('close'); // provider closes itself; wait for that first
await expect(page.getByText('Signed in as')).toBeVisible({ timeout: 15_000 });
});
A reusable helper keeps this pattern out of every test:
export async function openInNewTab(
context: BrowserContext,
action: () => Promise<void>,
loadState: 'load' | 'domcontentloaded' | 'networkidle' = 'domcontentloaded',
): Promise<Page> {
const [newPage] = await Promise.all([context.waitForEvent('page'), action()]);
await newPage.waitForLoadState(loadState);
return newPage;
}
Guardrails
- Never invent a selector, OAuth field, or redirect URL you weren't
shown — this is a draft the engineer verifies against the real flow.
- Never subscribe to the page/popup event after the triggering click —
that's the race this skill exists to prevent.
- Never use
waitForTimeout to "wait for the tab to open," and never
select a tab by context.pages() index — hold the returned reference.
- Never spawn a fresh
browser.newContext() for an OAuth popup — it
discards the session cookies the flow needs.
- Assert on a new page only after
waitForLoadState — the page object
existing is not the same as its content being ready.
1---2name: pw-multi-tab-handler3description: Writes race-free Playwright code for any flow that opens a second tab, popup, or window — target="_blank" links, "open in new window" buttons, OAuth/SSO consent screens, payment redirects, and PDF preview tabs. Use when an SDET says "my test can't find the element after this link opens a new tab", "handle this OAuth popup", "window.open breaks my test", or a test needs to manage several open tabs at once. Produces code that subscribes to the new-page event before triggering the action, never after — a draft the engineer verifies against the real flow.4license: MIT5---67# PW Multi-Tab Handler89You write **race-free tab/popup-handling code the engineer must verify**10against the real flow — never assume a selector, OAuth field, or redirect11URL exists. The one rule underneath every pattern here: a new page is an12**event to subscribe to before the click**, never something to poll for13afterward.1415## When to use16- A click opens a new tab/window (`target="_blank"`, `window.open`, a17 payment or SSO redirect) and the test needs to interact with it.18- Several tabs are open at once and the test needs to switch between them19 reliably.20- Someone reports a popup test that hangs, times out, or is flaky.2122## When *not* to use23- Generating the rest of the test around this flow → `pw-test-generator`.24- The flakiness isn't tab-related (timing, shared state, missing await25 elsewhere) → `pw-flaky-debugger`.26- The tab-opening element itself has a brittle locator →27 `pw-locator-fixer`.2829## Workflow301. **Subscribe before you act.** Register `context.waitForEvent('page')` or31 `page.waitForEvent('popup')` *before* the triggering action, and await32 both together with `Promise.all([...])`. Subscribing after the click is33 a race — the event may already have fired.342. **Prefer `page.waitForEvent('popup')`** over `context.waitForEvent('page')`35 when one specific element triggers the new window (e.g. `window.open`) —36 it scopes the wait to the exact opener rather than any new page in the37 context.383. **Always `await newPage.waitForLoadState()`** before any locator or URL39 assertion — the page object resolves the instant the tab exists, not40 when it has content.414. **Hold a reference, never index by position.** `context.pages()[1]` is42 not portable across Chromium/Firefox/WebKit; capture and use the43 returned `Page` object.445. **For an OAuth/SSO popup**, drive the provider's screen inside the45 popup, then wait on `popup.waitForEvent('close')` as the signal auth46 finished before asserting on the main page — with a generous timeout,47 since provider redirects are slow. Prefer replaying a saved48 `storageState` from one login in global setup over driving the popup in49 every test.506. **Close popups you opened** (or rely on context teardown) — leaked tabs51 slow the suite and can steal modal focus.527. **All tabs in one `BrowserContext` share cookies/storage** — a53 cross-origin OAuth popup still arrives as a `page` event; it never54 needs a new context, which would throw away the session.5556## Output shape57```typescript58import { test, expect } from '@playwright/test';5960test('opens docs in a new tab', async ({ context, page }) => {61 await page.goto('/app');6263 // Subscribe BEFORE the click, await both together.64 const [newPage] = await Promise.all([65 context.waitForEvent('page'),66 page.getByRole('link', { name: 'Open docs' }).click(),67 ]);6869 await newPage.waitForLoadState('domcontentloaded'); // page exists immediately; content doesn't70 await expect(newPage).toHaveURL(/\/docs/);71 await newPage.close();72});7374test('logs in via an OAuth popup', async ({ page }) => {75 const popupPromise = page.waitForEvent('popup');76 await page.getByRole('button', { name: 'Continue with Google' }).click();77 const oauth = await popupPromise;7879 await oauth.waitForLoadState('domcontentloaded');80 // ... drive the provider's consent screen inside `oauth` ...8182 await oauth.waitForEvent('close'); // provider closes itself; wait for that first83 await expect(page.getByText('Signed in as')).toBeVisible({ timeout: 15_000 });84});85```8687A reusable helper keeps this pattern out of every test:88```typescript89export async function openInNewTab(90 context: BrowserContext,91 action: () => Promise<void>,92 loadState: 'load' | 'domcontentloaded' | 'networkidle' = 'domcontentloaded',93): Promise<Page> {94 const [newPage] = await Promise.all([context.waitForEvent('page'), action()]);95 await newPage.waitForLoadState(loadState);96 return newPage;97}98```99100## Guardrails101- Never invent a selector, OAuth field, or redirect URL you weren't102 shown — this is a draft the engineer verifies against the real flow.103- Never subscribe to the page/popup event after the triggering click —104 that's the race this skill exists to prevent.105- Never use `waitForTimeout` to "wait for the tab to open," and never106 select a tab by `context.pages()` index — hold the returned reference.107- Never spawn a fresh `browser.newContext()` for an OAuth popup — it108 discards the session cookies the flow needs.109- Assert on a new page only after `waitForLoadState` — the page object110 existing is not the same as its content being ready.