aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-08-03 23:04:41 -0700
committerAdam Soltys <[email protected]>2026-08-03 23:04:41 -0700
commit94c504b650bc13106e93abe4e9c45e840d2ee61e (patch)
treea4e0348ca48bc1712b33ad76d9ea0b8bfc486015
parent7b7e93a6086171fec8a710063c86dbe5d4b9841f (diff)
downloadcoinosv3-94c504b650bc13106e93abe4e9c45e840d2ee61e.tar.gz
coinosv3-94c504b650bc13106e93abe4e9c45e840d2ee61e.zip
A deploy shouldn't refresh the page out from under you
The app is inlined in the page, so when a new service worker took control the page reloaded — otherwise it would keep running the old build. But navigations are network-first: the refresh that triggered the update had already fetched the new page, so the reload landed a second or two later as an unasked-for second refresh. The page and the worker now carry the same build token and the page asks which build took over, reloading only on a real mismatch. The token is hashed before substitution, since a page cannot contain a hash of itself. tools/sw-reload-test.js pins all three cases against a swappable server: a first visit doesn't reload itself, a refresh after a deploy is the only refresh, and a page left sitting open across a deploy is still reloaded — exactly once. The previous build takes two loads for one manual refresh; this one takes one. Co-Authored-By: Claude Opus 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01Wvj4aoeMZwA7VUGdJP9Swi
-rw-r--r--build.js42
-rw-r--r--tools/sw-reload-test.js95
2 files changed, 129 insertions, 8 deletions
diff --git a/build.js b/build.js
index fde56b5..634f4f3 100644
--- a/build.js
+++ b/build.js
@@ -47,6 +47,15 @@ self.addEventListener('install', (e) => {
e.waitUntil(caches.open(CACHE).then((c) => c.addAll(SHELL)));
});
+// The page asks which build we are, and reloads only if it's running an older
+// one. Without this it reloads on every controller change, including the very
+// common case where the navigation that woke us already fetched the new page.
+self.addEventListener('message', (e) => {
+ if (e.data && e.data.type === 'sw-version' && e.source) {
+ e.source.postMessage({ type: 'sw-version', version: '{{VERSION}}' });
+ }
+});
+
self.addEventListener('activate', (e) => {
e.waitUntil(
caches
@@ -107,13 +116,26 @@ const PWA_HEAD = `<link rel="manifest" href="manifest.webmanifest">
`;
// Register with updateViaCache:'none' so the browser always re-fetches sw.js
-// (never from HTTP cache) and detects a new build immediately. When the new SW
-// takes control (it skipWaiting + clients.claim), reload once so the fresh
-// inlined app runs — but only if a SW was already controlling this page, so a
-// first-ever visit doesn't reload.
+// (never from HTTP cache) and detects a new build immediately.
+//
+// When a new worker takes control we may need to reload, because the app is
+// inlined in the page: a page serving the old build would keep running it. But
+// navigations are network-first, so the refresh that triggered the update
+// usually delivered the NEW page already — reloading then is pure
+// interruption, a second unasked-for refresh a beat after your own.
+//
+// So ask the new worker which build it is and reload only on a genuine
+// mismatch. The page's own token is stamped in at build time; both come from
+// the same {{VERSION}}, hashed before substitution so the two agree.
const SW_REGISTER =
- `<script>if('serviceWorker'in navigator){var _had=!!navigator.serviceWorker.controller,_ref=false;` +
- `navigator.serviceWorker.addEventListener('controllerchange',function(){if(_had&&!_ref){_ref=true;location.reload()}});` +
+ `<script>if('serviceWorker'in navigator){var _v='{{VERSION}}',_had=!!navigator.serviceWorker.controller,_ref=false;` +
+ `function _ask(c){try{c&&c.postMessage({type:'sw-version'})}catch(e){}}` +
+ `navigator.serviceWorker.addEventListener('message',function(e){` +
+ `if(!e.data||e.data.type!=='sw-version')return;` +
+ // _v.charAt(0)==='{' means the token never got substituted; treating that as
+ // a mismatch would reload forever.
+ `if(_had&&!_ref&&e.data.version&&_v.charAt(0)!=='{'&&e.data.version!==_v){_ref=true;location.reload()}});` +
+ `navigator.serviceWorker.addEventListener('controllerchange',function(){_ask(navigator.serviceWorker.controller)});` +
`addEventListener('load',function(){navigator.serviceWorker.register('sw.js',{updateViaCache:'none'}).catch(function(){})})}</script>`;
// Bundle the standalone jsQR decoder (sets window.jsQR) for lazy loading.
@@ -239,7 +261,12 @@ ${pwa ? SW_REGISTER + '\n' : ''}</body>
if (import.meta.main) {
await mkdir('dist', { recursive: true });
- const html = await buildHtml({ minify: true });
+ // Hashed with {{VERSION}} still a placeholder, so the page and the worker
+ // can both be stamped with the same token — the page cannot contain a hash
+ // of itself.
+ const rawHtml = await buildHtml({ minify: true });
+ const version = Bun.hash(rawHtml).toString(36);
+ const html = rawHtml.replaceAll('{{VERSION}}', version);
await Bun.write('dist/index.html', html);
// Lazy-loaded QR decoder — kept out of index.html, fetched only when a
@@ -253,7 +280,6 @@ if (import.meta.main) {
// PWA sidecars.
await Bun.write('dist/manifest.webmanifest', JSON.stringify(MANIFEST, null, 2));
- const version = Bun.hash(html).toString(36);
// Full builds get the auto-answering NWC worker; feature-stripped builds
// keep the notify-only handler (the responder would just dead-weight them).
const feats = enabledFeatures();
diff --git a/tools/sw-reload-test.js b/tools/sw-reload-test.js
new file mode 100644
index 0000000..a869d9d
--- /dev/null
+++ b/tools/sw-reload-test.js
@@ -0,0 +1,95 @@
+// A deploy should not refresh the page out from under you.
+//
+// The app is inlined in the page, so when a new worker takes over, a page
+// still running the old build has to reload. But navigations are
+// network-first: the refresh that triggered the update has usually already
+// fetched the new page, and reloading then is a second, unasked-for refresh a
+// beat after your own. Both halves are asserted here — the quiet case and the
+// case where a reload is genuinely owed.
+//
+// Run: bun tools/sw-reload-test.js (needs a built dist/)
+import puppeteer from 'puppeteer-core';
+
+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') };
+let current = builds.v1;
+
+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') 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.
+ const f = Bun.file('dist' + p);
+ 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('console', (m) => { if (m.type() === 'error') console.log(' [page]', m.text().slice(0, 160)); });
+page.on('pageerror', (e) => console.log(' [pageerror]', e.message.slice(0, 160)));
+
+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;
+};
+
+try {
+ console.log('[first visit]');
+ await page.goto('http://localhost:5223/', { waitUntil: 'load' });
+ const reg = await page.evaluate(async () => {
+ try {
+ const r = await navigator.serviceWorker.register('sw.js', { updateViaCache: 'none' });
+ return { ok: true, scope: r.scope, active: !!r.active, installing: !!r.installing, waiting: !!r.waiting };
+ } catch (e) { return { ok: false, err: String(e).slice(0, 200) }; }
+ });
+ console.log(' register:', JSON.stringify(reg));
+ 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[refreshing after a deploy]');
+ current = builds.v2;
+ const before = loads;
+ await page.reload({ waitUntil: 'load' });
+ await sleep(6000); // well past install + activate + claim
+ check('your refresh is the only refresh', loads === before + 1, `${loads - before} load(s) — one is yours`);
+ check('and the new build is what is running', await page.evaluate(() => document.documentElement.outerHTML.includes("_v='deadbeef00001'")));
+
+ console.log('\n[a deploy while the page sits open]');
+ current = builds.v3;
+ const before2 = loads;
+ // no navigation: just let the worker notice, the way the periodic update
+ // check would
+ await page.evaluate(() => navigator.serviceWorker.getRegistration().then((r) => r && r.update()));
+ for (let i = 0; i < 40 && loads === before2; i++) await sleep(250);
+ check('a stale page still gets reloaded', loads === before2 + 1, `${loads - before2} reload(s)`);
+ await sleep(3000);
+ check('and only once', loads === before2 + 1, `${loads - before2} reload(s)`);
+ check('now running the newest build', await page.evaluate(() => document.documentElement.outerHTML.includes("_v='deadbeef00002'")));
+} catch (e) {
+ check('run completed', false, e.message);
+} finally {
+ await browser.close();
+ server.stop(true);
+}
+console.log(ok ? '\n✅ sw reload behaviour is right' : '\n❌ failures above');
+process.exit(ok ? 0 : 1);