From 3d9ee5f44dea9edb1ef5af28f5f265d88d8b9f29 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 18 Jul 2026 07:04:56 +0000 Subject: [PATCH 1/3] feat(pwa): commit retirement kill-switch, offline-version binding guard, and dev teardown flag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Close the three open findings from the 2026-07-17 PWA setup review with zero cache-semantics change (CACHE_VERSION untouched): - public/sw-kill-switch.js: the retirement worker docs/pwa.md rule 6 requires — skipWaiting on install, deletes all clinical-kb-pwa-* caches including unknown future versions, unregisters, no fetch listener. Locked by tests/pwa-kill-switch.test.ts (prefix parity with the live worker, foreign-cache preservation, caches-unavailable resilience). - tests/pwa-manifest.test.ts: bind the offline.html sha256 to the sw.js CACHE_VERSION pairing so an offline-document edit cannot ship without a fresh, never-reused version bump. - pwa-lifecycle: ?pwa-dev=0 on local hosts unregisters the dev-session worker and deletes owned caches; production and non-local hosts ignore the flag. Dom test proves foreign workers and caches stay untouched. - docs/pwa.md: rules 1 and 6 now reference the committed guards; the local-dev cleanup step documents the opt-out flag. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01EXsJcLrbZUXwnBeG91cVo9 --- docs/pwa.md | 15 ++-- public/sw-kill-switch.js | 50 +++++++++++ src/components/pwa-lifecycle.tsx | 42 +++++++++- tests/pwa-kill-switch.test.ts | 137 +++++++++++++++++++++++++++++++ tests/pwa-lifecycle.dom.test.tsx | 39 +++++++++ tests/pwa-manifest.test.ts | 22 +++++ 6 files changed, 297 insertions(+), 8 deletions(-) create mode 100644 public/sw-kill-switch.js create mode 100644 tests/pwa-kill-switch.test.ts diff --git a/docs/pwa.md b/docs/pwa.md index 4e73e19b8..da56c7f74 100644 --- a/docs/pwa.md +++ b/docs/pwa.md @@ -173,7 +173,9 @@ clinical-kb-pwa-static- Operational rules: 1. Bump `CACHE_VERSION` whenever the offline document, precache contents, cache allowlist, strategy, or response - semantics change, and whenever a deployment must force eviction of previously cached assets. + semantics change, and whenever a deployment must force eviction of previously cached assets. A guard in + `tests/pwa-manifest.test.ts` binds the `offline.html` content hash to `CACHE_VERSION`, so an offline-document edit + cannot ship without a version bump. 2. Change the worker script in the same deployment. `/sw.js` is served with `no-cache, no-store, must-revalidate`, and registration uses `updateViaCache: "none"`, so the browser can detect the new script. 3. Activation deletes only owned obsolete caches. It retains the two newest prior static caches and the new worker @@ -184,7 +186,9 @@ Operational rules: 5. Do not reuse an old cache version after a rollback. A rollback may encounter clients and caches from the newer release; publish another unique version if cache semantics need to move backward. 6. Do not remove `/sw.js` as a way to retire the PWA. Installed workers can outlive that response. A future retirement - must ship an explicit worker that deletes the owned cache prefix and unregisters itself. + must ship an explicit worker that deletes the owned cache prefix and unregisters itself. The committed retirement + worker at `public/sw-kill-switch.js` implements exactly this: deploy its content as `/sw.js` in a single release + (never delete the route). Its behavior is locked by `tests/pwa-kill-switch.test.ts`. 7. CacheStorage is evictable and installation can fail under storage pressure. The application must remain a fully usable online website when that happens; it does not request persistent storage for these replaceable public assets. @@ -243,9 +247,10 @@ Normal development sessions do not register a worker. For focused testing: 3. In browser DevTools, wait for `/sw.js` to be activated and controlling the page. 4. Test navigation fallback and CacheStorage as described below. Local runtime caching of HMR and Next.js chunks is disabled by the worker. -5. When finished, use DevTools **Application -> Service Workers -> Unregister**, then clear the two - `clinical-kb-pwa-*` caches. The query flag prevents new registration; it does not automatically remove a worker - registered during an earlier opt-in session. +5. When finished, open the same URL with `/?pwa-dev=0`: on local hosts this unregisters the worker and deletes the + `clinical-kb-pwa-*` caches automatically. The manual path still works too — DevTools + **Application -> Service Workers -> Unregister**, then clear the two `clinical-kb-pwa-*` caches. Loading without any + flag only prevents new registration; it does not remove a worker registered during an earlier opt-in session. Use local/static/mocked checks by default: diff --git a/public/sw-kill-switch.js b/public/sw-kill-switch.js new file mode 100644 index 000000000..3339491c8 --- /dev/null +++ b/public/sw-kill-switch.js @@ -0,0 +1,50 @@ +/* Clinical KB PWA retirement worker (docs/pwa.md rule 6). + * + * This file is NOT registered during normal operation. To retire the PWA, ship + * this file's CONTENT as /sw.js (replace public/sw.js with it) in a single + * deployment. /sw.js is served no-store and registered with + * `updateViaCache: "none"`, so installed clients fetch the replacement on + * their next update check; it then deletes every owned cache, unregisters + * itself, and the site continues as a plain web application. Never remove the + * /sw.js route instead — installed workers can outlive a 404. + */ + +const CACHE_PREFIX = "clinical-kb-pwa-"; + +self.addEventListener("install", (event) => { + // Retirement is the documented exception to the no-auto-skipWaiting rule: + // the tear-down worker must replace the caching worker without waiting for + // every tab to close. + event.waitUntil(self.skipWaiting()); +}); + +self.addEventListener("activate", (event) => { + event.waitUntil( + (async () => { + let names = []; + try { + names = await caches.keys(); + } catch { + // CacheStorage may be unavailable; unregistration must still proceed. + } + await Promise.allSettled( + names.filter((name) => name.startsWith(CACHE_PREFIX)).map((name) => caches.delete(name)), + ); + try { + await self.registration.unregister(); + } catch { + // Even if unregistration fails, this worker has no fetch handler, so + // every request already goes straight to the network. + } + try { + await self.clients.claim(); + } catch { + // Best-effort: pages this worker never claims stop being controlled on + // their next navigation anyway. + } + })(), + ); +}); + +// Deliberately no fetch listener: retirement means the network handles every +// request and nothing is ever served from or written to CacheStorage. diff --git a/src/components/pwa-lifecycle.tsx b/src/components/pwa-lifecycle.tsx index 6782699f7..9e120b66e 100644 --- a/src/components/pwa-lifecycle.tsx +++ b/src/components/pwa-lifecycle.tsx @@ -6,6 +6,8 @@ const SERVICE_WORKER_URL = "/sw.js"; const INSTALL_DISMISSAL_KEY = "clinical-kb-pwa-install-dismissed-at"; const INSTALL_DISMISSAL_MS = 30 * 24 * 60 * 60 * 1000; const UPDATE_CHECK_INTERVAL_MS = 60 * 60 * 1000; +const PWA_CACHE_PREFIX = "clinical-kb-pwa-"; +const LOCAL_HOSTNAMES = new Set(["localhost", "127.0.0.1", "::1", "[::1]"]); type InstallChoice = { outcome: "accepted" | "dismissed"; platform: string }; @@ -62,6 +64,30 @@ function rememberInstallDismissal() { } } +async function teardownLocalPwa() { + try { + const registrations = await navigator.serviceWorker.getRegistrations(); + await Promise.allSettled( + registrations + .filter((registration) => + [registration.active, registration.waiting, registration.installing].some((worker) => + worker?.scriptURL.endsWith(SERVICE_WORKER_URL), + ), + ) + .map((registration) => registration.unregister()), + ); + if ("caches" in window) { + const cacheNames = await window.caches.keys(); + await Promise.allSettled( + cacheNames.filter((name) => name.startsWith(PWA_CACHE_PREFIX)).map((name) => window.caches.delete(name)), + ); + } + } catch { + // Teardown is a local-dev convenience; on failure the documented manual + // DevTools path in docs/pwa.md still applies. + } +} + const cardClassName = "pointer-events-auto rounded-xl border border-[color:var(--border-lux)] bg-[color:var(--surface-raised)] p-4 text-[color:var(--text)] shadow-[var(--shadow-elevated)]"; const primaryButtonClassName = @@ -72,7 +98,9 @@ const secondaryButtonClassName = /** * Owns installability, service-worker updates, and cross-route connectivity UI. * The worker is production-first; `?pwa-dev=1` enables a cache-safe localhost - * path for focused browser tests without persisting normal HMR assets. + * path for focused browser tests without persisting normal HMR assets, and + * `?pwa-dev=0` (local hosts only) unregisters that worker and deletes the + * owned caches again. */ export function PwaLifecycle() { const isOnline = useSyncExternalStore(subscribeConnectivity, getConnectivitySnapshot, getServerConnectivitySnapshot); @@ -144,10 +172,18 @@ export function PwaLifecycle() { }, []); useEffect(() => { - const developmentOptIn = new URLSearchParams(window.location.search).get("pwa-dev") === "1"; - if (process.env.NODE_ENV !== "production" && !developmentOptIn) return; if (!("serviceWorker" in navigator) || window.isSecureContext === false) return; + const pwaDevFlag = new URLSearchParams(window.location.search).get("pwa-dev"); + if (pwaDevFlag === "0" && LOCAL_HOSTNAMES.has(window.location.hostname)) { + // Explicit local opt-out: unregister the worker a previous `?pwa-dev=1` + // session installed and delete the owned caches. Non-local hosts ignore + // the flag entirely. + void teardownLocalPwa(); + return; + } + if (process.env.NODE_ENV !== "production" && pwaDevFlag !== "1") return; + let cancelled = false; let cancelScheduledRegistration: () => void = () => {}; const registrationCleanups = new Set<() => void>(); diff --git a/tests/pwa-kill-switch.test.ts b/tests/pwa-kill-switch.test.ts new file mode 100644 index 000000000..28428eb52 --- /dev/null +++ b/tests/pwa-kill-switch.test.ts @@ -0,0 +1,137 @@ +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { createContext, runInContext } from "node:vm"; + +import { describe, expect, it, vi } from "vitest"; + +const KILL_SWITCH_SOURCE = readFileSync(resolve(process.cwd(), "public/sw-kill-switch.js"), "utf8"); +const WORKER_SOURCE = readFileSync(resolve(process.cwd(), "public/sw.js"), "utf8"); + +class ExtendableEventHarness { + private readonly lifetimePromises: Promise[] = []; + + waitUntil(promise: Promise): void { + this.lifetimePromises.push(Promise.resolve(promise)); + } + + async settle(): Promise { + let settledCount = 0; + while (settledCount < this.lifetimePromises.length) { + const pending = this.lifetimePromises.slice(settledCount); + settledCount = this.lifetimePromises.length; + await Promise.all(pending); + } + } +} + +function createKillSwitchHarness(seededCacheNames: string[], { keysUnavailable = false } = {}) { + const listeners = new Map void>>(); + const names = new Set(seededCacheNames); + const deletedNames: string[] = []; + const openedNames: string[] = []; + const unregister = vi.fn(async () => true); + const claimClients = vi.fn(async () => undefined); + const skipWaiting = vi.fn(async () => undefined); + + const cacheStorage = { + async delete(name: string): Promise { + deletedNames.push(name); + return names.delete(name); + }, + async keys(): Promise { + if (keysUnavailable) throw new Error("CacheStorage unavailable"); + return [...names]; + }, + async open(name: string): Promise { + openedNames.push(name); + throw new Error("The retirement worker must never open or write caches."); + }, + }; + + const workerGlobal = { + addEventListener(type: string, listener: (event: unknown) => void) { + const registered = listeners.get(type) ?? []; + registered.push(listener); + listeners.set(type, registered); + }, + clients: { claim: claimClients }, + registration: { unregister }, + skipWaiting, + }; + + runInContext(KILL_SWITCH_SOURCE, createContext({ caches: cacheStorage, console, self: workerGlobal }), { + filename: "public/sw-kill-switch.js", + }); + + return { + claimClients, + deletedNames, + listeners, + openedNames, + skipWaiting, + unregister, + remainingCacheNames: () => [...names], + async dispatch(type: string): Promise { + const event = new ExtendableEventHarness(); + for (const listener of listeners.get(type) ?? []) listener(event); + await event.settle(); + }, + }; +} + +describe("PWA retirement kill-switch worker", () => { + it("targets exactly the cache prefix owned by the production worker", () => { + const killSwitchPrefix = KILL_SWITCH_SOURCE.match(/const CACHE_PREFIX = "([^"]+)";/)?.[1]; + const workerPrefix = WORKER_SOURCE.match(/const CACHE_PREFIX = "([^"]+)";/)?.[1]; + + expect(killSwitchPrefix).toBeTruthy(); + expect(killSwitchPrefix).toBe(workerPrefix); + }); + + it("activates immediately as the documented retirement exception to no-auto-skipWaiting", async () => { + const harness = createKillSwitchHarness([]); + + await harness.dispatch("install"); + + expect(harness.skipWaiting).toHaveBeenCalledTimes(1); + }); + + it("deletes every owned cache generation, including unknown future versions, and leaves foreign caches", async () => { + const harness = createKillSwitchHarness([ + "clinical-kb-pwa-shell-2026-07-15-v1", + "clinical-kb-pwa-static-2026-07-15-v1", + "clinical-kb-pwa-static-2099-12-31-v9", + "unrelated-application-cache", + ]); + + await harness.dispatch("activate"); + + expect([...harness.deletedNames].sort()).toEqual([ + "clinical-kb-pwa-shell-2026-07-15-v1", + "clinical-kb-pwa-static-2026-07-15-v1", + "clinical-kb-pwa-static-2099-12-31-v9", + ]); + expect(harness.remainingCacheNames()).toEqual(["unrelated-application-cache"]); + expect(harness.unregister).toHaveBeenCalledTimes(1); + expect(harness.claimClients).toHaveBeenCalledTimes(1); + }); + + it("registers no fetch handler and never opens or writes CacheStorage", async () => { + const harness = createKillSwitchHarness(["clinical-kb-pwa-shell-2026-07-15-v1"]); + + await harness.dispatch("install"); + await harness.dispatch("activate"); + + expect(harness.listeners.has("fetch")).toBe(false); + expect(harness.openedNames).toEqual([]); + }); + + it("still unregisters when CacheStorage enumeration is unavailable", async () => { + const harness = createKillSwitchHarness(["clinical-kb-pwa-shell-2026-07-15-v1"], { keysUnavailable: true }); + + await harness.dispatch("activate"); + + expect(harness.deletedNames).toEqual([]); + expect(harness.unregister).toHaveBeenCalledTimes(1); + }); +}); diff --git a/tests/pwa-lifecycle.dom.test.tsx b/tests/pwa-lifecycle.dom.test.tsx index 999c2da29..4742226b2 100644 --- a/tests/pwa-lifecycle.dom.test.tsx +++ b/tests/pwa-lifecycle.dom.test.tsx @@ -86,6 +86,45 @@ describe("PwaLifecycle", () => { ); }); + it("tears down the locally registered worker and owned caches via the explicit ?pwa-dev=0 opt-out", async () => { + window.history.replaceState({}, "", "/?pwa-dev=0"); + const { container } = installServiceWorkerStub(); + + const makeRegistration = (scriptURL: string) => { + const registration = new EventTarget() as ServiceWorkerRegistration & { unregister: ReturnType }; + Object.defineProperties(registration, { + active: { configurable: true, value: { scriptURL } }, + waiting: { configurable: true, value: null }, + installing: { configurable: true, value: null }, + unregister: { configurable: true, value: vi.fn().mockResolvedValue(true) }, + }); + return registration; + }; + const ownRegistration = makeRegistration("http://localhost/sw.js"); + const foreignRegistration = makeRegistration("http://localhost/other-worker.js"); + Object.defineProperty(container, "getRegistrations", { + configurable: true, + value: vi.fn().mockResolvedValue([ownRegistration, foreignRegistration]), + }); + + const cacheDelete = vi.fn().mockResolvedValue(true); + Object.defineProperty(window, "caches", { + configurable: true, + value: { + delete: cacheDelete, + keys: vi.fn().mockResolvedValue(["clinical-kb-pwa-shell-2026-07-15-v1", "unrelated-cache"]), + } as unknown as CacheStorage, + }); + + render(); + + await waitFor(() => expect(ownRegistration.unregister).toHaveBeenCalledTimes(1)); + expect(foreignRegistration.unregister).not.toHaveBeenCalled(); + await waitFor(() => expect(cacheDelete).toHaveBeenCalledWith("clinical-kb-pwa-shell-2026-07-15-v1")); + expect(cacheDelete).not.toHaveBeenCalledWith("unrelated-cache"); + expect(container.register).not.toHaveBeenCalled(); + }); + it("announces lost and restored connectivity without claiming private data is available offline", async () => { render(); diff --git a/tests/pwa-manifest.test.ts b/tests/pwa-manifest.test.ts index 13a8c7d6d..186d703e2 100644 --- a/tests/pwa-manifest.test.ts +++ b/tests/pwa-manifest.test.ts @@ -1,3 +1,4 @@ +import { createHash } from "node:crypto"; import { readFileSync } from "node:fs"; import { join } from "node:path"; import { describe, expect, it } from "vitest"; @@ -68,6 +69,27 @@ describe("PWA manifest and public bootstrap resources", () => { expect(offlineHtml).toMatch(/queries, answers, documents, uploads, signed URLs, or API responses/i); }); + it("binds the precached offline document to the service-worker cache version", () => { + // The offline document is precached at install time only, so an edit that + // ships without a CACHE_VERSION bump strands installed clients on the old + // copy indefinitely (docs/pwa.md rules 1 and 5). Update BOTH fields of + // this pairing together: bump CACHE_VERSION in public/sw.js to a brand-new + // value (never reuse a previous one, even for rollbacks) and record the + // new offline.html hash here. + const expectedPairing = { + cacheVersion: "2026-07-15-v1", + offlineHtmlSha256: "52d290906336bf7d6d71797e3ede038e0ac84c826dc1c09f04f3f27d29117f8a", + }; + + const workerSource = readFileSync(join(process.cwd(), "public", "sw.js"), "utf8"); + const cacheVersion = workerSource.match(/const CACHE_VERSION = "([^"]+)";/)?.[1]; + const offlineHtml = readFileSync(join(process.cwd(), "public", "offline.html"), "utf8"); + const offlineHtmlSha256 = createHash("sha256").update(offlineHtml).digest("hex"); + + expect(cacheVersion).toBeTruthy(); + expect({ cacheVersion, offlineHtmlSha256 }).toEqual(expectedPairing); + }); + it("sets explicit no-cache and scope headers for the service-worker entry point", () => { const nextConfig = readFileSync(join(process.cwd(), "next.config.ts"), "utf8"); From 2b1c365e6b7d4c590fc6e6bdb0e058adae9cf232 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 18 Jul 2026 07:24:10 +0000 Subject: [PATCH 2/3] docs: record PWA hardening phase review in branch ledger Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01EXsJcLrbZUXwnBeG91cVo9 --- docs/branch-review-ledger.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/branch-review-ledger.md b/docs/branch-review-ledger.md index a9ba48756..15fa47cba 100644 --- a/docs/branch-review-ledger.md +++ b/docs/branch-review-ledger.md @@ -596,3 +596,4 @@ Use this ledger to prevent repeated branch and PR reviews when the reviewed HEAD | 2026-07-17 | final historical branch/worktree cleanup against `origin/main` | 5d195d7ca8752b2ae4006725c6b145c5662bb687 | branch-cleanup | Merged PRs #716 and #717, closed superseded PR #700, and removed the three clean task worktrees. Deleted exact remote refs for `codex/mobile-search-phone-fix-20260717` (`590f32b73`), `claude/audit-findings-review-phgz92` (`ea3b8f95b8`), `codex/chat-forms-import-6914` (`b05da82f82`), `codex/chat-supabase-migration-preflight-b463` (`1ef0faee95`), and `codex/dsm-diagnosis-mode` (`f6cda83ca6`); the merged #716/#717 branches were deleted automatically. Deleted 14 unregistered local refs only after direct-main ancestry, exact ledger deletion-pending proof, or exact merged-PR commit provenance. Final inventory found zero remote branches without an open PR or registered worktree, zero locally merged or exact deletion-pending orphan refs, and no retired target refs or paths. Thirteen non-ancestor local refs and 25 registered worktrees remain preserved because they are backups, patch-unique/unresolved, open-PR-owned, or ownership could not be safely disproved. | Fresh fetch/prune; exact GitHub PR/head/merge associations; cherry-pick-aware logs; DSM PR #661 exact commit/file provenance; exact leased remote deletes; exact-old-value local `update-ref` deletes; clean-worktree and path/process checks; worktree prune; final zero-orphan inventory. The Codex task registry lookup timed out, so ambiguous registered worktrees were conservatively retained. No Supabase, OpenAI, production-data, or live clinical workflow ran. | | 2026-07-15 | HEAD detached 570e6ba56 + WIP tree | 570e6ba56ae60bea56a32801b9cc96c5a8dfde4f | thorough multi-lens review: WIP RAG/schema + clinical design/UI + architecture/bug-hunt | Changes requested: no P0. Confirmed P1s in WIP � registryCorpusDetailHref typecheck break; ChunkLoadCache error/null poisoning across parallel hydrations; registry cleanup `::uuid` cast abort; corrector GIN unused by query path; new table-facts trgm index expression mismatch vs trgm_matches. Design: production clinical shell stays token/a11y-aligned; favourites nav multi-gradient bars and mockup hex drift fight clinical density. Residual: concurrent cache race, SECURITY DEFINER revoke gaps, schema/migration lifecycle drift, accidental pnpm-lock.yaml. | `npm run typecheck` (red: registry link callers + stale .next apps types); static SQL/expr/diff review; architecture + bug-hunt agents; design-system grep (tokens, reduced-motion, forced-colors). Not run: vitest, verify:*, ensure/browser screenshots, live Supabase/OpenAI. frontend-ui-reviewer subagent blocked by usage limit � design pass done inline. | +| 2026-07-18 | claude/clinical-kb-pwa-review-asi3wb (PR #826) | 3d9ee5f44dea9edb1ef5af28f5f265d88d8b9f29 | PWA hardening implementation (plan Phase 1) | Implemented the three open findings from the 2026-07-17 PWA setup review with zero cache-semantics change: committed the rule-6 retirement worker `public/sw-kill-switch.js` with a five-test lock (`tests/pwa-kill-switch.test.ts`), bound the `offline.html` sha256 to the sw.js `CACHE_VERSION` pairing in `tests/pwa-manifest.test.ts` (drift trap closed), added the `?pwa-dev=0` local teardown to `pwa-lifecycle.tsx` with a dom test proving foreign workers and caches stay untouched, and updated `docs/pwa.md` rules 1 and 6 plus the local-dev cleanup step. Phase 0 of the approved plan (pr-policy `base_ref` checkout fix + the Set-Cookie worker-test case) was found already merged to main and skipped. | Focused Vitest 53/53. `verify:cheap` and the `verify:pr-local` unit stage green except `tests/pdf-extraction-budget.test.ts`, which fails identically on clean main in this container (child-process semantics; baselined twice). `verify:ui` 218 passed with 2 container-baselined pre-existing failures: the `ui-pwa` installability test (Chromium `in-incognito` artifact, reproduced from a clean-main detached worktree with its own server) and the `ui-smoke` document-viewer PDF-canvas mobile test (also fails on clean main `54229f0`; flagged as possible upstream regression). `format:check` clean for repo files. Conditional build/bundle stages deferred to the blocking hosted CI Build job on PR #826. No provider-backed checks run. | From 8406bc45bd03ecbc4850f181200c27f6d9ac8bad Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 18 Jul 2026 07:41:52 +0000 Subject: [PATCH 3/3] fix: exact-match the owned worker URL in ?pwa-dev=0 teardown A suffix check would also unregister an unrelated same-origin worker at a nested path like /other-app/sw.js. Addresses the CodeRabbit review finding on PR #826; the foreign-worker fixture now uses a nested /sw.js path to lock the exact-match behavior. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01EXsJcLrbZUXwnBeG91cVo9 --- src/components/pwa-lifecycle.tsx | 8 ++++++-- tests/pwa-lifecycle.dom.test.tsx | 6 ++++-- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/src/components/pwa-lifecycle.tsx b/src/components/pwa-lifecycle.tsx index 9e120b66e..1db258cf4 100644 --- a/src/components/pwa-lifecycle.tsx +++ b/src/components/pwa-lifecycle.tsx @@ -66,12 +66,16 @@ function rememberInstallDismissal() { async function teardownLocalPwa() { try { + // Exact-match the owned worker URL: a suffix check would also catch an + // unrelated same-origin worker registered at a nested path like + // /other-app/sw.js. + const ownedWorkerUrl = new URL(SERVICE_WORKER_URL, window.location.origin).href; const registrations = await navigator.serviceWorker.getRegistrations(); await Promise.allSettled( registrations .filter((registration) => - [registration.active, registration.waiting, registration.installing].some((worker) => - worker?.scriptURL.endsWith(SERVICE_WORKER_URL), + [registration.active, registration.waiting, registration.installing].some( + (worker) => worker?.scriptURL === ownedWorkerUrl, ), ) .map((registration) => registration.unregister()), diff --git a/tests/pwa-lifecycle.dom.test.tsx b/tests/pwa-lifecycle.dom.test.tsx index 4742226b2..4214fddc2 100644 --- a/tests/pwa-lifecycle.dom.test.tsx +++ b/tests/pwa-lifecycle.dom.test.tsx @@ -100,8 +100,10 @@ describe("PwaLifecycle", () => { }); return registration; }; - const ownRegistration = makeRegistration("http://localhost/sw.js"); - const foreignRegistration = makeRegistration("http://localhost/other-worker.js"); + const ownRegistration = makeRegistration(new URL("/sw.js", window.location.origin).href); + // Nested path ending in /sw.js: proves teardown exact-matches the owned + // worker URL instead of suffix-matching. + const foreignRegistration = makeRegistration(new URL("/other-app/sw.js", window.location.origin).href); Object.defineProperty(container, "getRegistrations", { configurable: true, value: vi.fn().mockResolvedValue([ownRegistration, foreignRegistration]),