From bea3af4b45b97c61fca5cebf9c64a5d6c12b4a80 Mon Sep 17 00:00:00 2001 From: Maurice Date: Fri, 25 Sep 2026 22:50:59 +0000 Subject: [PATCH] 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. --- client/index.html | 3 + client/public/shell-guard.js | 154 ++++++ .../components/shared/ErrorBoundary.test.tsx | 48 +- .../src/components/shared/ErrorBoundary.tsx | 16 +- client/src/main.tsx | 4 + client/src/utils/chunkReload.test.ts | 180 ++++++- client/src/utils/chunkReload.ts | 64 ++- client/src/utils/globalErrorHandlers.test.ts | 3 +- client/src/utils/serviceWorkerShell.test.ts | 481 ++++++++++++++++++ client/src/utils/serviceWorkerShell.ts | 242 +++++++++ client/src/utils/versionHandover.test.ts | 164 +++++- client/src/utils/versionHandover.ts | 68 ++- client/tests/unit/utils/shellGuard.test.ts | 236 +++++++++ server/src/nest/platform/platform.routes.ts | 44 +- .../src/nest/platform/spa-fallback.filter.ts | 9 +- server/tests/e2e/spa-fallback.e2e.test.ts | 112 ++++ .../unit/nest/platform.controller.test.ts | 93 +++- 17 files changed, 1867 insertions(+), 54 deletions(-) create mode 100644 client/public/shell-guard.js create mode 100644 client/src/utils/serviceWorkerShell.test.ts create mode 100644 client/src/utils/serviceWorkerShell.ts create mode 100644 client/tests/unit/utils/shellGuard.test.ts create mode 100644 server/tests/e2e/spa-fallback.e2e.test.ts diff --git a/client/index.html b/client/index.html index d31606d03..bc8f8325c 100644 --- a/client/index.html +++ b/client/index.html @@ -8,6 +8,9 @@ + + diff --git a/client/public/shell-guard.js b/client/public/shell-guard.js new file mode 100644 index 000000000..4c2232f0a --- /dev/null +++ b/client/public/shell-guard.js @@ -0,0 +1,154 @@ +/* + * Boot guard for the app shell (#2524). + * + * Loaded as an external classic script in (NOT a module), like + * theme-boot.js, so it runs before the entry module and complies with the + * production CSP (script-src 'self'). + * + * The entry module and every chunk it imports statically have to load before + * any of the app's own code runs. When one of them does not (a service worker + * precache that keeps index.html under a chunk's name, a proxy that hands out a + * stale file), the module script fails as a whole: nothing renders, and no error + * screen or reload button of the app is there to help. A reload changes nothing + * either, since the same precache answers it. This notices exactly that failure + * and, while the server answers, loads the page again from the server under a + * URL no cache has seen. When a service worker served the page, the app shell + * it keeps (the Workbox precache and the worker itself) is thrown away first, + * since that is what answered with the broken file. Once per session, so a build + * that is broken on the server cannot put the page in a reload loop. Offline + * nothing happens: the precache is then the only way the app can start at all. + * + * It also strips the query parameter such a reload carries, before the app + * reads the URL. Keep the parameter and the cache filter in sync with + * src/utils/chunkReload.ts and src/utils/serviceWorkerShell.ts. + * + * It must never throw. + */ +(function () { + var PARAM = 'trek-reload'; + var MARKER = 'trek:shell-reload'; + var HEALTH_TIMEOUT_MS = 3000; + + try { + var here = new URL(window.location.href); + if (here.searchParams.has(PARAM)) { + here.searchParams.delete(PARAM); + window.history.replaceState(window.history.state, '', here.pathname + here.search + here.hash); + } + } catch (e) { + /* the parameter stays in the address bar, which is harmless */ + } + + // The built entry lives under /assets/. The dev server's /src/main.tsx does + // not, so a syntax error while developing is left to Vite's own overlay. + function isEntry(el) { + return !!el && el.tagName === 'SCRIPT' && el.type === 'module' && /\/assets\//.test(el.src || ''); + } + + function alreadyTried() { + try { + return !!sessionStorage.getItem(MARKER); + } catch (e) { + // Without the marker there is no loop protection, so do nothing. + return true; + } + } + + function markTried() { + try { + sessionStorage.setItem(MARKER, String(Date.now())); + return true; + } catch (e) { + return false; + } + } + + // Same test as probeNow() in src/sync/connectivity.ts: TREK's own health JSON, + // not an edge proxy's login page and not a request that never came back. + function serverAnswers() { + var ctrl = typeof AbortController === 'function' ? new AbortController() : null; + var timer = ctrl ? setTimeout(function () { ctrl.abort(); }, HEALTH_TIMEOUT_MS) : null; + return fetch('/api/health', { + credentials: 'include', + cache: 'no-store', + redirect: 'manual', + signal: ctrl ? ctrl.signal : undefined, + }).then(function (res) { + var type = res.headers.get('content-type') || ''; + return res.ok && type.indexOf('application/json') !== -1; + }, function () { + return false; + }).then(function (ok) { + if (timer) clearTimeout(timer); + return ok; + }); + } + + function servedByWorker() { + try { + return !!(navigator.serviceWorker && navigator.serviceWorker.controller); + } catch (e) { + return false; + } + } + + function dropAppShell() { + var jobs = []; + if (typeof caches !== 'undefined') { + jobs.push(caches.keys().then(function (names) { + return Promise.all(names.filter(function (name) { + return name.indexOf('-precache-') !== -1; + }).map(function (name) { + return caches.delete(name); + })); + })); + } + if (navigator.serviceWorker) { + jobs.push(navigator.serviceWorker.getRegistration().then(function (reg) { + return reg ? reg.unregister() : false; + })); + } + return Promise.all(jobs); + } + + function reloadFresh() { + var url = new URL(window.location.href); + url.searchParams.set(PARAM, Date.now().toString(36)); + window.location.replace(url.href); + } + + function recover() { + if (alreadyTried()) return; + // Read now: a page the worker did not serve failed on the network, and its + // precache has nothing to do with it. + var fromWorker = servedByWorker(); + serverAnswers().then(function (ok) { + if (!ok || alreadyTried() || !markTried()) return; + var dropped = fromWorker ? dropAppShell() : Promise.resolve(); + return dropped.then(null, function () { + /* reload regardless, the server has the current build */ + }).then(reloadFresh); + }).then(null, function () { + /* never block boot */ + }); + } + + try { + // Error and load events of a script do not bubble, so both are caught on + // their way down. On the document, since a load event never reaches window. + document.addEventListener('error', function (event) { + if (isEntry(event.target)) recover(); + }, true); + // The shell started, so a later update in this session may recover too. + document.addEventListener('load', function (event) { + if (!isEntry(event.target)) return; + try { + sessionStorage.removeItem(MARKER); + } catch (e) { + /* nothing to clear */ + } + }, true); + } catch (e) { + /* never block boot */ + } +})(); diff --git a/client/src/components/shared/ErrorBoundary.test.tsx b/client/src/components/shared/ErrorBoundary.test.tsx index 62d18bf47..be5e7b7ed 100644 --- a/client/src/components/shared/ErrorBoundary.test.tsx +++ b/client/src/components/shared/ErrorBoundary.test.tsx @@ -1,6 +1,14 @@ import React from 'react'; import { render, screen, fireEvent } from '../../../tests/helpers/render'; import ErrorBoundary, { RootErrorFallback } from './ErrorBoundary'; +import * as chunkReload from '../../utils/chunkReload'; + +// Only the Reload button's way out is replaced; the boundary's own automatic reload +// (reloadOnceForChunk) keeps running the real code. +vi.mock('../../utils/chunkReload', async (importOriginal) => ({ + ...(await importOriginal()), + reloadOntoCurrentBuild: vi.fn(), +})); // React logs every caught error itself; without this each of these tests prints a // full component stack and buries the real output. @@ -146,9 +154,10 @@ describe('ErrorBoundary', () => { }); it('FE-COMP-ERRBOUND-011: a chunk failure reloads once, then stops', () => { + // The recovery reload is a location.replace onto a fresh URL (chunkReload). const reload = vi.fn(); Object.defineProperty(window, 'location', { - value: { ...window.location, reload }, + value: { ...window.location, replace: reload }, writable: true, }); @@ -214,3 +223,40 @@ describe('RootErrorFallback', () => { expect(screen.getByText('A new version is available')).toBeInTheDocument(); }); }); + +describe('Reload button', () => { + let reload: ReturnType; + beforeEach(() => { + reload = vi.fn(); + Object.defineProperty(window, 'location', { value: { ...window.location, reload }, writable: true }); + vi.mocked(chunkReload.reloadOntoCurrentBuild).mockClear(); + }); + + // A plain reload is answered by the service worker with the shell that just + // failed, which is how the update screen outlived every reload (#2524). + it('FE-COMP-ERRBOUND-017: after a dead chunk it reloads onto the current build', () => { + render( {}} isChunkError />); + fireEvent.click(screen.getByText('Reload page')); + expect(chunkReload.reloadOntoCurrentBuild).toHaveBeenCalledTimes(1); + expect(reload).not.toHaveBeenCalled(); + }); + + it('FE-COMP-ERRBOUND-018: the route fallback for a dead chunk does the same', () => { + sessionStorage.setItem('trek:chunk-reload', '1'); + render( + + + , + ); + fireEvent.click(screen.getByText('Reload page')); + expect(chunkReload.reloadOntoCurrentBuild).toHaveBeenCalledTimes(1); + expect(reload).not.toHaveBeenCalled(); + }); + + it('FE-COMP-ERRBOUND-019: any other failure keeps the plain reload', () => { + render( {}} isChunkError={false} />); + fireEvent.click(screen.getByText('Reload page')); + expect(reload).toHaveBeenCalledTimes(1); + expect(chunkReload.reloadOntoCurrentBuild).not.toHaveBeenCalled(); + }); +}); diff --git a/client/src/components/shared/ErrorBoundary.tsx b/client/src/components/shared/ErrorBoundary.tsx index de75be4e0..78452e614 100644 --- a/client/src/components/shared/ErrorBoundary.tsx +++ b/client/src/components/shared/ErrorBoundary.tsx @@ -1,7 +1,7 @@ import React from 'react' import { AlertTriangle, RefreshCw, RotateCcw } from 'lucide-react' import { useTranslation } from '../../i18n' -import { isChunkLoadError, reloadOnceForChunk } from '../../utils/chunkReload' +import { isChunkLoadError, reloadOnceForChunk, reloadOntoCurrentBuild } from '../../utils/chunkReload' /** * The app had none of these, so a single throw during render unmounted the whole @@ -95,6 +95,16 @@ export default class ErrorBoundary extends React.Component window.location.reload()} + onClick={() => reloadPage(isChunkError)} className={[ 'inline-flex items-center gap-1.5 rounded-lg border px-3 py-2 text-caption font-medium', mobile ? 'border-[color:var(--m-rowbr)] text-m-ink' : 'border-edge text-content', @@ -201,7 +211,7 @@ export function RootErrorFallback({ error, isChunkError }: FallbackState) { {message && {message}}