Files
cobble/src/components/general/service-worker-registrar.tsx
T
tbenandClaude Opus 5.5 6e678cbcde Let a new service worker take over by itself
The update popup showed on installed PWAs after an update had already
landed: the new worker sat waiting behind the open window. Nothing in the
worker is per-version, so it now skips the wait on install, and the
popup is gone.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 13:38:07 +01:00

140 lines
4.8 KiB
TypeScript

"use client";
import { useEffect, useRef } from "react";
import { PwaServiceWorkerPath } from "@/src/data/pwa-data";
import clearDevelopmentServiceWorker from "@/src/functions/frontend/clear-development-service-worker";
const UpdateCheckMs = 60 * 60 * 1000;
/**
* Development only: waits out a `clients.claim` that raced the clear, so
* the clearing reload doesn't come back controlled by the old worker.
*/
const DevelopmentClearRetryMs = 3000;
/**
* Development only: guards against reloading in a loop when the worker
* can't be cleared (a restricted profile, say).
*/
const DevelopmentClearAttemptsKey = "cobble-dev-sw-clear";
const DevelopmentClearMaxAttempts = 3;
/**
* Registers the app's service worker (production only, so development
* HMR is never cached) and checks for a new one hourly and whenever the
* app comes back into view. A new worker takes over by itself
* (`public/sw.js`), so there is nothing to offer and nothing reloads: a
* bot reply may be running. New builds reach the device through the
* loading page.
*
* In development it does the opposite: it clears the worker and the
* caches a production deploy left on the device, so the dev server's
* code is never served from a build's cache (see
* `clear-development-service-worker.ts`).
*/
export default function ServiceWorkerRegistrar() {
const registration = useRef<ServiceWorkerRegistration | null>(null);
useEffect(() => {
if (process.env.NODE_ENV != "production") return;
if (typeof navigator == "undefined") return;
if (!("serviceWorker" in navigator) || !window.isSecureContext) return;
let cancelled = false;
let interval: ReturnType<typeof setInterval> | undefined;
navigator.serviceWorker
.register(PwaServiceWorkerPath, {
scope: "/",
updateViaCache: "none",
})
.then((reg) => {
if (cancelled) return;
registration.current = reg;
interval = setInterval(() => reg.update().catch(() => {}), UpdateCheckMs);
})
.catch((error) => {
console.log("Service worker registration failed:", error);
});
const checkOnVisible = () => {
if (document.visibilityState == "visible") {
registration.current?.update().catch(() => {});
}
};
document.addEventListener("visibilitychange", checkOnVisible);
return () => {
cancelled = true;
if (interval) clearInterval(interval);
document.removeEventListener("visibilitychange", checkOnVisible);
};
}, []);
useEffect(() => {
if (process.env.NODE_ENV == "production") return;
if (typeof navigator == "undefined") return;
if (!("serviceWorker" in navigator)) return;
let cancelled = false;
let retry: ReturnType<typeof setTimeout> | undefined;
/**
* A worker claimed this tab while clearing or right after it:
* clear again, then reload into the uncontrolled tab. Without
* this, the reload would land back on the cached build.
*/
const onControllerChange = () => {
if (cancelled) return;
retry = setTimeout(() => {
if (cancelled) return;
clearDevelopmentServiceWorker()
.then((cleared) => {
if (!cancelled && cleared) window.location.reload();
})
.catch(() => undefined);
}, DevelopmentClearRetryMs);
};
navigator.serviceWorker.addEventListener("controllerchange", onControllerChange);
clearDevelopmentServiceWorker()
.then((cleared) => {
if (cancelled) return;
if (!cleared) {
window.sessionStorage.removeItem(DevelopmentClearAttemptsKey);
return;
}
/**
* This load is still the old worker's code: one reload
* takes the dev server's, twice at most.
*/
const attempts = Number(
window.sessionStorage.getItem(DevelopmentClearAttemptsKey) || 0,
);
if (attempts >= DevelopmentClearMaxAttempts) return;
window.sessionStorage.setItem(
DevelopmentClearAttemptsKey,
String(attempts + 1),
);
window.location.reload();
})
.catch(() => undefined);
return () => {
cancelled = true;
if (retry) clearTimeout(retry);
navigator.serviceWorker.removeEventListener(
"controllerchange",
onControllerChange,
);
};
}, []);
return null;
}