mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
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:
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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user