Files
HealthLog/src/components/service-worker-registrar.tsx
T
Marc-André Bombeck b4c6c2cc88 feat(pwa): register the service worker app-wide
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.
2026-06-14 10:44:45 +02:00

82 lines
3.0 KiB
TypeScript

"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 `<VersionPoller>` (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;
}