Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/pwa-aggressive-update.md
Original file line number Diff line number Diff line change
@@ -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.)
8 changes: 8 additions & 0 deletions app/sw.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
51 changes: 42 additions & 9 deletions components/pwa/ServiceWorkerUpdater.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(() => {
Expand All @@ -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);
};
}, []);

Expand Down
Loading