diff --git a/tests/ui/offline.spec.ts b/tests/ui/offline.spec.ts index cb41eb8..3a6ebdd 100644 --- a/tests/ui/offline.spec.ts +++ b/tests/ui/offline.spec.ts @@ -92,6 +92,23 @@ test('a place in an episode saved offline waits, and is saved once back', async expect((await call(page, '/api/entries/offline-blog/offline-2')).position).toBe(321); }); +test('a page older than the script the server sends is loaded again, not run with it', async ({ page }) => { + // #449: a page kept from before a deploy asks for its script by the old hash, and the server + // sends the new one whatever the name says. + await controlled(page); + const mine = await page.evaluate(() => document.querySelector('script[src^="/app.js"]')!.getAttribute('src')!); + const kept = () => page.evaluate(async () => (await (await caches.open('ipx-page')).keys()).map(k => new URL(k.url).pathname + new URL(k.url).search)); + await expect.poll(kept).toContain(mine); + await page.evaluate(() => { (window as unknown as { stale: boolean }).stale = true; }); + const reloaded = page.waitForEvent('load'); + expect(await page.evaluate(() => fetch('/app.js?v=000000000000').then(() => 'given', () => 'refused'))).toBe('refused'); + await reloaded; + expect(await page.evaluate(() => 'stale' in window), 'the page was loaded again').toBe(false); + // What is kept is still the script under its own name, not the new one under the old. + expect(await kept()).toContain(mine); + expect(await kept()).not.toContain('/app.js?v=000000000000'); +}); + test('online, a picture in an item\'s text still comes from its publisher, through the worker', async ({ page }) => { await controlled(page); await page.locator('#feedlist .feed', { hasText: 'Offline Blog' }).click({ timeout: 20_000 }); diff --git a/web/CHANGELOG.md b/web/CHANGELOG.md index 9ddcf4b..9760eb0 100644 --- a/web/CHANGELOG.md +++ b/web/CHANGELOG.md @@ -33,6 +33,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- A page kept for offline from before a deploy, shown on waking before the network was back, is loaded again rather than run with the new script, which left it dead until reloaded by hand (#449). - On a phone the totals along the bottom (All Subscriptions: N items, N unread) are text alone, centred just above the home indicator, instead of a band and rule that ran to the bottom edge and were cut off by the screen's rounded corners (#424). - The admin page's top bar clears the status bar in the iOS app, so its back button can be tapped; the page asked for no room under the clock as the app page does (#423). - Swiping, or j, through more than 50 items no longer stops at the 50th until a reload: the next ones are fetched as you near the end, and Load more on the Unread tab no longer skips as many unread items as you had read (#383). diff --git a/web/sw/sw.ts b/web/sw/sw.ts index e82142c..97c004b 100644 --- a/web/sw/sw.ts +++ b/web/sw/sw.ts @@ -48,7 +48,7 @@ sw.addEventListener('fetch', e => { } if (req.mode === 'navigate') e.respondWith(page(req)); // Named by a hash of their contents: what is kept is what the network would send. - else if (url.searchParams.has('v') && !url.pathname.startsWith('/api/')) e.respondWith(hashed(req)); + else if (url.searchParams.has('v') && !url.pathname.startsWith('/api/')) e.respondWith(hashed(req, e.clientId)); else e.respondWith(networkFirst(req, url.pathname.startsWith('/api/') ? API : PAGE)); }); @@ -70,12 +70,21 @@ async function page(req: Request): Promise { } } -async function hashed(req: Request): Promise { +async function hashed(req: Request, client: string): Promise { const kept = await caches.match(req, { cacheName: PAGE }); if (kept) return kept; const r = await fetch(req); if (r.ok) { - const c = await caches.open(PAGE), path = new URL(req.url).pathname; + const url = new URL(req.url); + // The server sends what it has now, whatever version the name asks for. A page kept from + // before a deploy, shown on waking before the network was back, got the new script under its + // old name, died at the first element it lacked (#449), and the new script was kept under the + // old name. Such a page is stale: it is loaded again, from the network, and not given the file. + if (await sha12(await r.clone().arrayBuffer()) !== url.searchParams.get('v')) { + void reloadStale(client); + return Response.error(); + } + const c = await caches.open(PAGE), path = url.pathname; // The version before this one is no use once this one is here. for (const k of await c.keys()) if (new URL(k.url).pathname === path) await c.delete(k); await c.put(req, r.clone()); @@ -83,6 +92,23 @@ async function hashed(req: Request): Promise { return r; } +/// What web/build.mts names a file by: the first 12 hex digits of its SHA-256. +async function sha12(body: ArrayBuffer): Promise { + const d = new Uint8Array(await crypto.subtle.digest('SHA-256', body)); + return Array.from(d.slice(0, 6), b => b.toString(16).padStart(2, '0')).join(''); +} + +// Once in a while at most: if the page itself still cannot be had from the network, the kept one +// comes back, asks for its old files again, and would be reloaded round and round. +let reloadedAt = 0; +async function reloadStale(id: string) { + if (Date.now() - reloadedAt < 30_000) return; + const c = await sw.clients.get(id); + if (!c || !('navigate' in c)) return; + reloadedAt = Date.now(); + await (c as WindowClient).navigate(c.url); +} + async function networkFirst(req: Request, name: string): Promise { try { const r = await fetch(req);