fix(pwa): recover from a dead chunk and a broken precache after an update (#2524)

After an update, a tab or a device whose service worker still serves the
previous build asks for chunks the update removed. The server answered those
requests with index.html and a 200, so the import failed with a MIME error,
and everything caching in between was free to keep the HTML as the chunk. A
precache install that met a missing file stored it, and Workbox reuses hashed
entries by URL, so the device showed "A new version is available" on every
load, whatever the reload or the cleared browser cache, and carried the broken
entry into every later release that keeps the file's name.

The SPA fallback now answers a GET for a build file that is not on disk with
a plain 404 (no-store) instead of index.html. A precache install refuses it,
so the worker whose cache is complete stays in charge until the new build is.
index.html is sent relative to the public dir, which also stops an install
living under a dot directory from answering every deep link with a 404.

On the client, the reload after a dead chunk (the automatic one and the
button on the update screen) no longer goes straight back to the worker that
served the broken shell. When that same worker is still in charge and the
server answers, its precache and registration are dropped first, so the
reload reaches the build the server runs now. Offline, and in offline mode,
the shell is kept. Every recovery reload, and the reload of the version
handover, asks for the page under a one-off query parameter, so a proxy or
CDN that keeps index.html against its headers cannot hand back the stale
shell either.

A chunk the entry imports statically fails before any of that code runs.
shell-guard.js, a small classic script loaded ahead of the entry, watches for
exactly that failure and runs the same recovery, once per session.

A stylesheet, font or icon that holds a page raises no error at all, so once
the app has started and the browser is idle, it looks through the precache
for entries that hold a page under a build file's name, fetches each from
the server past every cache and puts it back in place. A repaired stylesheet
is loaded again, and so are the app's stylesheets when a font was repaired,
since the browser does not try a face that failed again in the same
document. Only the headers are read, a precache found sound is not looked at
again until its entries change, nothing is fetched while nothing is broken,
and nothing happens offline, in offline mode or without storage. An answer
that is not the file (an auth wall, its login page, a server error) leaves
the entry for the next launch.

A page the recovery loaded from the network already runs the version the
server reports, so the version handover now records it instead of reloading
it a second time once the new worker takes over. A page loaded past an older
worker that is still in charge, as a hard reload does, still asks for the new
worker and records the version only once that has taken over, so the next
tab or launch cannot get the old shell without a handover.

Dropping the worker ends a Web Push subscription with it, so on such a device
the settings card shows push as off until it is switched on again. A missing
sw-push.js now fails the worker install with a 404 rather than index.html; the
comments that described the old answer say so.
This commit is contained in:
Maurice
2026-09-25 22:50:59 +00:00
parent 83d591dc6c
commit c9dfe9c688
19 changed files with 1877 additions and 59 deletions
+174 -6
View File
@@ -1,4 +1,11 @@
import { isChunkLoadError, reloadOnceForChunk, clearChunkReloadMarker } from './chunkReload';
import { isChunkLoadError, reloadOnceForChunk, clearChunkReloadMarker, reloadFresh, FRESH_LOAD_PARAM } from './chunkReload';
const net = vi.hoisted(() => ({
probeNow: vi.fn(async () => 'online' as 'online' | 'offline' | 'proxy-wall'),
isEffectivelyOffline: vi.fn(() => false),
}));
vi.mock('../sync/connectivity', () => ({ probeNow: net.probeNow }));
vi.mock('../sync/networkMode', () => ({ isEffectivelyOffline: net.isEffectivelyOffline }));
beforeEach(() => {
sessionStorage.clear();
@@ -27,12 +34,54 @@ describe('isChunkLoadError', () => {
});
});
/**
* A recovery reload is a location.replace onto a fresh URL (reloadFresh), so the
* stub records the replace. `plain` is the location.reload() it must not use.
*/
function stubLocation(href = 'http://localhost/trips/4?tab=plan#day-2') {
const reload = vi.fn();
const plain = vi.fn();
Object.defineProperty(window, 'location', {
value: { ...window.location, href, replace: reload, reload: plain },
writable: true,
});
return { reload, plain };
}
describe('reloadFresh', () => {
it('FE-UTIL-CHUNK-015: loads the same page under a URL no cache has seen (#2524)', () => {
const { reload, plain } = stubLocation();
vi.spyOn(Date, 'now').mockReturnValue(1790358416100);
reloadFresh();
expect(plain).not.toHaveBeenCalled();
expect(reload).toHaveBeenCalledTimes(1);
const target = new URL(reload.mock.calls[0][0] as string);
// Path, query and hash stay, so the app comes back where it was.
expect(target.pathname).toBe('/trips/4');
expect(target.searchParams.get('tab')).toBe('plan');
expect(target.hash).toBe('#day-2');
expect(target.searchParams.get(FRESH_LOAD_PARAM)).toBe((1790358416100).toString(36));
});
it('FE-UTIL-CHUNK-016: two reloads never share a URL', () => {
const { reload } = stubLocation('http://localhost/dashboard');
const now = vi.spyOn(Date, 'now').mockReturnValue(1790358416100);
reloadFresh();
now.mockReturnValue(1790358416101);
reloadFresh();
expect(reload.mock.calls[0][0]).not.toBe(reload.mock.calls[1][0]);
});
it('FE-UTIL-CHUNK-017: falls back to a plain reload when the URL cannot be built', () => {
const { reload, plain } = stubLocation('not a url');
reloadFresh();
expect(reload).not.toHaveBeenCalled();
expect(plain).toHaveBeenCalledTimes(1);
});
});
describe('reloadOnceForChunk', () => {
function stubReload() {
const reload = vi.fn();
Object.defineProperty(window, 'location', { value: { ...window.location, reload }, writable: true });
return reload;
}
const stubReload = () => stubLocation().reload;
it('FE-UTIL-CHUNK-004: reloads the first time and refuses afterwards', () => {
const reload = stubReload();
@@ -71,3 +120,122 @@ describe('reloadOnceForChunk', () => {
expect(() => clearChunkReloadMarker()).not.toThrow();
});
});
describe('reloadOntoCurrentBuild', () => {
class FakeWorker {}
// The worker that served the page is read when serviceWorkerShell loads, so the
// fake container goes in first and the modules are loaded afresh.
async function setUp({ servedBy, controller }: { servedBy: FakeWorker | null; controller: FakeWorker | null }) {
const registration = { unregister: vi.fn(async () => true) };
const container = { controller: servedBy, getRegistration: vi.fn(async () => registration) };
Object.defineProperty(navigator, 'serviceWorker', { value: container, configurable: true });
const deleted: string[] = [];
Object.defineProperty(globalThis, 'caches', {
value: {
keys: vi.fn(async () => ['workbox-precache-v2-http://localhost/', 'map-tiles']),
delete: vi.fn(async (name: string) => { deleted.push(name); return true; }),
},
configurable: true,
});
const { reload } = stubLocation();
vi.resetModules();
const mod = await import('./chunkReload');
container.controller = controller;
return { mod, reload, registration, deleted, container };
}
beforeEach(() => {
net.probeNow.mockReset().mockResolvedValue('online');
net.isEffectivelyOffline.mockReset().mockReturnValue(false);
});
afterEach(() => {
Reflect.deleteProperty(navigator, 'serviceWorker');
Reflect.deleteProperty(globalThis, 'caches');
});
it('FE-UTIL-CHUNK-008: just reloads once a newer worker has taken over since the page loaded', async () => {
const { mod, reload, registration, deleted } = await setUp({ servedBy: new FakeWorker(), controller: new FakeWorker() });
mod.reloadOntoCurrentBuild();
// That worker answers the reload with the new shell; nothing to throw away.
expect(reload).toHaveBeenCalledTimes(1);
expect(net.probeNow).not.toHaveBeenCalled();
expect(registration.unregister).not.toHaveBeenCalled();
expect(deleted).toEqual([]);
});
it('FE-UTIL-CHUNK-009: just reloads when the page came from the network', async () => {
const { mod, reload, registration } = await setUp({ servedBy: null, controller: null });
mod.reloadOntoCurrentBuild();
expect(reload).toHaveBeenCalledTimes(1);
expect(registration.unregister).not.toHaveBeenCalled();
});
it('FE-UTIL-CHUNK-010: drops the precache and the worker first when that worker would answer the reload again (#2524)', async () => {
const worker = new FakeWorker();
const { mod, reload, registration, deleted } = await setUp({ servedBy: worker, controller: worker });
mod.reloadOntoCurrentBuild();
// Not before the shell is gone: the reload would come back to the same error.
expect(reload).not.toHaveBeenCalled();
await vi.waitFor(() => expect(reload).toHaveBeenCalledTimes(1));
expect(deleted).toEqual(['workbox-precache-v2-http://localhost/']);
expect(registration.unregister).toHaveBeenCalledTimes(1);
});
it.each(['offline', 'proxy-wall'] as const)(
'FE-UTIL-CHUNK-011: keeps the shell when the server does not answer (%s)',
async (state) => {
net.probeNow.mockResolvedValue(state);
const worker = new FakeWorker();
const { mod, reload, registration, deleted } = await setUp({ servedBy: worker, controller: worker });
mod.reloadOntoCurrentBuild();
await vi.waitFor(() => expect(reload).toHaveBeenCalledTimes(1));
// Offline, the precache is the only way the app starts at all.
expect(deleted).toEqual([]);
expect(registration.unregister).not.toHaveBeenCalled();
},
);
it('FE-UTIL-CHUNK-012: keeps the shell in offline mode without even asking the server', async () => {
net.isEffectivelyOffline.mockReturnValue(true);
const worker = new FakeWorker();
const { mod, reload, registration } = await setUp({ servedBy: worker, controller: worker });
mod.reloadOntoCurrentBuild();
await vi.waitFor(() => expect(reload).toHaveBeenCalledTimes(1));
expect(net.probeNow).not.toHaveBeenCalled();
expect(registration.unregister).not.toHaveBeenCalled();
});
it('FE-UTIL-CHUNK-013: still reloads when dropping the shell fails', async () => {
const worker = new FakeWorker();
const { mod, reload, registration } = await setUp({ servedBy: worker, controller: worker });
registration.unregister.mockRejectedValue(new Error('InvalidStateError'));
mod.reloadOntoCurrentBuild();
await vi.waitFor(() => expect(reload).toHaveBeenCalledTimes(1));
});
it('FE-UTIL-CHUNK-018: drops nothing when a newer worker takes over while the server is asked', async () => {
const worker = new FakeWorker();
const { mod, reload, registration, deleted, container } = await setUp({ servedBy: worker, controller: worker });
net.probeNow.mockImplementation(async () => {
// The browser's own sw.js check finished the handover in the meantime. Its
// precache is the new build's, and dropping it would cost a full download.
container.controller = new FakeWorker();
return 'online';
});
mod.reloadOntoCurrentBuild();
await vi.waitFor(() => expect(reload).toHaveBeenCalledTimes(1));
expect(deleted).toEqual([]);
expect(registration.unregister).not.toHaveBeenCalled();
});
it('FE-UTIL-CHUNK-014: the automatic reload after a dead chunk takes the same way, once', async () => {
const worker = new FakeWorker();
const { mod, reload, registration } = await setUp({ servedBy: worker, controller: worker });
expect(mod.reloadOnceForChunk()).toBe(true);
await vi.waitFor(() => expect(reload).toHaveBeenCalledTimes(1));
expect(registration.unregister).toHaveBeenCalledTimes(1);
expect(mod.reloadOnceForChunk()).toBe(false);
});
});
+63 -1
View File
@@ -10,6 +10,10 @@
* Kept out of the component module so main.tsx can use it without pulling React in.
*/
import { probeNow } from '../sync/connectivity';
import { isEffectivelyOffline } from '../sync/networkMode';
import { dropAppShell, reloadHitsSameWorker } from './serviceWorkerShell';
const RELOAD_MARKER = 'trek:chunk-reload';
/** Vite's own message, plus what Chrome/Firefox/Safari say for a failed module fetch. */
@@ -28,6 +32,64 @@ export function isChunkLoadError(error: unknown): boolean {
return CHUNK_ERROR_PATTERNS.some(p => haystack.includes(p));
}
/**
* The query parameter a recovery reload carries. public/shell-guard.js strips it
* again before the app reads the URL.
*/
export const FRESH_LOAD_PARAM = 'trek-reload';
/**
* location.reload(), except that the page is asked for under a URL no cache has
* seen yet. A reverse proxy or CDN that keeps index.html against its headers
* would answer a plain reload with the shell that just failed, and the page
* would come back to the same missing chunk every time (#2524).
*/
export function reloadFresh(): void {
try {
const url = new URL(window.location.href);
url.searchParams.set(FRESH_LOAD_PARAM, Date.now().toString(36));
window.location.replace(url.href);
} catch {
window.location.reload();
}
}
/**
* Reload so that the page comes back on the build the server runs now.
*
* Usually a fresh reload is that. The chunk is gone because a newer service
* worker took over after this page loaded (it drops the previous build's files
* from its precache as it does), or because there is no worker and the tab
* outlived a deploy. Either way the reload is answered with the current shell.
*
* It is not when the worker that served this page is still the one in charge.
* That worker would answer the reload with the very shell whose chunk it just
* failed to deliver, so its precache is what is broken, and the page would come
* back to the same error on every reload, cleared browser cache or not (#2524).
* Then the precache and the worker are dropped first, so the reload reaches the
* server. Only while the server answers and the app is not in offline mode:
* offline, that precache is the only way the app starts at all, and a failed
* chunk says nothing about it being broken. The worker is looked at again once
* the server has answered, since a new one may have taken over in the meantime,
* and then there is nothing to drop.
*/
export function reloadOntoCurrentBuild(): void {
if (!reloadHitsSameWorker()) {
reloadFresh();
return;
}
void (async () => {
try {
if (!isEffectivelyOffline() && (await probeNow()) === 'online' && reloadHitsSameWorker()) {
await dropAppShell();
}
} catch {
// Reload regardless: at worst it shows this screen again.
}
reloadFresh();
})();
}
/**
* Reload once per session for a given chunk failure, then give up and let the
* fallback show. Without the marker a chunk that is genuinely missing — a broken
@@ -42,7 +104,7 @@ export function reloadOnceForChunk(): boolean {
// loop, so prefer showing the fallback with its manual reload button.
return false;
}
window.location.reload();
reloadOntoCurrentBuild();
return true;
}
+2 -1
View File
@@ -3,13 +3,14 @@ import { installGlobalErrorHandlers } from './globalErrorHandlers';
let teardown: () => void;
let consoleError: ReturnType<typeof vi.spyOn>;
// A chunk failure reloads through chunkReload's fresh reload, a location.replace.
let reload: ReturnType<typeof vi.fn>;
beforeEach(() => {
sessionStorage.clear();
consoleError = vi.spyOn(console, 'error').mockImplementation(() => {});
reload = vi.fn();
Object.defineProperty(window, 'location', { value: { ...window.location, reload }, writable: true });
Object.defineProperty(window, 'location', { value: { ...window.location, replace: reload }, writable: true });
teardown = installGlobalErrorHandlers();
});
+481
View File
@@ -0,0 +1,481 @@
/**
* The worker that served the page is read once, when the module loads, so every
* test installs its fake service worker container first and imports afresh.
*/
class FakeWorker {
constructor(public scriptURL = '/sw.js') {}
}
interface FakeRegistration {
unregister: ReturnType<typeof vi.fn>
}
function installContainer(controller: FakeWorker | null, registration?: FakeRegistration) {
const container = {
controller,
getRegistration: vi.fn(async () => registration),
}
Object.defineProperty(navigator, 'serviceWorker', { value: container, configurable: true })
return container
}
function installCaches(names: string[]) {
const deleted: string[] = []
const store = {
keys: vi.fn(async () => names),
delete: vi.fn(async (name: string) => { deleted.push(name); return true }),
}
Object.defineProperty(globalThis, 'caches', { value: store, configurable: true })
return deleted
}
const net = vi.hoisted(() => ({ isEffectivelyOffline: vi.fn(() => false) }))
vi.mock('../sync/networkMode', () => ({ isEffectivelyOffline: net.isEffectivelyOffline }))
async function load() {
vi.resetModules()
return import('./serviceWorkerShell')
}
afterEach(() => {
Reflect.deleteProperty(navigator, 'serviceWorker')
Reflect.deleteProperty(globalThis, 'caches')
vi.unstubAllGlobals()
net.isEffectivelyOffline.mockReturnValue(false)
})
const ORIGIN = 'http://localhost:3000'
const PAGE = '<!doctype html><html><head><script type="module" src="/assets/index-a1.js"></script></head></html>'
function file(body: string, type: string, status = 200): Response {
return new Response(body, { status, headers: { 'content-type': type } })
}
/** One Workbox precache, as Cache Storage hands it to the page. */
class FakePrecache {
readonly entries: Map<string, Response>
constructor(files: Record<string, Response>) {
this.entries = new Map(Object.entries(files).map(([path, res]) => [ORIGIN + path, res]))
}
keys = vi.fn(async () => [...this.entries.keys()].map(url => new Request(url)))
match = vi.fn(async (key: Request) => this.entries.get(key.url)?.clone())
// Cache Storage lists a replaced entry last, as if it were new.
put = vi.fn(async (key: Request, res: Response) => { this.entries.delete(key.url); this.entries.set(key.url, res) })
delete = vi.fn(async (key: Request) => this.entries.delete(key.url))
typeOf(path: string): string | null | undefined {
return this.entries.get(ORIGIN + path)?.headers.get('content-type')
}
async text(path: string): Promise<string | undefined> {
return this.entries.get(ORIGIN + path)?.clone().text()
}
}
function installPrecache(files: Record<string, Response>) {
const precache = new FakePrecache(files)
const others = new FakePrecache({ '/tiles/1/2/3.png': file('png', 'text/html') })
const byName: Record<string, FakePrecache> = {
[`workbox-precache-v2-${ORIGIN}/`]: precache,
// Not the precache: whatever a tile server put there is not ours to judge.
'map-tiles': others,
}
Object.defineProperty(globalThis, 'caches', {
value: { keys: vi.fn(async () => Object.keys(byName)), open: vi.fn(async (name: string) => byName[name]) },
configurable: true,
})
return { precache, others }
}
/** The server behind the precache: what it answers for each path it is asked for. */
function serve(files: Record<string, Response | Error>) {
const fetch = vi.fn(async (url: string) => {
const answer = files[new URL(url).pathname]
if (answer instanceof Error) throw answer
return answer ? answer.clone() : file('{"error":"Not found"}', 'application/json', 404)
})
vi.stubGlobal('fetch', fetch)
return fetch
}
const CSS = 'body{font-family:Poppins}'
describe('serviceWorkerShell', () => {
it('FE-UTIL-SWSHELL-001: notes the worker that served the page', async () => {
const worker = new FakeWorker()
installContainer(worker)
const shell = await load()
expect(shell.servingWorker()).toBe(worker)
expect(shell.currentController()).toBe(worker)
})
it('FE-UTIL-SWSHELL-002: a reload hits the same worker until another one takes over', async () => {
const container = installContainer(new FakeWorker())
const shell = await load()
expect(shell.reloadHitsSameWorker()).toBe(true)
container.controller = new FakeWorker()
expect(shell.reloadHitsSameWorker()).toBe(false)
})
it('FE-UTIL-SWSHELL-003: a page that came from the network has no worker to blame', async () => {
const container = installContainer(null)
const shell = await load()
expect(shell.servingWorker()).toBeNull()
expect(shell.reloadHitsSameWorker()).toBe(false)
// A worker that claims the page later did not serve it.
container.controller = new FakeWorker()
expect(shell.reloadHitsSameWorker()).toBe(false)
})
it('FE-UTIL-SWSHELL-004: without service worker support there is no controller', async () => {
const shell = await load()
expect(shell.currentController()).toBeNull()
expect(shell.reloadHitsSameWorker()).toBe(false)
})
it('FE-UTIL-SWSHELL-005: reading the controller where site data is blocked counts as none', async () => {
Object.defineProperty(navigator, 'serviceWorker', {
get() { throw new Error('SecurityError') },
configurable: true,
})
const shell = await load()
expect(shell.currentController()).toBeNull()
})
it('FE-UTIL-SWSHELL-006: dropping the shell deletes the precache and the worker, and nothing else', async () => {
const registration = { unregister: vi.fn(async () => true) }
installContainer(new FakeWorker(), registration)
const deleted = installCaches([
'workbox-precache-v2-https://trek.example/',
'map-tiles',
'gl-map-styles',
'gl-map-offline',
'user-uploads',
])
const shell = await load()
await shell.dropAppShell()
// Offline map regions and cached uploads were downloaded on purpose.
expect(deleted).toEqual(['workbox-precache-v2-https://trek.example/'])
expect(registration.unregister).toHaveBeenCalledTimes(1)
})
it('FE-UTIL-SWSHELL-007: dropping the shell copes with no registration and no Cache Storage', async () => {
installContainer(null, undefined)
const shell = await load()
await expect(shell.dropAppShell()).resolves.toBeUndefined()
})
})
describe('repairPrecache (#2524)', () => {
it('FE-UTIL-SWSHELL-010: puts a stylesheet kept as a page back, fetched past the precache', async () => {
const { precache, others } = installPrecache({
'/assets/index-N6AFa1mB.css': file(PAGE, 'text/html; charset=utf-8'),
'/assets/index-a1.js': file('export {}', 'text/javascript'),
'/index.html?__WB_REVISION__=131d93be': file(PAGE, 'text/html'),
'/?__WB_REVISION__=1': file(PAGE, 'text/html'),
})
const fetch = serve({ '/assets/index-N6AFa1mB.css': file(CSS, 'text/css; charset=UTF-8') })
const shell = await load()
expect(await shell.repairPrecache()).toEqual(['/assets/index-N6AFa1mB.css'])
expect(precache.typeOf('/assets/index-N6AFa1mB.css')).toBe('text/css; charset=UTF-8')
expect(await precache.text('/assets/index-N6AFa1mB.css')).toBe(CSS)
// Only the broken entry is fetched, under a URL the precache route does not know.
expect(fetch).toHaveBeenCalledTimes(1)
const [url, init] = fetch.mock.calls[0] as unknown as [string, RequestInit]
expect(new URL(url).pathname).toBe('/assets/index-N6AFa1mB.css')
expect(new URL(url).searchParams.get('trek-repair')).toBeTruthy()
expect(init).toMatchObject({ cache: 'no-store', credentials: 'same-origin', redirect: 'error' })
// Kept without the one-off URL it came from, since a module is known by it.
const kept = precache.put.mock.calls[0][1] as Response
expect(kept.url).toBe('')
// Pages are pages, and other caches are not the precache.
expect(precache.typeOf('/index.html?__WB_REVISION__=131d93be')).toBe('text/html')
expect(precache.typeOf('/?__WB_REVISION__=1')).toBe('text/html')
expect(others.match).not.toHaveBeenCalled()
})
it('FE-UTIL-SWSHELL-011: a revisioned file is fetched without its revision and kept under it', async () => {
const { precache } = installPrecache({
'/fonts/Poppins-Bold.ttf?__WB_REVISION__=92934d92': file(PAGE, 'text/html'),
'/icons/icon-192x192.png?__WB_REVISION__=5f1a': file(PAGE, 'text/html'),
'/assets/vendor-core-Dbh_uXoX.js': file(PAGE, 'text/html'),
})
const fetch = serve({
'/fonts/Poppins-Bold.ttf': file('ttf', 'font/ttf'),
'/icons/icon-192x192.png': file('png', 'image/png'),
'/assets/vendor-core-Dbh_uXoX.js': file('export {}', 'text/javascript; charset=UTF-8'),
})
const shell = await load()
expect((await shell.repairPrecache()).sort()).toEqual([
'/assets/vendor-core-Dbh_uXoX.js', '/fonts/Poppins-Bold.ttf', '/icons/icon-192x192.png',
])
expect(precache.typeOf('/fonts/Poppins-Bold.ttf?__WB_REVISION__=92934d92')).toBe('font/ttf')
expect(precache.typeOf('/icons/icon-192x192.png?__WB_REVISION__=5f1a')).toBe('image/png')
expect(precache.typeOf('/assets/vendor-core-Dbh_uXoX.js')).toBe('text/javascript; charset=UTF-8')
const fontUrl = new URL((fetch.mock.calls as unknown as [string][]).map(([u]) => u).find(u => u.includes('Poppins'))!)
expect(fontUrl.searchParams.has('__WB_REVISION__')).toBe(false)
})
it('FE-UTIL-SWSHELL-012: an entry the server has no file for is removed, so the network and the next install get it', async () => {
const { precache } = installPrecache({ '/assets/DashboardPage-v4ODOTOr.js': file(PAGE, 'text/html') })
serve({})
const shell = await load()
expect(await shell.repairPrecache()).toEqual([])
expect(precache.entries.size).toBe(0)
expect(localStorage.getItem('trek:precache-checked')).toBeTruthy()
})
it.each([
['no network', new TypeError('Failed to fetch')],
['a redirect to an auth wall', new TypeError('Failed to fetch: redirect')],
['the login page of a proxy', file(PAGE, 'text/html; charset=utf-8')],
['an auth wall that refuses', file('Unauthorized', 'text/plain', 401)],
['a server error', file('{"error":"Internal"}', 'application/json', 500)],
])('FE-UTIL-SWSHELL-013: with %s the entry stays, and the next launch tries again', async (_label, answer) => {
const { precache } = installPrecache({ '/assets/index-N6AFa1mB.css': file(PAGE, 'text/html') })
const fetch = serve({ '/assets/index-N6AFa1mB.css': answer })
const shell = await load()
expect(await shell.repairPrecache()).toEqual([])
expect(precache.typeOf('/assets/index-N6AFa1mB.css')).toBe('text/html')
expect(precache.delete).not.toHaveBeenCalled()
expect(localStorage.getItem('trek:precache-checked')).toBeNull()
fetch.mockImplementation(async () => file(CSS, 'text/css'))
expect(await shell.repairPrecache()).toEqual(['/assets/index-N6AFa1mB.css'])
})
it('FE-UTIL-SWSHELL-014: a sound precache is looked through once, until its entries change', async () => {
const { precache } = installPrecache({
'/assets/index-N6AFa1mB.css': file(CSS, 'text/css'),
'/assets/index-a1.js': file('export {}', 'text/javascript'),
})
const fetch = serve({})
const shell = await load()
await shell.repairPrecache()
expect(precache.match).toHaveBeenCalledTimes(2)
await shell.repairPrecache()
expect(precache.match).toHaveBeenCalledTimes(2)
expect(fetch).not.toHaveBeenCalled()
// A new install brought entries, and with them maybe a broken one.
precache.entries.set(`${ORIGIN}/assets/index-b2.js`, file(PAGE, 'text/html'))
fetch.mockImplementation(async () => file('export {}', 'text/javascript'))
expect(await shell.repairPrecache()).toEqual(['/assets/index-b2.js'])
expect(precache.match).toHaveBeenCalledTimes(5)
// The repaired entry moved to the end of the list, which changes nothing.
await shell.repairPrecache()
expect(precache.match).toHaveBeenCalledTimes(5)
})
it('FE-UTIL-SWSHELL-015: without Cache Storage there is nothing to put right', async () => {
const shell = await load()
await expect(shell.repairPrecache()).resolves.toEqual([])
})
it('FE-UTIL-SWSHELL-016: with storage blocked it does nothing, rather than look through the precache on every launch', async () => {
const { precache } = installPrecache({ '/assets/index-N6AFa1mB.css': file(PAGE, 'text/html') })
const fetch = serve({ '/assets/index-N6AFa1mB.css': file(CSS, 'text/css') })
vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => { throw new DOMException('blocked', 'SecurityError') })
const shell = await load()
expect(await shell.repairPrecache()).toEqual([])
expect(precache.keys).not.toHaveBeenCalled()
expect(fetch).not.toHaveBeenCalled()
vi.restoreAllMocks()
})
it('FE-UTIL-SWSHELL-017: a failed fetch leaves the rest for the next launch, and keeps what it put right', async () => {
const { precache } = installPrecache({
'/assets/index-N6AFa1mB.css': file(PAGE, 'text/html'),
'/assets/maplibre-B2k4QVOw.css': file(PAGE, 'text/html'),
'/assets/Inter-latin.woff2': file(PAGE, 'text/html'),
})
const fetch = serve({
'/assets/index-N6AFa1mB.css': file(CSS, 'text/css'),
'/assets/maplibre-B2k4QVOw.css': new TypeError('Failed to fetch'),
'/assets/Inter-latin.woff2': file('woff2', 'font/woff2'),
})
const shell = await load()
expect(await shell.repairPrecache()).toEqual(['/assets/index-N6AFa1mB.css'])
expect(fetch).toHaveBeenCalledTimes(2)
expect(precache.typeOf('/assets/Inter-latin.woff2')).toBe('text/html')
expect(localStorage.getItem('trek:precache-checked')).toBeNull()
})
it('FE-UTIL-SWSHELL-018: a server that does not answer is given up on after a while', async () => {
vi.useFakeTimers()
try {
installPrecache({ '/assets/index-N6AFa1mB.css': file(PAGE, 'text/html') })
vi.stubGlobal('fetch', vi.fn((_url: string, init: RequestInit) => new Promise((_resolve, reject) => {
init.signal!.addEventListener('abort', () => reject(new DOMException('aborted', 'AbortError')))
})))
const shell = await load()
const done = shell.repairPrecache()
await vi.advanceTimersByTimeAsync(15_000)
await expect(done).resolves.toEqual([])
expect(localStorage.getItem('trek:precache-checked')).toBeNull()
} finally {
vi.useRealTimers()
}
})
})
describe('restyle and schedulePrecacheRepair (#2524)', () => {
function stylesheet(href: string): HTMLLinkElement {
const link = document.createElement('link')
link.rel = 'stylesheet'
link.crossOrigin = ''
link.href = href
document.head.appendChild(link)
return link
}
const sheets = () => [...document.head.querySelectorAll<HTMLLinkElement>('link[rel="stylesheet"]')]
afterEach(() => {
document.head.querySelectorAll('link').forEach(link => link.remove())
})
it('FE-UTIL-SWSHELL-020: loads a stylesheet taken from a broken entry again, and drops the old link once it has', async () => {
const broken = stylesheet('/assets/index-N6AFa1mB.css')
const sound = stylesheet('/assets/leaflet-B1.css')
const shell = await load()
shell.restyle(['/assets/index-N6AFa1mB.css'])
const fresh = sheets().find(l => l !== broken && l !== sound)!
expect(new URL(fresh.href).pathname).toBe('/assets/index-N6AFa1mB.css')
expect(fresh.crossOrigin).toBe('')
expect(broken.isConnected).toBe(true)
fresh.dispatchEvent(new Event('load'))
expect(broken.isConnected).toBe(false)
// In the old link's place, so the cascade stays as the build ordered it.
expect(sheets()).toEqual([fresh, sound])
})
it('FE-UTIL-SWSHELL-021: a stylesheet that fails to load again leaves the page as it was', async () => {
const broken = stylesheet('/assets/index-N6AFa1mB.css')
const shell = await load()
shell.restyle(['/assets/index-N6AFa1mB.css'])
sheets().find(l => l !== broken)!.dispatchEvent(new Event('error'))
expect(sheets()).toEqual([broken])
})
it('FE-UTIL-SWSHELL-027: a font put right loads the app\'s own stylesheets again, where its face is declared', async () => {
const entry = stylesheet('/assets/index-N6AFa1mB.css')
const lazy = stylesheet('/assets/maplibre-B2k4QVOw.css')
const foreign = stylesheet('https://tiles.example.org/assets/style.css')
const other = stylesheet('/custom/theme.css')
const shell = await load()
shell.restyle(['/assets/poppins-latin-400-normal-cpxAROuN.woff2'])
const fresh = sheets().filter(l => ![entry, lazy, foreign, other].includes(l))
expect(fresh.map(l => new URL(l.href).pathname)).toEqual(['/assets/index-N6AFa1mB.css', '/assets/maplibre-B2k4QVOw.css'])
fresh.forEach(l => l.dispatchEvent(new Event('load')))
expect(sheets()).toEqual([...fresh, foreign, other])
})
it('FE-UTIL-SWSHELL-028: an icon or a script put right leaves the stylesheets alone', async () => {
const entry = stylesheet('/assets/index-N6AFa1mB.css')
const shell = await load()
shell.restyle(['/icons/icon-192x192.png', '/assets/dashboardModel-C_5ygTOX.js'])
expect(sheets()).toEqual([entry])
})
it('FE-UTIL-SWSHELL-022: once the page has loaded and the browser is idle, the repair runs and restyles the page', async () => {
const idle = vi.fn((run: () => void) => { run(); return 1 })
vi.stubGlobal('requestIdleCallback', idle)
const { precache } = installPrecache({ '/assets/index-N6AFa1mB.css': file(PAGE, 'text/html') })
serve({ '/assets/index-N6AFa1mB.css': file(CSS, 'text/css') })
const broken = stylesheet('/assets/index-N6AFa1mB.css')
const shell = await load()
shell.schedulePrecacheRepair()
// jsdom's document has finished loading, so it only waits for idle time.
expect(idle).toHaveBeenCalledTimes(1)
await vi.waitFor(() => expect(sheets()).toHaveLength(2))
expect(precache.typeOf('/assets/index-N6AFa1mB.css')).toBe('text/css')
sheets()[1].dispatchEvent(new Event('load'))
expect(broken.isConnected).toBe(false)
})
it('FE-UTIL-SWSHELL-023: offline, or in offline mode, the precache is left as it is', async () => {
vi.stubGlobal('requestIdleCallback', (run: () => void) => { run(); return 1 })
net.isEffectivelyOffline.mockReturnValue(true)
const { precache } = installPrecache({ '/assets/index-N6AFa1mB.css': file(PAGE, 'text/html') })
const fetch = serve({})
const shell = await load()
shell.schedulePrecacheRepair()
await new Promise(r => setTimeout(r, 0))
expect(precache.keys).not.toHaveBeenCalled()
expect(fetch).not.toHaveBeenCalled()
})
it('FE-UTIL-SWSHELL-024: a page still loading waits for its load event first', async () => {
const idle = vi.fn()
vi.stubGlobal('requestIdleCallback', idle)
const readyState = vi.spyOn(document, 'readyState', 'get').mockReturnValue('interactive')
const shell = await load()
shell.schedulePrecacheRepair()
expect(idle).not.toHaveBeenCalled()
readyState.mockRestore()
window.dispatchEvent(new Event('load'))
expect(idle).toHaveBeenCalledTimes(1)
})
it('FE-UTIL-SWSHELL-025: without idle callbacks it waits a moment instead, and a refusing Cache Storage is left for the next launch', async () => {
vi.useFakeTimers()
try {
vi.stubGlobal('requestIdleCallback', undefined)
const keys = vi.fn(async () => { throw new DOMException('blocked', 'SecurityError') })
Object.defineProperty(globalThis, 'caches', { value: { keys }, configurable: true })
const shell = await load()
shell.schedulePrecacheRepair()
expect(keys).not.toHaveBeenCalled()
await vi.advanceTimersByTimeAsync(2_000)
expect(keys).toHaveBeenCalledTimes(1)
} finally {
vi.useRealTimers()
}
})
it('FE-UTIL-SWSHELL-026: nothing put right leaves the stylesheets alone', async () => {
const link = stylesheet('/assets/index-N6AFa1mB.css')
const shell = await load()
shell.restyle([])
expect(sheets()).toEqual([link])
})
})
+244
View File
@@ -0,0 +1,244 @@
/**
* The service worker that answers this page's navigations, and the way out when
* the app shell it keeps can no longer start.
*
* Kept free of React so main.tsx and the error handlers can use it early.
*/
import { isEffectivelyOffline } from '../sync/networkMode'
/** Part of the name of every Workbox precache, and of no other cache the app keeps. */
const PRECACHE = '-precache-'
/** The worker controlling this page, or null. Reading it can throw where site data is blocked. */
export function currentController(): ServiceWorker | null {
try {
return 'serviceWorker' in navigator ? navigator.serviceWorker.controller : null
} catch {
return null
}
}
// The worker that served this page, noted when the app loads. The browser checks
// sw.js on every navigation by itself, so a new build can take over while this
// page still runs the previous one. A controller other than this one means the
// new shell is only a reload away. A page served without a worker came from the
// network, so a worker claiming it later is no takeover.
const servedBy = currentController()
/** The worker that served this page when it loaded, or null when it came from the network. */
export function servingWorker(): ServiceWorker | null {
return servedBy
}
/**
* True when a reload would be answered by the same worker that served this page,
* which means with the same app shell.
*/
export function reloadHitsSameWorker(): boolean {
const worker = currentController()
return worker !== null && worker === servedBy
}
/**
* Throws away the app shell this browser keeps: the Workbox precache and the
* worker that serves it. The next navigation then goes to the server, gets the
* index.html of the build it runs now, and registers that build's worker, which
* precaches from scratch instead of reusing an entry of the broken cache.
*
* Map tiles, GL styles and cached uploads live in caches of their own and stay.
* A Web Push subscription does not: it belongs to the registration and ends with
* it, so the settings card shows push as off on this device until it is switched
* on again. Only call this while the server answers: offline, the precache is
* the only way the app can start at all.
*
* public/shell-guard.js does the same for a shell whose entry cannot load, before
* any of this code runs. Keep the two in step.
*/
export async function dropAppShell(): Promise<void> {
if (typeof caches !== 'undefined') {
const names = await caches.keys()
await Promise.all(names.filter(name => name.includes(PRECACHE)).map(name => caches.delete(name)))
}
const reg = 'serviceWorker' in navigator ? await navigator.serviceWorker.getRegistration() : undefined
if (reg) await reg.unregister()
}
// Workbox keys a file without a hash in its name as url?__WB_REVISION__=<hash>.
const REVISION_PARAM = '__WB_REVISION__'
// A build file asked for under this parameter gets past the precache, whose route
// only knows the plain URL, and past any cache in between that keeps files by URL.
const REPAIR_PARAM = 'trek-repair'
const REPAIR_TIMEOUT_MS = 15_000
// The entries the precache held when it was last found sound, so that an
// unchanged precache is not looked through again on every launch.
const CHECKED_KEY = 'trek:precache-checked'
// What the precache keeps besides index.html (globPatterns in vite.config.js,
// plus the web manifest). None of these may ever be a page.
const BUILD_FILE = /\.(?:js|css|svg|png|woff2?|ttf|webmanifest)$/i
interface Precache { name: string, cache: Cache, keys: readonly Request[] }
function isPage(response: Response): boolean {
return (response.headers.get('content-type') ?? '').toLowerCase().includes('text/html')
}
/**
* Which entries the precaches hold, in a few characters. Order does not count:
* a repaired entry moves to the end of the list.
*/
function fingerprint(precaches: readonly Precache[]): string {
const urls = precaches.flatMap(({ name, keys }) => keys.map(key => `${name} ${key.url}`)).sort((a, b) => a.localeCompare(b))
let hash = 0x811c9dc5
for (const text of urls) {
for (let i = 0; i < text.length; i++) hash = Math.imul(hash ^ text.charCodeAt(i), 0x01000193)
}
return `${urls.length}.${(hash >>> 0).toString(36)}`
}
/** The last fingerprint found sound, '' for none, or null when storage is blocked. */
function lastChecked(): string | null {
try {
return localStorage.getItem(CHECKED_KEY) ?? ''
} catch {
return null
}
}
function noteChecked(print: string): void {
try {
localStorage.setItem(CHECKED_KEY, print)
} catch {
// The next launch looks through the precache once more.
}
}
/**
* The build file behind a precache entry, fetched past every cache. 'gone' when
* the server says it has no such file. Throws when no usable answer came: no
* network, a timeout, a redirect to an auth wall, its login page, a server error.
*/
async function fetchBuildFile(key: Request): Promise<Response | 'gone'> {
const url = new URL(key.url)
url.searchParams.delete(REVISION_PARAM)
url.searchParams.set(REPAIR_PARAM, Date.now().toString(36))
const ctrl = new AbortController()
const timer = setTimeout(() => ctrl.abort(), REPAIR_TIMEOUT_MS)
try {
const res = await fetch(url.href, { cache: 'no-store', credentials: 'same-origin', redirect: 'error', signal: ctrl.signal })
if (res.status === 404 || res.status === 410) return 'gone'
if (res.status !== 200 || isPage(res)) throw new Error(`${res.status} for ${url.pathname}`)
// A response of its own, without the one-off URL it was fetched from: a
// module is known by its response URL. The body streams into the cache.
return new Response(res.body, { status: 200, statusText: res.statusText, headers: res.headers })
} finally {
clearTimeout(timer)
}
}
/**
* Puts right precache entries that hold a page instead of the build file their
* URL names, and returns the paths of the ones it put right (#2524).
*
* A precache install keeps whatever answered with a 200. A server that answered
* a missing build file with index.html, as 4.3.2 and older did, or a proxy that
* answered with its login page, left a page under the name of a script, a
* stylesheet or a font. Workbox reuses an entry for as long as a later release
* lists its URL, without fetching it again, and every hashed file under
* /assets/ keeps its URL until its content changes. So the broken entry is
* carried from release to release. A broken script at least fails its import,
* and the app recovers from that (chunkReload, shell-guard.js). A broken
* stylesheet, font or icon raises no error at all: the app just loses its
* layout or its typeface, on every launch, and offline too.
*
* Each such entry is fetched from the server again and put back in its place.
* One the server does not have is removed, so the file is asked for on the
* network and the next install stores it. When a fetch fails, the rest is left
* for the next launch. A precache found sound is not looked at again until its
* entries change, and without storage to note that in, nothing is done.
*/
export async function repairPrecache(): Promise<string[]> {
const checked = lastChecked()
if (checked === null || typeof caches === 'undefined') return []
const names = (await caches.keys()).filter(name => name.includes(PRECACHE))
const precaches = await Promise.all(names.map(async (name): Promise<Precache> => {
const cache = await caches.open(name)
return { name, cache, keys: await cache.keys() }
}))
const print = fingerprint(precaches)
if (checked === print) return []
const repaired: string[] = []
for (const { cache, keys } of precaches) {
// Only the headers are read here, never a body.
const broken = (await Promise.all(keys.map(async key => {
if (!BUILD_FILE.test(new URL(key.url).pathname)) return null
const response = await cache.match(key)
return response && isPage(response) ? key : null
}))).filter((key): key is Request => key !== null)
// One at a time: this is a way out, not a race, and a big chunk is not held
// in memory next to another.
for (const key of broken) {
let fresh: Response | 'gone'
try {
fresh = await fetchBuildFile(key)
} catch {
return repaired
}
if (fresh === 'gone') {
await cache.delete(key)
} else {
await cache.put(key, fresh)
repaired.push(new URL(key.url).pathname)
}
}
}
noteChecked(print)
return repaired
}
const FONT_FILE = /\.(?:woff2?|ttf)$/i
/**
* Loads the stylesheets this page took from a broken precache entry again, now
* that the entry holds the stylesheet. The old link goes once the new one has
* loaded, so the page keeps what styles it has until then.
*
* A font face that failed is not tried again in the same document. When a font
* was put right, the app's own stylesheets, which declare every face it uses,
* are loaded again too, and the faces they declare anew load the repaired file.
*/
export function restyle(paths: readonly string[]): void {
if (paths.length === 0) return
const fontRepaired = paths.some(path => FONT_FILE.test(path))
for (const link of document.querySelectorAll<HTMLLinkElement>('link[rel~="stylesheet"][href]')) {
const url = new URL(link.href, document.baseURI)
const declaresFonts = fontRepaired && url.origin === location.origin && url.pathname.startsWith('/assets/')
if (!declaresFonts && !paths.includes(url.pathname)) continue
const fresh = link.cloneNode() as HTMLLinkElement
fresh.addEventListener('load', () => link.remove(), { once: true })
fresh.addEventListener('error', () => fresh.remove(), { once: true })
link.after(fresh)
}
}
/**
* Runs {@link repairPrecache} once the page has loaded and the browser is idle,
* and only while the app is online: offline there is nothing to fetch the files
* from.
*/
export function schedulePrecacheRepair(): void {
const run = () => {
if (isEffectivelyOffline()) return
repairPrecache().then(restyle, () => {
// Cache Storage refused, so the next launch tries again.
})
}
const whenIdle = () => {
if (typeof requestIdleCallback === 'function') requestIdleCallback(run, { timeout: 10_000 })
else setTimeout(run, 2_000)
}
if (document.readyState === 'complete') whenIdle()
else window.addEventListener('load', whenIdle, { once: true })
}
+161 -3
View File
@@ -19,6 +19,7 @@ class FakeWorker extends EventTarget {
}
class FakeRegistration {
active: FakeWorker | null = null
installing: FakeWorker | null = null
waiting: FakeWorker | null = null
update = vi.fn(async () => this)
@@ -40,6 +41,7 @@ class FakeContainer extends EventTarget {
function installWorker(controller: FakeWorker | null = new FakeWorker('activated')) {
const registration = new FakeRegistration()
registration.active = controller
const container = new FakeContainer(controller)
container.getRegistration.mockResolvedValue(registration)
Object.defineProperty(navigator, 'serviceWorker', { configurable: true, value: container })
@@ -71,11 +73,20 @@ function breakStorage(method: 'getItem' | 'setItem', broken: () => Storage) {
}
const marker = () => localStorage.getItem('trek_app_version')
// The version the bundle under test is built as (vite define, from package.json).
const BUILT = __TREK_UI_VERSION__
let reload: ReturnType<typeof vi.fn>
let plainReload: ReturnType<typeof vi.fn>
beforeEach(() => {
// Every reload here asks for the page under a fresh URL (reloadFresh), which is
// a location.replace. `plainReload` is the location.reload() it must not use.
reload = vi.fn()
Object.defineProperty(window, 'location', { writable: true, value: { ...window.location, reload } })
plainReload = vi.fn()
Object.defineProperty(window, 'location', {
writable: true,
value: { ...window.location, href: 'http://localhost/dashboard', replace: reload, reload: plainReload },
})
})
afterEach(() => {
@@ -216,6 +227,80 @@ describe('reconcileAppVersion: a new version without a handover to wait for', ()
container.takeOver()
expect(reload).not.toHaveBeenCalled()
})
it('FE-UTIL-VERSION-015: a page from the network built as the new version records it without a reload (#2524)', async () => {
const { container } = installWorker(null)
container.getRegistration.mockResolvedValue(undefined)
const { reconcileAppVersion } = await loadModule()
localStorage.setItem('trek_app_version', '4.3.0')
await reconcileAppVersion(BUILT)
expect(marker()).toBe(BUILT)
expect(reload).not.toHaveBeenCalled()
expect(plainReload).not.toHaveBeenCalled()
})
it('FE-UTIL-VERSION-016: ...and the worker registered after it claims the page without a reload (#2524)', async () => {
// Where the way out of a broken shell lands: the precache and the worker were
// dropped, the page came from the server, and the new worker is installing.
const { container, registration } = installWorker(null)
updateInstalls(registration)
const { reconcileAppVersion } = await loadModule()
localStorage.setItem('trek_app_version', '4.3.0')
await reconcileAppVersion(BUILT)
container.takeOver()
expect(marker()).toBe(BUILT)
expect(registration.update).not.toHaveBeenCalled()
expect(reload).not.toHaveBeenCalled()
})
it('FE-UTIL-VERSION-019: ...and so does a page the worker registered after it has claimed already (#2524)', async () => {
const { container, registration } = installWorker(null)
const { reconcileAppVersion } = await loadModule()
localStorage.setItem('trek_app_version', '4.3.0')
// The fresh precache was quick, and its worker took the page before the server answered.
container.takeOver()
registration.active = container.controller
await reconcileAppVersion(BUILT)
expect(marker()).toBe(BUILT)
expect(registration.update).not.toHaveBeenCalled()
expect(reload).not.toHaveBeenCalled()
})
it('FE-UTIL-VERSION-017: a page a worker served goes through the handover whatever it was built as', async () => {
const { container, registration } = installWorker()
updateInstalls(registration)
const { reconcileAppVersion } = await loadModule()
localStorage.setItem('trek_app_version', '4.3.0')
await reconcileAppVersion(BUILT)
expect(marker()).toBe('4.3.0')
container.takeOver()
expect(reload).toHaveBeenCalledTimes(1)
})
it('FE-UTIL-VERSION-018: the reload asks for the page under a fresh URL, past a proxy that keeps index.html (#2524)', async () => {
const { container } = installWorker(null)
container.getRegistration.mockResolvedValue(undefined)
const { reconcileAppVersion } = await loadModule()
localStorage.setItem('trek_app_version', '4.3.0')
// A kept index.html runs an older bundle than the server reports.
await reconcileAppVersion('4.3.1')
expect(plainReload).not.toHaveBeenCalled()
expect(reload).toHaveBeenCalledTimes(1)
const target = new URL(reload.mock.calls[0][0] as string)
expect(target.pathname).toBe('/dashboard')
expect(target.searchParams.get('trek-reload')).toBeTruthy()
})
})
describe('reconcileAppVersion: handing over to the new build', () => {
@@ -289,6 +374,79 @@ describe('reconcileAppVersion: handing over to the new build', () => {
})
})
describe('reconcileAppVersion: a page loaded past an older worker', () => {
// A hard reload, or a load with the worker bypassed, gets the new bundle from
// the network while the previous build's worker stays registered and would
// serve its own shell to the next tab or launch (#2524).
function pastOlderWorker() {
const installed = installWorker(null)
installed.registration.active = new FakeWorker('activated')
return installed
}
it('FE-UTIL-VERSION-025: asks for the new worker and records the version only once it has taken over', async () => {
const { container, registration } = pastOlderWorker()
const incoming = updateInstalls(registration)
const { reconcileAppVersion } = await loadModule()
localStorage.setItem('trek_app_version', '4.3.0')
await reconcileAppVersion(BUILT)
expect(registration.update).toHaveBeenCalledTimes(1)
expect(marker()).toBe('4.3.0')
incoming.become('activated')
container.takeOver()
expect(marker()).toBe(BUILT)
// The page runs that bundle already, so there is nothing to reload for.
expect(reload).not.toHaveBeenCalled()
expect(plainReload).not.toHaveBeenCalled()
})
it('FE-UTIL-VERSION-026: an install that goes redundant leaves the marker for the next launch', async () => {
const { container, registration } = pastOlderWorker()
const incoming = updateInstalls(registration)
const { reconcileAppVersion } = await loadModule()
localStorage.setItem('trek_app_version', '4.3.0')
await reconcileAppVersion(BUILT)
incoming.become('redundant')
container.takeOver()
expect(marker()).toBe('4.3.0')
expect(reload).not.toHaveBeenCalled()
})
it('FE-UTIL-VERSION-027: an unchanged sw.js means that worker is this build, so the version is recorded', async () => {
const { container, registration } = pastOlderWorker()
const { reconcileAppVersion } = await loadModule()
localStorage.setItem('trek_app_version', '4.3.0')
// It claimed the page while the update was being checked.
registration.update.mockImplementation(async () => {
container.controller = registration.active
return registration
})
await reconcileAppVersion(BUILT)
expect(registration.update).toHaveBeenCalledTimes(1)
expect(marker()).toBe(BUILT)
expect(reload).not.toHaveBeenCalled()
})
it('FE-UTIL-VERSION-028: an update that fails leaves the marker for the next launch', async () => {
const { registration } = pastOlderWorker()
registration.update.mockRejectedValue(new TypeError('Failed to update a ServiceWorker: network error'))
const { reconcileAppVersion } = await loadModule()
localStorage.setItem('trek_app_version', '4.3.0')
await reconcileAppVersion(BUILT)
expect(marker()).toBe('4.3.0')
expect(reload).not.toHaveBeenCalled()
})
})
describe('reconcileAppVersion: the reload guard', () => {
it('FE-UTIL-VERSION-030: a marker that cannot be written does not turn into a reload loop', async () => {
const { reconcileAppVersion } = await loadModule()
@@ -309,9 +467,9 @@ describe('reconcileAppVersion: the reload guard', () => {
localStorage.setItem('trek_app_version', '4.3.0')
await reconcileAppVersion('4.3.1')
await reconcileAppVersion('4.3.2')
await reconcileAppVersion('4.3.1-1')
expect(marker()).toBe('4.3.2')
expect(marker()).toBe('4.3.1-1')
expect(reload).toHaveBeenCalledTimes(2)
})
+44 -24
View File
@@ -17,25 +17,20 @@
* version and session.
*/
import { reloadFresh } from './chunkReload'
import { currentController, servingWorker } from './serviceWorkerShell'
const VERSION_KEY = 'trek_app_version'
const RELOAD_KEY = 'trek_app_version_reload'
/** The worker controlling this page, or null. Reading it can throw where site data is blocked. */
function currentController(): ServiceWorker | null {
try {
return 'serviceWorker' in navigator ? navigator.serviceWorker.controller : null
} catch {
return null
}
}
// The version this bundle was built as (vite.config.js). A bare tsc run has none,
// and then no version counts as this bundle's own.
const BUNDLE_VERSION: string = typeof __TREK_UI_VERSION__ === 'string' ? __TREK_UI_VERSION__ : ''
// The worker that served this page, noted when the app loads. The browser checks
// sw.js on every navigation by itself, and when that check installs the new build
// before the server has told us its version, the takeover is over before anyone
// listens for it. A controller other than this one means the new shell is only a
// reload away. A page served without a worker came from the network, so a worker
// claiming it later is no takeover.
const servedBy = currentController()
// The worker that served this page, noted when the app loads (serviceWorkerShell).
// When the browser's own check of sw.js installs the new build before the server
// has told us its version, the takeover is over before anyone listens for it.
const servedBy = servingWorker()
function markApplied(version: string): void {
try { localStorage.setItem(VERSION_KEY, version) } catch { /* site data blocked */ }
@@ -54,10 +49,18 @@ function reloadOnce(version: string): void {
} catch {
return
}
window.location.reload()
// Under a URL no cache has seen yet: without a worker, a proxy that keeps
// index.html against its headers would answer a plain reload with the very
// shell this page runs, and the marker has moved on already (#2524).
reloadFresh()
}
async function handOver(reg: ServiceWorkerRegistration, version: string): Promise<void> {
/**
* Asks the worker for the new build and moves the marker once it has taken over.
* `runsIt` is a page that already runs the new bundle, so the takeover needs no
* reload to show it.
*/
async function handOver(reg: ServiceWorkerRegistration, version: string, runsIt: boolean): Promise<void> {
// Listening before asking for the update, so a quick takeover is not missed.
// Giving up disarms the listener: the reload belongs to this launch, and one
// arriving later in the session would hit the user in the middle of an edit.
@@ -65,7 +68,7 @@ async function handOver(reg: ServiceWorkerRegistration, version: string): Promis
navigator.serviceWorker.addEventListener('controllerchange', () => {
if (!armed) return
markApplied(version)
reloadOnce(version)
if (!runsIt) reloadOnce(version)
}, { once: true })
try {
@@ -80,7 +83,7 @@ async function handOver(reg: ServiceWorkerRegistration, version: string): Promis
// sw.js is unchanged, so the release changed nothing the precache holds and
// the bundle this page runs is the current one.
armed = false
if (currentController() === servedBy) markApplied(version)
if (runsIt || currentController() === servedBy) markApplied(version)
return
}
// Precaching is all or nothing: one failed request out of several hundred and
@@ -129,14 +132,31 @@ export async function reconcileAppVersion(reported: unknown): Promise<void> {
// Left for the next launch. A reload now would only reach the old worker again.
return
}
// Without a worker the page came from the network and runs the new bundle
// already, so the reload only confirms it. A controller other than the one
// that served the page means the browser finished the handover by itself,
// and the reload is what shows the new build.
// A page that came from the network and was built as this version runs it
// already. That is where the way out of a broken app shell lands, before the
// new app could move the marker (#2524): the old worker is gone, and the one
// registered since is this build. Reloading once it has taken over would only
// interrupt the user a few seconds in, with nothing new to show. An older
// worker still in charge (this page was loaded past it, as a hard reload
// does) would serve its own build to the next tab or launch, so it is handed
// over like any other, just without the reload.
const runsIt = !servedBy && version === BUNDLE_VERSION
if (runsIt && (!reg?.active || reg.active === currentController())) {
markApplied(version)
return
}
// Without a worker the page came from the network, yet was not built as this
// version: a proxy or CDN handed out an index.html it kept, or the server
// reports a version its bundle was not built as, and then the reload only
// confirms what runs. A controller other than the one that served the page
// means the browser finished the handover by itself, and the reload is what
// shows the new build.
if (!reg || (servedBy && currentController() !== servedBy)) {
markApplied(version)
reloadOnce(version)
return
}
await handOver(reg, version)
await handOver(reg, version, runsIt)
}