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
+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)
})