102 lines
4.4 KiB
JavaScript
102 lines
4.4 KiB
JavaScript
/* ============================================================================
|
|
* sw-update — applies a waiting service-worker update in place.
|
|
*
|
|
* Framework-free and dependency-free on purpose (same pattern as
|
|
* async-guard.js):
|
|
* • Loads as a plain <script> under CSP `script-src 'self'` (browser
|
|
* 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.
|
|
*
|
|
* 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.
|
|
* ========================================================================== */
|
|
(function (root) {
|
|
'use strict';
|
|
|
|
/**
|
|
* Applies a pending SW update: messages the waiting worker to skipWaiting(),
|
|
* waits for controllerchange, then reloads exactly once.
|
|
*
|
|
* @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>}
|
|
*/
|
|
async function applyUpdate({ reg, container, reload, setTimeout: setTimeoutFn }) {
|
|
const scheduleTimeout = setTimeoutFn || (typeof setTimeout !== 'undefined' ? setTimeout : null);
|
|
let waiting = reg && reg.waiting;
|
|
|
|
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));
|
|
}
|
|
|
|
if (!waiting) {
|
|
// Nothing to activate (e.g. banner was shown from a broadcast message
|
|
// rather than an actual waiting worker) — just reload.
|
|
reload();
|
|
return;
|
|
}
|
|
|
|
let reloaded = false;
|
|
const reloadOnce = () => {
|
|
if (reloaded) return;
|
|
reloaded = true;
|
|
reload();
|
|
};
|
|
|
|
container.addEventListener('controllerchange', reloadOnce, { once: true });
|
|
// Safety net in case controllerchange never fires (e.g. no controller yet).
|
|
if (scheduleTimeout) scheduleTimeout(reloadOnce, 3000);
|
|
|
|
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 };
|
|
|
|
if (typeof module !== 'undefined' && module.exports) {
|
|
module.exports = SwUpdate;
|
|
} else {
|
|
root.SwUpdate = SwUpdate;
|
|
}
|
|
})(typeof globalThis !== 'undefined' ? globalThis : this);
|