163 lines
4.7 KiB
TypeScript
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;
|
|
}
|
|
}
|