Skip to content

PWA: the shell never reloads itself — a waiting worker, a build stamp, and a Reload the user takes - #751

Merged
AllTerrainDeveloper merged 1 commit into
trunkfrom
fix/no-programmatic-shell-reload
Sep 3, 2026
Merged

PWA: the shell never reloads itself — a waiting worker, a build stamp, and a Reload the user takes#751
AllTerrainDeveloper merged 1 commit into
trunkfrom
fix/no-programmatic-shell-reload

Conversation

@AllTerrainDeveloper

@AllTerrainDeveloper AllTerrainDeveloper commented Sep 3, 2026

Copy link
Copy Markdown
Collaborator

What happened

A deploy landed on openstation.blog while a desktop with two windows was open. The new service worker installed, the shell's controllerchange handler hard-reloaded the page, and both windows were gone when it came back.

Two things made that routine rather than rare: every release is a byte change in the served worker (the plugin version rides in its preamble), and the resume-time update check (#749) finds it the moment the tab returns to the foreground. So every release reloaded the desktop, whether or not the shell itself had changed.

The rule

The shell never reloads itself. Not on a worker takeover, not on an interval, not on a version bump. At most it offers a reload, and only when the shell's own built files really changed on the server.

How

The worker waits. sw.ts no longer calls skipWaiting() on install — the standard prompt-for-update shape. The only thing that makes it take over is an os-sw-skip-waiting message from the shell. Taking over on install had a second problem beyond the reload: the activate handler drops the previous version's caches, so a worker that claimed a page still running the old bundle left it able to ask for a lazy bundle from a bucket that no longer existed. A page and its worker now change together.

The shell asks. registerServiceWorker() watches for a waiting worker (registration.waiting at boot, updatefoundinstalled mid-session) and asks it which shell build it was served with (os-sw-get-buildos-sw-build).

  • Same stamp, or unknown on either side: the worker is told to take over silently. Caches refresh; nothing is shown; nothing reloads.
  • Different stamp: a persistent toast — A new version of OpenStation is available — with a Reload action. The worker keeps waiting until the user takes it.

Taking it does three things in order: swap the waiting worker in and wait for controllerchange (bounded by a short timeout), flush the session and wait for the server's answer, then reload. The unload beacon racing the request that reads the session back is how the windows were lost; createSessionSaver() grows flush() for exactly this.

A swap another tab caused is handled too: the page compares against the new controller and makes the same offer when the shell changed. A first install is never a takeover.

The stamp. openstation_shell_build_stamp() is a content hash over assets/css/*.css and assets/js/*.js, memoised in one transient behind the files' (path, size, mtime) signature. Bytes, not clocks: a deploy that rewrites every mtime without changing contents is not a change, and neither is a version bump. It rides in openStationConfig.pwa.shellBuild and in the worker preamble — so a deploy that changed the shell is also, finally, a new worker.

Tests

  • sw-register-takeover.test.ts — the waiting flow end to end, the silent swap, unknown build, older server with no stamp, mid-session arrival, first install, another tab's swap, the swap timeout; plus two source scans: nothing under src/pwa/ calls location.reload(), and sw.ts has exactly one skipWaiting(), under the shell's message.
  • session-saver-flush.test.tsflush() writes ahead of the debounce, lets an in-flight write finish, sends nothing when the server already holds the session, never rejects.
  • openStationShellBuildStamp.php — stable across calls, unchanged by mtime, changed by bytes and by a new file, '' when nothing is built.
  • openStationPwaAdminAssetCache.php — the preamble carries the stamp.

Docs: docs/pwa.md (release flow, message surface, PHP surface).

Gates

typecheck, eslint, build, phpcs clean · vitest 5690 passed · PHPUnit 2840 passed.

Manual test

Open two windows. DevTools → Application → Service Workers → Update: the new worker installs and swaps silently, nothing visible happens. Add a comment to assets/css/desktop.css, Update again: the worker shows as waiting and the toast appears. Reload from it: both windows come back.

🤖 Generated with Claude Code

Open WordPress Playground Preview

…, and a Reload the user takes

A deploy landing while the desktop was open installed a new service
worker, the `controllerchange` handler hard-reloaded the shell, and two
open windows came back as none. Every release was a byte change in the
served worker (the plugin version rides in its preamble), and the
resume-time update check found it the moment the tab came back, so
every release reloaded the desktop whether or not the shell had changed.

The shell no longer reloads on its own, ever. The worker stops calling
`skipWaiting()` on install and waits; the shell finds it there
(`registration.waiting` at boot, `updatefound` → `installed`
mid-session) and asks which shell build it was served with
(`os-sw-get-build` → `os-sw-build`). Same stamp, or unknown on either
side: the shell tells it to take over silently (`os-sw-skip-waiting`).
A different stamp — the shell's own files really changed on the server
— shows a persistent toast with a Reload action, and the worker keeps
waiting until the user takes it. Taking it swaps the worker in, flushes
the session and waits for the server's answer, then reloads — the
unload beacon racing the request that reads the session back is how
the windows were lost.

The stamp is `openstation_shell_build_stamp()`: a content hash over
`assets/css/*.css` and `assets/js/*.js`, memoised behind the files'
(path, size, mtime) signature — bytes, not clocks, so a deploy that
rewrites every mtime without changing contents is not a change, and
neither is a version bump. It rides in `openStationConfig.pwa.shellBuild`
and in the worker preamble.

`createSessionSaver()` grows `flush()`, which writes now and resolves
once the server has answered, letting a write already on the wire
finish first.

Tests pin the policy: the waiting flow end to end, the silent swap, the
unknown-build case, the first install, the swap another tab caused, the
swap timeout; `flush()`; the stamp's stability across mtimes and its
sensitivity to bytes; the preamble carrying it; and two source scans —
nothing under `src/pwa/` calls `location.reload()`, and `sw.ts` has one
`skipWaiting()`, under the shell's message.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@AllTerrainDeveloper
AllTerrainDeveloper merged commit 59e6f52 into trunk Sep 3, 2026
5 checks passed
@AllTerrainDeveloper
AllTerrainDeveloper deleted the fix/no-programmatic-shell-reload branch September 3, 2026 17:16
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant