diff --git a/e2e/pwa-offline.spec.ts b/e2e/pwa-offline.spec.ts new file mode 100644 index 000000000..05701ce0d --- /dev/null +++ b/e2e/pwa-offline.spec.ts @@ -0,0 +1,64 @@ +import { expect, test } from "./setup/test"; +import { MOBILE_ROUTES_STORAGE_STATE_PATH } from "./setup/global-setup"; + +/** + * What the installed app shows without a connection, with the service worker + * actually running. + * + * Runs in the `chromium-service-worker` project, the one that lets the worker + * register (every other project blocks it; see `playwright.config.ts`), and + * like the other spec there it uses no route mocks: the claim is about what + * the worker itself serves. + * + * 1. A page that is open when the connection drops stays the app and says + * so (the offline strip). + * 2. Opening a page without a connection gets the worker's offline page: + * the word "Offline" and a working retry control, never the browser's + * error. (Signed-in pages are never cached, by design: the proxy pins + * `private, no-store` on them because some carry the record in the + * HTML. So this is what an offline launch of the installed app shows.) + * 3. When the connection returns, that page reloads into the app by + * itself. + * + * The account is the populated mobile-sweep account; nothing here writes. + */ +test.describe("offline, with the service worker", () => { + test.use({ storageState: MOBILE_ROUTES_STORAGE_STATE_PATH }); + + test("an open page says it is offline, a new one opens the offline page, and both recover", async ({ + page, + context, + }) => { + await page.goto("/medications"); + await page.waitForFunction( + () => navigator.serviceWorker?.controller !== null, + undefined, + { timeout: 20_000 }, + ); + await expect(page.locator("#main-content")).toBeVisible(); + await page.waitForLoadState("networkidle"); + + try { + await context.setOffline(true); + await expect(page.locator('[data-slot="offline-banner"]')).toBeVisible(); + + const response = await page.goto("/timeline"); + expect(response?.status()).toBe(503); + await expect(page.getByText("Offline", { exact: true })).toBeVisible(); + const retry = page.getByRole("link", { name: "Reload" }); + await expect(retry).toBeVisible(); + const box = (await retry.boundingBox())!; + expect(box.width).toBeGreaterThanOrEqual(44); + expect(box.height).toBeGreaterThanOrEqual(44); + + // Back online: the offline page reloads itself into the app. + const reloaded = page.waitForEvent("load"); + await context.setOffline(false); + await reloaded; + await expect(page.locator("#main-content")).toBeVisible(); + expect(new URL(page.url()).pathname).toBe("/timeline"); + } finally { + await context.setOffline(false); + } + }); +}); diff --git a/playwright.config.ts b/playwright.config.ts index 715b38b97..ed9e0ffb8 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -110,7 +110,10 @@ export default defineConfig({ name: "chromium-desktop", // The disk-layer spec needs a live service worker, which the shared // `use` block blocks for every other spec. It runs in its own project. - testIgnore: ["v137-record-session-fence-offline.spec.ts"], + testIgnore: [ + "v137-record-session-fence-offline.spec.ts", + "pwa-offline.spec.ts", + ], use: { ...devices["Desktop Chrome"], viewport: { width: 1280, height: 720 }, @@ -130,10 +133,14 @@ export default defineConfig({ // cannot pass is worse than no check, so the spec gets a project rather // than a caveat. // - // It uses no route mocks for exactly this reason, and it is the only - // spec in this project. + // It uses no route mocks for exactly this reason. v1.42 — the offline + // page journey (`pwa-offline.spec.ts`) joined it on the same terms: + // what the worker itself serves without a connection, no mocks. name: "chromium-service-worker", - testMatch: ["v137-record-session-fence-offline.spec.ts"], + testMatch: [ + "v137-record-session-fence-offline.spec.ts", + "pwa-offline.spec.ts", + ], use: { ...devices["Desktop Chrome"], viewport: { width: 1280, height: 720 }, @@ -220,6 +227,7 @@ export default defineConfig({ "doctor-report-delegate.spec.ts", // Runs only in the service-worker project. "v137-record-session-fence-offline.spec.ts", + "pwa-offline.spec.ts", // The Apple Health import journey uploads an archive into the one // shared account and its refusal control asserts that the account's // Apple-Health row count did not move. A second project running the diff --git a/public/sw.js b/public/sw.js index c83b525f8..784022a63 100644 --- a/public/sw.js +++ b/public/sw.js @@ -341,6 +341,14 @@ async function networkFirstApi(request) { * 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 working offline from the cached chunks and the + * allowlisted data reads. */ function isCacheableNavigation(request, response) { const cacheControl = response.headers.get("Cache-Control") || ""; @@ -568,16 +576,28 @@ async function networkFirst(event, cacheName) { // "Offline" token + a generic retry hint expressed as an icon // (the round-trip arrow), so the page does the right thing in any // locale without shipping a translation bundle into the worker. - return new Response( - 'HealthLog — Offline

HealthLog

Offline

', - { - status: 503, - headers: { "Content-Type": "text/html; charset=utf-8" }, - }, - ); + // + // v1.42 — the arrow is a real control now (a link to the same URL, + // 64 px), the page reloads by itself as soon as the connection is + // back, it follows the device's light or dark scheme, and it keeps + // clear of the notch and home indicator of an installed app. + return new Response(OFFLINE_PAGE, { + status: 503, + headers: { "Content-Type": "text/html; charset=utf-8" }, + }); } } +const OFFLINE_PAGE = + 'HealthLog — Offline

HealthLog

Offline

' + + ''; + /** * Cache a successful network response without making CacheStorage * availability part of the fetch contract. Quota, eviction, or trim failures diff --git a/src/__tests__/service-worker.test.ts b/src/__tests__/service-worker.test.ts index c64e25563..a62b16f78 100644 --- a/src/__tests__/service-worker.test.ts +++ b/src/__tests__/service-worker.test.ts @@ -217,6 +217,10 @@ function bootServiceWorker(): SwHarness { URL, Promise, console, + // Read through the context so a test can install fake timers before + // dispatching (the slow-navigation fallback arms one per navigation). + setTimeout: (...args: Parameters) => setTimeout(...args), + clearTimeout: (id: ReturnType) => clearTimeout(id), }; vm.createContext(context); vm.runInContext(SW_SOURCE, context); @@ -617,6 +621,23 @@ describe("sw.js — networkFirst offline fallback", () => { }); }); +describe("sw.js — offline page", () => { + it("offers a retry control, reloads by itself when the connection returns, and follows the device scheme", async () => { + const harness = bootServiceWorker(); + const res = await dispatchNavigationFetch(harness, "/"); + expect(res.status).toBe(503); + const html = await res.text(); + // A real control, not a decorative icon: a link back to the same URL. + expect(html).toMatch(/ { it("does not cache a navigation response that carries Cache-Control: no-store", async () => { const harness = bootServiceWorker(); diff --git a/src/app/__tests__/error-chunk-reload-guard.test.ts b/src/app/__tests__/error-chunk-reload-guard.test.ts index 5e2c5a55a..96cd5ec36 100644 --- a/src/app/__tests__/error-chunk-reload-guard.test.ts +++ b/src/app/__tests__/error-chunk-reload-guard.test.ts @@ -1,5 +1,6 @@ /** - * Unit tests for the chunk-error auto-reload guard in `src/app/error.tsx`. + * Unit tests for the chunk-error auto-reload guard in + * `src/lib/pwa/chunk-reload.ts` (both error boundaries call it). * * The guard is keyed on the running shell's build version: one reload * attempt per BROKEN SHELL, not one per session. After a successful @@ -10,7 +11,11 @@ import { describe, expect, it } from "vitest"; -import { chunkReloadGuardValue, shouldAttemptChunkReload } from "../error"; +import { + chunkReloadGuardValue, + isChunkLoadError, + shouldAttemptChunkReload, +} from "@/lib/pwa/chunk-reload"; describe("shouldAttemptChunkReload", () => { it("reloads when no attempt is recorded", () => { @@ -39,3 +44,39 @@ describe("shouldAttemptChunkReload", () => { expect(shouldAttemptChunkReload("unversioned", "")).toBe(false); }); }); + +describe("isChunkLoadError", () => { + it.each([ + [{ name: "ChunkLoadError", message: "x" }], + [{ name: "Error", message: "Loading chunk 123 failed." }], + [{ name: "Error", message: "Loading CSS chunk 7 failed." }], + [ + { + name: "Error", + message: "Failed to load chunk /_next/static/chunks/a.js", + }, + ], + [ + { + name: "TypeError", + message: "Failed to fetch dynamically imported module: /x.js", + }, + ], + // WebKit and Firefox word a failed dynamic import differently. + [{ name: "TypeError", message: "Importing a module script failed." }], + [ + { + name: "TypeError", + message: "error loading dynamically imported module: /x.js", + }, + ], + ])("recognises %j", (err) => { + expect(isChunkLoadError(err)).toBe(true); + }); + + it("leaves other errors to the error page", () => { + expect( + isChunkLoadError({ name: "TypeError", message: "x is undefined" }), + ).toBe(false); + }); +}); diff --git a/src/app/error.tsx b/src/app/error.tsx index 90c65e283..f51ae9359 100644 --- a/src/app/error.tsx +++ b/src/app/error.tsx @@ -2,47 +2,7 @@ import { useEffect } from "react"; import { ErrorDetails } from "@/components/error-details"; - -const CHUNK_RELOAD_KEY = "healthlog:chunk-reload-attempted"; -const SHELL_VERSION = process.env.NEXT_PUBLIC_APP_VERSION || ""; - -function isChunkLoadError(err: Error & { digest?: string }): boolean { - if (err.name === "ChunkLoadError") return true; - const msg = err.message ?? ""; - return ( - msg.includes("Loading chunk") || - msg.includes("Loading CSS chunk") || - msg.includes("Failed to load chunk") || - msg.includes("Failed to fetch dynamically imported module") - ); -} - -/** - * Guard value recorded per auto-reload attempt — the running shell's - * build version, so the suppression is scoped to ONE shell. After a - * successful heal the reloaded page carries the NEW version and the - * guard re-arms by construction; only a shell that reloaded and is - * STILL broken (the new build is also missing the chunk) stays - * suppressed. Builds without an injected version degrade to the legacy - * once-per-session behaviour via the "unversioned" sentinel. - */ -export function chunkReloadGuardValue(shellVersion: string): string { - return shellVersion || "unversioned"; -} - -/** - * Pure reload decision so the guard semantics carry direct unit - * coverage. `stored` is the sessionStorage value from the last attempt - * (`null` when none happened); legacy guard values (the pre-v1.16.8 - * timestamp) never match a version string, so a session stuck from - * before the change heals on its next chunk error. - */ -export function shouldAttemptChunkReload( - stored: string | null, - shellVersion: string, -): boolean { - return stored !== chunkReloadGuardValue(shellVersion); -} +import { reloadOnceForChunkError } from "@/lib/pwa/chunk-reload"; export default function AppError({ error, @@ -60,30 +20,10 @@ export default function AppError({ g.__healthlog_onError?.(error); } - // v1.4.38.3 — auto-recover from a stale-shell chunk-load error. - // After a deploy the cached shell still references old chunk - // filenames; they 404 and Next.js surfaces them as `ChunkLoadError`. - // A single hard reload fetches the new shell + chunk graph. - // The sessionStorage guard is keyed on the shell version (see - // `chunkReloadGuardValue`) so it suppresses a loop within ONE - // broken shell but re-arms after every deploy — the pre-v1.16.8 - // once-per-session guard exhausted on multi-deploy days and left - // the user on the error page with no automatic way out. - if (typeof window !== "undefined" && isChunkLoadError(error)) { - try { - const stored = window.sessionStorage.getItem(CHUNK_RELOAD_KEY); - if (shouldAttemptChunkReload(stored, SHELL_VERSION)) { - window.sessionStorage.setItem( - CHUNK_RELOAD_KEY, - chunkReloadGuardValue(SHELL_VERSION), - ); - window.location.reload(); - } - } catch { - // sessionStorage can throw under strict privacy modes — fall - // through to the error UI in that case. - } - } + // v1.4.38.3 — auto-recover from a stale-shell chunk-load error with a + // single reload (`src/lib/pwa/chunk-reload.ts`, shared with the root + // boundary). + reloadOnceForChunkError(error); }, [error]); return ; diff --git a/src/app/global-error.tsx b/src/app/global-error.tsx index 23170697b..536dbe0aa 100644 --- a/src/app/global-error.tsx +++ b/src/app/global-error.tsx @@ -1,5 +1,9 @@ "use client"; +import { useEffect } from "react"; + +import { reloadOnceForChunkError } from "@/lib/pwa/chunk-reload"; + /** * Root-level error boundary. Renders when even the root layout itself fails, * so it cannot rely on any providers (i18n, query, auth). Keep it static, @@ -12,6 +16,12 @@ export default function GlobalError({ error: Error & { digest?: string }; reset: () => void; }) { + // A chunk of the root layout's client tree missing after a deploy lands + // here rather than in `app/error.tsx`; it gets the same single reload. + useEffect(() => { + reloadOnceForChunkError(error); + }, [error]); + async function handleCopy() { // Strip query string — OAuth callback routes carry sensitive tokens. const loc = typeof window !== "undefined" ? window.location : null; diff --git a/src/lib/pwa/chunk-reload.ts b/src/lib/pwa/chunk-reload.ts new file mode 100644 index 000000000..5e7c847a4 --- /dev/null +++ b/src/lib/pwa/chunk-reload.ts @@ -0,0 +1,89 @@ +/** + * One reload after a stale-shell chunk failure. + * + * After a deploy, a page that was open across it still references the + * previous build's chunk files; they 404 and the lazy load fails. A client + * navigation already recovers on its own (Next turns a failed chunk during a + * route change into a full page load), but a chunk that fails inside a page + * (a `next/dynamic` panel, a lazily imported sheet) surfaces in an error + * boundary instead. Both boundaries, the route one (`app/error.tsx`) and the + * root one (`app/global-error.tsx`), call `reloadOnceForChunkError`, which + * loads the page once so it arrives with the new build's chunk graph. + * + * The guard is keyed on the running shell's version: it suppresses a loop + * within ONE broken shell, and re-arms after every deploy (the pre-v1.16.8 + * once-per-session key exhausted itself on multi-deploy days and stranded the + * user on the error page). + */ + +const CHUNK_RELOAD_KEY = "healthlog:chunk-reload-attempted"; + +/** + * A failed chunk, in the words of each loader and engine: webpack's + * `ChunkLoadError` / "Loading chunk", its CSS twin, Turbopack's "Failed to + * load chunk", and a native dynamic import as Chromium, WebKit and Firefox + * report it. + */ +export function isChunkLoadError(err: { name?: string; message?: string }) { + if (err.name === "ChunkLoadError") return true; + const msg = err.message ?? ""; + return ( + msg.includes("Loading chunk") || + msg.includes("Loading CSS chunk") || + msg.includes("Failed to load chunk") || + msg.includes("Failed to fetch dynamically imported module") || + msg.includes("Importing a module script failed") || + msg.includes("error loading dynamically imported module") + ); +} + +/** + * Guard value recorded per auto-reload attempt — the running shell's build + * version, so the suppression is scoped to ONE shell. After a successful + * heal the reloaded page carries the NEW version and the guard re-arms by + * construction; only a shell that reloaded and is STILL broken (the new + * build is also missing the chunk) stays suppressed. Builds without an + * injected version degrade to once-per-session via the "unversioned" + * sentinel. + */ +export function chunkReloadGuardValue(shellVersion: string): string { + return shellVersion || "unversioned"; +} + +/** + * Pure reload decision. `stored` is the sessionStorage value from the last + * attempt (`null` when none happened); legacy guard values (the pre-v1.16.8 + * timestamp) never match a version string, so a session stuck from before + * the change heals on its next chunk error. + */ +export function shouldAttemptChunkReload( + stored: string | null, + shellVersion: string, +): boolean { + return stored !== chunkReloadGuardValue(shellVersion); +} + +/** + * Reload once if `error` is a chunk failure and this shell has not tried + * yet. Returns whether a reload was started. + */ +export function reloadOnceForChunkError( + error: { name?: string; message?: string }, + shellVersion: string = process.env.NEXT_PUBLIC_APP_VERSION || "", +): boolean { + if (typeof window === "undefined" || !isChunkLoadError(error)) return false; + try { + const stored = window.sessionStorage.getItem(CHUNK_RELOAD_KEY); + if (!shouldAttemptChunkReload(stored, shellVersion)) return false; + window.sessionStorage.setItem( + CHUNK_RELOAD_KEY, + chunkReloadGuardValue(shellVersion), + ); + } catch { + // sessionStorage can throw under strict privacy modes; without a guard + // a reload could loop, so fall through to the error UI instead. + return false; + } + window.location.reload(); + return true; +}