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.
This commit is contained in:
Maurice
2026-09-25 22:50:59 +00:00
parent 26ab4a58b1
commit bea3af4b45
17 changed files with 1867 additions and 54 deletions
+3
View File
@@ -8,6 +8,9 @@
<!-- Pre-paint appearance (FOUC fix). External classic script so it runs <!-- Pre-paint appearance (FOUC fix). External classic script so it runs
before first paint AND complies with the prod CSP (script-src 'self'). --> before first paint AND complies with the prod CSP (script-src 'self'). -->
<script src="/theme-boot.js"></script> <script src="/theme-boot.js"></script>
<!-- Recovers a shell whose entry module cannot load (#2524). Classic for the
same reasons, and ahead of the entry, which it has to watch. -->
<script src="/shell-guard.js"></script>
<!-- PWA / iOS --> <!-- PWA / iOS -->
<meta name="theme-color" content="#09090b" /> <meta name="theme-color" content="#09090b" />
+154
View File
@@ -0,0 +1,154 @@
/*
* Boot guard for the app shell (#2524).
*
* Loaded as an external classic script in <head> (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 */
}
})();
@@ -1,6 +1,14 @@
import React from 'react'; import React from 'react';
import { render, screen, fireEvent } from '../../../tests/helpers/render'; import { render, screen, fireEvent } from '../../../tests/helpers/render';
import ErrorBoundary, { RootErrorFallback } from './ErrorBoundary'; 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<typeof import('../../utils/chunkReload')>()),
reloadOntoCurrentBuild: vi.fn(),
}));
// React logs every caught error itself; without this each of these tests prints a // React logs every caught error itself; without this each of these tests prints a
// full component stack and buries the real output. // 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', () => { 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(); const reload = vi.fn();
Object.defineProperty(window, 'location', { Object.defineProperty(window, 'location', {
value: { ...window.location, reload }, value: { ...window.location, replace: reload },
writable: true, writable: true,
}); });
@@ -214,3 +223,40 @@ describe('RootErrorFallback', () => {
expect(screen.getByText('A new version is available')).toBeInTheDocument(); expect(screen.getByText('A new version is available')).toBeInTheDocument();
}); });
}); });
describe('Reload button', () => {
let reload: ReturnType<typeof vi.fn>;
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(<RootErrorFallback error={new Error(CHUNK_MESSAGE)} reset={() => {}} 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(
<ErrorBoundary boundaryId="route" level="route">
<Boom message={CHUNK_MESSAGE} />
</ErrorBoundary>,
);
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(<RootErrorFallback error={new Error('boot failed')} reset={() => {}} isChunkError={false} />);
fireEvent.click(screen.getByText('Reload page'));
expect(reload).toHaveBeenCalledTimes(1);
expect(chunkReload.reloadOntoCurrentBuild).not.toHaveBeenCalled();
});
});
+13 -3
View File
@@ -1,7 +1,7 @@
import React from 'react' import React from 'react'
import { AlertTriangle, RefreshCw, RotateCcw } from 'lucide-react' import { AlertTriangle, RefreshCw, RotateCcw } from 'lucide-react'
import { useTranslation } from '../../i18n' 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 * 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<ErrorBoundaryProps, E
} }
} }
/**
* The fallback's Reload button. After a dead chunk a plain reload can come back
* to this very screen, because the service worker answers it with the shell it
* already failed to load (#2524), so that case goes through the chunk recovery.
*/
function reloadPage(isChunkError: boolean) {
if (isChunkError) reloadOntoCurrentBuild()
else window.location.reload()
}
interface FallbackProps extends FallbackState { interface FallbackProps extends FallbackState {
level: Level level: Level
variant: 'desktop' | 'mobile' variant: 'desktop' | 'mobile'
@@ -161,7 +171,7 @@ export function ErrorFallback({ error, reset, isChunkError, level, variant, labe
{(!isPanel || isChunkError) && ( {(!isPanel || isChunkError) && (
<button <button
type="button" type="button"
onClick={() => window.location.reload()} onClick={() => reloadPage(isChunkError)}
className={[ className={[
'inline-flex items-center gap-1.5 rounded-lg border px-3 py-2 text-caption font-medium', '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', mobile ? 'border-[color:var(--m-rowbr)] text-m-ink' : 'border-edge text-content',
@@ -201,7 +211,7 @@ export function RootErrorFallback({ error, isChunkError }: FallbackState) {
{message && <code className="max-w-full truncate text-caption text-content-faint">{message}</code>} {message && <code className="max-w-full truncate text-caption text-content-faint">{message}</code>}
<button <button
type="button" type="button"
onClick={() => window.location.reload()} onClick={() => reloadPage(isChunkError)}
className="mt-1 inline-flex items-center gap-1.5 rounded-lg bg-accent px-3 py-2 text-caption font-medium text-white" className="mt-1 inline-flex items-center gap-1.5 rounded-lg bg-accent px-3 py-2 text-caption font-medium text-white"
> >
<RefreshCw size={14} aria-hidden /> <RefreshCw size={14} aria-hidden />
+4
View File
@@ -36,6 +36,7 @@ import { startConnectivityProbe } from './sync/connectivity'
import { requestPersistentStorage } from './sync/persistentStorage' import { requestPersistentStorage } from './sync/persistentStorage'
import ErrorBoundary, { RootErrorFallback } from './components/shared/ErrorBoundary' import ErrorBoundary, { RootErrorFallback } from './components/shared/ErrorBoundary'
import { installGlobalErrorHandlers } from './utils/globalErrorHandlers' import { installGlobalErrorHandlers } from './utils/globalErrorHandlers'
import { schedulePrecacheRepair } from './utils/serviceWorkerShell'
maybeInstallTouchDragPolyfill() maybeInstallTouchDragPolyfill()
startConnectivityProbe() startConnectivityProbe()
@@ -43,6 +44,9 @@ startConnectivityProbe()
requestPersistentStorage() requestPersistentStorage()
// Event handlers and async code never reach a boundary; this is where they land. // Event handlers and async code never reach a boundary; this is where they land.
installGlobalErrorHandlers() installGlobalErrorHandlers()
// A page the precache keeps under a stylesheet's or a script's name would be
// handed on to every later release (#2524).
schedulePrecacheRepair()
ReactDOM.createRoot(document.getElementById('root')!).render( ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode> <React.StrictMode>
+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(() => { beforeEach(() => {
sessionStorage.clear(); sessionStorage.clear();
@@ -27,12 +34,54 @@ describe('isChunkLoadError', () => {
}); });
}); });
describe('reloadOnceForChunk', () => { /**
function stubReload() { * 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 reload = vi.fn();
Object.defineProperty(window, 'location', { value: { ...window.location, reload }, writable: true }); const plain = vi.fn();
return reload; 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', () => {
const stubReload = () => stubLocation().reload;
it('FE-UTIL-CHUNK-004: reloads the first time and refuses afterwards', () => { it('FE-UTIL-CHUNK-004: reloads the first time and refuses afterwards', () => {
const reload = stubReload(); const reload = stubReload();
@@ -71,3 +120,122 @@ describe('reloadOnceForChunk', () => {
expect(() => clearChunkReloadMarker()).not.toThrow(); 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. * 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'; const RELOAD_MARKER = 'trek:chunk-reload';
/** Vite's own message, plus what Chrome/Firefox/Safari say for a failed module fetch. */ /** 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)); 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 * 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 * 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. // loop, so prefer showing the fallback with its manual reload button.
return false; return false;
} }
window.location.reload(); reloadOntoCurrentBuild();
return true; return true;
} }
+2 -1
View File
@@ -3,13 +3,14 @@ import { installGlobalErrorHandlers } from './globalErrorHandlers';
let teardown: () => void; let teardown: () => void;
let consoleError: ReturnType<typeof vi.spyOn>; let consoleError: ReturnType<typeof vi.spyOn>;
// A chunk failure reloads through chunkReload's fresh reload, a location.replace.
let reload: ReturnType<typeof vi.fn>; let reload: ReturnType<typeof vi.fn>;
beforeEach(() => { beforeEach(() => {
sessionStorage.clear(); sessionStorage.clear();
consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}); consoleError = vi.spyOn(console, 'error').mockImplementation(() => {});
reload = vi.fn(); 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(); 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])
})
})
+242
View File
@@ -0,0 +1,242 @@
/**
* 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.
* 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 { class FakeRegistration {
active: FakeWorker | null = null
installing: FakeWorker | null = null installing: FakeWorker | null = null
waiting: FakeWorker | null = null waiting: FakeWorker | null = null
update = vi.fn(async () => this) update = vi.fn(async () => this)
@@ -40,6 +41,7 @@ class FakeContainer extends EventTarget {
function installWorker(controller: FakeWorker | null = new FakeWorker('activated')) { function installWorker(controller: FakeWorker | null = new FakeWorker('activated')) {
const registration = new FakeRegistration() const registration = new FakeRegistration()
registration.active = controller
const container = new FakeContainer(controller) const container = new FakeContainer(controller)
container.getRegistration.mockResolvedValue(registration) container.getRegistration.mockResolvedValue(registration)
Object.defineProperty(navigator, 'serviceWorker', { configurable: true, value: container }) 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') 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 reload: ReturnType<typeof vi.fn>
let plainReload: ReturnType<typeof vi.fn>
beforeEach(() => { 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() 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(() => { afterEach(() => {
@@ -216,6 +227,80 @@ describe('reconcileAppVersion: a new version without a handover to wait for', ()
container.takeOver() container.takeOver()
expect(reload).not.toHaveBeenCalled() 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', () => { 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', () => { describe('reconcileAppVersion: the reload guard', () => {
it('FE-UTIL-VERSION-030: a marker that cannot be written does not turn into a reload loop', async () => { it('FE-UTIL-VERSION-030: a marker that cannot be written does not turn into a reload loop', async () => {
const { reconcileAppVersion } = await loadModule() const { reconcileAppVersion } = await loadModule()
@@ -309,9 +467,9 @@ describe('reconcileAppVersion: the reload guard', () => {
localStorage.setItem('trek_app_version', '4.3.0') localStorage.setItem('trek_app_version', '4.3.0')
await reconcileAppVersion('4.3.1') 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) expect(reload).toHaveBeenCalledTimes(2)
}) })
+44 -24
View File
@@ -17,25 +17,20 @@
* version and session. * version and session.
*/ */
import { reloadFresh } from './chunkReload'
import { currentController, servingWorker } from './serviceWorkerShell'
const VERSION_KEY = 'trek_app_version' const VERSION_KEY = 'trek_app_version'
const RELOAD_KEY = 'trek_app_version_reload' const RELOAD_KEY = 'trek_app_version_reload'
/** The worker controlling this page, or null. Reading it can throw where site data is blocked. */ // The version this bundle was built as (vite.config.js). A bare tsc run has none,
function currentController(): ServiceWorker | null { // and then no version counts as this bundle's own.
try { const BUNDLE_VERSION: string = typeof __TREK_UI_VERSION__ === 'string' ? __TREK_UI_VERSION__ : ''
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 // The worker that served this page, noted when the app loads (serviceWorkerShell).
// sw.js on every navigation by itself, and when that check installs the new build // When the browser's own check of sw.js installs the new build before the server
// before the server has told us its version, the takeover is over before anyone // has told us its version, the takeover is over before anyone listens for it.
// listens for it. A controller other than this one means the new shell is only a const servedBy = servingWorker()
// reload away. A page served without a worker came from the network, so a worker
// claiming it later is no takeover.
const servedBy = currentController()
function markApplied(version: string): void { function markApplied(version: string): void {
try { localStorage.setItem(VERSION_KEY, version) } catch { /* site data blocked */ } try { localStorage.setItem(VERSION_KEY, version) } catch { /* site data blocked */ }
@@ -54,10 +49,18 @@ function reloadOnce(version: string): void {
} catch { } catch {
return 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. // 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 // 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. // 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', () => { navigator.serviceWorker.addEventListener('controllerchange', () => {
if (!armed) return if (!armed) return
markApplied(version) markApplied(version)
reloadOnce(version) if (!runsIt) reloadOnce(version)
}, { once: true }) }, { once: true })
try { 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 // sw.js is unchanged, so the release changed nothing the precache holds and
// the bundle this page runs is the current one. // the bundle this page runs is the current one.
armed = false armed = false
if (currentController() === servedBy) markApplied(version) if (runsIt || currentController() === servedBy) markApplied(version)
return return
} }
// Precaching is all or nothing: one failed request out of several hundred and // 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. // Left for the next launch. A reload now would only reach the old worker again.
return 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 // A page that came from the network and was built as this version runs it
// that served the page means the browser finished the handover by itself, // already. That is where the way out of a broken app shell lands, before the
// and the reload is what shows the new build. // 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)) { if (!reg || (servedBy && currentController() !== servedBy)) {
markApplied(version) markApplied(version)
reloadOnce(version) reloadOnce(version)
return return
} }
await handOver(reg, version) await handOver(reg, version, runsIt)
} }
+236
View File
@@ -0,0 +1,236 @@
/**
* public/shell-guard.js (#2524): the classic boot script that recovers a shell
* whose entry module cannot load. It is not a module, so it is run here from its
* source, the way the browser runs it from index.html.
*/
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
const SOURCE = readFileSync(resolve(process.cwd(), 'public/shell-guard.js'), 'utf8');
const ENTRY = '/assets/index-BZcJ9-lk.js';
const MARKER = 'trek:shell-reload';
type Listener = [string, EventListenerOrEventListenerObject, boolean | AddEventListenerOptions | undefined];
let listeners: Listener[] = [];
let scripts: HTMLElement[] = [];
/** Runs the guard and keeps its listeners, so every test starts with exactly one set. */
function runGuard(): void {
const add = vi.spyOn(document, 'addEventListener');
new Function(SOURCE)();
listeners = add.mock.calls.map(call => call as unknown as Listener);
add.mockRestore();
}
function moduleScript(src: string, type = 'module'): HTMLScriptElement {
const el = document.createElement('script');
el.type = type;
el.src = src;
document.head.appendChild(el);
scripts.push(el);
return el;
}
function fail(el: Element): void {
el.dispatchEvent(new Event('error'));
}
/** Lets the guard's promise chain run to its end. */
async function settle(): Promise<void> {
for (let i = 0; i < 10; i++) await new Promise(r => setTimeout(r, 0));
}
function healthAnswer(ok: boolean, type: string) {
return { ok, headers: { get: (name: string) => (name.toLowerCase() === 'content-type' ? type : null) } };
}
interface Env {
replace: ReturnType<typeof vi.fn>;
fetch: ReturnType<typeof vi.fn>;
unregister: ReturnType<typeof vi.fn>;
deleted: string[];
}
/** A page a service worker served, unless `controlled` is false. */
function install(href = 'http://localhost/trips/4?tab=plan#day-2', controlled = true): Env {
const replace = vi.fn();
Object.defineProperty(window, 'location', { value: { ...window.location, href, replace }, writable: true, configurable: true });
const fetch = vi.fn(async () => healthAnswer(true, 'application/json; charset=utf-8'));
vi.stubGlobal('fetch', fetch);
const unregister = vi.fn(async () => true);
Object.defineProperty(navigator, 'serviceWorker', {
value: { controller: controlled ? { scriptURL: 'http://localhost/sw.js' } : null, getRegistration: vi.fn(async () => ({ unregister })) },
configurable: true,
});
const deleted: string[] = [];
Object.defineProperty(globalThis, 'caches', {
value: {
keys: vi.fn(async () => ['workbox-precache-v2-http://localhost/', 'map-tiles', 'gl-map-offline', 'user-uploads']),
delete: vi.fn(async (name: string) => { deleted.push(name); return true; }),
},
configurable: true,
});
return { replace, fetch, unregister, deleted };
}
const originalLocation = Object.getOwnPropertyDescriptor(window, 'location');
beforeEach(() => {
sessionStorage.clear();
});
afterEach(() => {
for (const [type, fn, options] of listeners) document.removeEventListener(type, fn, options);
listeners = [];
for (const el of scripts) el.remove();
scripts = [];
vi.unstubAllGlobals();
vi.restoreAllMocks();
Reflect.deleteProperty(navigator, 'serviceWorker');
Reflect.deleteProperty(globalThis, 'caches');
if (originalLocation) Object.defineProperty(window, 'location', originalLocation);
});
describe('shell-guard.js', () => {
it('FE-SHELLGUARD-001: strips the fresh-reload parameter and keeps the rest of the URL', () => {
window.history.replaceState({ usr: 1 }, '', '/trips/4?tab=plan&trek-reload=mfz3x1&x=2#day-2');
runGuard();
expect(window.location.pathname + window.location.search + window.location.hash).toBe('/trips/4?tab=plan&x=2#day-2');
expect(window.history.state).toEqual({ usr: 1 });
window.history.replaceState(null, '', '/');
});
it('FE-SHELLGUARD-002: leaves a URL without the parameter alone', () => {
window.history.replaceState(null, '', '/dashboard?tab=trips');
const replaceState = vi.spyOn(window.history, 'replaceState');
runGuard();
expect(replaceState).not.toHaveBeenCalled();
window.history.replaceState(null, '', '/');
});
it('FE-SHELLGUARD-003: an entry that cannot load drops the app shell and loads the page afresh', async () => {
const env = install();
vi.spyOn(Date, 'now').mockReturnValue(1790358416100);
runGuard();
fail(moduleScript(ENTRY));
await vi.waitFor(() => expect(env.replace).toHaveBeenCalledTimes(1));
expect(env.fetch).toHaveBeenCalledWith('/api/health', expect.objectContaining({ cache: 'no-store', redirect: 'manual' }));
// Only the precache: offline map regions and cached uploads were downloaded on purpose.
expect(env.deleted).toEqual(['workbox-precache-v2-http://localhost/']);
expect(env.unregister).toHaveBeenCalledTimes(1);
const target = new URL(env.replace.mock.calls[0][0] as string);
expect(target.pathname + target.hash).toBe('/trips/4#day-2');
expect(target.searchParams.get('tab')).toBe('plan');
expect(target.searchParams.get('trek-reload')).toBe((1790358416100).toString(36));
});
it.each([
['offline', () => Promise.reject(new TypeError('Failed to fetch'))],
['a proxy login page', async () => healthAnswer(true, 'text/html')],
['a server error', async () => healthAnswer(false, 'application/json')],
])('FE-SHELLGUARD-004: keeps the shell when the server does not answer (%s)', async (_case, answer) => {
const env = install();
env.fetch.mockImplementation(answer);
runGuard();
fail(moduleScript(ENTRY));
await settle();
// Offline, the precache is the only way the app starts at all.
expect(env.fetch).toHaveBeenCalledTimes(1);
expect(env.deleted).toEqual([]);
expect(env.unregister).not.toHaveBeenCalled();
expect(env.replace).not.toHaveBeenCalled();
// Nothing was tried, so a later load may still recover.
expect(sessionStorage.getItem(MARKER)).toBeNull();
});
it('FE-SHELLGUARD-005: recovers once per session, so a broken build cannot loop', async () => {
const env = install();
runGuard();
fail(moduleScript(ENTRY));
await vi.waitFor(() => expect(env.replace).toHaveBeenCalledTimes(1));
// The reloaded page fails the same way: the marker is still set.
fail(moduleScript(ENTRY));
await settle();
expect(env.fetch).toHaveBeenCalledTimes(1);
expect(env.replace).toHaveBeenCalledTimes(1);
});
it('FE-SHELLGUARD-006: an entry that loaded clears the marker, so a later update may recover too', async () => {
const env = install();
sessionStorage.setItem(MARKER, '1790358416100');
runGuard();
moduleScript(ENTRY).dispatchEvent(new Event('load'));
expect(sessionStorage.getItem(MARKER)).toBeNull();
fail(moduleScript(ENTRY));
await vi.waitFor(() => expect(env.replace).toHaveBeenCalledTimes(1));
});
it('FE-SHELLGUARD-007: does nothing without session storage, where it could not stop a loop', async () => {
const env = install();
vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => {
throw new Error('storage blocked');
});
runGuard();
fail(moduleScript(ENTRY));
await settle();
expect(env.fetch).not.toHaveBeenCalled();
expect(env.replace).not.toHaveBeenCalled();
});
it('FE-SHELLGUARD-008: still reloads when dropping the shell fails', async () => {
const env = install();
env.unregister.mockRejectedValue(new Error('InvalidStateError'));
runGuard();
fail(moduleScript(ENTRY));
await vi.waitFor(() => expect(env.replace).toHaveBeenCalledTimes(1));
});
it('FE-SHELLGUARD-011: a page that came from the network is reloaded afresh and its worker kept', async () => {
// Nothing the worker keeps answered this load, so throwing it away would only
// cost the offline shell.
const env = install('http://localhost/dashboard', false);
runGuard();
fail(moduleScript(ENTRY));
await vi.waitFor(() => expect(env.replace).toHaveBeenCalledTimes(1));
expect(env.deleted).toEqual([]);
expect(env.unregister).not.toHaveBeenCalled();
expect(new URL(env.replace.mock.calls[0][0] as string).searchParams.has('trek-reload')).toBe(true);
});
it('FE-SHELLGUARD-009: works where there is no service worker and no Cache Storage', async () => {
const env = install();
Reflect.deleteProperty(navigator, 'serviceWorker');
Reflect.deleteProperty(globalThis, 'caches');
runGuard();
fail(moduleScript(ENTRY));
// A proxy that kept a stale index.html is bypassed by the fresh URL alone.
await vi.waitFor(() => expect(env.replace).toHaveBeenCalledTimes(1));
});
it('FE-SHELLGUARD-010: ignores every other failure', async () => {
const env = install();
runGuard();
// Vite's dev entry, a classic script, a lazy chunk's stylesheet, an image.
fail(moduleScript('/src/main.tsx'));
fail(moduleScript('/registerSW.js', 'text/javascript'));
const link = document.createElement('link');
link.rel = 'stylesheet';
link.href = '/assets/DashboardPage-v4ODOTOr.css';
document.head.appendChild(link);
scripts.push(link);
fail(link);
const img = document.createElement('img');
img.src = '/uploads/covers/missing.jpg';
document.body.appendChild(img);
scripts.push(img);
fail(img);
// A runtime error of the app is an ErrorEvent on window, not a failed script.
window.dispatchEvent(new Event('error'));
await settle();
expect(env.fetch).not.toHaveBeenCalled();
expect(env.replace).not.toHaveBeenCalled();
});
});
+39 -5
View File
@@ -20,6 +20,43 @@ import { StorageInvalidKeyError, StorageNotFoundError, type StorageCategory } fr
export const PUBLIC_DIR = path.join(__dirname, '../../../public'); export const PUBLIC_DIR = path.join(__dirname, '../../../public');
// File types the client build emits. No page of the app ends in one of these.
const BUILD_FILE_RE = /\.(?:m?js|css|map|wasm|webmanifest|json|png|jpe?g|gif|webp|avif|svg|ico|woff2?|ttf|otf)$/i;
/**
* Whether a request asks for a file of the built client rather than a page of it.
*
* Such a request that the static handler could not answer is a file that does not
* exist, and it must end in a 404. It used to get index.html like a page (#2524).
* A tab still running the previous build asks for a chunk the update removed and
* gets HTML with a 200, which the module loader rejects. Worse, a 200 is a success
* to everything that caches: a service worker's precache stores the HTML under the
* chunk's name and keeps reusing it in every later version that keeps the name,
* and a reverse proxy that caches static files by extension hands it out as the
* chunk. A 404 is refused by the precache and never stands in for the file.
*/
export function isBuildFilePath(pathname: string): boolean {
return pathname.startsWith('/assets/') || BUILD_FILE_RE.test(pathname);
}
/**
* The production answer to a GET that nothing else matched: index.html for a page
* of the app, a plain 404 in the TREK envelope for a build file that is not there.
* Shared by both fallback forms (applyPlatformSpa and SpaFallbackFilter).
*/
export function answerUnmatchedGet(req: Request, res: Response, message = 'Not Found'): void {
if (isBuildFilePath(req.path)) {
res.setHeader('Cache-Control', 'no-store');
res.status(404).json({ error: message });
return;
}
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
// Relative to root, so that send() checks only 'index.html' for dot segments. Given
// the absolute path it refused an install living under a dot directory (a
// ~/.local checkout, say) and every deep link came back as a 404.
res.sendFile('index.html', { root: PUBLIC_DIR });
}
/** /**
* express.static replacement for the four public /uploads mounts (storage * express.static replacement for the four public /uploads mounts (storage
* slice 3). Parity contract (spec §Serving): identical ETag / Last-Modified / * slice 3). Parity contract (spec §Serving): identical ETag / Last-Modified /
@@ -172,17 +209,14 @@ export function applyPlatformUploads(app: express.Application, storage: StorageS
* NOT use this (its router terminates unmatched requests with a 404 before any * NOT use this (its router terminates unmatched requests with a 404 before any
* post-init route runs, and Express 5's path-to-regexp rejects a bare '*'); it serves * post-init route runs, and Express 5's path-to-regexp rejects a bare '*'); it serves
* the SPA via the SpaFallbackFilter instead. Both produce the identical result: * the SPA via the SpaFallbackFilter instead. Both produce the identical result:
* unmatched GET → index.html in production. * unmatched GET → index.html in production, or a 404 for a missing build file.
*/ */
export function applyPlatformSpa(app: express.Application): void { export function applyPlatformSpa(app: express.Application): void {
applyPlatformStatic(app); applyPlatformStatic(app);
// Case-sensitive on purpose (legacy parity). // Case-sensitive on purpose (legacy parity).
if (readEnv().app.nodeEnv !== 'production') return; if (readEnv().app.nodeEnv !== 'production') return;
// /.*/ rather than '*' so the helper is Express-4 and Express-5 safe. // /.*/ rather than '*' so the helper is Express-4 and Express-5 safe.
app.get(/.*/, (_req: Request, res: Response) => { app.get(/.*/, (req: Request, res: Response) => answerUnmatchedGet(req, res));
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
res.sendFile(path.join(PUBLIC_DIR, 'index.html'));
});
} }
/** /**
@@ -1,8 +1,7 @@
import { ArgumentsHost, Catch, ExceptionFilter, NotFoundException } from '@nestjs/common'; import { ArgumentsHost, Catch, ExceptionFilter, NotFoundException } from '@nestjs/common';
import type { Request, Response } from 'express'; import type { Request, Response } from 'express';
import path from 'node:path';
import { readEnv } from '../../app-config'; import { readEnv } from '../../app-config';
import { PUBLIC_DIR } from './platform.routes'; import { answerUnmatchedGet } from './platform.routes';
/** /**
* Serves the built SPA (index.html) for any request the NestJS router did not * Serves the built SPA (index.html) for any request the NestJS router did not
@@ -17,6 +16,9 @@ import { PUBLIC_DIR } from './platform.routes';
* client) keeps the standard TREK `{ error }` 404 envelope. The `@Catch(NotFoundException)` * client) keeps the standard TREK `{ error }` 404 envelope. The `@Catch(NotFoundException)`
* is more specific than the global TrekExceptionFilter, so Nest routes 404s here * is more specific than the global TrekExceptionFilter, so Nest routes 404s here
* while every other error still flows through TrekExceptionFilter. * while every other error still flows through TrekExceptionFilter.
*
* A GET for a build file that is not on disk (a chunk of the previous release,
* say) is not a page and gets the 404 envelope too, never index.html (#2524).
*/ */
@Catch(NotFoundException) @Catch(NotFoundException)
export class SpaFallbackFilter implements ExceptionFilter { export class SpaFallbackFilter implements ExceptionFilter {
@@ -27,8 +29,7 @@ export class SpaFallbackFilter implements ExceptionFilter {
// Case-sensitive on purpose (legacy parity). // Case-sensitive on purpose (legacy parity).
if (readEnv().app.nodeEnv === 'production' && req.method === 'GET') { if (readEnv().app.nodeEnv === 'production' && req.method === 'GET') {
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate'); answerUnmatchedGet(req, res, exception.message || 'Not Found');
res.sendFile(path.join(PUBLIC_DIR, 'index.html'));
return; return;
} }
+112
View File
@@ -0,0 +1,112 @@
/**
* SPA fallback e2e (#2524). Boots a Nest app with the production static layer and
* the two global filters wired the way AppModule and bootstrap wire them, then asks
* over HTTP for a page, for a build file that exists, and for build files an
* update removed.
*
* The last case is the bug: a tab or service worker still on the previous release
* asks for its chunks, and those requests used to be answered with index.html and a
* 200, which a precache then kept as the chunk. They must be a plain 404.
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import fs from 'node:fs';
import path from 'node:path';
import request from 'supertest';
import type { Server } from 'http';
import { Module, type INestApplication } from '@nestjs/common';
import { Test } from '@nestjs/testing';
import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter';
import { SpaFallbackFilter } from '../../src/nest/platform/spa-fallback.filter';
import { applyPlatformStatic, PUBLIC_DIR } from '../../src/nest/platform/platform.routes';
@Module({})
class NoRoutesModule {}
const PRESENT_CHUNK = 'assets/e2e-2524-present-Q1w2E3r4.js';
const created: string[] = [];
const createdDirs: string[] = [];
/** Writes a file below the served public dir unless the build already put one there. */
function ensurePublicFile(rel: string, content: string): void {
const file = path.join(PUBLIC_DIR, rel);
if (fs.existsSync(file)) return;
const dir = path.dirname(file);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
createdDirs.push(dir);
}
fs.writeFileSync(file, content);
created.push(file);
}
describe('SPA fallback e2e: pages get index.html, missing build files a 404 (#2524)', () => {
const previousEnv = process.env.NODE_ENV;
let app: INestApplication;
let server: Server;
beforeAll(async () => {
ensurePublicFile('index.html', '<!DOCTYPE html><html><body><div id="root"></div></body></html>');
ensurePublicFile(PRESENT_CHUNK, 'export default 1;\n');
process.env.NODE_ENV = 'production';
const moduleRef = await Test.createTestingModule({ imports: [NoRoutesModule] }).compile();
app = moduleRef.createNestApplication();
// Before init, like bootstrap.ts: a real file is answered ahead of Nest's router.
applyPlatformStatic(app.getHttpAdapter().getInstance());
// Same pair and order as the APP_FILTER providers in AppModule.
app.useGlobalFilters(new TrekExceptionFilter(), new SpaFallbackFilter());
await app.init();
server = app.getHttpServer();
});
afterAll(async () => {
await app?.close();
for (const file of created) fs.rmSync(file, { force: true });
for (const dir of createdDirs.reverse()) fs.rmSync(dir, { recursive: true, force: true });
if (previousEnv === undefined) delete process.env.NODE_ENV;
else process.env.NODE_ENV = previousEnv;
});
it('answers a page of the app with index.html', async () => {
const res = await request(server).get('/dashboard');
expect(res.status).toBe(200);
expect(res.headers['content-type']).toContain('text/html');
expect(res.headers['cache-control']).toBe('no-cache, no-store, must-revalidate');
expect(res.text.toLowerCase()).toContain('<html');
});
it('serves a build file that exists', async () => {
const res = await request(server).get(`/${PRESENT_CHUNK}`);
expect(res.status).toBe(200);
expect(res.headers['content-type']).toContain('javascript');
});
it('answers a chunk of the previous release with a 404, not index.html', async () => {
const res = await request(server).get('/assets/DashboardPage-v4ODOTOr.js');
expect(res.status).toBe(404);
expect(res.headers['content-type']).toContain('application/json');
expect(res.headers['cache-control']).toBe('no-store');
expect(res.body).toEqual({ error: 'Cannot GET /assets/DashboardPage-v4ODOTOr.js' });
});
// The whole answer is asserted, not just the status: a checkout under a dot
// directory (~/.local) made the old fallback fail with a 404 of its own, which
// a status check alone would count as the fix.
it.each([
['a cache-busted retry of that chunk', '/assets/DashboardPage-v4ODOTOr.js?t=1790358416100'],
['its stylesheet', '/assets/DashboardPage-v4ODOTOr.css'],
['a top-level build file', '/manifest-e2e-2524.webmanifest'],
])('does the same for %s', async (_case, url) => {
const res = await request(server).get(url);
expect(res.status).toBe(404);
expect(res.headers['content-type']).toContain('application/json');
expect(res.headers['cache-control']).toBe('no-store');
expect(res.body).toEqual({ error: `Cannot GET ${url}` });
});
it('keeps the JSON envelope for a non-GET miss', async () => {
const res = await request(server).post('/dashboard').send({});
expect(res.status).toBe(404);
expect(res.body).toEqual({ error: 'Cannot POST /dashboard' });
});
});
@@ -17,6 +17,8 @@ import {
applyPlatformSpa, applyPlatformSpa,
applyPlatformStatic, applyPlatformStatic,
storageStaticHandler, storageStaticHandler,
isBuildFilePath,
PUBLIC_DIR,
} from '../../../src/nest/platform/platform.routes'; } from '../../../src/nest/platform/platform.routes';
import { SpaFallbackFilter } from '../../../src/nest/platform/spa-fallback.filter'; import { SpaFallbackFilter } from '../../../src/nest/platform/spa-fallback.filter';
import { StorageNotFoundError, StorageInvalidKeyError } from '../../../src/nest/storage/storage.types'; import { StorageNotFoundError, StorageInvalidKeyError } from '../../../src/nest/storage/storage.types';
@@ -405,10 +407,56 @@ describe('applyPlatformSpa', () => {
const catchAll = calls.find((c) => c.method === 'get'); const catchAll = calls.find((c) => c.method === 'get');
expect(catchAll).toBeDefined(); expect(catchAll).toBeDefined();
const res = makeRes(); const res = makeRes();
catchAll!.handlers[0]({}, res); catchAll!.handlers[0]({ path: '/trips/7' }, res);
expect(res.headers['Cache-Control']).toBe('no-cache, no-store, must-revalidate'); expect(res.headers['Cache-Control']).toBe('no-cache, no-store, must-revalidate');
expect(String(res.body)).toContain('FILE:'); expect(String(res.body)).toContain('FILE:');
expect(String(res.body)).toContain('index.html'); expect(String(res.body)).toContain('index.html');
expect(res.sendFile).toHaveBeenCalledWith('index.html', { root: PUBLIC_DIR });
});
it('answers a missing build file with a 404, not index.html (#2524)', () => {
process.env.NODE_ENV = 'production';
const { app, calls } = fakeApp();
applyPlatformSpa(app);
const catchAll = calls.find((c) => c.method === 'get');
const res = makeRes();
catchAll!.handlers[0]({ path: '/assets/DashboardPage-v4ODOTOr.js' }, res);
expect(res.statusCode).toBe(404);
expect(res.body).toEqual({ error: 'Not Found' });
expect(res.headers['Cache-Control']).toBe('no-store');
expect(res.sendFile).not.toHaveBeenCalled();
});
});
describe('isBuildFilePath', () => {
it.each([
'/assets/DashboardPage-v4ODOTOr.js',
'/assets/index-cFGeBWen.css',
'/assets/poppins-latin-400-normal-abc.woff2',
'/assets/anything-without-a-known-extension',
'/sw.js',
'/workbox-2da51cb1.js',
'/registerSW.js',
'/manifest.webmanifest',
'/icons/icon-192x192.png',
'/favicon.ico',
'/fonts/Inter.TTF',
])('treats %s as a build file', (p) => {
expect(isBuildFilePath(p)).toBe(true);
});
it.each([
'/',
'/dashboard',
'/trips/12/files',
'/journey/3/studio',
'/plugins/trip-todos',
'/help/Atlas',
'/shared/0f3a9c',
'/public/journey/AbC_-12',
'/oauth/consent',
])('treats the page %s as a page', (p) => {
expect(isBuildFilePath(p)).toBe(false);
}); });
}); });
@@ -416,22 +464,55 @@ describe('SpaFallbackFilter', () => {
const original = process.env.NODE_ENV; const original = process.env.NODE_ENV;
afterEach(() => { process.env.NODE_ENV = original; }); afterEach(() => { process.env.NODE_ENV = original; });
function host(req: { method: string }, res: ReturnType<typeof makeRes>) { function host(req: { method: string; path?: string }, res: ReturnType<typeof makeRes>) {
return { switchToHttp: () => ({ getRequest: () => req, getResponse: () => res }) } as never; return { switchToHttp: () => ({ getRequest: () => req, getResponse: () => res }) } as never;
} }
it('serves index.html for an unmatched GET in production', () => { it('serves index.html for an unmatched GET in production', () => {
process.env.NODE_ENV = 'production'; process.env.NODE_ENV = 'production';
const res = makeRes(); const res = makeRes();
new SpaFallbackFilter().catch(new NotFoundException('nope'), host({ method: 'GET' }, res)); new SpaFallbackFilter().catch(new NotFoundException('nope'), host({ method: 'GET', path: '/dashboard' }, res));
expect(res.headers['Cache-Control']).toBe('no-cache, no-store, must-revalidate'); expect(res.headers['Cache-Control']).toBe('no-cache, no-store, must-revalidate');
expect(String(res.body)).toContain('index.html'); expect(String(res.body)).toContain('index.html');
}); });
it('answers a GET for a chunk that is not on disk with a 404 in production (#2524)', () => {
process.env.NODE_ENV = 'production';
const res = makeRes();
new SpaFallbackFilter().catch(
new NotFoundException('Cannot GET /assets/DashboardPage-v4ODOTOr.js'),
host({ method: 'GET', path: '/assets/DashboardPage-v4ODOTOr.js' }, res),
);
// A 200 with index.html here is what a service worker precached as the chunk.
expect(res.statusCode).toBe(404);
expect(res.body).toEqual({ error: 'Cannot GET /assets/DashboardPage-v4ODOTOr.js' });
expect(res.headers['Cache-Control']).toBe('no-store');
expect(res.sendFile).not.toHaveBeenCalled();
});
it('answers a missing top-level build file with a 404 too', () => {
process.env.NODE_ENV = 'production';
const res = makeRes();
new SpaFallbackFilter().catch(new NotFoundException(), host({ method: 'GET', path: '/workbox-0000aaaa.js' }, res));
expect(res.statusCode).toBe(404);
expect(res.body).toEqual({ error: 'Not Found' });
});
it('answers a missing build file whose exception has no message with Not Found (#2524)', () => {
process.env.NODE_ENV = 'production';
const res = makeRes();
const exc = new NotFoundException();
Object.defineProperty(exc, 'message', { value: '' });
new SpaFallbackFilter().catch(exc, host({ method: 'GET', path: '/assets/gone-0000aaaa.js' }, res));
expect(res.statusCode).toBe(404);
expect(res.body).toEqual({ error: 'Not Found' });
expect(res.sendFile).not.toHaveBeenCalled();
});
it('keeps the JSON 404 envelope for a non-GET miss in production', () => { it('keeps the JSON 404 envelope for a non-GET miss in production', () => {
process.env.NODE_ENV = 'production'; process.env.NODE_ENV = 'production';
const res = makeRes(); const res = makeRes();
new SpaFallbackFilter().catch(new NotFoundException('gone'), host({ method: 'POST' }, res)); new SpaFallbackFilter().catch(new NotFoundException('gone'), host({ method: 'POST', path: '/dashboard' }, res));
expect(res.statusCode).toBe(404); expect(res.statusCode).toBe(404);
expect(res.body).toEqual({ error: 'gone' }); expect(res.body).toEqual({ error: 'gone' });
}); });
@@ -439,7 +520,7 @@ describe('SpaFallbackFilter', () => {
it('keeps the JSON 404 envelope outside production even for GET', () => { it('keeps the JSON 404 envelope outside production even for GET', () => {
process.env.NODE_ENV = 'development'; process.env.NODE_ENV = 'development';
const res = makeRes(); const res = makeRes();
new SpaFallbackFilter().catch(new NotFoundException('missing'), host({ method: 'GET' }, res)); new SpaFallbackFilter().catch(new NotFoundException('missing'), host({ method: 'GET', path: '/dashboard' }, res));
expect(res.statusCode).toBe(404); expect(res.statusCode).toBe(404);
expect(res.body).toEqual({ error: 'missing' }); expect(res.body).toEqual({ error: 'missing' });
}); });
@@ -450,7 +531,7 @@ describe('SpaFallbackFilter', () => {
const exc = new NotFoundException(); const exc = new NotFoundException();
// force an empty message so the || branch is taken // force an empty message so the || branch is taken
Object.defineProperty(exc, 'message', { value: '' }); Object.defineProperty(exc, 'message', { value: '' });
new SpaFallbackFilter().catch(exc, host({ method: 'GET' }, res)); new SpaFallbackFilter().catch(exc, host({ method: 'GET', path: '/dashboard' }, res));
expect(res.body).toEqual({ error: 'Not Found' }); expect(res.body).toEqual({ error: 'Not Found' });
}); });
}); });