Files
baya-monorepo/client/src/components/common/ErrorBoundary.tsx
T
2026-07-19 21:31:59 +03:30

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;