feat(sw): offer a reload when a newer version is ready

The shell is served cache-first, which is what makes the app work on a
train and also what makes a deploy invisible: a reader with the tab open
— the reader this app is built for — keeps running the bundle they first
loaded, so a new game or a corrected date reaches their device and sits
there with nothing saying why the page looks unchanged. An old app shown
as current is the same failure as old events shown as current.

So the worker now installs quietly and waits instead of calling
skipWaiting(), the page notices it waiting and says so, and the reader's
tap sends the skip-waiting message and reloads on controllerchange. The
app never reloads itself: someone may be mid-way through typing in one of
their own events, and the notice says what a reload costs (their place on
the page) and what it does not (marks and notes live in localStorage).

Detection is derived rather than remembered. build:static grew into a
script that stamps sw.js with a hash of the built shell, because the
browser only offers a worker whose bytes differ, and the predecessor —
a hand-bumped CACHE_VERSION — had already been forgotten once. The feed
is deliberately not part of that hash: it changes twice a day, needs no
reload, and announcing it would teach readers to dismiss the notice
unread. The cache name stays put for the same reason a per-build one
would be wrong — it holds the feed an offline reader is reading.

A first install is not an update and stays silent.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
Lucas Winther
2026-08-17 18:55:08 +02:00
co-authored by Claude Opus 5
parent 7130a94b21
commit a4ab5aa36c
11 changed files with 804 additions and 20 deletions
+19
View File
@@ -269,6 +269,25 @@ A lane may now be a game the reader invented, so `gameMeta` is a context resolve
and total) rather than a direct lookup — a lane can outlive its game when an import carries an event
whose game did not come with it.
## Shipping a new version
The shell is cached cache-first, so a reader with the tab open keeps the bundle they first loaded.
An old app presented as current is the same failure as old events presented as current, so a waiting
version is disclosed and reloaded on a tap (PRD F14, `docs/ARCHITECTURE.md` § Shipping a new version
to an open page). Four things hold it up:
- **`sw.js` must not `skipWaiting()` on install.** It activates only on the `skip-waiting` message
the reader's tap sends. Claiming an open page unasked runs the old bundle against the new cache and
says nothing.
- **`__BUILD__` must stay in `sw.js`.** `scripts/build-static.ts` substitutes a hash of the built
shell for it, which is what makes a deploy's worker bytes differ and therefore detectable. It
throws if the placeholder is gone — do not "fix" that by dropping the substitution. There is no
`CACHE_VERSION` bump ritual any more; the cache name is a namespace, and per-build names would
discard the stored feed an offline reader is reading.
- **The feed is not part of the build id.** It changes twice a day and needs no reload; announcing it
as a new version teaches readers to dismiss the notice unread.
- **The app never reloads itself.** Someone may be mid-way through typing an event in.
## Conventions
- **Zod schemas are the single source of truth for types.** Derive with `z.infer<>`; never