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.
This commit is contained in:
Marc-André Bombeck committed 2026-06-14 10:44:45 +02:00
1 parent b59f8118a9
commit b4c6c2cc88
3 files changed
+114

No files matched your search

@@ -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);
});
});
+2
View File
@@ -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}
<Toaster position="bottom-right" richColors />
<VersionPoller />
<ServiceWorkerRegistrar />
</AppSettingsProvider>
</I18nProvider>
</ThemeProvider>
@@ -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 `<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;
}