PWA: the shell never reloads itself — a waiting worker, a build stamp, and a Reload the user takes - #751
Merged
Conversation
…, 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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What happened
A deploy landed on openstation.blog while a desktop with two windows was open. The new service worker installed, the shell's
controllerchangehandler 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.tsno longer callsskipWaiting()on install — the standard prompt-for-update shape. The only thing that makes it take over is anos-sw-skip-waitingmessage 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.waitingat boot,updatefound→installedmid-session) and asks it which shell build it was served with (os-sw-get-build→os-sw-build).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()growsflush()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 overassets/css/*.cssandassets/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 inopenStationConfig.pwa.shellBuildand 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 undersrc/pwa/callslocation.reload(), andsw.tshas exactly oneskipWaiting(), under the shell's message.session-saver-flush.test.ts—flush()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