Show the update banner only when the running build differs from the server and make Refresh UI land the new shell on flaky links

This commit is contained in:
Jonathan Sykes
2026-09-13 17:42:50 +08:00
parent 5fb863ba25
commit 91dab289c3
8 changed files with 526 additions and 266 deletions

View File

@@ -1,5 +1,5 @@
/* ============================================================================
* sw-update — applies a waiting service-worker update in place.
* sw-update — makes "Refresh UI" actually land the new build.
*
* Framework-free and dependency-free on purpose (same pattern as
* async-guard.js):
@@ -7,53 +7,128 @@
* global `window.SwUpdate`).
* • `require`-able by `node --test` (CommonJS `module.exports`).
*
* Bug this fixes (Task #61): "Update ready" kept reappearing right after the
* user clicked "Reload now" / "Refresh UI". The old flow called
* hardReloadUI(), which unregisters the service worker and wipes every cache
* before navigating — forcing a brand-new install on the next load. That
* fresh install briefly has `reg.waiting` truthy again (a normal but
* transient SW lifecycle state), which registerServiceWorker() misread as a
* genuinely new update and re-showed the banner immediately.
* History: the "Update available keeps showing" loop was patched three times
* by chasing individual triggers (a transient `reg.waiting`, the activate
* broadcast, the buildTag poll firing before a worker was waiting). It kept
* coming back on the real deployment because the homelab link is slow and
* drops requests: the new worker's install (`cache.addAll`, all-or-nothing)
* failed, "Refresh UI" reloaded into the OLD cached shell, and the next
* successful install re-opened the banner — sometimes on a page that was
* already current (a failed install leaves a partial cache that the
* activate handler then mistook for a previous deploy).
*
* Fix: activate the *already-installed* waiting worker in place —
* postMessage SKIP_WAITING to it, wait for it to actually take control
* (`controllerchange`), and only then reload. The reloaded page is served by
* the new SW from its very first request, and no fresh install/registration
* cycle happens, so the banner has nothing to spuriously re-trigger on.
* The fix no longer depends on the service-worker install lifecycle:
* 1. refreshShellInPlace() downloads a fresh copy of every file the shell
* caches hold (cache-busted, so even an old worker's cache-first
* handler can't answer with the stale copy) — ALL of them or nothing —
* and writes them into every versioned shell cache. Whichever worker
* serves the next load, it serves the new build.
* 2. Only then is a waiting worker (if any) activated, and the page
* reloaded once.
* The banner itself only opens when the build the page is running differs
* from the server's (see app.js maybeShowUpdateBanner), so no lifecycle
* event can re-open it once the page is current.
* ========================================================================== */
(function (root) {
'use strict';
const BUST_PARAM = '__ytpfresh';
function withTimeout(promise, ms, setTimeoutFn, label) {
if (!ms || !setTimeoutFn) return promise;
return Promise.race([
promise,
new Promise((_, reject) => setTimeoutFn(() => reject(new Error(label || 'timed out')), ms)),
]);
}
/**
* Applies a pending SW update: messages the waiting worker to skipWaiting(),
* waits for controllerchange, then reloads exactly once.
* Re-downloads every file held by the versioned shell caches and replaces
* the cached copies. All-or-nothing: if any download fails, nothing is
* written and it throws, so a flaky connection can never leave a
* half-old/half-new shell.
*
* @param {object} opts
* @param {ServiceWorkerRegistration|null} opts.reg the current registration
* @param {ServiceWorkerContainer} opts.container navigator.serviceWorker
* @param {() => void} opts.reload called at most once
* @param {(fn: () => void, ms: number) => any} [opts.setTimeout] injectable for tests
* @returns {Promise<void>}
* @param {CacheStorage} opts.cachesApi
* @param {(url: string, init: object) => Promise<Response>} opts.fetchFn
* @param {(name: string) => boolean} opts.isShellCache
* @param {string} [opts.bust] cache-busting token
* @param {number} [opts.timeoutMs] overall download budget
* @param {number} [opts.attempts] tries per file (default 3)
* @param {number} [opts.attemptTimeoutMs] bound on a single try
* @param {Function} [opts.setTimeout]
* @returns {Promise<{refreshed: number, caches: number}>}
*/
async function applyUpdate({ reg, container, reload, setTimeout: setTimeoutFn }) {
const scheduleTimeout = setTimeoutFn || (typeof setTimeout !== 'undefined' ? setTimeout : null);
let waiting = reg && reg.waiting;
async function refreshShellInPlace({ cachesApi, fetchFn, isShellCache, bust, timeoutMs, attempts, attemptTimeoutMs, setTimeout: setTimeoutFn }) {
const names = (await cachesApi.keys()).filter(isShellCache);
if (!names.length) return { refreshed: 0, caches: 0 }; // uncontrolled page: a reload already hits the network
if (!waiting && reg && typeof reg.update === 'function') {
// The banner can be triggered by the server buildTag poll before the
// browser has fetched the new sw.js at all. With no waiting worker, a
// bare reload would be served the OLD cache-first shell, the new SW
// would then install in the background, and the banner would reappear
// — the "update available keeps showing" loop. Fetch the update now
// and wait (bounded) for it to reach `installed` so a single click
// activates the new version.
try { await reg.update(); } catch { /* offline / fetch failed — fall through */ }
waiting = reg.waiting || (await waitForInstalled(reg, scheduleTimeout, 8000));
const urls = new Set();
for (const name of names) {
const cache = await cachesApi.open(name);
for (const req of await cache.keys()) {
const u = new URL(req.url);
if (u.searchParams.has(BUST_PARAM)) { await cache.delete(req); continue; } // leftovers of a failed try
urls.add(u.href);
}
}
const token = bust || String(Date.now());
// Each file gets a few attempts (the homelab link drops requests), each
// bounded so one hung connection can't eat the whole budget.
const fetchOne = async (url) => {
let last = null;
for (let attempt = 0; attempt < (attempts || 3); attempt++) {
const u = new URL(url);
u.searchParams.set(BUST_PARAM, token + '-' + attempt);
const ctl = typeof AbortController !== 'undefined' ? new AbortController() : null;
try {
const res = await withTimeout(
fetchFn(u.href, { cache: 'reload', credentials: 'same-origin', signal: ctl ? ctl.signal : undefined }),
attemptTimeoutMs, setTimeoutFn, `${new URL(url).pathname} timed out`);
if (res && res.ok) return [url, res];
last = new Error(`${new URL(url).pathname} → ${res ? res.status : 'no response'}`);
} catch (err) {
if (ctl) { try { ctl.abort(); } catch { /* already done */ } }
last = err;
}
}
throw last;
};
const fetched = await withTimeout(Promise.all([...urls].map(fetchOne)),
timeoutMs, setTimeoutFn, 'update download timed out');
for (const name of names) {
const cache = await cachesApi.open(name);
for (const [url, res] of fetched) await cache.put(url, res.clone());
// An old worker's cache-first handler may have stored the busted URLs.
for (const req of await cache.keys()) {
if (new URL(req.url).searchParams.has(BUST_PARAM)) await cache.delete(req);
}
}
return { refreshed: fetched.length, caches: names.length };
}
/**
* Applies an update: refresh the shell in place (throws on failure — the
* caller reports it and nothing reloads), then activate a waiting worker if
* there is one, then reload exactly once.
*
* @param {object} opts
* @param {ServiceWorkerRegistration|null} opts.reg
* @param {ServiceWorkerContainer} opts.container
* @param {() => void} opts.reload called at most once
* @param {() => Promise<any>} [opts.refreshShell] refreshShellInPlace bound to real APIs
* @param {Function} [opts.setTimeout] injectable for tests
* @returns {Promise<void>}
*/
async function applyUpdate({ reg, container, reload, refreshShell, setTimeout: setTimeoutFn }) {
const scheduleTimeout = setTimeoutFn || (typeof setTimeout !== 'undefined' ? setTimeout : null);
if (refreshShell) await refreshShell();
const waiting = reg && reg.waiting;
if (!waiting) {
// Nothing to activate (e.g. banner was shown from a broadcast message
// rather than an actual waiting worker) — just reload.
reload();
return;
}
@@ -64,34 +139,14 @@
reloaded = true;
reload();
};
// The waiting worker's cache was refreshed too, so activating it is safe
// either way; reload on controllerchange, or after a short safety net.
container.addEventListener('controllerchange', reloadOnce, { once: true });
// Safety net in case controllerchange never fires (e.g. no controller yet).
if (scheduleTimeout) scheduleTimeout(reloadOnce, 3000);
if (scheduleTimeout) scheduleTimeout(reloadOnce, 4000);
waiting.postMessage({ type: 'SKIP_WAITING' });
}
/**
* Waits for reg.installing to reach the `installed` state (at which point
* it becomes reg.waiting), bounded by a timeout. Resolves with the waiting
* worker or null.
*/
function waitForInstalled(reg, scheduleTimeout, ms) {
return new Promise((resolve) => {
const sw = reg.installing;
if (!sw || typeof sw.addEventListener !== 'function') { resolve(null); return; }
let settled = false;
const settle = (v) => { if (!settled) { settled = true; resolve(v); } };
sw.addEventListener('statechange', () => {
if (sw.state === 'installed') settle(reg.waiting || sw);
else if (sw.state === 'redundant') settle(null);
});
if (scheduleTimeout) scheduleTimeout(() => settle(reg.waiting || null), ms);
});
}
const SwUpdate = { applyUpdate };
const SwUpdate = { applyUpdate, refreshShellInPlace, BUST_PARAM };
if (typeof module !== 'undefined' && module.exports) {
module.exports = SwUpdate;