From dd71f6b2ab0527d633b47cab4a953a261342e0fe Mon Sep 17 00:00:00 2001 From: Jonathan Sykes Date: Thu, 2 Jul 2026 13:40:36 +0800 Subject: [PATCH] fix: fetch and activate the new service worker on Refresh UI when none is waiting yet --- frontend/sw-update.js | 33 +++++++++++++++++- frontend/sw-update.test.js | 70 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 102 insertions(+), 1 deletion(-) diff --git a/frontend/sw-update.js b/frontend/sw-update.js index b219bba..85c9de0 100644 --- a/frontend/sw-update.js +++ b/frontend/sw-update.js @@ -36,8 +36,20 @@ * @returns {Promise} */ async function applyUpdate({ reg, container, reload, setTimeout: setTimeoutFn }) { - const waiting = reg && reg.waiting; 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 @@ -60,6 +72,25 @@ 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) { diff --git a/frontend/sw-update.test.js b/frontend/sw-update.test.js index 4704cab..a729aab 100644 --- a/frontend/sw-update.test.js +++ b/frontend/sw-update.test.js @@ -93,6 +93,76 @@ test('falls back to a plain reload when there is no waiting worker', async () => assert.strictEqual(reloadCount, 1); }); +test('with no waiting worker, fetches the SW update and activates the newly installed worker (buildTag-poll path)', async () => { + // Simulates: server redeployed (banner shown by the /api/version poll) but + // the browser hasn't fetched the new sw.js yet — reg.waiting is null until + // reg.update() is called and the new worker finishes installing. + const messages = []; + const container = fakeContainer(); + let reloadCount = 0; + + const stateListeners = []; + const installing = { + state: 'installing', + addEventListener: (type, fn) => { if (type === 'statechange') stateListeners.push(fn); }, + postMessage: (m) => messages.push(m), + }; + const reg = { + waiting: null, + installing: null, + update() { + // Browser found a byte-different sw.js → a new worker starts installing. + this.installing = installing; + return Promise.resolve(); + }, + }; + + const done = applyUpdate({ + reg, + container, + reload: () => { reloadCount++; }, + setTimeout: () => {}, // no-op — we drive state transitions manually + }); + + // Let applyUpdate reach the waitForInstalled stage, then finish the install. + await Promise.resolve(); await Promise.resolve(); + installing.state = 'installed'; + reg.waiting = installing; + stateListeners.forEach((fn) => fn()); + await Promise.resolve(); await Promise.resolve(); + + assert.deepStrictEqual(messages, [{ type: 'SKIP_WAITING' }], 'skip-waiting sent to the freshly installed worker'); + assert.strictEqual(reloadCount, 0, 'must not reload before the new SW takes control'); + + container.fireControllerChange(); + await done; + assert.strictEqual(reloadCount, 1); +}); + +test('with no waiting worker and no update found, reloads once after the bounded wait', async () => { + const container = fakeContainer(); + let reloadCount = 0; + const timeouts = []; + + const reg = { + waiting: null, + installing: null, + update: () => Promise.resolve(), // update check ran; nothing new + }; + + const done = applyUpdate({ + reg, + container, + reload: () => { reloadCount++; }, + setTimeout: (fn) => { timeouts.push(fn); }, + }); + + await Promise.resolve(); await Promise.resolve(); + // reg.installing is null → waitForInstalled resolves immediately with null. + await done; + assert.strictEqual(reloadCount, 1, 'plain reload when the update check finds nothing'); +}); + test('falls back to a plain reload when there is no registration at all', async () => { const container = fakeContainer(); let reloadCount = 0;