feat(client): catch render errors instead of blanking the page (#1805)

* feat(client): catch render errors instead of blanking the page

There were no error boundaries anywhere, so one throw during render unmounted the
whole tree and left the user staring at a white page with no way back.

ErrorBoundary holds the state in a class — getDerivedStateFromError has no hook
equivalent — and renders a function component for the visible half, so the panel
can translate normally. TranslationContext isn't exported, so contextType was not
an option anyway.

Two things it does beyond showing a message. A chunk that 404s after a deploy gets
one reload rather than a retry button: React.lazy caches the rejected promise, so
retrying re-throws immediately. And the reload happens once per session, or a
genuinely missing chunk would spin the tab.

The global handlers in main.tsx cover what no boundary reaches — event handlers
and async code. They filter axios errors: the interceptor already redirects on
401/403 and there are 600+ toast.error call sites, so reporting again would mean
double toasts and, for the redirects, loops.

* feat(client): isolate routes, widgets and the shell chrome

The route boundary goes on the single line where all 16 protected routes render
their content, inside MobileShell rather than around it: the navigation survives,
so a broken page is something the user can walk away from instead of a dead end.
The mobile --m-* tokens live on the shell's .m-root too, so a fallback outside it
would render invisible.

key={location.pathname}, not resetKeys — ProtectedRoute is the same component at
the same position for every protected route, so otherwise a failure could follow
the user across a navigation.

The public routes render outside ProtectedRoute and were therefore uncovered,
including /login (where people land when everything else failed) and the two
anonymous share pages. PublicRoute wraps those seven.

The five global widgets and the shell chrome get fallback={null} each: a broken
widget should disappear quietly, and if BottomNav dies the user loses the very
thing they need to leave the route.

* feat(client): isolate the GL maps and the plugin frame

Both map wrappers put the boundary outside their Suspense, not inside: Suspense
handles a pending promise, a rejected one throws straight past it. Falling back to
the Leaflet renderer keeps a usable map rather than showing an error card — the
same downgrade the Suspense fallback already does while loading.

PluginFrame is third-party code rendering in our tree; a throw there should cost
the plugin, not the page. resetKeys={[pluginId]} so switching plugins clears it.

The locale loader had no .catch (TranslationContext.tsx). After a deploy the chunk
can be gone, which produced an unhandled rejection and left the UI silently on the
previous language. It now keeps the strings it has and says so in the console.
This commit is contained in:
Maurice
2026-08-06 23:42:57 +02:00
committed by GitHub
parent 6c63a9bb60
commit 39b6d9f786
36 changed files with 1017 additions and 50 deletions
@@ -0,0 +1,216 @@
import React from 'react';
import { render, screen, fireEvent } from '../../../tests/helpers/render';
import ErrorBoundary, { RootErrorFallback } from './ErrorBoundary';
// 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', () => {
const reload = vi.fn();
Object.defineProperty(window, 'location', {
value: { ...window.location, 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();
});
});
@@ -0,0 +1,212 @@
import React from 'react'
import { AlertTriangle, RefreshCw, RotateCcw } from 'lucide-react'
import { useTranslation } from '../../i18n'
import { isChunkLoadError, reloadOnceForChunk } from '../../utils/chunkReload'
/**
* The app had none of these, so a single throw during render unmounted the whole
* tree and left a blank page. This catches the render path and swaps in a panel
* the user can act on.
*
* What it does NOT catch, and no boundary does: event handlers, anything async,
* and errors during SSR. Those go to the global handlers in main.tsx.
*
* A class is required — getDerivedStateFromError has no hook equivalent. It holds
* state only; the visible part is a function component underneath, so it can use
* useTranslation() like everything else. TranslationContext isn't exported, so
* contextType wouldn't have worked anyway.
*/
type Level = 'root' | 'route' | 'panel'
interface FallbackState {
error: unknown
reset: () => void
isChunkError: boolean
}
export interface ErrorBoundaryProps {
children: React.ReactNode
/** Stable name in the console line, e.g. 'route', 'plugin-frame', 'planner-tabs'. */
boundaryId: string
level?: Level
/** Mobile uses its own tokens; they only resolve inside MobileShell's .m-root. */
variant?: 'desktop' | 'mobile'
/** Shown instead of the generic wording — a plugin name, a panel title. */
label?: string
fallback?: React.ReactNode | ((state: FallbackState) => React.ReactNode)
/** Any change here clears the error, e.g. [tripId] or [activeTab]. */
resetKeys?: readonly unknown[]
onReset?: () => void
onError?: (error: unknown, info: React.ErrorInfo) => void
}
interface ErrorBoundaryState {
error: unknown
hasError: boolean
}
function keysChanged(a: readonly unknown[] = [], b: readonly unknown[] = []): boolean {
return a.length !== b.length || a.some((v, i) => !Object.is(v, b[i]))
}
export default class ErrorBoundary extends React.Component<ErrorBoundaryProps, ErrorBoundaryState> {
state: ErrorBoundaryState = { error: null, hasError: false }
static getDerivedStateFromError(error: unknown): ErrorBoundaryState {
return { error, hasError: true }
}
componentDidCatch(error: unknown, info: React.ErrorInfo) {
console.error(`[ErrorBoundary:${this.props.boundaryId}]`, error, info.componentStack)
this.props.onError?.(error, info)
// A chunk that 404s can't be retried — React.lazy keeps the rejected promise,
// so every later render throws the same thing. One reload picks up the new
// index; after that the fallback stays put rather than looping.
if (isChunkLoadError(error)) reloadOnceForChunk()
}
componentDidUpdate(prev: ErrorBoundaryProps) {
if (this.state.hasError && keysChanged(prev.resetKeys, this.props.resetKeys)) {
this.reset()
}
}
reset = () => {
this.props.onReset?.()
this.setState({ error: null, hasError: false })
}
render() {
if (!this.state.hasError) return this.props.children
const { fallback, level = 'panel', variant = 'desktop', label } = this.props
const state: FallbackState = {
error: this.state.error,
reset: this.reset,
isChunkError: isChunkLoadError(this.state.error),
}
if (typeof fallback === 'function') return fallback(state)
if (fallback !== undefined) return fallback
return <ErrorFallback {...state} level={level} variant={variant} label={label} />
}
}
interface FallbackProps extends FallbackState {
level: Level
variant: 'desktop' | 'mobile'
label?: string
}
/**
* The visible half. A function component so it can translate; anything mounted
* above TranslationProvider gets RootErrorFallback instead, which has no keys to
* look up in the first place.
*/
export function ErrorFallback({ error, reset, isChunkError, level, variant, label }: FallbackProps) {
const { t } = useTranslation()
const isPanel = level === 'panel'
const mobile = variant === 'mobile'
const title = isChunkError
? t('common.errorUpdateTitle')
: label
? t('common.errorPluginTitle')
: isPanel ? t('common.errorPanelTitle') : t('common.errorTitle')
const body = isChunkError
? t('common.errorUpdateBody')
: isPanel ? t('common.errorPanelBody') : t('common.errorBody')
const message = error instanceof Error ? error.message : null
return (
<div
role="alert"
className={[
'flex flex-col items-center justify-center gap-3 rounded-xl border p-6 text-center',
isPanel ? 'h-full min-h-[180px]' : 'm-6 min-h-[240px]',
mobile ? 'border-[color:var(--m-rowbr)] bg-m-card text-m-ink' : 'border-edge bg-surface-card',
].join(' ')}
>
<AlertTriangle size={22} className={mobile ? 'text-m-muted' : 'text-content-muted'} aria-hidden />
<div className="space-y-1">
<p className={`text-subtitle font-semibold ${mobile ? 'text-m-ink' : 'text-content'}`}>{title}</p>
{label && <p className={`text-body ${mobile ? 'text-m-muted' : 'text-content-secondary'}`}>{label}</p>}
<p className={`text-body ${mobile ? 'text-m-muted' : 'text-content-secondary'}`}>{body}</p>
</div>
{/* Worth showing: TREK is self-hosted, so the person seeing this is often the
same person who will paste it into an issue. */}
{message && (
<code className={`max-w-full truncate text-caption ${mobile ? 'text-m-faint' : 'text-content-faint'}`}>
{message}
</code>
)}
<div className="flex flex-wrap items-center justify-center gap-2 pt-1">
{/* No retry for a dead chunk — React.lazy would throw again immediately. */}
{!isChunkError && (
<button
type="button"
onClick={reset}
className="inline-flex items-center gap-1.5 rounded-lg bg-accent px-3 py-2 text-caption font-medium text-white"
>
<RotateCcw size={14} aria-hidden />
{t('common.errorRetry')}
</button>
)}
{(!isPanel || isChunkError) && (
<button
type="button"
onClick={() => window.location.reload()}
className={[
'inline-flex items-center gap-1.5 rounded-lg border px-3 py-2 text-caption font-medium',
mobile ? 'border-[color:var(--m-rowbr)] text-m-ink' : 'border-edge text-content',
].join(' ')}
>
<RefreshCw size={14} aria-hidden />
{t('common.errorReload')}
</button>
)}
</div>
</div>
)
}
/**
* For the boundary outside TranslationProvider. Deliberately untranslated: if the
* provider is what crashed, useTranslation() would hand back the default context
* whose `t` echoes the key, and the user would read "common.errorTitle".
*/
export function RootErrorFallback({ error, isChunkError }: FallbackState) {
const message = error instanceof Error ? error.message : null
return (
<div
role="alert"
className="flex min-h-dvh flex-col items-center justify-center gap-3 p-6 text-center"
style={{ fontFamily: 'var(--font-system)' }}
>
<AlertTriangle size={24} className="text-content-muted" aria-hidden />
<p className="text-subtitle font-semibold text-content">
{isChunkError ? 'A new version is available' : 'TREK could not start'}
</p>
<p className="text-body text-content-secondary">
{isChunkError
? 'TREK was updated while this tab was open. Reload to get the new version.'
: 'Reloading usually fixes this. Your data is safe.'}
</p>
{message && <code className="max-w-full truncate text-caption text-content-faint">{message}</code>}
<button
type="button"
onClick={() => window.location.reload()}
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 />
Reload page
</button>
</div>
)
}