fix(sw): complete cache-clearing + add controllerchange auto-reload

All cache-clearing buttons and the IDAA clear-caches page previously
cleared IDB/localStorage but left service worker registrations and Cache
Storage intact. On the next reload the SW re-served the old JS bundle,
leaving users stuck on stale code despite appearing to reload. This
caused recurring stale-state reports from IDAA and other clients for
4+ months.

Two gaps closed:
1. Every clear path (root page buttons, sys bar, help tech, idaa/clear-caches)
   now unregisters SW registrations and clears Cache Storage before touching
   IDB and localStorage. Order: SW → Cache Storage → IDB → localStorage.
2. Added controllerchange listener in +layout.svelte effect 4. When the new
   SW activates and calls clients.claim(), this listener reloads the page so
   open tabs run the new JS bundle instead of keeping old code in memory
   indefinitely. Without this, skipWaiting + clients.claim work correctly on
   the SW side but the page side never picks up the update.

Also added thorough code comments and updated REFERENCE__Common_Agent_Mistakes
(#15) and BOOTSTRAP doc (#8) to document the full root cause so this cannot
be silently re-broken by a future agent or refactor.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Scott Idem
2026-06-22 12:17:51 -04:00
parent 468ed61b39
commit 81874ffa5d
7 changed files with 197 additions and 28 deletions

View File

@@ -459,11 +459,18 @@ class:to-90%={$ae_sess.show_help_tech} -->
onclick={async () => {
const edit_mode = $ae_loc.edit_mode;
const confirm_msg = edit_mode
? 'Clear all IDB caches, localStorage, and sessionStorage? Your sign-in will be preserved. This will reload the page.'
: 'Clear all IDB caches? This will reload the page.';
? 'Clear all IDB caches, service worker caches, localStorage, and sessionStorage? Your sign-in will be preserved. This will reload the page.'
: 'Clear all IDB caches and service worker caches? This will reload the page.';
if (!confirm(confirm_msg)) return;
if ('serviceWorker' in navigator) {
const registrations = await navigator.serviceWorker.getRegistrations();
for (const reg of registrations) await reg.unregister();
}
const cache_keys = await caches.keys();
for (const key of cache_keys) await caches.delete(key);
// Enumerate and delete every IDB database on this origin.
const db_list = await indexedDB.databases();
console.log('[clear_reload] IDB databases found:', db_list.map((d) => d.name));
@@ -501,12 +508,19 @@ class:to-90%={$ae_sess.show_help_tech} -->
onclick={async () => {
if (
!confirm(
'FULL RESET: Delete ALL IndexedDB databases, clear localStorage and sessionStorage, then reload? This cannot be undone.'
'FULL RESET: Unregister service workers, clear all caches, delete ALL IndexedDB databases, clear localStorage and sessionStorage, then reload? This cannot be undone.'
)
) {
return;
}
if ('serviceWorker' in navigator) {
const registrations = await navigator.serviceWorker.getRegistrations();
for (const reg of registrations) await reg.unregister();
}
const cache_keys = await caches.keys();
for (const key of cache_keys) await caches.delete(key);
// Enumerate every IDB database on this origin and delete them all.
const db_list = await indexedDB.databases();
console.log('[clear_all] IDB databases found:', db_list.map((d) => d.name));

View File

@@ -99,14 +99,25 @@ async function handle_clear_idb_only() {
window.location.reload();
}
// ── Dev: clear all browser storage + all IndexedDB databases, then reload ──
// ── Dev: full reset — SW + Cache Storage + IDB + localStorage/sessionStorage ──
// SW and Cache Storage MUST be cleared here. Clearing IDB/localStorage alone leaves
// the SW serving old JS bundles from its own Cache Storage on the next reload,
// which means the user stays stuck on old code. Order: SW → cache → IDB → storage.
async function handle_clear_storage_and_idb() {
if (
!confirm(
'FULL RESET: Delete ALL IndexedDB databases, clear localStorage and sessionStorage, then reload? This cannot be undone.'
'FULL RESET: Unregister service workers, clear all caches, delete IndexedDB databases, clear localStorage and sessionStorage, then reload? This cannot be undone.'
)
)
return;
if ('serviceWorker' in navigator) {
const registrations = await navigator.serviceWorker.getRegistrations();
for (const reg of registrations) await reg.unregister();
}
const cache_keys = await caches.keys();
for (const key of cache_keys) await caches.delete(key);
const db_list = await indexedDB.databases();
console.log('[clear_all] IDB databases found:', db_list.map((d) => d.name));
for (const db of db_list) {

View File

@@ -274,6 +274,27 @@ function clear_sess() {
sessionStorage.clear();
}
// CRITICAL — all three storage layers must be cleared together or the fix is incomplete.
//
// The service worker (SW) maintains its own Cache Storage that is entirely separate from
// IndexedDB, localStorage, and sessionStorage. The SW uses Cache Storage to serve the
// app's JS/CSS bundles and HTML shell directly from disk — bypassing the network entirely
// for those assets. This means:
//
// Clearing IDB + localStorage ONLY:
// → SW is still registered and still serving old JS bundles from its cache.
// → Next reload: SW intercepts, returns OLD cached JS → user is still stuck.
//
// Clearing SW registrations ONLY:
// → Cache Storage still contains old entries. A newly registered SW may serve them
// before it rebuilds the cache from the network, causing a transient stale state.
//
// Clearing BOTH SW registrations AND Cache Storage, THEN IDB/localStorage:
// → Next reload: no SW intercepts → fresh HTML from server → fresh JS bundle
// filenames → fresh JS loaded → user is on current code. ✅
//
// This function is called by the version-mismatch detection path (flag_hard_reload).
// All manual cache-clearing buttons in the app must follow the same order.
async function clear_stale_service_worker_state() {
if (!browser || sw_heal_in_flight) return;
sw_heal_in_flight = true;
@@ -307,6 +328,27 @@ async function clear_stale_service_worker_state() {
$effect(() => {
if (!browser) return;
// SW update → page reload bridge. DO NOT REMOVE.
//
// How SW updates propagate (all three steps are required):
// 1. service-worker.js calls skipWaiting() on install → new SW activates immediately
// instead of waiting for all existing tabs to close.
// 2. service-worker.js calls clients.claim() on activate → new SW takes control of
// all currently open tabs without requiring a navigation.
// 3. THIS LISTENER: when the new SW takes control, `controllerchange` fires on every
// open tab. We reload so the tab runs the new JS bundle rather than keeping the
// old JS that was already loaded in memory.
//
// Without step 3, users can be stuck on old code indefinitely after a deployment —
// even though the SW correctly updated under them. The tab shows no error; it just
// silently runs whatever JS it had at the time the SW changed. This caused IDAA and
// other users to run stale code for weeks at a time across multiple deployments.
// Added 2026-06-22 to close this gap.
const on_controller_change = () => window.location.reload();
if ('serviceWorker' in navigator) {
navigator.serviceWorker.addEventListener('controllerchange', on_controller_change);
}
// Initialise PWA install prompt listener (captures beforeinstallprompt early).
pwa_install.init();
@@ -390,7 +432,12 @@ $effect(() => {
if (!$ae_loc.is_native) $ae_loc.is_native = true;
}
return () => window.removeEventListener('message', handler);
return () => {
window.removeEventListener('message', handler);
if ('serviceWorker' in navigator) {
navigator.serviceWorker.removeEventListener('controllerchange', on_controller_change);
}
};
});
// 5. SESSION EXPIRED EFFECT — watches ae_auth_error and raises the banner when the API signals 401/403.

View File

@@ -91,11 +91,18 @@ onMount(() => {
onclick={async () => {
const edit_mode = $ae_loc.edit_mode;
const confirm_msg = edit_mode
? 'Clear all IDB caches, localStorage, and sessionStorage? Your sign-in will be preserved. This will reload the page.'
: 'Clear all IDB caches? This will reload the page.';
? 'Clear all IDB caches, service worker caches, localStorage, and sessionStorage? Your sign-in will be preserved. This will reload the page.'
: 'Clear all IDB caches and service worker caches? This will reload the page.';
if (!confirm(confirm_msg)) return;
if ('serviceWorker' in navigator) {
const registrations = await navigator.serviceWorker.getRegistrations();
for (const reg of registrations) await reg.unregister();
}
const cache_keys = await caches.keys();
for (const key of cache_keys) await caches.delete(key);
const db_list = await indexedDB.databases();
for (const db of db_list) {
if (db.name) indexedDB.deleteDatabase(db.name);
@@ -111,7 +118,7 @@ onMount(() => {
window.location.reload();
}}
class="btn btn-sm preset-tonal-surface hover:preset-outlined-warning text-error-300 hover:text-error-800 m-1 transition-all"
title="Clear & Reload: Delete all IDB caches and reload. In edit mode also clears localStorage/sessionStorage, preserving your sign-in.">
title="Clear & Reload: Delete all IDB caches, unregister service workers, and reload. In edit mode also clears localStorage/sessionStorage, preserving your sign-in.">
<!-- <span class="fas fa-sync mx-1"></span> -->
<RefreshCw class="mx-1" />
Reload
@@ -121,12 +128,21 @@ onMount(() => {
onclick={async () => {
if (
!confirm(
'FULL RESET: Delete ALL IndexedDB databases, clear localStorage and sessionStorage, then reload? This cannot be undone.'
'FULL RESET: Unregister service workers, clear all caches, delete IndexedDB databases, clear localStorage and sessionStorage, then reload? This cannot be undone.'
)
) {
return;
}
// Unregister service workers and clear Cache Storage so stale
// SW-cached assets don't survive the reset and re-serve old bundles.
if ('serviceWorker' in navigator) {
const registrations = await navigator.serviceWorker.getRegistrations();
for (const reg of registrations) await reg.unregister();
}
const cache_keys = await caches.keys();
for (const key of cache_keys) await caches.delete(key);
const db_list = await indexedDB.databases();
console.log('[clear_all] IDB databases found:', db_list.map((d) => d.name));
for (const db of db_list) {
@@ -139,7 +155,7 @@ onMount(() => {
window.location.reload();
}}
class="btn btn-sm preset-tonal-surface hover:preset-outlined-warning text-error-300 hover:text-error-800 m-1 p-1 transition-all"
title="Full Reset: Delete ALL IndexedDB databases, clear localStorage and sessionStorage for this origin, then reload.">
title="Full Reset: Unregister service workers, clear all caches, delete IndexedDB databases, clear localStorage and sessionStorage for this origin, then reload.">
<!-- <span class="fas fa-sync mx-1"></span> -->
<RefreshCcwDot class="mx-1" />
Clear Storage and Reload

View File

@@ -11,6 +11,8 @@ interface ClearStep {
}
let steps: ClearStep[] = $state([
{ label: 'Service workers', status: 'pending', detail: '' },
{ label: 'Service worker caches', status: 'pending', detail: '' },
{ label: 'IndexedDB databases', status: 'pending', detail: '' },
{ label: 'Local storage', status: 'pending', detail: '' },
{ label: 'Session storage', status: 'pending', detail: '' }
@@ -20,45 +22,79 @@ let overall_done = $state(false);
let had_error = $state(false);
async function clear_all_caches() {
// IDB — enumerate and delete every database on this origin
// Service workers
steps[0].status = 'running';
try {
const db_list = await indexedDB.databases();
for (const db of db_list) {
if (db.name) indexedDB.deleteDatabase(db.name);
if ('serviceWorker' in navigator) {
const registrations = await navigator.serviceWorker.getRegistrations();
for (const reg of registrations) await reg.unregister();
steps[0].detail = `${registrations.length} unregistered`;
} else {
steps[0].detail = 'Not supported';
}
steps[0].status = 'done';
steps[0].detail = `${db_list.length} database${db_list.length !== 1 ? 's' : ''} cleared`;
} catch (e) {
steps[0].status = 'error';
steps[0].detail = String(e);
had_error = true;
}
// localStorage
// Cache Storage (SW asset caches)
steps[1].status = 'running';
try {
localStorage.clear();
if ('caches' in window) {
const cache_keys = await caches.keys();
for (const key of cache_keys) await caches.delete(key);
steps[1].detail = `${cache_keys.length} cache${cache_keys.length !== 1 ? 's' : ''} cleared`;
} else {
steps[1].detail = 'Not supported';
}
steps[1].status = 'done';
steps[1].detail = 'Cleared';
} catch (e) {
steps[1].status = 'error';
steps[1].detail = String(e);
had_error = true;
}
// sessionStorage
// IDB — enumerate and delete every database on this origin
steps[2].status = 'running';
try {
sessionStorage.clear();
const db_list = await indexedDB.databases();
for (const db of db_list) {
if (db.name) indexedDB.deleteDatabase(db.name);
}
steps[2].status = 'done';
steps[2].detail = 'Cleared';
steps[2].detail = `${db_list.length} database${db_list.length !== 1 ? 's' : ''} cleared`;
} catch (e) {
steps[2].status = 'error';
steps[2].detail = String(e);
had_error = true;
}
// localStorage
steps[3].status = 'running';
try {
localStorage.clear();
steps[3].status = 'done';
steps[3].detail = 'Cleared';
} catch (e) {
steps[3].status = 'error';
steps[3].detail = String(e);
had_error = true;
}
// sessionStorage
steps[4].status = 'running';
try {
sessionStorage.clear();
steps[4].status = 'done';
steps[4].detail = 'Cleared';
} catch (e) {
steps[4].status = 'error';
steps[4].detail = String(e);
had_error = true;
}
overall_done = true;
// Notify parent window (Novi page) that the clear is complete.