aboutsummaryrefslogtreecommitdiffstats
path: root/tools/sw-reload-test.js
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 /tools/sw-reload-test.js
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]>
Diffstat (limited to 'tools/sw-reload-test.js')
-rw-r--r--tools/sw-reload-test.js111
1 files changed, 79 insertions, 32 deletions
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);