feat(sw): offer a reload when a newer version is ready

The shell is served cache-first, which is what makes the app work on a
train and also what makes a deploy invisible: a reader with the tab open
— the reader this app is built for — keeps running the bundle they first
loaded, so a new game or a corrected date reaches their device and sits
there with nothing saying why the page looks unchanged. An old app shown
as current is the same failure as old events shown as current.

So the worker now installs quietly and waits instead of calling
skipWaiting(), the page notices it waiting and says so, and the reader's
tap sends the skip-waiting message and reloads on controllerchange. The
app never reloads itself: someone may be mid-way through typing in one of
their own events, and the notice says what a reload costs (their place on
the page) and what it does not (marks and notes live in localStorage).

Detection is derived rather than remembered. build:static grew into a
script that stamps sw.js with a hash of the built shell, because the
browser only offers a worker whose bytes differ, and the predecessor —
a hand-bumped CACHE_VERSION — had already been forgotten once. The feed
is deliberately not part of that hash: it changes twice a day, needs no
reload, and announcing it would teach readers to dismiss the notice
unread. The cache name stays put for the same reason a per-build one
would be wrong — it holds the feed an offline reader is reading.

A first install is not an update and stays silent.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
Lucas Winther
2026-08-17 18:55:08 +02:00
co-authored by Claude Opus 5
parent 7130a94b21
commit a4ab5aa36c
11 changed files with 804 additions and 20 deletions
+18
View File
@@ -11,7 +11,9 @@ import { Welcome } from "./components/Welcome.tsx";
import { Colophon } from "./components/Colophon.tsx";
import { Legend } from "./components/Legend.tsx";
import { Toast } from "./components/Toast.tsx";
import { UpdateNotice } from "./components/UpdateNotice.tsx";
import { KEYS } from "./state/storage.ts";
import { useAppUpdate } from "./state/useAppUpdate.ts";
import { useMarkSet } from "./state/useMarkSet.ts";
import { useProgress } from "./state/useProgress.ts";
import { useDailyLog, type DailyLogMap } from "./state/useDailyLog.ts";
@@ -553,10 +555,26 @@ export function App() {
);
}
/**
* Every screen goes through here, which is why the update notice lives here
* rather than beside the list: a reader who is being told "events unavailable"
* or is still picking their games needs the offer at least as much as one
* reading a calendar — a bundle too old for the feed it just downloaded
* (`fetchFeed`'s schemaVersion refusal) lands on exactly that error screen, and
* a reload is the fix.
*/
function Shell({ children }: { children: React.ReactNode }) {
const update = useAppUpdate();
return (
<div className="mx-auto min-h-full max-w-2xl border-hairline sm:border-x">
{children}
{update.available && (
<UpdateNotice
applying={update.applying}
onApply={update.apply}
onDismiss={update.dismiss}
/>
)}
</div>
);
}
+68
View File
@@ -0,0 +1,68 @@
/**
* "A new version is ready."
*
* The one thing this app sells is that what it shows you is current, and a
* cache-first shell quietly breaks that promise for its most loyal reader — the
* one who never closes the tab. This is the disclosure, in the same spirit as
* the offline banner: say what is true, then let them choose.
*
* It is an offer, never a swap. Reloading mid-sentence while someone types their
* own event in would be the app taking a decision that costs them work, so the
* reader picks the moment. Dismissing is free — nothing they have marked, typed
* or ticked lives in the bundle, and the offer comes back next load.
*
* Sits a toast's height up from the bottom edge so an undo toast can never land
* on top of it, and low enough to be reachable with a thumb.
*/
export function UpdateNotice({
applying,
onApply,
onDismiss,
}: {
applying: boolean;
onApply: () => void;
onDismiss: () => void;
}) {
return (
<div
role="status"
aria-live="polite"
className="pointer-events-none fixed inset-x-0 bottom-20 z-50 flex justify-center px-4"
>
<div className="pointer-events-auto flex max-w-md items-center gap-3 rounded-xl border border-hairline bg-raised px-4 py-3 shadow-lg">
<span aria-hidden className="size-1.5 shrink-0 rounded-full bg-near" />
<p className="min-w-0 flex-1 text-xs leading-relaxed text-muted">
A new version of Event Clock is ready.{" "}
<span className="text-faint">
Your marks and notes are kept reloading only loses your place on
the page.
</span>
</p>
<button
type="button"
onClick={onApply}
disabled={applying}
className="shrink-0 rounded-md px-2 py-1 text-xs font-semibold text-near transition-colors duration-150 hover:text-ink disabled:text-faint"
>
{applying ? "Reloading…" : "Reload"}
</button>
<button
type="button"
onClick={onDismiss}
aria-label="Not now"
className="shrink-0 text-faint transition-colors duration-150 hover:text-muted"
>
<svg viewBox="0 0 16 16" className="size-3.5" aria-hidden>
<path
d="M4 4l8 8M12 4l-8 8"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
/>
</svg>
</button>
</div>
</div>
);
}
+174
View File
@@ -0,0 +1,174 @@
import { useCallback, useEffect, useRef, useState } from "react";
/**
* Whether a newer build of the app is sitting on the device, and how to take it.
*
* The service worker serves the shell cache-first (PRD F10), which is what makes
* this app work on a train — and also what makes a deploy invisible. Someone who
* leaves the tab open for a week keeps running the bundle they first loaded: a
* new game, a fixed parser or a corrected date reaches their device and then sits
* there, with nothing anywhere saying why the app looks unchanged. That is the
* same class of failure as presenting stale events as current.
*
* So the deal is: the worker installs quietly and waits, this hook notices it
* waiting, and the reader decides when to lose their scroll position. Nothing
* they have marked, typed or ticked lives in the bundle — it is all in
* localStorage — so reloading costs them nothing but the scroll, and dismissing
* costs them nothing at all, because the offer comes back on the next load.
*/
/** What a page sends a waiting worker to ask it to take over. Matches sw.js. */
export const SKIP_WAITING = "skip-waiting";
/** How often an open page asks the server whether a newer build exists. */
export const CHECK_INTERVAL_MS = 60 * 60 * 1000;
/**
* Is there a new version waiting *for this page*?
*
* A worker that finished installing with no controller in charge is a first
* install, not an update: nothing is being replaced, the reader is already
* looking at the newest thing there is, and telling them a new version is
* available would be a lie on their first visit.
*/
export function isUpdateReady(
registration: { waiting: object | null },
hasController: boolean,
): boolean {
return registration.waiting !== null && hasController;
}
/**
* Whether enough time has passed to ask again.
*
* Hourly, against our own origin — not a source wiki, so § Scraping conduct is
* not in play. The feed itself refreshes twice a day, so anything faster would
* be asking a question that cannot have a new answer.
*/
export function dueForCheck(
lastCheckedMs: number,
nowMs: number,
intervalMs = CHECK_INTERVAL_MS,
): boolean {
return nowMs - lastCheckedMs >= intervalMs;
}
export interface AppUpdate {
/** A newer version is installed, and the reader has not waved it away. */
available: boolean;
/** The reader asked for it; the reload is in flight. */
applying: boolean;
apply: () => void;
dismiss: () => void;
}
export function useAppUpdate(): AppUpdate {
const [ready, setReady] = useState(false);
const [dismissed, setDismissed] = useState(false);
const [applying, setApplying] = useState(false);
/** The worker the Reload button is wired to. */
const waiting = useRef<ServiceWorker | null>(null);
const reloaded = useRef(false);
const reload = useCallback(() => {
if (reloaded.current) return;
reloaded.current = true;
location.reload();
}, []);
useEffect(() => {
if (!("serviceWorker" in navigator)) return;
if (!location.protocol.startsWith("http")) return;
let stopped = false;
let registration: ServiceWorkerRegistration | null = null;
let lastChecked = Date.now();
const offer = (worker: ServiceWorker | null) => {
if (stopped || worker === null) return;
if (!isUpdateReady({ waiting: worker }, navigator.serviceWorker.controller !== null)) return;
if (waiting.current === worker) return;
waiting.current = worker;
// A second, different version arriving re-opens an offer the reader
// dismissed: they declined *that* build, not every future one.
setDismissed(false);
setReady(true);
};
const watch = (worker: ServiceWorker) => {
const onState = () => {
if (worker.state === "installed") offer(worker);
// Superseded, or it failed to install. Drop the offer rather than
// leaving a Reload button wired to a worker that can never take over.
if (worker.state === "redundant" && waiting.current === worker) {
waiting.current = null;
setReady(false);
}
};
worker.addEventListener("statechange", onState);
};
const check = () => {
lastChecked = Date.now();
// A failure here is a reader who is offline or a server that is down.
// Both are normal and neither is worth a word on screen.
void registration?.update().catch(() => {});
};
const onVisible = () => {
if (document.visibilityState !== "visible") return;
if (!dueForCheck(lastChecked, Date.now())) return;
check();
};
const timer = setInterval(() => {
if (dueForCheck(lastChecked, Date.now())) check();
}, CHECK_INTERVAL_MS);
document.addEventListener("visibilitychange", onVisible);
// `ready` rather than registering here: main.tsx registers after load so the
// worker never delays first paint, and this resolves once that has happened.
void navigator.serviceWorker.ready.then((reg) => {
if (stopped) return;
registration = reg;
// A worker that finished installing during an earlier visit is already
// waiting when this page loads — a reload does not release it. Without
// this the reader is never told about a version already on their device.
offer(reg.waiting);
if (reg.installing !== null) watch(reg.installing);
reg.addEventListener("updatefound", () => {
if (reg.installing !== null) watch(reg.installing);
});
});
return () => {
stopped = true;
clearInterval(timer);
document.removeEventListener("visibilitychange", onVisible);
};
}, []);
const apply = useCallback(() => {
const worker = waiting.current;
if (worker === null) return;
setApplying(true);
// Reload when the new worker takes over, not when the message is sent:
// reloading first would re-run the old bundle, because a plain reload does
// not release a waiting worker.
navigator.serviceWorker.addEventListener("controllerchange", reload, {
once: true,
});
worker.postMessage({ type: SKIP_WAITING });
// Belt and braces: if the handover never lands, reload anyway rather than
// leaving a button that visibly did nothing. Worst case they get the same
// version back and the notice returns, which is honest.
setTimeout(reload, 3000);
}, [reload]);
return {
available: ready && !dismissed,
applying,
apply,
dismiss: () => setDismissed(true),
};
}
+77 -18
View File
@@ -1,5 +1,6 @@
/*
* Service worker: keep the app usable without a network.
* Service worker: keep the app usable without a network, and hand a reader the
* next version when there is one.
*
* This app is a good offline candidate — the reader's question ("what expires
* next?") is answered entirely by data already on the device, and countdowns
@@ -11,14 +12,37 @@
* feed (events.json) network-first with cache fallback — fresher is better,
* but stale events beat a blank screen
*
* Bump CACHE_VERSION on any shell change; old caches are deleted on activate.
* Serving the shell cache-first is also what makes a deploy invisible: the
* reader this app is built for leaves the tab open for days, so without a
* deliberate handshake they keep running the bundle they first loaded. So this
* worker installs quietly, waits, and steps in only when the page asks — see
* `message` below and src/client/state/useAppUpdate.ts.
*/
// v2: reader-authored games and events (PRD F13) changed main.js. The shell is
// served cache-first, so without this bump a returning reader keeps the old
// bundle and none of the new UI reaches them — the page looks unchanged and
// nothing anywhere says why.
const CACHE_VERSION = "event-clock-v2";
/*
* Replaced at build time with a hash of the built shell (scripts/build-static.ts).
*
* Its whole job is to make this file's bytes differ when the app differs: the
* browser decides an update exists by byte-comparing sw.js, so a deploy that
* left this file untouched would never be offered to anyone. Left literal in an
* unbuilt copy, where it is a harmless constant.
*/
const BUILD = "__BUILD__";
/*
* Exposed rather than merely declared, for two reasons: it is the quickest way
* to see which build a device is actually running (devtools → Application →
* Service Workers → inspect), and a constant nothing reads is a constant the
* next person deletes as dead code — which would silently end update detection.
*/
self.BUILD = BUILD;
/*
* The cache's name, not the app's version — and deliberately *not* derived from
* BUILD. Everything in here is refetched on install, so a deploy does not need
* a new bucket; giving it one would throw away the cached feed, which is the
* copy an offline reader is reading. Bump it only to abandon a cache whose
* shape changed.
*/
const CACHE_NAME = "event-clock-v2";
// Paths are derived from the registration scope, so the same worker is
// correct at a domain root and under a subpath (GitHub Pages) alike.
const BASE = new URL("./", self.registration.scope);
@@ -26,16 +50,15 @@ const at = (path) => new URL(path, BASE).toString();
const SHELL = ["", "index.html", "styles.css", "main.js"].map(at);
const FEED = new URL("data/events.v1.json", BASE).pathname;
const FONT_HOSTS = new Set(["fonts.googleapis.com", "fonts.gstatic.com"]);
/** What a page sends to ask a waiting worker to take over now. */
const SKIP_WAITING = "skip-waiting";
self.addEventListener("install", (event) => {
event.waitUntil(
caches
.open(CACHE_VERSION)
// addAll is atomic — one 404 would leave nothing cached, so failures are
// tolerated per-item and the fetch handler fills gaps later.
.then((cache) => Promise.allSettled(SHELL.map((url) => cache.add(url))))
.then(() => self.skipWaiting()),
);
// No skipWaiting here. Taking over an open page unasked means the running
// bundle and the cached shell come from two different builds, and the reader
// is told nothing about either. A first install has no worker to wait for and
// activates immediately regardless.
event.waitUntil(precache());
});
self.addEventListener("activate", (event) => {
@@ -44,13 +67,49 @@ self.addEventListener("activate", (event) => {
.keys()
.then((keys) =>
Promise.all(
keys.filter((k) => k !== CACHE_VERSION).map((k) => caches.delete(k)),
keys.filter((k) => k !== CACHE_NAME).map((k) => caches.delete(k)),
),
)
.then(() => self.clients.claim()),
);
});
/**
* Store the shell this worker was built with.
*
* Not `cache.addAll`: it is atomic, so one 404 would leave nothing cached at
* all. Each item is allowed to fail on its own and the fetch handler fills the
* gap later.
*
* `cache: "reload"` because none of these URLs are fingerprinted — main.js is
* main.js at every version, and the HTTP cache would happily hand this brand
* new worker the previous deploy's copy of it.
*/
async function precache() {
const cache = await caches.open(CACHE_NAME);
await Promise.allSettled(
SHELL.map(async (url) => {
const response = await fetch(new Request(url, { cache: "reload" }));
if (response.ok) await cache.put(url, response);
}),
);
}
/**
* The one thing a page can ask of a worker that is waiting: step in now.
*
* Sent when the reader taps Reload on the update notice. The page reloads on
* `controllerchange` rather than on send, so this message is the whole
* handshake — and it exists only because the reader asked, which is why
* `install` does not do it unprompted.
*/
self.addEventListener("message", (event) => {
const data = event.data;
if (typeof data === "object" && data !== null && data.type === SKIP_WAITING) {
void self.skipWaiting();
}
});
self.addEventListener("fetch", (event) => {
const { request } = event;
if (request.method !== "GET") return;
@@ -80,7 +139,7 @@ self.addEventListener("fetch", (event) => {
* the freshest events we ever saw; a failure falls back to that copy.
*/
async function feedFirst(request) {
const cache = await caches.open(CACHE_VERSION);
const cache = await caches.open(CACHE_NAME);
try {
const response = await fetch(request);
if (response.ok) await cache.put(request, response.clone());
@@ -102,7 +161,7 @@ async function feedFirst(request) {
* next visit without ever blocking this one.
*/
async function shellFirst(request) {
const cache = await caches.open(CACHE_VERSION);
const cache = await caches.open(CACHE_NAME);
const cached = await cache.match(request, { ignoreSearch: true });
const network = fetch(request)