The service worker never updated in a standalone PWA: `/api/events` matched the NetworkFirst runtime cache, so workbox tried to read an endless SSE body into the cache. That read never finishes, keeping the old worker alive and parking every new worker in "waiting" forever. Exempt the stream, register the worker ourselves so we can poll for updates (timer while visible, on foreground, on reconnect), and reload the page when the new worker takes over — unless a file has unsaved edits, where a toast lets the user pick the moment. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
60 lines
2.4 KiB
TypeScript
60 lines
2.4 KiB
TypeScript
/**
|
|
* Service-worker registration + update detection.
|
|
*
|
|
* Installed as a standalone PWA (Brave/Chrome on Android), the app is rarely
|
|
* cold-started: the OS keeps the document alive across days, so the browser's
|
|
* only built-in update check — which runs on navigation — never fires and the
|
|
* user keeps looking at the bundle that shipped whenever they last opened it.
|
|
*
|
|
* So we check for a new worker ourselves: on a timer while the app is on
|
|
* screen, whenever it comes back to the foreground, and when the network
|
|
* returns. The generated worker calls skipWaiting()/clientsClaim(), so a new
|
|
* build activates on its own and takes over this page's fetches; `onUpdate`
|
|
* fires at that handover and the caller decides when to reload onto it.
|
|
*
|
|
* We deliberately don't use the plugin's `virtual:pwa-register` helper: its
|
|
* prompt mode activates the new worker by postMessage(SKIP_WAITING), which a
|
|
* worker that the browser has already stopped never handles — the message
|
|
* listener is registered asynchronously, after the worker script evaluates.
|
|
*/
|
|
|
|
/** How often to ask the server whether sw.js changed. */
|
|
const POLL_MS = 60_000;
|
|
|
|
export function setupPWA(onUpdate: () => void): void {
|
|
if (!("serviceWorker" in navigator)) return;
|
|
|
|
// A page loaded before any worker existed gets claimed by the very first one.
|
|
// That handover is an install, not an update, and must not trigger a reload.
|
|
const hadController = !!navigator.serviceWorker.controller;
|
|
let handled = false;
|
|
|
|
navigator.serviceWorker.addEventListener("controllerchange", () => {
|
|
if (!hadController || handled) return;
|
|
handled = true;
|
|
onUpdate();
|
|
});
|
|
|
|
navigator.serviceWorker
|
|
.register("/sw.js", { scope: "/" })
|
|
.then((registration) => {
|
|
// `update()` bypasses the HTTP cache for sw.js, so a changed worker is
|
|
// picked up even while the old one is still controlling the page.
|
|
const check = () => {
|
|
if (!navigator.onLine) return;
|
|
registration.update().catch(() => {
|
|
/* offline or server down — the next tick retries */
|
|
});
|
|
};
|
|
|
|
setInterval(check, POLL_MS);
|
|
window.addEventListener("online", check);
|
|
document.addEventListener("visibilitychange", () => {
|
|
if (document.visibilityState === "visible") check();
|
|
});
|
|
})
|
|
.catch(() => {
|
|
/* no worker → the app still runs, just without offline support */
|
|
});
|
|
}
|