From 94c504b650bc13106e93abe4e9c45e840d2ee61e Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Mon, 3 Aug 2026 23:04:41 -0700 Subject: A deploy shouldn't refresh the page out from under you MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01Wvj4aoeMZwA7VUGdJP9Swi --- build.js | 42 ++++++++++++++++++++++++++++++++++-------- 1 file changed, 34 insertions(+), 8 deletions(-) (limited to 'build.js') 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 = ` `; // 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 = - ``; // Bundle the standalone jsQR decoder (sets window.jsQR) for lazy loading. @@ -239,7 +261,12 @@ ${pwa ? SW_REGISTER + '\n' : ''} 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(); -- cgit v1.2.3