126 lines
4.3 KiB
TypeScript
126 lines
4.3 KiB
TypeScript
import { Component, type ErrorInfo, type ReactNode } from 'react';
|
|
import { isStaleChunkError } from '@/lib/staleChunk';
|
|
|
|
/**
|
|
* The last line before a white page.
|
|
*
|
|
* React unmounts the entire tree when a render throws and nothing catches it.
|
|
* With no boundary anywhere in the app, one bad field shape on one card takes
|
|
* the whole console down to an empty document — no message, no route, nothing
|
|
* to act on but the browser console. That is exactly how a POS-health response
|
|
* typed as an array but delivered as an object presented itself: a blank screen
|
|
* on sign-in, with the cause four layers down.
|
|
*
|
|
* So this is not decoration. It converts "the app is broken" into "this screen
|
|
* is broken, and here is what it said", which is the difference between a bug
|
|
* report and a guess.
|
|
*
|
|
* Deliberately a class: `getDerivedStateFromError` has no hook equivalent.
|
|
*/
|
|
|
|
interface Props {
|
|
children: ReactNode;
|
|
/** Names the area in the message — "the Console page", "the assistant". */
|
|
area?: string;
|
|
}
|
|
|
|
interface State {
|
|
error: Error | null;
|
|
}
|
|
|
|
export class ErrorBoundary extends Component<Props, State> {
|
|
override state: State = { error: null };
|
|
|
|
static getDerivedStateFromError(error: Error): State {
|
|
return { error };
|
|
}
|
|
|
|
override componentDidCatch(error: Error, info: ErrorInfo) {
|
|
// Kept as console output rather than sent anywhere: there is no error
|
|
// reporting endpoint, and inventing one would be a network call nobody
|
|
// asked for. The component stack is the part that is not in the throw.
|
|
console.error('[nearle] render failed', error, info.componentStack);
|
|
}
|
|
|
|
private reset = () => {
|
|
this.setState({ error: null });
|
|
};
|
|
|
|
private reload = () => {
|
|
window.location.reload();
|
|
};
|
|
|
|
override render() {
|
|
const { error } = this.state;
|
|
if (!error) return this.props.children;
|
|
|
|
const area = this.props.area ?? 'this screen';
|
|
|
|
/**
|
|
* A page whose code never arrived cannot be re-rendered into existence.
|
|
*
|
|
* "Try again" clears the error and renders the same `lazy()` component,
|
|
* which requests the same missing file and throws the same error — the
|
|
* button looked like a recovery and was a loop. `lib/staleChunk.ts` already
|
|
* reloads once on its own; landing here means that reload has happened and
|
|
* not helped, or was suppressed to avoid a boot loop, so the honest offer
|
|
* is a reload the person chooses and a message that names the cause.
|
|
*/
|
|
const isStale = isStaleChunkError(error);
|
|
|
|
return (
|
|
<div
|
|
role="alert"
|
|
style={{
|
|
margin: '48px auto',
|
|
maxWidth: 620,
|
|
padding: '28px 32px',
|
|
borderRadius: 16,
|
|
border: '1px solid #F1D3D3',
|
|
background: '#FFFBFB',
|
|
fontFamily: 'inherit',
|
|
}}
|
|
>
|
|
<h2 style={{ margin: '0 0 8px', fontSize: 18, fontWeight: 600, color: '#8A1F1F' }}>
|
|
{isStale ? 'This page was updated while you were working' : `Something on ${area} failed to render`}
|
|
</h2>
|
|
<p style={{ margin: '0 0 16px', fontSize: 14, lineHeight: 1.6, color: '#5C4747' }}>
|
|
{isStale
|
|
? 'A new version of the console was deployed, so the file this tab was about to load no longer exists. Nothing is wrong with your data — reloading picks up the current version.'
|
|
: 'The rest of the console is fine — this is one screen, not the whole app. The message below is what broke, and the full stack is in the browser console.'}
|
|
</p>
|
|
<pre
|
|
style={{
|
|
margin: '0 0 20px',
|
|
padding: '12px 14px',
|
|
borderRadius: 10,
|
|
background: '#FFF1F1',
|
|
color: '#7A2020',
|
|
fontSize: 13,
|
|
lineHeight: 1.5,
|
|
whiteSpace: 'pre-wrap',
|
|
wordBreak: 'break-word',
|
|
}}
|
|
>
|
|
{error.message || String(error)}
|
|
</pre>
|
|
<button
|
|
type="button"
|
|
onClick={isStale ? this.reload : this.reset}
|
|
style={{
|
|
padding: '9px 18px',
|
|
borderRadius: 999,
|
|
border: '1px solid #D9C0C0',
|
|
background: '#FFFFFF',
|
|
fontSize: 14,
|
|
fontWeight: 500,
|
|
cursor: 'pointer',
|
|
}}
|
|
>
|
|
{isStale ? 'Reload the page' : 'Try again'}
|
|
</button>
|
|
</div>
|
|
);
|
|
}
|
|
}
|