fix: stop the endless "new version available" prompt

The update prompt reappeared after every refresh, forever.

Root cause: the worker was registered with `{ type: 'module' }`, but
SvelteKit/Vite bundles src/service-worker.js down to a classic script with no
imports or exports (verified: zero import/export statements in the build
output). Registering a classic script as a module made the browser install a
fresh worker on every register() call — so every page load produced a
"waiting" worker, which is precisely the signal the prompt keys off. Clicking
Refresh activated that worker and the next load did the same thing again.

Reproduced deterministically before the fix: with byte-identical script bytes
(confirmed by hashing two fetches), a single registration and a single scope,
every load still ended with waiting: "installed" and the prompt visible.
After removing the option, three consecutive loads of the same build leave no
waiting worker and no prompt.

Two related defects found while investigating:

- controllerchange fired a full page reload on a FIRST visit. The worker calls
  clients.claim(), so a page that starts with no controller gets one, which is
  a controllerchange — every new visitor was silently getting a redundant
  reload. Now guarded on whether the page had a controller to begin with.

- A worker already waiting from a previous visit was invisible. `updatefound`
  does not fire for one that is already installed, so someone who closed the
  tab with an update pending would never be offered it again. The waiting
  worker is now checked at registration time.

Also clears the prompt when Refresh is clicked rather than leaving it up
during the hand-over.

Verified the full lifecycle against two real builds: unchanged build across
repeated loads shows no prompt; a genuinely new build shows it; clicking
Refresh activates the worker and the prompt does not return on reload; the
previous version's cache is pruned.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
TehRiehlDealandClaude Opus 5 committed 2026-08-09 02:16:53 -07:00
1 parent 85086e31b2
commit c4afe501b6
1 file changed
+30 -9
+30 -9
View File
@@ -52,18 +52,34 @@
onMount(() => {
if (!('serviceWorker' in navigator)) return;
// Whether this page was already under a worker's control when it loaded.
// The worker calls clients.claim(), so a FIRST-EVER visit also fires
// controllerchange — reloading on that would give every new visitor a
// pointless full page reload. Only an actual hand-over should reload.
const hadController = !!navigator.serviceWorker.controller;
let reloading = false;
// The worker no longer calls skipWaiting() on install, so a new version
// waits instead of taking over a page running the previous deploy's JS.
// When it does take over, reload once so page and worker agree.
navigator.serviceWorker.addEventListener('controllerchange', () => {
if (reloading) return;
if (!hadController || reloading) return;
reloading = true;
location.reload();
});
/** Offer the update, once, for whichever worker is waiting. */
const offer = (worker: ServiceWorker) => {
updateReady = () => {
updateReady = null; // don't leave the prompt up while we hand over
worker.postMessage({ type: 'SKIP_WAITING' });
};
};
navigator.serviceWorker
.register('/service-worker.js', { type: 'module' })
// NOTE: no `type: 'module'`. SvelteKit/Vite bundles the worker down to a
// classic script with no imports, and registering it as a module made the
// browser install a fresh worker on EVERY register() call — i.e. an
// endless "new version available" prompt that reappeared after each
// refresh, forever.
.register('/service-worker.js')
.then((registration) => {
// The old code's comment claimed updates were checked periodically, but
// nothing ever called update(). Without this a long-lived tab only
@@ -76,15 +92,20 @@
);
window.addEventListener('pagehide', () => clearInterval(hourly));
// A worker may already be waiting from a previous visit. `updatefound`
// does not fire for that one, so without this check the update stays
// invisible and the visitor is stuck on the old version.
if (registration.waiting && hadController) offer(registration.waiting);
registration.addEventListener('updatefound', () => {
const newWorker = registration.installing;
if (!newWorker) return;
newWorker.addEventListener('statechange', () => {
if (newWorker.state !== 'installed' || !navigator.serviceWorker.controller) return;
// A new version is ready and waiting. Previously this only logged,
// and in DEV only, so the SKIP_WAITING handler was dead code.
updateReady = () => newWorker.postMessage({ type: 'SKIP_WAITING' });
if (newWorker.state !== 'installed') return;
// No controller means this is the first install, not an update.
if (!navigator.serviceWorker.controller) return;
offer(newWorker);
});
});
})