Why does a Playwright test that writes localStorage after page.goto() fail to change how the app boots?
answer
- goto resolves after the app already read
- Need a hook before page scripts
- Init script runs per navigation and frame
- Function plus serializable argument, or a file
- Re-seeds on every navigation, so guard it
basics
~10 sBecause the app has already started and read storage by the time the navigation resolves. Seeded values must exist before the document's own scripts run, which is what context.addInitScript() and page.addInitScript() are for.
solid answer
~50 s`page.goto()` resolves after the document has loaded, so the booking app's bundle has already run and read whatever was in `localStorage` at boot. Writing the value afterwards changes storage but not the render that already happened. `context.addInitScript()` registers a script that Playwright evaluates **after the document is created but before any of the page's own scripts**, on every page in the context, on every navigation, and in every child frame as it attaches — exactly the window where seeding belongs. It takes a function (with an optional serializable argument) or a `{ path }` or `{ content }` file, and it is the same mechanism used to stub browser APIs before boot. Two caveats: the order of several init scripts is undefined, and because it re-runs on every navigation it will re-seed values the app itself changed.
code
typescript · 6 linesawait context.addInitScript(flags => {
window.localStorage.setItem('hotel.flags', JSON.stringify(flags));
}, { roomGallery: true, currency: 'EUR' });
const page = await context.newPage();
await page.goto('https://hotels.example/rooms/12');go deeper
Remember that a navigation finishing means the app has already read storage. Seeding must happen earlier, and addInitScript is the hook that runs before the page's own scripts.
Explain the exact window the init script occupies, that it applies to every page and navigation in the context, and how arguments reach it since closures do not.
Show judgment about which state belongs in an init script versus a cookie versus a post-load evaluate, and mention the re-seeding trap that makes multi-navigation tests flaky.
Own how much of the app's boot state a suite is allowed to fake, and where faking start-up state stops testing the product and starts testing your fixtures.
## What `page.goto()` has already allowed to happen By the time a navigation resolves, the document exists, its scripts have run and the application has read its start-up state. A single-page booking app reads its feature flags, its cached search filters and its locale from `localStorage` during the first render. Writing those keys afterwards through `page.evaluate()` mutates storage, but the component tree was built from the old values — the test then asserts against a UI the seed never influenced. Nothing failed; the write was simply late. ## The window an init script occupies `context.addInitScript()` (and its per-page twin) registers a script that Playwright evaluates: - whenever a page is created in the context, **or** navigated; - whenever a child frame is attached or navigated, evaluated in that frame; - after the document has been created, but **before any of the document's own scripts run**. That last line is the whole point. It is the only ordinary hook that lands between "there is a document with an origin" and "the app has started", which is why it is used both for seeding storage and for stubbing globals the app touches on boot. ## Choosing the right tool for the state | State to plant | Mechanism | Runs | |---|---|---| | Cookies | `context.addCookies()` | before the request that carries them | | `localStorage`, `sessionStorage`, stubbed globals | `context.addInitScript()` | before the page's scripts, every navigation | | Anything the app re-reads later | `page.evaluate()` | after load, when lateness is acceptable | ## Shapes the API accepts 1. A function plus one serializable argument: `addInitScript(flags => { ... }, { roomGallery: true })`. The function is serialised to the browser, so it cannot close over variables from your test file — pass them as the argument instead. 2. `{ path: 'mocks/preload.js' }` to load a file from disk, which keeps large stubs out of the spec. 3. `{ content: '...' }` for a source string built at run time. ## The two gotchas - **Order is undefined.** The documentation says the evaluation order of several scripts installed with `browserContext.addInitScript()` and `page.addInitScript()` is not defined, so never write one that depends on another having run. Combine them into a single script instead. - **It re-runs on every navigation.** Seeding `hotel.flags` and then having the test navigate from search to a room page re-applies the seed, clobbering anything the app wrote in between. When that matters, guard the script — check `window.location.hostname`, or only write the key when it is absent. ## Origins still apply An init script runs inside the page's own origin, so a write to `localStorage` lands in the store of the document being loaded. That is what you want when the first navigation is to the booking site; it is not what you want if the test parks on a blank page first, where there is no useful origin to write to. Seed for the origin you are about to visit. ## What to say about the alternative `page.evaluate()` after `goto` is not wrong — it is right whenever the app re-reads the value on demand, for instance a preference read each time a dialog opens, and the test can then reload to prove the boot path too. The rule of thumb: state consumed at start-up goes in an init script, state consumed on interaction can be written afterwards.
- Why can an init script not use a variable defined in your test file?The function is serialised and evaluated inside the browser, so its closure does not travel with it. Pass whatever it needs as the second argument to `addInitScript`, which is serialised alongside it, or load a self-contained file with the `path` form.
- How do you stop an init script re-seeding a value the app has since changed?Make the script conditional, since it runs on every navigation: write the key only when it is missing, or only when `window.location.hostname` matches the origin under test. Otherwise a second navigation silently reverts the state the first page produced.
- What besides storage is worth planting in an init script?Anything the app reads at boot and you need to control: a stubbed `Math.random` for deterministic room ordering, a frozen locale helper, a flag that switches an experiment off. The constraint is the same — if the bundle reads it during start-up, the value must already be there.
saying these in an interview costs you the question
- Writes localStorage after goto and expects a rerender
- Thinks addInitScript runs immediately on the current page
- Assumes several init scripts run in registration order
- Forgets the script re-runs on every navigation
- Uses a closure variable inside the injected function