Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/branch-review-ledger.md
Original file line number Diff line number Diff line change
Expand Up @@ -598,3 +598,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. |
15 changes: 10 additions & 5 deletions docs/pwa.md
Original file line number Diff line number Diff line change
Expand Up @@ -173,7 +173,9 @@ clinical-kb-pwa-static-<CACHE_VERSION>
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
Expand All @@ -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.

Expand Down Expand Up @@ -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:

Expand Down
50 changes: 50 additions & 0 deletions public/sw-kill-switch.js
Original file line number Diff line number Diff line change
@@ -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.
46 changes: 43 additions & 3 deletions src/components/pwa-lifecycle.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 };

Expand Down Expand Up @@ -62,6 +64,34 @@ 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 === ownedWorkerUrl,
),
)
.map((registration) => registration.unregister()),
Comment thread
coderabbitai[bot] marked this conversation as resolved.
);
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 =
Expand All @@ -72,7 +102,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);
Expand Down Expand Up @@ -144,10 +176,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>();
Expand Down
137 changes: 137 additions & 0 deletions tests/pwa-kill-switch.test.ts
Original file line number Diff line number Diff line change
@@ -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<unknown>[] = [];

waitUntil(promise: Promise<unknown>): void {
this.lifetimePromises.push(Promise.resolve(promise));
}

async settle(): Promise<void> {
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<string, Array<(event: unknown) => 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<boolean> {
deletedNames.push(name);
return names.delete(name);
},
async keys(): Promise<string[]> {
if (keysUnavailable) throw new Error("CacheStorage unavailable");
return [...names];
},
async open(name: string): Promise<never> {
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<void> {
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);
});
});
41 changes: 41 additions & 0 deletions tests/pwa-lifecycle.dom.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,47 @@ 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<typeof vi.fn> };
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(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]),
});

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(<PwaLifecycle />);

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(<PwaLifecycle />);

Expand Down
Loading