92 lines
3.1 KiB
TypeScript
92 lines
3.1 KiB
TypeScript
'use client';
|
|
import { Component, ErrorInfo, ReactNode } from 'react';
|
|
import { Stack, Typography } from '@mui/material';
|
|
import AppButton from './AppButton';
|
|
import AppIcon from './AppIcon';
|
|
|
|
export interface ErrorBoundaryProps {
|
|
children: ReactNode;
|
|
/** Diagnostic-only segment name (console log label) — never shown to the user. */
|
|
name: string;
|
|
/** Already-translated fallback copy — kept presentational/caller-owned (like every other shared
|
|
* primitive in this kit) so this file never imports next-intl; it sits at the top of the `common`
|
|
* barrel, so an eager i18n dependency here would drag `next-intl` into every test that imports
|
|
* anything from `@/components`, mocked or not. */
|
|
title: string;
|
|
body: string;
|
|
retryLabel: string;
|
|
}
|
|
|
|
interface State {
|
|
hasError: boolean;
|
|
error?: Error;
|
|
errorInfo?: ErrorInfo;
|
|
}
|
|
|
|
/**
|
|
* Error boundary wrapping a subtree of the app (a shell's content area). Renders a branded fallback with
|
|
* a retry affordance (re-mounts the children by clearing `hasError`); the raw error + component stack are
|
|
* **logged, never shown**, in production — only a development build renders the dev-only `<details>`
|
|
* block.
|
|
* @component ErrorBoundary
|
|
*/
|
|
class ErrorBoundary extends Component<ErrorBoundaryProps, State> {
|
|
constructor(props: ErrorBoundaryProps) {
|
|
super(props);
|
|
this.state = { hasError: false };
|
|
}
|
|
|
|
static getDerivedStateFromError(error: Error) {
|
|
return { hasError: true, error };
|
|
}
|
|
|
|
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
|
|
this.setState({ error, errorInfo });
|
|
console.error(`[ErrorBoundary:${this.props.name}]`, error, errorInfo.componentStack);
|
|
}
|
|
|
|
handleRetry = () => {
|
|
this.setState({ hasError: false, error: undefined, errorInfo: undefined });
|
|
};
|
|
|
|
render() {
|
|
if (this.state.hasError) {
|
|
const { error, errorInfo } = this.state;
|
|
return (
|
|
<Stack
|
|
role="alert"
|
|
sx={{ alignItems: 'center', justifyContent: 'center', gap: 1.5, py: 6, px: 2, textAlign: 'center' }}
|
|
>
|
|
<AppIcon icon="warning" size={36} color="var(--bal-warning)" />
|
|
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
|
|
{this.props.title}
|
|
</Typography>
|
|
<Typography variant="body2" sx={{ color: 'text.secondary', maxWidth: 360 }}>
|
|
{this.props.body}
|
|
</Typography>
|
|
<AppButton color="primary" variant="outlined" onClick={this.handleRetry}>
|
|
{this.props.retryLabel}
|
|
</AppButton>
|
|
{process.env.NODE_ENV !== 'production' && (error || errorInfo) ? (
|
|
<Typography
|
|
component="details"
|
|
variant="caption"
|
|
sx={{ color: 'text.secondary', maxWidth: 480, textAlign: 'start', whiteSpace: 'pre-wrap' }}
|
|
>
|
|
<Typography component="summary" variant="caption" sx={{ cursor: 'pointer' }}>
|
|
Dev-only error detail
|
|
</Typography>
|
|
{error?.toString()}
|
|
{'\n'}
|
|
{errorInfo?.componentStack}
|
|
</Typography>
|
|
) : null}
|
|
</Stack>
|
|
);
|
|
}
|
|
return this.props.children;
|
|
}
|
|
}
|
|
|
|
export default ErrorBoundary;
|