The seed registry
seed() stores session storage entries for an origin the page is not on yet, so the app finds them when it starts
there. This page explains how it does that without touching the navigation, and when it fails. How to use it is on
Seeding session storage.
The problem
Section titled “The problem”An app that reads session storage as it starts must find the entries there before its first script runs. Writing
after the load is too late, and writing from a framenavigated listener races the app’s first script and loses.
Playwright’s addInitScript runs before the first script of every new document, so it is the place to write, and since
Playwright 1.59 it returns a Disposable that removes it. Two things are left to solve: when to remove it, and what it
must not do in the documents that run it before then.
One registry per page
Section titled “One registry per page”SessionStorage.seed() writes at once when the page is on the origin. Otherwise it hands the entries to the page’s
SeedRegistry (src/helpers/sessionStorageSeed.ts), created on first use and kept in a WeakMap keyed by the page,
so every SessionStorage on the page shares it. The registry keeps one pending seed per origin; a second seed for the
same origin registers a script with the merged entries, later values winning, then disposes of the previous one.
Calls for one origin take turns, in the order they were made (see
overlapping calls).
The first pending seed registers four listeners: framenavigated, domcontentloaded, console and close. When
nothing is pending and no applied document can still report a failure, all four are removed: a page that never seeds,
or has applied its seeds, runs exactly as without POMWright.
The seed for one origin goes through these states:
stateDiagram-v2 [*] --> pending: seed, away from the origin pending --> pending: another seed merges its entries pending --> confirming: the main frame commits on the origin confirming --> [*]: DOMContentLoaded, or the next commit confirming --> failed: the script reports a refused write pending --> failed: the page closes after a passing test
The script
Section titled “The script”seedScript is a plain function that Playwright serialises, so it refers to nothing outside its body. It runs in every
new document of the page, in every frame, and writes only when all three hold:
- the main frame:
window === window.top, so an iframe on the seeded origin inside another page, and thesrcdocorabout:blankframes that inherit its origin, are left alone; - the origin:
location.originis the seeded one; - the first arrival:
navigation.activation.fromisnull, so the previous document of the main frame was on another origin, or there was none.
The third condition is what makes disposal at the commit enough. Disposal reaches the browser a few milliseconds after
the app’s first document on the origin commits, up to a few hundred under load. Without the condition, an app whose
first script writes a seeded key and reloads, or moves to another page on the origin, within that time would have the
key overwritten again: about once in a hundred such rounds on every engine. With it, a reload or a hop within the
origin has activation.from set and is skipped: no overwrite in 300 rounds per engine.
A write the browser refuses (the storage quota) is caught and logged with console.error under a prefix of
POMWright’s own, with the origin and the browser’s error.
A seeded navigation
Section titled “A seeded navigation”sequenceDiagram participant Test participant Registry as the seed registry participant Browser participant App as the app Test->>Registry: seed(entries), away from the origin Registry->>Browser: page.evaluate, the Navigation API check, once per page Registry->>Browser: page.addInitScript(seedScript, the origin and the entries) Test->>Browser: any navigation that reaches the origin Browser->>App: the request, untouched Note over Browser: a new document - the script writes before the first script Browser-->>Registry: framenavigated, the main frame on the origin Registry->>Browser: dispose of the script Note over Registry: confirming until the document's DOMContentLoaded or the next commit
- The commit. When
framenavigatedreports the main frame on a pending origin, that document has already run the script. The registry removes the seed from the pending ones and disposes of its script without waiting for the browser’s answer. - The confirming window. A refused write reaches Node as a console message after the commit. The script runs
before the document parses, so the message arrives before that document’s
domcontentloadedand before the next main-frame commit, on every engine. Until one of those, the applied seed is kept as confirming. A prefixed message for it throws the failure from theconsolelistener. Later messages are ignored.
The checks in seed
Section titled “The checks in seed”- The Navigation API, once per page, by
page.evaluatein the current document (which works onabout:blank): withoutnavigation.activationthe script could not tell a first arrival from a reload, soseedthrows before anything is registered. - A commit during registration. A temporary
framenavigatedlistener watches the main frame from the moment the call reaches the registry until its turn ends, so the wait for earlier calls and the Navigation API check are covered too. If the page commits a document on the origin in that time, it is unknown whether that document ran the script: on Chromium,addInitScriptresolved after such a commit whether or not the document had run it. A call that sees the commit before registering throws without registering; one that sees it whileaddInitScriptis pending disposes of the new script and throws.
Overlapping calls
Section titled “Overlapping calls”A call’s turn reads the pending seed, registers a script with the merged entries, stores it as the pending seed and
disposes of the previous script, with two awaits in between. Each origin has a chain of turns: a call joins it
synchronously when it reaches the registry, and its turn starts once the previous one has settled, whether it
succeeded or failed. So calls that overlap, in a Promise.all or without await, end exactly as if each had been
awaited in turn, and a call that fails leaves the pending seed as it was. Chains are per origin, so seeds for different
origins never wait for each other.
Without the chain, two calls that overlap read the same pending seed. Each merges only that seed with its own entries,
so one call’s keys are lost; the second stored record replaces the first, whose script stays registered and
untracked; and both dispose of the same previous script, the second time with Playwright’s “disposed more than once”
error. The untracked script is the worst of it: since a return from another origin is a first arrival again, it writes
the seeded values after every such return, over what the app has stored. A commit watch that started only at
addInitScript would miss a commit during the Navigation API check: a seed not awaited before a goto on a fresh
page would resolve on Chromium without its entries.
Rejected:
- A lock taken inside the turn, after the Navigation API check: it orders the calls by who finished the check first, not by the order they were made.
- Throwing on an overlap: a
Promise.allof seeds is a reasonable thing to write. - Coalescing, one registration of everything merged so far: the commit check would no longer belong to the call that made it.
Failures
Section titled “Failures”Every failure carries the line of the seed call, captured when it was called (see
call sites):
- No Navigation API and a commit during registration are thrown by the
seedcall itself. - A refused write is thrown from the
consolelistener, which fails the test at once. - Never applied. When the page closes after a test that has passed so far, a seed still waiting throws, naming the origin and the keys.
Limits
Section titled “Limits”- A popup is another page, which does not run the page’s init scripts, so a hop that opens a new tab leaves the seed waiting, and the page’s close fails the test. Seeding every page of the context would also seed pages the test did not mean to.
- A quick return. The first-arrival condition cannot help when the app’s first document on the origin writes a seeded key, leaves for a document that commits on another origin, and that document sends the browser straight back, all before the disposal arrives: the return is a first arrival again. Forced on localhost with a page that bounces back as its first script runs, it never happened: no overwrite in 350 rounds per engine, 300 of them with six parallel workers.
Why not a route
Section titled “Why not a route”An earlier design of 3.0.0, never released, had seed answer the navigation with a document of POMWright’s own that
wrote the entries and then continued the navigation, through a page.route. It was chosen when addInitScript could
not be removed. A route sees
only what Playwright intercepts, so it needed a proxy for server redirects, synthesised Sec-Fetch-* headers, a
re-submitted form for POSTs (multipart and text/plain could not be), a fragment carried over by hand, and a detour so
that WebKit’s goto waited for the app. Service workers and the test’s own mocks bypassed it, and the app saw a
different request than without a seed. An init script runs in whatever document commits, however it was answered, and
leaves the navigation alone.