Files
TREK/client/src/components/shared/ErrorBoundary.test.tsx
T
Maurice c9dfe9c688 fix(pwa): recover from a dead chunk and a broken precache after an update (#2524)
After an update, a tab or a device whose service worker still serves the
previous build asks for chunks the update removed. The server answered those
requests with index.html and a 200, so the import failed with a MIME error,
and everything caching in between was free to keep the HTML as the chunk. A
precache install that met a missing file stored it, and Workbox reuses hashed
entries by URL, so the device showed "A new version is available" on every
load, whatever the reload or the cleared browser cache, and carried the broken
entry into every later release that keeps the file's name.

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

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

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

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

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

Dropping the worker ends a Web Push subscription with it, so on such a device
the settings card shows push as off until it is switched on again. A missing
sw-push.js now fails the worker install with a 404 rather than index.html; the
comments that described the old answer say so.
2026-09-26 10:36:02 +02:00

263 lines
9.7 KiB
TypeScript

import React from 'react';
import { render, screen, fireEvent } from '../../../tests/helpers/render';
import ErrorBoundary, { RootErrorFallback } from './ErrorBoundary';
import * as chunkReload from '../../utils/chunkReload';
// Only the Reload button's way out is replaced; the boundary's own automatic reload
// (reloadOnceForChunk) keeps running the real code.
vi.mock('../../utils/chunkReload', async (importOriginal) => ({
...(await importOriginal<typeof import('../../utils/chunkReload')>()),
reloadOntoCurrentBuild: vi.fn(),
}));
// React logs every caught error itself; without this each of these tests prints a
// full component stack and buries the real output.
let consoleError: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
consoleError = vi.spyOn(console, 'error').mockImplementation(() => {});
sessionStorage.clear();
});
afterEach(() => {
consoleError.mockRestore();
});
function Boom({ message = 'kaboom' }: { message?: string }): React.ReactElement {
throw new Error(message);
}
const CHUNK_MESSAGE = 'Failed to fetch dynamically imported module: /assets/DashboardPage-abc123.js';
describe('ErrorBoundary', () => {
it('FE-COMP-ERRBOUND-001: renders its children while nothing throws', () => {
render(
<ErrorBoundary boundaryId="test">
<p>all good</p>
</ErrorBoundary>,
);
expect(screen.getByText('all good')).toBeInTheDocument();
expect(screen.queryByRole('alert')).toBeNull();
});
it('FE-COMP-ERRBOUND-002: swaps in the fallback when a child throws', () => {
render(
<ErrorBoundary boundaryId="test">
<Boom />
</ErrorBoundary>,
);
expect(screen.getByRole('alert')).toBeInTheDocument();
// The message is surfaced on purpose — self-hosted users file their own issues.
expect(screen.getByText('kaboom')).toBeInTheDocument();
});
it('FE-COMP-ERRBOUND-003: logs the boundaryId so a report says which one tripped', () => {
render(
<ErrorBoundary boundaryId="planner-tabs">
<Boom />
</ErrorBoundary>,
);
expect(consoleError).toHaveBeenCalledWith(
'[ErrorBoundary:planner-tabs]',
expect.any(Error),
expect.anything(),
);
});
it('FE-COMP-ERRBOUND-004: a custom node fallback replaces the default panel', () => {
render(
<ErrorBoundary boundaryId="test" fallback={<p>quiet fallback</p>}>
<Boom />
</ErrorBoundary>,
);
expect(screen.getByText('quiet fallback')).toBeInTheDocument();
expect(screen.queryByRole('alert')).toBeNull();
});
it('FE-COMP-ERRBOUND-005: fallback={null} hides a broken widget without a trace', () => {
const { container } = render(
<ErrorBoundary boundaryId="widget:test" fallback={null}>
<Boom />
</ErrorBoundary>,
);
expect(container).toBeEmptyDOMElement();
});
it('FE-COMP-ERRBOUND-006: a function fallback receives the error and the reset handle', () => {
render(
<ErrorBoundary
boundaryId="test"
fallback={s => <p>{s.error instanceof Error ? s.error.message : 'none'} / {String(s.isChunkError)}</p>}
>
<Boom message="detail" />
</ErrorBoundary>,
);
expect(screen.getByText('detail / false')).toBeInTheDocument();
});
it('FE-COMP-ERRBOUND-007: retry clears the error and re-renders the children', () => {
function Flaky({ shouldThrow }: { shouldThrow: boolean }) {
if (shouldThrow) throw new Error('transient');
return <p>recovered</p>;
}
function Harness() {
const [shouldThrow, setShouldThrow] = React.useState(true);
return (
<ErrorBoundary boundaryId="test" onReset={() => setShouldThrow(false)}>
<Flaky shouldThrow={shouldThrow} />
</ErrorBoundary>
);
}
render(<Harness />);
fireEvent.click(screen.getByText('Try again'));
expect(screen.getByText('recovered')).toBeInTheDocument();
});
it('FE-COMP-ERRBOUND-008: a changed resetKey clears the error on its own', () => {
function Harness({ id, throws }: { id: number; throws: boolean }) {
return (
<ErrorBoundary boundaryId="test" resetKeys={[id]}>
{throws ? <Boom /> : <p>panel {id}</p>}
</ErrorBoundary>
);
}
const { rerender } = render(<Harness id={1} throws />);
expect(screen.getByRole('alert')).toBeInTheDocument();
// Switching to another trip/tab must not carry the previous failure over.
rerender(<Harness id={2} throws={false} />);
expect(screen.getByText('panel 2')).toBeInTheDocument();
});
it('FE-COMP-ERRBOUND-009: an unchanged resetKey leaves the error standing', () => {
function Harness({ id }: { id: number }) {
return (
<ErrorBoundary boundaryId="test" resetKeys={[id]}>
<Boom />
</ErrorBoundary>
);
}
const { rerender } = render(<Harness id={1} />);
rerender(<Harness id={1} />);
expect(screen.getByRole('alert')).toBeInTheDocument();
});
it('FE-COMP-ERRBOUND-010: a dead chunk offers a reload instead of a retry', () => {
render(
<ErrorBoundary boundaryId="route" level="route">
<Boom message={CHUNK_MESSAGE} />
</ErrorBoundary>,
);
// React.lazy caches the rejection, so a retry button would be a button that
// does nothing — only a reload can pick up the new index.
expect(screen.queryByText('Try again')).toBeNull();
expect(screen.getByText('Reload page')).toBeInTheDocument();
expect(screen.getByText('A new version is available')).toBeInTheDocument();
});
it('FE-COMP-ERRBOUND-011: a chunk failure reloads once, then stops', () => {
// The recovery reload is a location.replace onto a fresh URL (chunkReload).
const reload = vi.fn();
Object.defineProperty(window, 'location', {
value: { ...window.location, replace: reload },
writable: true,
});
render(
<ErrorBoundary boundaryId="a">
<Boom message={CHUNK_MESSAGE} />
</ErrorBoundary>,
);
expect(reload).toHaveBeenCalledTimes(1);
// Second failure in the same session: the marker is set, so no reload loop.
render(
<ErrorBoundary boundaryId="b">
<Boom message={CHUNK_MESSAGE} />
</ErrorBoundary>,
);
expect(reload).toHaveBeenCalledTimes(1);
});
it('FE-COMP-ERRBOUND-012: a labelled boundary names what failed', () => {
render(
<ErrorBoundary boundaryId="plugin-frame" label="Koffi & Friends">
<Boom />
</ErrorBoundary>,
);
expect(screen.getByText('Koffi & Friends')).toBeInTheDocument();
expect(screen.getByText('This plugin could not be shown')).toBeInTheDocument();
});
it('FE-COMP-ERRBOUND-013: a panel fallback says the rest of the page still works', () => {
render(
<ErrorBoundary boundaryId="panel" level="panel">
<Boom />
</ErrorBoundary>,
);
expect(screen.getByText('This section could not be shown')).toBeInTheDocument();
expect(screen.getByText('The rest of the page still works.')).toBeInTheDocument();
});
it('FE-COMP-ERRBOUND-014: the mobile variant uses the mobile tokens', () => {
render(
<ErrorBoundary boundaryId="route" level="route" variant="mobile">
<Boom />
</ErrorBoundary>,
);
// --m-* only resolves inside MobileShell's .m-root; the desktop classes would
// render an invisible card there.
expect(screen.getByRole('alert').className).toContain('bg-m-card');
});
});
describe('RootErrorFallback', () => {
it('FE-COMP-ERRBOUND-015: stays untranslated because it renders above the provider', () => {
render(<RootErrorFallback error={new Error('boot failed')} reset={() => {}} isChunkError={false} />);
// If it called t() and the provider was the thing that broke, the user would
// be reading "common.errorTitle".
expect(screen.getByText('TREK could not start')).toBeInTheDocument();
expect(screen.getByText('boot failed')).toBeInTheDocument();
});
it('FE-COMP-ERRBOUND-016: names a stale chunk as an update rather than a crash', () => {
render(<RootErrorFallback error={new Error(CHUNK_MESSAGE)} reset={() => {}} isChunkError />);
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();
});
});