From 09f3280c1c05945a2d232a310097c0e83ccedbad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 11 Nov 2026 20:40:32 +0100 Subject: [PATCH] fix(pwa): keep signed-in onboarding pages out of the page cache The proxy treats /onboarding/* as public, so those pages are not sent no-store, and the worker stored the documents a signed-in account saw during setup, against its own rule that only public pages land in the page cache. The worker now skips them by path, like the share view. --- public/sw.js | 31 +++++++++++----------------- src/__tests__/service-worker.test.ts | 16 ++++++++++++++ 2 files changed, 28 insertions(+), 19 deletions(-) diff --git a/public/sw.js b/public/sw.js index 315922846..0a4ece131 100644 --- a/public/sw.js +++ b/public/sw.js @@ -195,25 +195,13 @@ async function cacheFirst(request, cacheName) { } /** - * Privacy gate for the navigation page cache. Most app routes are - * client-fetch-only shells with no server-rendered PII (health JSON loads - * over `/api/*`, which is network-only here), so caching their HTML is - * safe. Two carve-outs: - * - * 1. A `Cache-Control: no-store` response opts itself out — the - * principled, future-proof rule for any current/future server RSC - * that renders user data and sets the header. - * 2. `/c/*` (the clinician-share view) renders health values + wellness - * scores straight into the HTML and emits `no-store`; the explicit - * path skip is belt-and-braces so a revoked share can never linger in - * CacheStorage and render back offline. - * - * In practice rule 1 covers every signed-in page: `src/proxy.ts` pins - * `private, no-store` on all of them, because several server-prefetch one - * account's record into the HTML. Only the public pages (sign-in, legal) - * land in this cache, so a navigation to an app page without a connection - * gets the offline page below, never somebody's cached record. A page that - * is already open keeps what it has in memory. + * Which navigation responses may land in the page cache. Signed-in pages + * never do: the proxy sends them `private, no-store` because some carry one + * account's record in the HTML. Two paths are skipped by name as well: the + * clinician share view `/c/*` (no-store too; a revoked share must never + * render back offline) and `/onboarding/*`. What is left are the public + * pages (sign-in, legal), so a navigation to an app page without a + * connection gets the offline page, never somebody's cached record. */ function isCacheableNavigation(request, response) { const cacheControl = response.headers.get("Cache-Control") || ""; @@ -221,6 +209,11 @@ function isCacheableNavigation(request, response) { try { const { pathname } = new URL(request.url); if (pathname === "/c" || pathname.startsWith("/c/")) return false; + // Public to the proxy, so not no-store, but rendered for a signed-in + // account during its setup. + if (pathname === "/onboarding" || pathname.startsWith("/onboarding/")) { + return false; + } } catch { // Unparseable URL — fall through; the no-store check already ran. } diff --git a/src/__tests__/service-worker.test.ts b/src/__tests__/service-worker.test.ts index 76f6099d5..5477607dd 100644 --- a/src/__tests__/service-worker.test.ts +++ b/src/__tests__/service-worker.test.ts @@ -622,6 +622,22 @@ describe("sw.js — networkFirst privacy gate", () => { expect(await pages.match(`${ORIGIN}/c/hls_abc123`)).toBeUndefined(); }); + it.each(["/onboarding", "/onboarding/who"])( + "does not cache the signed-in onboarding page %s", + async (path) => { + const harness = bootServiceWorker(); + harness.context.fetch = async () => healthlogPage("onboarding step"); + + const res = await dispatchNavigationFetch(harness, path); + expect(await res.clone().text()).toBe("onboarding step"); + await Promise.all(harness.lifetimePromises); + + expect( + await harness.cacheStorage.match(`${ORIGIN}${path}`), + ).toBeUndefined(); + }, + ); + it("still caches an ordinary navigation response", async () => { const harness = bootServiceWorker(); harness.context.fetch = async () => healthlogPage("app shell");