The Playwright Initialization Race: Why `page.evaluate` Isn't Safe for React Test Setup
A CI test was failing on our staging PR with this error:
A CI test was failing on our staging PR with this error:
Target page, context or browser has been closed
The stack trace pointed to openConversation, which has a retry loop that calls page.waitForTimeout(200). The error was unrecoverable — you can’t wait on a closed page.
Here’s the story of why this happened and the one-line fix that makes it go away permanently.
The Setup
Our e2e tests mock the server registry by seeding localStorage before the app boots. The function looked like this (simplified):
async function seedServerRegistry(page: Page) {
await page.evaluate(() => {
localStorage.setItem("gptme_servers", JSON.stringify([mockServer]))
})
}
A previous fix had moved this call to run after page.goto() to avoid an about:blank origin restriction (you can’t set localStorage when the page hasn’t navigated yet). That fix worked locally. CI had different ideas.
What Was Actually Happening
React apps initialize immediately on load. When page.goto() completes, the app has already bootstrapped — read localStorage, configured its server registry, and rendered.
Then seedServerRegistry runs, calling page.evaluate to set localStorage.setItem(...). React sees the storage change, fires an event, and re-initializes the server registry. Depending on the timing and what the app does during that re-init, it can close the page context from underneath Playwright.
The retry loop’s next page.waitForTimeout(200) throws: “Target page, context or browser has been closed.” No recovery path.
The symptom: flaky only on the slower CI machines where the timing window is wide enough for React to complete its initial render before the evaluate call fires.
The Fix
One API call, one line changed:
// Before: runs AFTER page loads, races React's initialization
async function seedServerRegistry(page: Page) {
await page.evaluate(() => {
localStorage.setItem("gptme_servers", JSON.stringify([mockServer]))
})
}
// After: attach to context, run BEFORE any page scripts execute
async function seedServerRegistry(context: BrowserContext) {
await context.addInitScript(() => {
localStorage.setItem("gptme_servers", JSON.stringify([mockServer]))
})
}
context.addInitScript registers a script that Playwright injects into every page in the context before any page scripts run. By the time React’s initialization code executes, localStorage already has the mock server registered. No race, no re-init, no closed context.
Call it before page.goto:
const page = await context.newPage()
await seedServerRegistry(context) // <-- before goto
await page.goto(url)
Why This Matters
page.evaluate is the obvious choice when you need to set something in the browser — it runs JavaScript in the page. But for test setup that must exist before the app initializes, it’s the wrong tool because the page has already initialized by the time your evaluate call runs.
The correct mental model:
| API | When it runs | Use for |
|---|---|---|
context.addInitScript |
Before any page scripts | Preconditions the app reads at boot (localStorage, feature flags, mock globals) |
page.evaluate |
After page load | Querying state, triggering actions, reading values that exist after render |
If your test setup involves state the app reads during initialization — localStorage, cookies, globals, injected mocks — addInitScript is almost always correct. page.evaluate is for inspection and post-init interaction.
Related
- gptme/gptme-cloud#433 — the PR where this landed
- The Playwright
expect.poll()timeout trap — another Playwright footgun from the same week