// Opening the app is instant, and a deploy never refreshes the page out from // under you. // // Navigations to / are cache-first: a launch paints the installed build's // shell without waiting on the network, and a deploy that lands meanwhile // installs in the background and opens on the next launch. A refresh goes to // the network and gets the new build at once, without a second reload. A page // that installs a newer build while open reloads only if nothing was touched, // and only as it is put away. // // Run: bun tools/sw-reload-test.js (needs a built dist/) import puppeteer from './puppeteer.js'; const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); let ok = true; const check = (n, c, d = '') => { console.log(` ${c ? '✓' : '✗'} ${n}${d ? ' — ' + d : ''}`); if (!c) ok = false; }; const html1 = await Bun.file('dist/index.html').text(); const sw1 = await Bun.file('dist/sw.js').text(); const token = (html1.match(/_v='([a-z0-9]+)'/) || [])[1]; if (!token) { console.log('no build token in dist/index.html — run bun run build'); process.exit(1); } // A "new deploy" is the same app under a different build token. const restamp = (tok) => ({ html: html1.replaceAll(token, tok), sw: sw1.replaceAll(token, tok) }); const builds = { v1: { html: html1, sw: sw1 }, v2: restamp('deadbeef00001'), v3: restamp('deadbeef00002'), v4: restamp('deadbeef00003') }; let current = builds.v1; let pageHits = 0; // network requests for the page itself let preloads = 0; const server = Bun.serve({ port: 5223, fetch(req) { const p = new URL(req.url).pathname; if (p === '/sw.js') return new Response(current.sw, { headers: { 'content-type': 'text/javascript', 'cache-control': 'no-store' } }); if (p === '/' || p === '/index.html') { pageHits++; // as cs nginx does: a launch's preload (no Cache-Control) gets no body if (req.headers.get('service-worker-navigation-preload')) { preloads++; if (!req.headers.get('cache-control')) return new Response(null, { status: 204 }); } return new Response(current.html, { headers: { 'content-type': 'text/html', 'cache-control': 'no-store' } }); } // The worker precaches the shell during install and addAll rejects on any // miss — a 404 here means it never activates and the test proves nothing. // The split build names its entry after the token (app-.js), so // a restamped build asks for app-deadbeef….js: the same file. const f = Bun.file('dist' + p.replace(/deadbeef0000\d/, token)); return new Response(f, { headers: { 'cache-control': 'no-store' } }); }, }); const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox'] }); const page = await browser.newPage(); let loads = 0; page.on('load', () => { loads++; }); page.on('pageerror', (e) => console.log(' [pageerror]', e.message.slice(0, 160))); const running = () => page.evaluate(() => (document.documentElement.outerHTML.match(/_v='([a-z0-9]+)'/) || [])[1]); const controlled = async (ms = 15000) => { for (let i = 0; i < ms / 250; i++) { if (await page.evaluate(() => !!navigator.serviceWorker.controller)) return true; await sleep(250); } return false; }; // the worker's own build, once the newest one has taken over const workerBuild = () => page.evaluate(() => new Promise((res) => { const c = navigator.serviceWorker.controller; if (!c) return res(null); const on = (e) => { if (e.data && e.data.type === 'sw-version') { navigator.serviceWorker.removeEventListener('message', on); res(e.data.version); } }; navigator.serviceWorker.addEventListener('message', on); c.postMessage({ type: 'sw-version' }); setTimeout(() => res(null), 2000); })); const waitWorker = async (tok) => { for (let i = 0; i < 60; i++) { if ((await workerBuild()) === tok) return true; await sleep(250); } return false; }; const putAway = () => page.evaluate(() => { Object.defineProperty(document, 'visibilityState', { configurable: true, get: () => 'hidden' }); document.dispatchEvent(new Event('visibilitychange')); }); try { console.log('[first visit]'); await page.goto('http://localhost:5223/', { waitUntil: 'load' }); await page.evaluate(() => navigator.serviceWorker.register('sw.js', { updateViaCache: 'none' })); check('worker takes control', await controlled()); await sleep(3000); check('a first visit does not reload itself', loads === 1, `${loads} load(s)`); console.log('\n[opening the app after a deploy]'); current = builds.v2; let hits = pageHits, pre = preloads, before = loads; await page.goto('http://localhost:5223/?u=x', { waitUntil: 'load' }); check('the launch paints the installed build', (await running()) === token, `running ${await running()}`); check('without fetching the page (only an empty launch preload)', pageHits - hits === preloads - pre, `${pageHits - hits} page request(s), ${preloads - pre} of them preloads`); check('the new build installs meanwhile', await waitWorker('deadbeef00001')); await sleep(1000); check('and does not reload a page on screen', loads === before + 1, `${loads - before} load(s)`); before = loads; await page.goto('http://localhost:5223/', { waitUntil: 'load' }); check('the next launch opens the new build', (await running()) === 'deadbeef00001', `running ${await running()}`); console.log('\n[refreshing after a deploy]'); current = builds.v3; before = loads; await page.reload({ waitUntil: 'load' }); check('a refresh gets the new build at once', (await running()) === 'deadbeef00002', `running ${await running()}`); await waitWorker('deadbeef00002'); await sleep(2000); check('your refresh is the only refresh', loads === before + 1, `${loads - before} load(s) — one is yours`); console.log('\n[a deploy while the page sits open, untouched]'); current = builds.v4; before = loads; await page.evaluate(() => navigator.serviceWorker.getRegistration().then((r) => r && r.update())); check('the new build installs', await waitWorker('deadbeef00003')); await sleep(1000); check('no reload while on screen', loads === before, `${loads - before} reload(s)`); await putAway(); for (let i = 0; i < 40 && loads === before; i++) await sleep(250); check('it reloads as it is put away', loads === before + 1, `${loads - before} reload(s)`); check('now running the newest build', (await running()) === 'deadbeef00003'); console.log('\n[a deploy while someone is using the page]'); current = builds.v1; before = loads; await page.mouse.click(5, 5); await page.evaluate(() => navigator.serviceWorker.getRegistration().then((r) => r && r.update())); check('the new build installs', await waitWorker(token)); await putAway(); await sleep(2000); check('a page in use is never reloaded', loads === before, `${loads - before} reload(s)`); check('an older build\'s chunks are still cached', await page.evaluate(async () => { const tok = (document.documentElement.outerHTML.match(/_v='([a-z0-9]+)'/) || [])[1]; return !!(await caches.match('app-' + tok + '.js')); })); } catch (e) { check('run completed', false, e.message); } finally { await browser.close(); server.stop(true); } console.log(ok ? '\n✅ sw launch + reload behaviour is right' : '\n❌ failures above'); process.exit(ok ? 0 : 1);