Files
TREK/client/src/components/shared/ErrorBoundary.tsx
T
Maurice bea3af4b45 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.
2026-09-26 04:22:50 +00:00

223 lines
8.2 KiB
TypeScript

import React from 'react'
import { AlertTriangle, RefreshCw, RotateCcw } from 'lucide-react'
import { useTranslation } from '../../i18n'
import { isChunkLoadError, reloadOnceForChunk, reloadOntoCurrentBuild } 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} />
}
}
/**
* 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 {
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={() => reloadPage(isChunkError)}
className={[
'inline-flex items-center gap-1.5 rounded-lg border px-3 py-2 text-caption font-medium',
mobile ? 'border-[color:var(--m-rowbr)] text-m-ink' : 'border-edge text-content',
].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={() => 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"
>
<RefreshCw size={14} aria-hidden />
Reload page
</button>
</div>
)
}