mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-20 05:56:53 +00:00
bea3af4b45
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.
223 lines
8.2 KiB
TypeScript
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>
|
|
)
|
|
}
|