Prompt to reload when a new version is available

The service worker used to skipWaiting() on install and claim clients on
activate, so a new build's assets swapped in silently and a long-open tab
kept running stale JS. Switch to the standard update flow: the worker now
waits until the page sends it a SKIP_WAITING message, and the page shows a
"A new version is available — Reload / Later" banner when a new worker
reaches "installed" while one is already controlling the tab (that
controller check suppresses the first-install prompt).

Reload posts SKIP_WAITING and reloads on controllerchange (guarded against
a reload loop and against the initial clients.claim on a fresh install);
Later dismisses until the next update. Since browsers only auto-check on
navigation, also poll registration.update() hourly and on visibilitychange.
Bump the cache to v9 so the old cache is cleaned up on activate.
This commit is contained in:
Alexander Heldt
2026-07-10 12:30:30 +00:00
parent 9c0427a1ec
commit dbcac0653e
4 changed files with 115 additions and 4 deletions
+63 -2
View File
@@ -1619,10 +1619,71 @@
window.addEventListener("online", () => { setStatus(); sync(); syncConfig(); });
window.addEventListener("offline", () => setStatus());
// Service worker (independent of auth).
// Service worker (independent of auth). The worker no longer auto-activates a
// new build; instead we detect the waiting worker and let the user choose when
// to swap onto fresh assets, so a long-open tab isn't left running stale JS.
if ("serviceWorker" in navigator) {
const updateBanner = document.getElementById("update-banner");
const updateReload = document.getElementById("update-reload");
const updateLater = document.getElementById("update-later");
let waitingWorker = null;
// Whether there was already a controlling worker when the page loaded. On a
// brand-new install there isn't, and clients.claim() fires an initial
// controllerchange we must NOT reload on (there's nothing to refresh to).
const hadController = !!navigator.serviceWorker.controller;
let reloadRequested = false; // user pressed Reload → controllerchange reloads
let refreshing = false; // guard against a reload loop
navigator.serviceWorker.addEventListener("controllerchange", () => {
if (refreshing) return;
if (!hadController && !reloadRequested) return; // first-install claim
refreshing = true;
location.reload();
});
function showUpdateBanner(worker) {
waitingWorker = worker;
updateBanner.hidden = false;
}
updateReload.addEventListener("click", () => {
reloadRequested = true;
updateBanner.hidden = true;
// Tell the waiting worker to activate; controllerchange then reloads us.
if (waitingWorker) waitingWorker.postMessage({ type: "SKIP_WAITING" });
});
// "Later" just dismisses; the next update (or reload) surfaces it again.
updateLater.addEventListener("click", () => { updateBanner.hidden = true; });
// Only prompt when a *previous* worker was already in control — that check
// is what suppresses the banner on the very first install.
function trackInstalling(worker) {
worker.addEventListener("statechange", () => {
if (worker.state === "installed" && navigator.serviceWorker.controller) {
showUpdateBanner(worker);
}
});
}
function watchForUpdate(reg) {
// A worker may already be waiting from a previous session's update.
if (reg.waiting && navigator.serviceWorker.controller) showUpdateBanner(reg.waiting);
reg.addEventListener("updatefound", () => {
if (reg.installing) trackInstalling(reg.installing);
});
// Browsers only auto-check for a new worker on navigation, so also poll
// hourly and whenever the tab becomes visible again.
setInterval(() => reg.update().catch(() => {}), 60 * 60 * 1000);
document.addEventListener("visibilitychange", () => {
if (document.visibilityState === "visible") reg.update().catch(() => {});
});
}
window.addEventListener("load", () => {
navigator.serviceWorker.register("sw.js").catch(err => console.error("SW", err));
navigator.serviceWorker.register("sw.js")
.then(watchForUpdate)
.catch(err => console.error("SW", err));
});
}