A page older than its script is loaded again, not run with it (#449)
Safari on a Mac threw at load on 2026-11-10: the script wired #sleep, which the page it ran in did not have. #sleep and its script came in together on 11-08, so the page was older than the script. The service worker answers a navigation it cannot fetch with the page it kept last, here one from before 11-08, and that page asked for its script by the old hash; by then the network answered, and Caddy, which ignores ?v=, sent today's app.js. The worker even kept it under the old name. Every ?v= is the first 12 hex digits of the file's SHA-256, so the worker now checks what it fetched against the name. On a mismatch it does not keep or give the file, and loads the page again from the network, at most once in 30 seconds so a page still unreachable is not reloaded round and round. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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);
|
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 }) => {
|
test('online, a picture in an item\'s text still comes from its publisher, through the worker', async ({ page }) => {
|
||||||
await controlled(page);
|
await controlled(page);
|
||||||
await page.locator('#feedlist .feed', { hasText: 'Offline Blog' }).click({ timeout: 20_000 });
|
await page.locator('#feedlist .feed', { hasText: 'Offline Blog' }).click({ timeout: 20_000 });
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
### Fixed
|
### 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).
|
- 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).
|
- 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).
|
- 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).
|
||||||
|
|||||||
32
web/sw/sw.ts
32
web/sw/sw.ts
@@ -48,7 +48,7 @@ sw.addEventListener('fetch', e => {
|
|||||||
}
|
}
|
||||||
if (req.mode === 'navigate') e.respondWith(page(req));
|
if (req.mode === 'navigate') e.respondWith(page(req));
|
||||||
// Named by a hash of their contents: what is kept is what the network would send.
|
// 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));
|
else e.respondWith(networkFirst(req, url.pathname.startsWith('/api/') ? API : PAGE));
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -70,12 +70,21 @@ async function page(req: Request): Promise<Response> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function hashed(req: Request): Promise<Response> {
|
async function hashed(req: Request, client: string): Promise<Response> {
|
||||||
const kept = await caches.match(req, { cacheName: PAGE });
|
const kept = await caches.match(req, { cacheName: PAGE });
|
||||||
if (kept) return kept;
|
if (kept) return kept;
|
||||||
const r = await fetch(req);
|
const r = await fetch(req);
|
||||||
if (r.ok) {
|
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.
|
// 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);
|
for (const k of await c.keys()) if (new URL(k.url).pathname === path) await c.delete(k);
|
||||||
await c.put(req, r.clone());
|
await c.put(req, r.clone());
|
||||||
@@ -83,6 +92,23 @@ async function hashed(req: Request): Promise<Response> {
|
|||||||
return r;
|
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<string> {
|
||||||
|
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<Response> {
|
async function networkFirst(req: Request, name: string): Promise<Response> {
|
||||||
try {
|
try {
|
||||||
const r = await fetch(req);
|
const r = await fetch(req);
|
||||||
|
|||||||
Reference in New Issue
Block a user