Files
BanGUI/frontend/src/components/ErrorBoundary.tsx
2026-05-04 13:13:01 +02:00

163 lines
4.7 KiB
TypeScript

/**
* React error boundary component.
*
* Catches render-time exceptions in child components and shows a fallback UI.
* This is the base component; use PageErrorBoundary or SectionErrorBoundary
* for page and section-level boundaries.
*
* All errors are logged using the telemetry service with structured context
* for distributed tracing and debugging.
*/
import React from "react";
import { Button, makeStyles, Text, tokens } from "@fluentui/react-components";
import { recordCritical } from "../utils/telemetry";
interface ErrorBoundaryState {
hasError: boolean;
errorMessage: string | null;
}
interface ErrorBoundaryProps {
children: React.ReactNode;
title?: string;
message?: string;
showReloadButton?: boolean;
isFullPage?: boolean;
onError?: (error: Error, errorInfo: React.ErrorInfo) => void;
}
interface ErrorBoundaryFallbackProps {
title: string;
message: string;
showReloadButton: boolean;
isFullPage: boolean;
onReload: () => void;
}
const useFullPageStyles = makeStyles({
root: {
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
minHeight: "100vh",
padding: tokens.spacingVerticalL,
textAlign: "center",
gap: tokens.spacingVerticalM,
},
message: {
maxWidth: "40rem",
},
});
const useSectionStyles = makeStyles({
root: {
display: "flex",
flexDirection: "column",
alignItems: "flex-start",
padding: tokens.spacingVerticalM,
backgroundColor: tokens.colorNeutralBackground3,
borderRadius: tokens.borderRadiusMedium,
border: `1px solid ${tokens.colorStatusWarningForeground1}`,
gap: tokens.spacingVerticalM,
},
message: {
color: tokens.colorNeutralForeground1,
},
});
function ErrorBoundaryFallback({
title,
message,
showReloadButton,
isFullPage,
onReload,
}: ErrorBoundaryFallbackProps): React.ReactElement {
const fullPageStyles = useFullPageStyles();
const sectionStyles = useSectionStyles();
const styles = isFullPage ? fullPageStyles : sectionStyles;
return (
<div className={styles.root} role="alert" data-testid="page-error-boundary">
<Text as={isFullPage ? "h1" : "h2"} size={isFullPage ? 700 : 500} weight="semibold">
{title}
</Text>
<Text size={isFullPage ? 300 : 200} className={isFullPage ? fullPageStyles.message : sectionStyles.message}>
{message}
</Text>
{showReloadButton && (
<Button appearance="primary" onClick={onReload}>
{isFullPage ? "Reload Page" : "Retry"}
</Button>
)}
</div>
);
}
export class ErrorBoundary extends React.Component<ErrorBoundaryProps, ErrorBoundaryState> {
constructor(props: ErrorBoundaryProps) {
super(props);
this.state = { hasError: false, errorMessage: null };
}
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
return { hasError: true, errorMessage: error.message || "Unknown error" };
}
componentDidCatch(error: Error, errorInfo: React.ErrorInfo): void {
const { onError } = this.props;
// Extract componentStack from errorInfo
// Note: componentStack is not part of the public React.ErrorInfo type definition,
// but is available at runtime via React DevTools. We cast to any to access it.
// This captures the React component hierarchy where the error occurred, which is
// valuable for debugging but may change in future React versions.
// TODO: Consider removing when React types include componentStack in ErrorInfo
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access
const componentStack = (errorInfo as any).componentStack as string | undefined;
// Log the error using telemetry for distributed tracing
recordCritical("component_render_error", error, {
component_stack: componentStack,
error_message: error.message,
});
if (onError) {
onError(error, errorInfo);
} else {
console.error("ErrorBoundary caught an error", { error, errorInfo });
}
}
handleReload = (): void => {
if (this.props.isFullPage) {
window.location.reload();
} else {
this.setState({ hasError: false, errorMessage: null });
}
};
render(): React.ReactNode {
if (this.state.hasError) {
const {
title = "Something went wrong",
message = "Please try again or contact support if the problem persists.",
showReloadButton = true,
isFullPage = true,
} = this.props;
return (
<ErrorBoundaryFallback
title={title}
message={message}
showReloadButton={showReloadButton}
isFullPage={isFullPage}
onReload={this.handleReload}
/>
);
}
return this.props.children;
}
}