fix(sw): postMessage all open tabs on activate to break stale-JS loop

After clients.claim(), the new SW sends SW_ACTIVATE_RELOAD to every open
window tab. +layout.svelte now listens for this alongside the existing
controllerchange listener. The had_controller guard prevents a spurious
reload on fresh page loads where no SW was controlling the tab.

WHY: controllerchange only fires in JS that already has the listener
(added 2026-06-22). Tabs stuck on builds from before that date (e.g.
October 2025 users) never registered it, so the SW update under them
was silent. The postMessage path reaches those tabs directly. Users who
are already on new JS get the reload from controllerchange; users on old
JS get it from the message event once they receive this new SW.

Note: October 2025 users still need a manual Full Reset on their first
visit after this deploy (old JS has no message listener). After that one
reset they are permanently self-healing.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Scott Idem
2026-06-24 12:21:21 -04:00
parent 37065b5a7c
commit dda7a91f9f
2 changed files with 35 additions and 7 deletions

View File

@@ -356,6 +356,21 @@ $effect(() => {
navigator.serviceWorker.addEventListener('controllerchange', on_controller_change);
}
// SW activate → reload bridge (parallel path to controllerchange above).
// WHY: controllerchange only fires in JS that has the listener registered.
// Tabs stuck on very old JS (pre-June-2026 builds) never set up that listener,
// so the new SW's postMessage from its activate event is the only way to reach
// them. The same had_controller guard prevents a spurious reload on fresh page
// loads where no SW was previously controlling the tab.
const on_sw_message = (event: MessageEvent) => {
if (event.data?.type === 'SW_ACTIVATE_RELOAD' && had_controller) {
window.location.reload();
}
};
if ('serviceWorker' in navigator) {
navigator.serviceWorker.addEventListener('message', on_sw_message);
}
// Initialise PWA install prompt listener (captures beforeinstallprompt early).
pwa_install.init();
@@ -443,6 +458,7 @@ $effect(() => {
window.removeEventListener('message', handler);
if ('serviceWorker' in navigator) {
navigator.serviceWorker.removeEventListener('controllerchange', on_controller_change);
navigator.serviceWorker.removeEventListener('message', on_sw_message);
}
};
});

View File

@@ -33,18 +33,30 @@ self.addEventListener('install', (event) => {
});
self.addEventListener('activate', (event) => {
// Remove previous cached data from other screens
async function deleteOldCaches() {
async function activate() {
// Remove previous cached data from other deployments.
for (const key of await caches.keys()) {
if (key !== CACHE) await caches.delete(key);
}
}
event.waitUntil(deleteOldCaches());
// Take control of all open tabs immediately after activation so they use
// the new service worker (and new cached assets) without needing a reload.
self.clients.claim();
await self.clients.claim();
// Tell every open window tab to reload so it runs the new JS bundle.
// WHY: clients.claim() makes this SW the active controller but does not
// swap the JS already executing in those tabs. The `controllerchange`
// listener in +layout.svelte handles tabs already running new JS, but tabs
// stuck on old JS (e.g. from a build months ago, before that listener was
// added) never registered it. This postMessage reaches those tabs directly.
// Tabs that ignore it are still on old code and need a manual Full Reset.
const client_list = await self.clients.matchAll({ type: 'window' });
for (const client of client_list) {
client.postMessage({ type: 'SW_ACTIVATE_RELOAD' });
}
}
event.waitUntil(activate());
});
self.addEventListener('fetch', (event) => {