From efbc15065d78b1bbc0a841b628faf3f6b23ee0a0 Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Fri, 6 Nov 2026 22:12:03 -0700 Subject: Opening the app paints the installed build from the cache: no network wait, and no whole-build download on the first launch after a deploy. Navigations to / were network-first, so every launch waited on a round trip for the page, and after each deploy (several a day) the page it got named a new 1 MB entry bundle plus changed chunks (~650 KB compressed) that had to download before anything showed — seconds on a phone's data, which is where "so slow to load" (a user on the APK, 2026-11-05) comes from. Now / is cache-first from this build's own cache; the browser checks sw.js on the same navigation, a new build installs in the background (shell, entry and chunks together) and the next launch opens it. A refresh (request cache mode reload/no-cache) still goes to the network and gets the new build at once. Other paths (server-rendered note and profile pages) stay network-first, and only this build's own page is stored as its shell — before, any navigation's response (a profile page included) was put as './'. Navigation preload is on, so a network-bound navigation's request starts while the worker is still starting; cs nginx answers a launch's preload (no Cache-Control on it; a refresh sends max-age=0) with an empty 204, so a launch spends no bytes on a page it paints from the cache, and the worker then fetches normally if it does need it. Activation keeps the previous build's cache one deploy longer, so a page launched from the old shell still finds its lazily imported chunks. A page that installs a newer build while open no longer reloads under your hands: if nothing has been touched it reloads as it is put away, otherwise the new build waits for the next launch. tools/sw-reload-test.js covers launch (old build painted, page not fetched beyond an empty preload, new build installs, no reload, next launch opens it), refresh (new build at once, one load), an untouched open page (reloads only when hidden) and a used one (never reloads, older chunks still cached) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- build.js | 97 +++++++++++++++++++++++++++++++++++++++++++++++++--------------- 1 file changed, 75 insertions(+), 22 deletions(-) (limited to 'build.js') diff --git a/build.js b/build.js index 838e891..4cb68f3 100644 --- a/build.js +++ b/build.js @@ -33,15 +33,16 @@ const MANIFEST = { ], }; -// Service worker: precache the app shell, network-first for the page (so a new -// deploy shows up), cache-first for the icons/manifest, and never touch the +// Service worker: precache the app shell, cache-first for opening the app at / +// (a new deploy opens on the launch after it installs; a refresh goes to the +// network), network-first for other pages, cache-first for assets, and never touch the // cross-origin explorer/Nostr requests. CACHE carries a build token so each // deploy supersedes the previous cache. {{VERSION}} is filled in at build time. const SW = `const CACHE = 'cold-{{VERSION}}'; // jsqr.js / nip46.js are precached so the lazy QR decoder and remote-signer // module are available offline too. app-{{VERSION}}.js is the main bundle — -// content-addressed, so a new deploy precaches a new name and the old cache -// is dropped wholesale. +// content-addressed, so a new deploy precaches a new name; the previous +// build's cache is kept one deploy longer (see activate). const SHELL = ['./', 'manifest.webmanifest', 'icon-192.png', 'icon-512.png', 'jsqr.js', 'nip46.js', 'verify-worker.js', 'PublicSans.woff2', 'PublicSans-Italic.woff2', 'app-{{VERSION}}.js'{{EXTRA_SHELL}}]; self.addEventListener('install', (e) => { @@ -58,13 +59,24 @@ self.addEventListener('message', (e) => { } }); +// Activating keeps the previous build's cache beside the new one: a page +// launched from the old shell (navigations to / are cache-first, below) keeps +// running the old build until its next launch, and its lazily imported chunks +// must still be found. Older caches go. Navigation preload starts a +// navigation's network request while the worker itself is still starting. +const META = 'coinos-sw-meta'; self.addEventListener('activate', (e) => { - e.waitUntil( - caches - .keys() - .then((keys) => Promise.all(keys.filter((k) => k !== CACHE).map((k) => caches.delete(k)))) - .then(() => self.clients.claim()) - ); + e.waitUntil((async () => { + try { if (self.registration.navigationPreload) await self.registration.navigationPreload.enable(); } catch (_) {} + const meta = await caches.open(META); + let order = []; + try { const r = await meta.match('cache-order'); if (r) order = await r.json(); } catch (_) {} + const keep = [CACHE, ...order.filter((k) => k !== CACHE)].slice(0, 2); + await meta.put('cache-order', new Response(JSON.stringify(keep))); + const keys = await caches.keys(); + await Promise.all(keys.filter((k) => k !== META && !keep.includes(k)).map((k) => caches.delete(k))); + await self.clients.claim(); + })()); }); // NWC push handling lives in src/sw-nwc.js (bundled and appended below when @@ -94,18 +106,50 @@ self.addEventListener('fetch', (e) => { if (u.pathname === '/docs' || u.pathname.startsWith('/docs/')) return; if (req.mode === 'navigate') { - // A note page comes back with its thread rendered into it (the server's - // note-page); that copy must not become the offline shell for every path. - const notePage = /^\\/(note|nevent)1/i.test(u.pathname); + // Opening the app (/, the installed app's and the Android app's start + // page) paints this build's shell from the cache, with no network wait: + // network-first made every launch wait on a round trip, and the first + // launch after each deploy download the whole new build (~650 KB + // compressed) before showing anything — seconds on a phone's data. The + // browser checks sw.js on the same navigation; a new build installs in + // the background (shell, entry and chunks together) and the next launch + // opens it. A refresh (pull-to-refresh, reload) still goes to the network. + // Other paths (note and profile pages come rendered by the server) stay + // network-first, falling back to the shell offline. + const root = u.pathname === '/'; + const refresh = req.cache === 'reload' || req.cache === 'no-cache'; + // The preload is answered with an empty 204 when it's for a launch (no + // Cache-Control on the request; a refresh sends max-age=0), so opening the + // app spends no bytes on a page it paints from the cache (cs nginx). + const preload = e.preloadResponse ? e.preloadResponse.catch(() => null) : Promise.resolve(null); + e.waitUntil(preload); // an unused preload must still settle + const net = () => preload.then((r) => (r && r.status !== 204 ? r : fetch(req))); + const shell = () => caches.open(CACHE).then((c) => c.match('./')).then((hit) => hit || caches.match('./')); + if (root && !refresh) { + e.respondWith( + caches.open(CACHE).then((c) => c.match('./')) + .then((hit) => hit || net()) + .catch(() => shell()) + ); + return; + } e.respondWith( - fetch(req) + net() .then((res) => { - if (notePage) return res; - const copy = res.clone(); - caches.open(CACHE).then((c) => c.put('./', copy)); + // Only this build's own page becomes its shell: a server-rendered + // note or profile page, or a newer build's page fetched before its + // worker installed, must not be what the next launch opens. + if (root && res.ok) { + const copy = res.clone(); + e.waitUntil(copy.text().then((t) => { + if (t.includes('app-{{VERSION}}.js')) { + return caches.open(CACHE).then((c) => c.put('./', new Response(t, { headers: copy.headers }))); + } + }).catch(() => {})); + } return res; }) - .catch(() => caches.match('./')) + .catch(() => shell()) ); return; } @@ -139,9 +183,8 @@ 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 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 +// When a new worker takes control, a page serving the old build keeps running +// it. A refresh goes to the network, 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. // @@ -155,7 +198,17 @@ const SW_REGISTER = `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()}});` + + // + // Opening the app paints the cached shell, so a build that installs while + // it's open finds it running the previous one. The new build then waits for + // the next launch — unless nothing has been touched yet: then it reloads as + // the page is put away (or now, if it already is), with nothing to lose. + // Never under someone's hands: a send half-typed while they fetch an + // invoice from another app must still be there when they come back. + `if(_had&&!_ref&&e.data.version&&_v.charAt(0)!=='{'&&e.data.version!==_v){_ref=true;_stale=true;_go()}});` + + `var _stale=false,_used=false;function _go(){if(_stale&&!_used&&document.visibilityState==='hidden')location.reload()}` + + `['pointerdown','keydown'].forEach(function(t){addEventListener(t,function(){_used=true},{capture:true,passive:true})});` + + `document.addEventListener('visibilitychange',_go);` + `navigator.serviceWorker.addEventListener('controllerchange',function(){_ask(navigator.serviceWorker.controller)});` + `addEventListener('load',function(){navigator.serviceWorker.register('/sw.js',{updateViaCache:'none'}).catch(function(){})})}`; -- cgit v1.2.3