diff --git a/src/components/__tests__/version-poller.test.ts b/src/components/__tests__/version-poller.test.ts index fb3a6b938..fd77fae98 100644 --- a/src/components/__tests__/version-poller.test.ts +++ b/src/components/__tests__/version-poller.test.ts @@ -52,3 +52,95 @@ describe("resolveVersionPollDecision", () => { ).toBe("reload"); }); }); + +describe("evictAndReload", () => { + /** + * The Reload action used to unregister every service worker. Unregistering + * a registration ends its Web Push subscription, and nothing re-creates it, + * so each update silently switched push reminders off in the installed app. + * The action now updates the registration and deletes only HealthLog's + * caches. + */ + function stubBrowser(cacheNames: string[]) { + const calls = { update: 0, unregister: 0, reload: 0 }; + const deleted: string[] = []; + const registration = { + update: async () => { + calls.update += 1; + }, + unregister: async () => { + calls.unregister += 1; + return true; + }, + }; + const g = globalThis as Record; + const saved = { + navigator: Object.getOwnPropertyDescriptor(globalThis, "navigator"), + caches: g.caches, + window: g.window, + sessionStorage: g.sessionStorage, + }; + Object.defineProperty(globalThis, "navigator", { + configurable: true, + value: { + serviceWorker: { getRegistrations: async () => [registration] }, + }, + }); + g.caches = { + keys: async () => cacheNames, + delete: async (name: string) => { + deleted.push(name); + return true; + }, + }; + g.window = { + location: { + reload: () => { + calls.reload += 1; + }, + }, + }; + g.sessionStorage = { setItem: () => {}, getItem: () => null }; + const restore = () => { + if (saved.navigator) + Object.defineProperty(globalThis, "navigator", saved.navigator); + g.caches = saved.caches; + g.window = saved.window; + g.sessionStorage = saved.sessionStorage; + }; + return { calls, deleted, restore }; + } + + it("updates the worker instead of unregistering it, so the push subscription survives", async () => { + const { evictAndReload } = await import("../version-poller"); + const browser = stubBrowser([]); + try { + await evictAndReload("9.9.9"); + } finally { + browser.restore(); + } + expect(browser.calls.unregister).toBe(0); + expect(browser.calls.update).toBe(1); + expect(browser.calls.reload).toBe(1); + }); + + it("deletes HealthLog's caches and leaves another app's on the same origin", async () => { + const { evictAndReload } = await import("../version-poller"); + const browser = stubBrowser([ + "healthlog-static-v1.41.2", + "healthlog-pages-v1.41.2", + "healthlog-data-v1.41.2", + "other-app-runtime", + ]); + try { + await evictAndReload("9.9.9"); + } finally { + browser.restore(); + } + expect(browser.deleted.sort()).toEqual([ + "healthlog-data-v1.41.2", + "healthlog-pages-v1.41.2", + "healthlog-static-v1.41.2", + ]); + }); +}); diff --git a/src/components/version-poller.tsx b/src/components/version-poller.tsx index 050784105..00391c215 100644 --- a/src/components/version-poller.tsx +++ b/src/components/version-poller.tsx @@ -5,6 +5,7 @@ import { toast } from "sonner"; import { apiGet } from "@/lib/api/api-fetch"; import { useTranslations } from "@/lib/i18n/context"; +import { HEALTHLOG_CACHE_NAME_RE } from "@/lib/pwa/sw-cache-policy"; /** * v1.4.38.4 — runtime self-heal for the stale-shell post-deploy @@ -27,14 +28,24 @@ import { useTranslations } from "@/lib/i18n/context"; * Reload — an in-progress form or chat draft is never destroyed by a * silent mid-session reload. On that click: * - * 1. Unregister every active service worker so the next page load - * doesn't reinstall the old SW. - * 2. Delete every CacheStorage entry so the precached root HTML - * and the `/_next/static/*` chunks from the previous deploy - * can't be served back. + * 1. Ask the service worker registration to update, so the new + * deploy's worker (its `sw-version.js` changed) installs and takes + * over; it activates at once (`skipWaiting` + `clients.claim`) and + * drops the previous release's caches itself. + * 2. Delete HealthLog's CacheStorage entries so the `/_next/static/*` + * chunks and page shells from the previous deploy can't be served + * back while that happens. * 3. `window.location.reload()` — fetches the fresh shell + new * chunk graph. * + * v1.42 — the worker is updated, never unregistered. Unregistering a + * registration ends its Web Push subscription, and nothing re-creates one: + * every click on Reload silently switched off push reminders in the + * installed app (the server kept the dead endpoint, the settings card + * showed push as off). An update keeps the registration and with it the + * subscription. The page navigation is network-first in the worker, so the + * reload reaches the new shell whichever worker answers it. + * * The toast is shown once per detected version (the poll keeps * running, but a repeated mismatch against a version we already * surfaced doesn't re-spam every 60 s). The reload guard is keyed on @@ -90,7 +101,7 @@ async function fetchLiveVersion(signal: AbortSignal): Promise { // to break the loop within one document lifetime. let inMemoryReloadGuard: string | null = null; -async function evictAndReload(targetVersion: string): Promise { +export async function evictAndReload(targetVersion: string): Promise { inMemoryReloadGuard = targetVersion; try { sessionStorage.setItem(SESSION_GUARD_KEY, targetVersion); @@ -102,16 +113,20 @@ async function evictAndReload(targetVersion: string): Promise { if ("serviceWorker" in navigator) { try { const regs = await navigator.serviceWorker.getRegistrations(); - await Promise.all(regs.map((r) => r.unregister())); + await Promise.all(regs.map((r) => r.update())); } catch { - /* best effort */ + /* best effort — the reload below still fetches the new shell */ } } if (typeof caches !== "undefined") { try { const keys = await caches.keys(); - await Promise.all(keys.map((k) => caches.delete(k))); + await Promise.all( + keys + .filter((k) => HEALTHLOG_CACHE_NAME_RE.test(k)) + .map((k) => caches.delete(k)), + ); } catch { /* best effort */ } diff --git a/src/lib/pwa/query-persister.ts b/src/lib/pwa/query-persister.ts index 6c996f5b3..27a5fb67e 100644 --- a/src/lib/pwa/query-persister.ts +++ b/src/lib/pwa/query-persister.ts @@ -35,16 +35,16 @@ import { import { trackBackgroundTask } from "@/lib/logging/background-tasks"; import { queryKeys } from "@/lib/query-keys"; import { isReadingSharedRecord } from "@/lib/query-keys/record-scope"; +// Session-end cleanup removes every current/legacy HealthLog cache family +// while leaving unrelated CacheStorage namespaces on the same origin +// untouched; the name contract is shared with `public/sw.js`. +import { HEALTHLOG_CACHE_NAME_RE } from "@/lib/pwa/sw-cache-policy"; const DB_NAME = "healthlog-query-cache"; const STORE = "kv"; const KEY = "react-query"; const VERSION_KEY = "react-query-version"; const MAX_AGE_MS = 24 * 60 * 60 * 1000; // discard anything older than a day -// Keep this ownership contract aligned with `public/sw.js`. Session-end -// cleanup removes every current/legacy HealthLog cache family while leaving -// unrelated CacheStorage namespaces on the same origin untouched. -const HEALTHLOG_CACHE_NAME_RE = /^healthlog-(?:static|pages|data)-/; /** * Upper bound on the serialized snapshot. The allowlist already caps WHAT is diff --git a/src/lib/pwa/sw-cache-policy.ts b/src/lib/pwa/sw-cache-policy.ts index f248535c3..d8d2a805d 100644 --- a/src/lib/pwa/sw-cache-policy.ts +++ b/src/lib/pwa/sw-cache-policy.ts @@ -14,6 +14,15 @@ * allowlisted by mistake — mirroring the idempotency cache's refusal. */ +/** + * The worker's own cache names: `healthlog-{static,pages,data}-`. + * Everything the app deletes from CacheStorage (session end, the "new + * version" reload) matches against this and nothing else, so another app's + * caches on the same origin are left alone. Keep in lockstep with + * `HEALTHLOG_CACHE_NAME_RE` in `public/sw.js`. + */ +export const HEALTHLOG_CACHE_NAME_RE = /^healthlog-(?:static|pages|data)-/; + /** * Path prefixes whose safe GET reads are eligible for the offline data cache. *