From 503802e3638e6d29326f99c23d6e3cbd9d5ef814 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 8 Oct 2026 17:23:47 +0200 Subject: [PATCH 1/5] fix(pwa): update the worker on Reload instead of unregistering it The "new version" Reload action unregistered every service worker before reloading. Unregistering a registration ends its Web Push subscription and nothing in the app re-creates one, so each update silently turned push reminders off in the installed app: the server kept the dead endpoint and the settings card showed push as off. Reload now asks the registration to update (the new worker activates at once and drops the previous release's caches), deletes only HealthLog's own caches, and reloads. The cache-name contract moves into sw-cache-policy.ts so the session-end cleanup and the poller share it. --- .../__tests__/version-poller.test.ts | 92 +++++++++++++++++++ src/components/version-poller.tsx | 33 +++++-- src/lib/pwa/query-persister.ts | 8 +- src/lib/pwa/sw-cache-policy.ts | 9 ++ 4 files changed, 129 insertions(+), 13 deletions(-) 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. * From ac212cf6b78de2b516a3c623d65efa6320c8df06 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 8 Oct 2026 17:23:47 +0200 Subject: [PATCH 2/5] fix(pwa): give the offline page a way back and reload once on any stale chunk Without a connection a signed-in page opens the worker's offline page: signed-in pages carry `private, no-store` from the proxy and are never cached, so this is also what an offline launch of the installed app shows. The page now offers a real 64 px retry control, reloads by itself when the connection returns, follows the device's light or dark scheme and keeps clear of the notch and home indicator. The worker's comment on the page cache says plainly that only public pages land in it. The single reload after a stale-shell chunk failure moves into src/lib/pwa/chunk-reload.ts and now runs in the root error boundary too, and it recognises a failed dynamic import as WebKit and Firefox word it. pwa-offline.spec.ts runs in the service-worker project and proves the offline strip, the offline page and the automatic recovery against the real worker. --- e2e/pwa-offline.spec.ts | 64 +++++++++++++ playwright.config.ts | 16 +++- public/sw.js | 34 +++++-- src/__tests__/service-worker.test.ts | 21 +++++ .../error-chunk-reload-guard.test.ts | 45 +++++++++- src/app/error.tsx | 70 ++------------- src/app/global-error.tsx | 10 +++ src/lib/pwa/chunk-reload.ts | 89 +++++++++++++++++++ 8 files changed, 271 insertions(+), 78 deletions(-) create mode 100644 e2e/pwa-offline.spec.ts create mode 100644 src/lib/pwa/chunk-reload.ts 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; +} From 56a3008cf0a7c634ef9cd3fdbfd94ec4826fe025 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 8 Oct 2026 17:23:57 +0200 Subject: [PATCH 3/5] fix(mobile): keep form fields at 16 px on every touch screen iOS and iPadOS Safari zoom the page into any focused field whose text is below 16 px. The fields switched to 14 px at a width breakpoint (`md:text-sm`, `sm:text-sm`), which a phone held sideways (844 px) and every iPad pass, and the native select was 14 px at every width, so the profile settings zoomed on a portrait phone too. A `pointer-fine:` variant (a mouse or trackpad is the primary pointer) now gates the compact size on Input, Textarea, NativeSelect, DateField, TimeField and the Coach composer; touch screens keep 16 px at any width and the desktop look is unchanged. --- src/app/globals.css | 14 ++++++++++++++ .../insights/coach-panel/coach-input.tsx | 2 +- src/components/ui/__tests__/textarea.test.tsx | 8 +++++--- src/components/ui/date-field.tsx | 10 ++++++---- src/components/ui/input.tsx | 2 +- src/components/ui/native-select.tsx | 5 ++++- src/components/ui/textarea.tsx | 13 ++++++++----- src/components/ui/time-field.tsx | 4 ++-- 8 files changed, 41 insertions(+), 17 deletions(-) diff --git a/src/app/globals.css b/src/app/globals.css index 91d72e44d..208d1ebd6 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -32,6 +32,20 @@ @custom-variant dark (&:is(.dark *)); +/* + * `pointer-fine:` — a mouse or trackpad is the primary pointer. Form fields + * take the compact 14 px type only under it: iOS and iPadOS Safari zoom the + * page into any focused field whose text is smaller than 16 px, and a width + * breakpoint alone (`md:text-sm`) handed 14 px to a phone held sideways + * (844 px) and to every iPad. Touch-first screens keep the 16 px base at + * every width. + */ +@custom-variant pointer-fine { + @media (hover: hover) and (pointer: fine) { + @slot; + } +} + @theme inline { /* Extra-small breakpoint (30rem / 480px) for the narrowest phones — the default scale starts at sm (640px), which leaves a gap between an iPhone SE diff --git a/src/components/insights/coach-panel/coach-input.tsx b/src/components/insights/coach-panel/coach-input.tsx index a6813f540..d651dd7d0 100644 --- a/src/components/insights/coach-panel/coach-input.tsx +++ b/src/components/insights/coach-panel/coach-input.tsx @@ -380,7 +380,7 @@ export function CoachInput({ autoCapitalize="sentences" rows={1} className={cn( - "order-1 w-full min-w-0 flex-1 resize-none bg-transparent text-base leading-relaxed outline-none sm:text-sm", + "order-1 w-full min-w-0 flex-1 resize-none bg-transparent text-base leading-relaxed outline-none sm:pointer-fine:text-sm", "px-2 py-1.5", "max-h-[9.5rem] overflow-auto", "[scrollbar-width:thin] [scrollbar-color:color-mix(in_srgb,var(--primary)_35%,transparent)_transparent]", diff --git a/src/components/ui/__tests__/textarea.test.tsx b/src/components/ui/__tests__/textarea.test.tsx index 6535a0b02..f8e6c4fc6 100644 --- a/src/components/ui/__tests__/textarea.test.tsx +++ b/src/components/ui/__tests__/textarea.test.tsx @@ -4,10 +4,12 @@ import { renderToStaticMarkup } from "react-dom/server"; import { Textarea } from "../textarea"; describe("