From b4c6c2cc88812ea2d0dcde786841a3011ecc6b26 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 14 Jun 2026 10:44:45 +0200 Subject: [PATCH] feat(pwa): register the service worker app-wide MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Until now `/sw.js` registered only behind the Web-Push opt-in, so any user who never enabled notifications ran with no service worker — the offline shell cache, the precached app shell, and the network-first page cache were all inert. Add a production-gated `ServiceWorkerRegistrar` that installs the worker for everyone, deferred to the `load` event wrapped in `requestIdleCallback` so install/precache stays off the first-paint critical path. Mount it beside `VersionPoller` in the root providers. The registrar only installs. `VersionPoller` remains the sole owner of the update→reload path (poll `/api/version`, then unregister + cache-wipe + reload on a server-ahead deploy); no `updatefound`/`controllerchange` handling lives here so the two cannot double-fire. `updateViaCache: "none"` forces a network byte-check of `sw.js` on every update check so a cached worker can never mask a new deploy. --- .../service-worker-registrar.test.ts | 31 +++++++ src/components/providers.tsx | 2 + src/components/service-worker-registrar.tsx | 81 +++++++++++++++++++ 3 files changed, 114 insertions(+) create mode 100644 src/components/__tests__/service-worker-registrar.test.ts create mode 100644 src/components/service-worker-registrar.tsx diff --git a/src/components/__tests__/service-worker-registrar.test.ts b/src/components/__tests__/service-worker-registrar.test.ts new file mode 100644 index 000000000..d4ece3ff7 --- /dev/null +++ b/src/components/__tests__/service-worker-registrar.test.ts @@ -0,0 +1,31 @@ +/** + * Gating predicate for app-wide service-worker registration. + * + * The registrar must install `/sw.js` only in production and only where the + * browser supports the API. Dev lacks the generated `/sw-version.js` (the + * `prebuild` step writes it) and a caching SW fights HMR, so a dev register + * would be actively harmful. + */ +import { describe, expect, it } from "vitest"; + +import { shouldRegisterServiceWorker } from "../service-worker-registrar"; + +describe("shouldRegisterServiceWorker", () => { + it("registers in production with window + serviceWorker support", () => { + expect(shouldRegisterServiceWorker("production", true, true)).toBe(true); + }); + + it("does not register outside production", () => { + expect(shouldRegisterServiceWorker("development", true, true)).toBe(false); + expect(shouldRegisterServiceWorker("test", true, true)).toBe(false); + expect(shouldRegisterServiceWorker(undefined, true, true)).toBe(false); + }); + + it("does not register without a window (SSR)", () => { + expect(shouldRegisterServiceWorker("production", false, true)).toBe(false); + }); + + it("does not register when serviceWorker is unsupported", () => { + expect(shouldRegisterServiceWorker("production", true, false)).toBe(false); + }); +}); diff --git a/src/components/providers.tsx b/src/components/providers.tsx index 69340dec8..dfba33019 100644 --- a/src/components/providers.tsx +++ b/src/components/providers.tsx @@ -19,6 +19,7 @@ import type { Locale } from "@/lib/i18n/config"; import { Toaster } from "@/components/ui/sonner"; import { AppSettingsProvider } from "@/components/app-settings-provider"; import { VersionPoller } from "@/components/version-poller"; +import { ServiceWorkerRegistrar } from "@/components/service-worker-registrar"; import { isDashboardSnapshotEnabled } from "@/lib/dashboard/snapshot-flag"; import { prefetchDashboardSnapshot } from "@/lib/queries/use-dashboard-snapshot"; import { prefetchMedicationsList } from "@/lib/queries/prefetch-medications"; @@ -184,6 +185,7 @@ export function Providers({ {children} + diff --git a/src/components/service-worker-registrar.tsx b/src/components/service-worker-registrar.tsx new file mode 100644 index 000000000..fb33f5fea --- /dev/null +++ b/src/components/service-worker-registrar.tsx @@ -0,0 +1,81 @@ +"use client"; + +import { useEffect } from "react"; + +/** + * Pure predicate for whether app-wide SW registration should proceed. + * Extracted so the production + capability gating is unit-testable without + * a DOM/effect harness. The registrar's `useEffect` calls this before it + * touches `navigator.serviceWorker`. + */ +export function shouldRegisterServiceWorker( + nodeEnv: string | undefined, + hasWindow: boolean, + hasServiceWorker: boolean, +): boolean { + if (nodeEnv !== "production") return false; + if (!hasWindow) return false; + if (!hasServiceWorker) return false; + return true; +} + +/** + * App-wide service-worker registration. + * + * Until this component shipped, `/sw.js` was registered exactly once — + * inside the Web-Push opt-in (`web-push-card.tsx`). Every user who never + * enabled notifications ran with no SW at all, so the offline shell cache, + * the precached app shell, and the network-first page cache were inert. This + * mounts the registration for everyone, gated on production. + * + * Lifecycle owner split: this component only INSTALLS the worker. The + * update→reload path stays solely with `` (polls + * `/api/version`, then unregister + cache-wipe + reload on a server-ahead + * deploy). No `updatefound` / `controllerchange` handling lives here — a + * second reload trigger would double-fire against the poller and risk a loop. + */ +export function ServiceWorkerRegistrar(): null { + useEffect(() => { + // Production only: dev lacks the generated `/sw-version.js` (written by + // the `prebuild` step), and a caching SW fights HMR. `process.env. + // NODE_ENV` is inlined into the client bundle at build time. + if ( + !shouldRegisterServiceWorker( + process.env.NODE_ENV, + typeof window !== "undefined", + typeof navigator !== "undefined" && "serviceWorker" in navigator, + ) + ) { + return; + } + + const register = () => { + const idle = + (window as unknown as { requestIdleCallback?: typeof setTimeout }) + .requestIdleCallback ?? ((cb: () => void) => setTimeout(cb, 1)); + idle(() => { + navigator.serviceWorker + // `updateViaCache: "none"` forces the browser to byte-check + // `/sw.js` (and its `importScripts('/sw-version.js')`) against the + // network on every update check rather than honouring the HTTP + // cache — without it a cached `sw.js` can mask a new deploy and the + // worker never sees the new `CACHE_VERSION`. + .register("/sw.js", { scope: "/", updateViaCache: "none" }) + .catch(() => { + /* best effort — registration failure must never break the app */ + }); + }); + }; + + // Defer to `load` (+ idle) so install/precache never contends with the + // first-paint critical path. If the window already fired `load`, run now. + if (document.readyState === "complete") { + register(); + return; + } + window.addEventListener("load", register, { once: true }); + return () => window.removeEventListener("load", register); + }, []); + + return null; +}