diff --git a/documentation/BOOTSTRAP__AI_Agent_Quickstart.md b/documentation/BOOTSTRAP__AI_Agent_Quickstart.md
index ff86ddb5..f4ab7027 100644
--- a/documentation/BOOTSTRAP__AI_Agent_Quickstart.md
+++ b/documentation/BOOTSTRAP__AI_Agent_Quickstart.md
@@ -299,7 +299,11 @@ Read this section first, then open the reference doc when your task touches one
5. **Sorting and search correctness** — `tmp_sort_*` comparator direction and Dexie sorting caveats.
6. **Network reliability** — retry classification in `api_*_object.ts` and timeout behavior.
7. **JSON field safety** — `*_json` null reads/writes and wrapper serialization behavior.
-8. **Service worker rollout behavior** — stale-tab symptoms, activation expectations, and trade-offs.
+8. **Service worker + cache clearing — MANDATORY four-layer wipe** — clearing IDB/localStorage
+ alone is a placebo. The SW Cache Storage is separate and must be cleared first or the SW
+ re-serves the old JS bundle on the very next reload. The `controllerchange` event listener in
+ `+layout.svelte` (effect 4) is also required or open tabs stay on old code after deploys.
+ See mistake #15 in the reference doc — this caused a recurring client issue for 4+ months.
9. **Local/remote config sync** — shadow fields that silently bypass an admin-synced master
field, SWR-await-after-write races, and stateful/conditional sync gates that desync local
state from history rather than current config. See the Pres Mgmt Config sync overhaul
diff --git a/documentation/REFERENCE__Common_Agent_Mistakes.md b/documentation/REFERENCE__Common_Agent_Mistakes.md
index 3c794b38..258c5c0f 100644
--- a/documentation/REFERENCE__Common_Agent_Mistakes.md
+++ b/documentation/REFERENCE__Common_Agent_Mistakes.md
@@ -141,14 +141,55 @@ Review policy: balanced curation.
**Verify:** Audit reads/writes for `cfg_json`, `data_json`, `poc_kv_json`, and similar nullable blob fields.
-### 15) Service worker stale-tab behavior misunderstood
-**Impact:** users run old code longer than expected, “can’t reproduce” bug reports.
+### 15) Incomplete cache clearing — SW and Cache Storage omitted
-**What happened:** deployment assumptions ignored SW activation lifecycle.
+**Impact:** Sev-1 class. Users stuck on old JS for weeks after deployments. “Fixed” bugs
+reappear because the affected user never loaded the fix. Nearly cost the IDAA client after
+4+ months of recurring reports that were repeatedly declared resolved.
-**Rule:** Keep SW activation behavior explicit (`skipWaiting`, `clients.claim`) and evaluate trade-offs for session-heavy flows.
+**What happened (two separate gaps, both required to fix):**
-**Verify:** After deploy, validate that long-lived tabs pick up new SW behavior as intended.
+**Gap A — Clear buttons did not clear the SW or Cache Storage.**
+The app has a service worker that caches every JS/CSS/HTML asset in its own Cache Storage
+(separate from IndexedDB and localStorage). All “clear caches” buttons only cleared IDB,
+localStorage, and sessionStorage — leaving the SW and its Cache Storage intact. On the next
+reload, the SW intercepted the request and returned the OLD cached JS bundle. The user appeared
+to reload but was still running old code. Clicking “Clear Storage and Reload” appeared to work
+but did nothing to break the stuck state.
+
+**Gap B — No `controllerchange` listener in the layout.**
+`service-worker.js` correctly calls `skipWaiting()` on install (new SW activates immediately)
+and `clients.claim()` on activate (new SW takes control of all open tabs). But the page-side
+half of this contract was missing: there was no `controllerchange` event listener to trigger
+a reload when the new SW took over. Result: every deployment correctly installed and activated
+a new SW, but any tab that was already open kept running the old JS bundle from memory —
+indefinitely, until the user happened to close and reopen the browser.
+
+**Who was affected:** Windows users who leave Chrome/Edge running for days or weeks with the
+tab pinned or restored via “continue where you left off.” Most users were never affected
+because normal browser habits (restart, close/reopen tab) naturally clear the stuck state.
+The pattern looked random but was actually a long-session-duration problem.
+
+**The fix (2026-06-22):**
+1. Every “clear” button and automated heal path now clears SW registrations + Cache Storage
+ BEFORE clearing IDB/localStorage. Order matters: SW → Cache Storage → IDB → storage.
+2. `controllerchange` listener added to `+layout.svelte` effect 4 — reloads the page when
+ a new SW takes control, so open tabs automatically get fresh JS after any deployment.
+3. `idaa/clear-caches/+page.svelte` (IDAA iframe tool) updated with the same fix.
+
+**Rule:** Any code that clears caches MUST clear all four layers in this order:
+1. `navigator.serviceWorker.getRegistrations()` → unregister each
+2. `caches.keys()` → delete each key
+3. `indexedDB.databases()` → delete each database
+4. `localStorage.clear()` + `sessionStorage.clear()`
+
+Clearing IDB/localStorage WITHOUT clearing SW and Cache Storage is not a fix — it is a
+placebo that makes the user think something changed while the old JS bundle is served again
+on the very next reload.
+
+**Verify:** After any future changes to cache-clearing paths, confirm all four layers are
+cleared. The `controllerchange` listener in `+layout.svelte` effect 4 must not be removed.
+`/testing/fix-sw` is the emergency escape route for users who are stuck on pre-fix code.
### 16) Local "shadow field" silently bypasses the admin-synced master field
**Impact:** an admin config toggle appears to do nothing — the UI updates fine when a local
diff --git a/src/lib/app_components/e_app_help_tech.svelte b/src/lib/app_components/e_app_help_tech.svelte
index 479da6d6..e147cc20 100644
--- a/src/lib/app_components/e_app_help_tech.svelte
+++ b/src/lib/app_components/e_app_help_tech.svelte
@@ -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));
diff --git a/src/lib/app_components/e_app_sys_bar.svelte b/src/lib/app_components/e_app_sys_bar.svelte
index 4157a501..f6149850 100644
--- a/src/lib/app_components/e_app_sys_bar.svelte
+++ b/src/lib/app_components/e_app_sys_bar.svelte
@@ -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) {
diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte
index b0249f26..819e82be 100644
--- a/src/routes/+layout.svelte
+++ b/src/routes/+layout.svelte
@@ -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.
diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte
index 3f06619a..9c400c34 100644
--- a/src/routes/+page.svelte
+++ b/src/routes/+page.svelte
@@ -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.">
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.">
Clear Storage and Reload
diff --git a/src/routes/idaa/clear-caches/+page.svelte b/src/routes/idaa/clear-caches/+page.svelte
index 05b21b96..592aa4cc 100644
--- a/src/routes/idaa/clear-caches/+page.svelte
+++ b/src/routes/idaa/clear-caches/+page.svelte
@@ -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.