Files
daily_console_web/src/components/ErrorBoundary.tsx
2026-08-28 11:15:47 +05:30

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>
);
}
}