From 72d8403b7c4bcfe5ab648a78aebfaa9fb4a0baf3 Mon Sep 17 00:00:00 2001 From: Ben Marshall Date: Sun, 28 Jun 2026 18:53:13 -0500 Subject: [PATCH] fix(pwa): promote waiting SW + periodic update checks so releases land MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The installed PWA could keep serving an old build: the updater only checked for a new SW on load/focus and only reacted to controllerchange, so a worker stuck in "waiting" never took over. Now the page actively messages waiting/installed workers to skip waiting (handled by a new SW message listener), watches updatefound, and re-checks on an interval — then reloads when the new worker controls the page. Verified the SKIP_WAITING handler ships in the built sw.js. Co-Authored-By: Claude Opus 4.8 --- .changeset/pwa-aggressive-update.md | 9 +++++ app/sw.ts | 8 ++++ components/pwa/ServiceWorkerUpdater.tsx | 51 ++++++++++++++++++++----- 3 files changed, 59 insertions(+), 9 deletions(-) create mode 100644 .changeset/pwa-aggressive-update.md diff --git a/.changeset/pwa-aggressive-update.md b/.changeset/pwa-aggressive-update.md new file mode 100644 index 0000000..fbe4461 --- /dev/null +++ b/.changeset/pwa-aggressive-update.md @@ -0,0 +1,9 @@ +--- +"pointsy": patch +--- + +Make the installed PWA update itself more reliably. The app now checks for a new +version on load, on focus, and on an interval, actively promotes a waiting service +worker (so a stuck one can't keep serving an old build), and reloads once the new +worker takes over — so releases reach the home-screen app without a manual +reinstall. (iOS still only refreshes the home-screen _icon_ on reinstall.) diff --git a/app/sw.ts b/app/sw.ts index 8a16b7d..500163f 100644 --- a/app/sw.ts +++ b/app/sw.ts @@ -22,6 +22,14 @@ const serwist = new Serwist({ serwist.addEventListeners(); +// Let the page promote a waiting worker (see ServiceWorkerUpdater) so a stuck +// "waiting" service worker can be told to take over immediately. +self.addEventListener("message", (event) => { + if ((event.data as { type?: string } | undefined)?.type === "SKIP_WAITING") { + void self.skipWaiting(); + } +}); + // --- Web Push ------------------------------------------------------------- interface PushPayload { diff --git a/components/pwa/ServiceWorkerUpdater.tsx b/components/pwa/ServiceWorkerUpdater.tsx index 244539d..f5f9589 100644 --- a/components/pwa/ServiceWorkerUpdater.tsx +++ b/components/pwa/ServiceWorkerUpdater.tsx @@ -3,11 +3,19 @@ import { useEffect } from "react"; /** - * Keeps the installed PWA current. Serwist revisions its precache every build - * and the SW uses skipWaiting + clientsClaim, but a running app keeps serving - * the old bundle until it's reloaded. This checks for a newer service worker on - * load and whenever the app regains focus, and reloads once the new one takes - * control — so each release reaches the installed app automatically. + * Keeps the installed PWA current. Serwist revisions its precache every build and + * the SW uses skipWaiting + clientsClaim, but a running app keeps serving the old + * bundle until something forces the new SW to take over and the page to reload. + * + * This: + * - checks for a newer service worker on load, on focus, and on an interval, + * - actively promotes any waiting/installed worker (postMessage + the SW's own + * skipWaiting), in case it didn't take over on its own, + * - reloads once the new worker takes control, + * so each release reaches the installed app without a manual reinstall. + * + * (Note: iOS only refreshes a home-screen PWA's *icon* on reinstall — that part + * can't be fixed from JS.) */ export function ServiceWorkerUpdater() { useEffect(() => { @@ -26,24 +34,49 @@ export function ServiceWorkerUpdater() { window.location.reload(); }; - const checkForUpdate = () => { + // Nudge a waiting/installed worker to activate immediately. The SW also calls + // skipWaiting itself, but a worker that got stuck "waiting" won't — this + // unsticks it. + const promote = (reg: ServiceWorkerRegistration) => { + reg.waiting?.postMessage({ type: "SKIP_WAITING" }); + }; + + const watch = (reg: ServiceWorkerRegistration) => { + promote(reg); + reg.addEventListener("updatefound", () => { + const next = reg.installing; + next?.addEventListener("statechange", () => { + if (next.state === "installed") promote(reg); + }); + }); + }; + + const check = () => { void sw .getRegistration() - .then((reg) => reg?.update()) + .then((reg) => { + if (!reg) return; + promote(reg); + return reg.update(); + }) .catch(() => {}); }; const onVisible = () => { - if (document.visibilityState === "visible") checkForUpdate(); + if (document.visibilityState === "visible") check(); }; sw.addEventListener("controllerchange", onControllerChange); document.addEventListener("visibilitychange", onVisible); - checkForUpdate(); + void sw.ready.then(watch).catch(() => {}); + check(); + // Re-check periodically so a long-lived session still picks up a release. + const interval = window.setInterval(check, 60_000); return () => { sw.removeEventListener("controllerchange", onControllerChange); document.removeEventListener("visibilitychange", onVisible); + window.clearInterval(interval); }; }, []);