aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-11-06 22:12:03 -0700
committerAdam Soltys <[email protected]>2026-11-06 22:12:03 -0700
commitefbc15065d78b1bbc0a841b628faf3f6b23ee0a0 (patch)
treed8d2b6b6ee63013d5753be68374a9ba2b83f21f4
parent9886d4f1f51a6f0671d68ac3ba115e02b27ed474 (diff)
downloadcoinosv3-efbc15065d78b1bbc0a841b628faf3f6b23ee0a0.tar.gz
coinosv3-efbc15065d78b1bbc0a841b628faf3f6b23ee0a0.zip
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)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
-rw-r--r--build.js97
-rw-r--r--tools/sw-reload-test.js111
2 files changed, 154 insertions, 54 deletions
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 = `<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 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(){})})}</script>`;
diff --git a/tools/sw-reload-test.js b/tools/sw-reload-test.js
index 6ad4fed..d626514 100644
--- a/tools/sw-reload-test.js
+++ b/tools/sw-reload-test.js
@@ -1,11 +1,12 @@
-// A deploy should not refresh the page out from under you.
+// Opening the app is instant, and a deploy never refreshes 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.
+// 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';
@@ -21,15 +22,25 @@ if (!token) { console.log('no build token in dist/index.html — run bun run bui
// 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') };
+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') return new Response(current.html, { headers: { 'content-type': 'text/html', '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-<token>.js), so
@@ -43,9 +54,9 @@ const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome
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 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;
@@ -53,45 +64,81 @@ const controlled = async (ms = 15000) => {
}
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' });
- 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));
+ 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[refreshing after a deploy]');
+ console.log('\n[opening the app after a deploy]');
current = builds.v2;
- const before = loads;
+ 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' });
- await sleep(6000); // well past install + activate + claim
+ 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`);
- 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
+ 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()));
- 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'")));
+ 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 reload behaviour is right' : '\n❌ failures above');
+console.log(ok ? '\n✅ sw launch + reload behaviour is right' : '\n❌ failures above');
process.exit(ok ? 0 : 1);