Improve error boundary granularity with page and section level boundaries

Implement three-level error boundary strategy:
- Top-level (app shell): catches critical failures
- Page-level: preserves navigation when page crashes
- Section-level: graceful degradation for charts/tables

Create new components:
- PageErrorBoundary: wraps page routes
- SectionErrorBoundary: wraps data-heavy sections

Enhance ErrorBoundary with customizable titles, messages, and reload behavior.

Apply page boundaries to all route handlers in App.tsx.

Apply section boundaries to:
- DashboardPage: server status, ban trend, country charts, ban list
- JailsPage: jail overview, ban/unban form, IP lookup
- MapPage: world map, ban table
- ConfigPage: configuration editor
- HistoryPage: history table, IP detail view
- BlocklistsPage: sources, schedule, import log

Update Web-Development.md with error boundary strategy documentation.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
2026-04-28 08:33:39 +02:00
parent 42beb9cf3b
commit da6433b2cf
12 changed files with 453 additions and 143 deletions

View File

@@ -2,6 +2,8 @@
* 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.
*/
import React from "react";
import { Button, makeStyles, Text, tokens } from "@fluentui/react-components";
@@ -13,14 +15,22 @@ interface ErrorBoundaryState {
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 useFallbackStyles = makeStyles({
const useFullPageStyles = makeStyles({
root: {
display: "flex",
flexDirection: "column",
@@ -36,20 +46,46 @@ const useFallbackStyles = makeStyles({
},
});
function ErrorBoundaryFallback({ message, onReload }: ErrorBoundaryFallbackProps): React.ReactElement {
const styles = useFallbackStyles();
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">
<Text as="h1" size={700} weight="semibold">
Something went wrong
<Text as={isFullPage ? "h1" : "h2"} size={isFullPage ? 700 : 500} weight="semibold">
{title}
</Text>
<Text size={300} className={styles.message}>
<Text size={isFullPage ? 300 : 200} className={isFullPage ? fullPageStyles.message : sectionStyles.message}>
{message}
</Text>
<Button appearance="primary" onClick={onReload}>
Reload
</Button>
{showReloadButton && (
<Button appearance="primary" onClick={onReload}>
{isFullPage ? "Reload Page" : "Retry"}
</Button>
)}
</div>
);
}
@@ -65,18 +101,37 @@ export class ErrorBoundary extends React.Component<ErrorBoundaryProps, ErrorBoun
}
componentDidCatch(error: Error, errorInfo: React.ErrorInfo): void {
console.error("ErrorBoundary caught an error", { error, errorInfo });
const { onError } = this.props;
if (onError) {
onError(error, errorInfo);
} else {
console.error("ErrorBoundary caught an error", { error, errorInfo });
}
}
handleReload = (): void => {
window.location.reload();
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
message={this.state.errorMessage ?? "Please try reloading the page."}
title={title}
message={message}
showReloadButton={showReloadButton}
isFullPage={isFullPage}
onReload={this.handleReload}
/>
);

View File

@@ -0,0 +1,42 @@
/**
* Page-level error boundary.
*
* Wraps entire page components to catch rendering errors while preserving
* the app shell (navigation, theme, auth). When an error occurs, shows a
* full-page fallback but the user can still navigate away via the sidebar.
*
* Use this for wrapping page components in App.tsx routes.
*/
import React from "react";
import { ErrorBoundary } from "./ErrorBoundary";
interface PageErrorBoundaryProps {
children: React.ReactNode;
pageName?: string;
onError?: (error: Error, errorInfo: React.ErrorInfo) => void;
}
/**
* Wraps a page component with error boundary protection.
*
* @param children - Page component to wrap
* @param pageName - Name of page for error message (default: "Page")
* @param onError - Optional callback for error logging
*/
export function PageErrorBoundary({
children,
pageName = "Page",
onError,
}: PageErrorBoundaryProps): React.JSX.Element {
return (
<ErrorBoundary
title={`${pageName} Error`}
message={`The ${pageName.toLowerCase()} encountered an error and could not load. Please try navigating to another page or reloading.`}
showReloadButton={true}
isFullPage={false}
onError={onError}
>
{children}
</ErrorBoundary>
);
}

View File

@@ -0,0 +1,46 @@
/**
* Section-level error boundary.
*
* Wraps individual data-heavy sections (charts, tables, forms) within a page
* to provide graceful degradation. When an error occurs, only that section
* fails to render; the rest of the page remains functional.
*
* Use this to wrap:
* - Charts (BanTrendChart, TopCountriesBarChart, etc.)
* - Data tables (BanTable, JailOverviewSection, etc.)
* - Forms with complex logic
* - Any component that fetches data or has risky side effects
*/
import React from "react";
import { ErrorBoundary } from "./ErrorBoundary";
interface SectionErrorBoundaryProps {
children: React.ReactNode;
sectionName?: string;
onError?: (error: Error, errorInfo: React.ErrorInfo) => void;
}
/**
* Wraps a section component with error boundary protection.
*
* @param children - Section component to wrap
* @param sectionName - Name of section for error message (default: "Section")
* @param onError - Optional callback for error logging
*/
export function SectionErrorBoundary({
children,
sectionName = "Section",
onError,
}: SectionErrorBoundaryProps): React.JSX.Element {
return (
<ErrorBoundary
title={`${sectionName} Unavailable`}
message={`Could not load ${sectionName.toLowerCase()}. The rest of the page is still functional.`}
showReloadButton={true}
isFullPage={false}
onError={onError}
>
{children}
</ErrorBoundary>
);
}