feat(frontend): add an error boundary, its fallback, and error reporting (#62)
SonarQube Analysis / sonarqube (pull_request) Failing after 5m0s
Tests / lint (pull_request) Failing after 18m19s
Tests / backend-unit (pull_request) Successful in 44s
Tests / frontend-e2e (pull_request) Failing after 7m9s

Three pieces, none of them mounted yet — the next change wires them into the tree.

ErrorBoundary is the only class component in the codebase, because getDerivedStateFromError and componentDidCatch have no hook equivalent. It knows nothing about antd and nothing about how reporting reaches the server: the fallback arrives as a render prop, which is what lets one boundary serve a full page, an inline region and a modal without knowing which it is.

ErrorFallback is the single place that decides whether a customer is shown a stack trace. The detail is gated on import.meta.env.DEV so a developer sees the throw immediately while a production bundle cannot render it at all — one decision in one file rather than the same judgement repeated at three mount points, where they would drift apart.

reportClientError posts to the endpoint added earlier and deliberately swallows its outcome. That is the one place in this feature where swallowing is correct: it runs inside componentDidCatch, so a reporter that rejected would throw from the very thing that exists to stop throws, with nothing left to catch it.

vite-env.d.ts was not in the plan and is needed. Nothing in this app had used import.meta.env before, so there was no ambient declaration for it and tsc rejected the DEV check outright. The standard one-line Vite reference fixes it, adds no dependency, and would have been needed by the next change regardless.

Verified: build clean, lint 0 errors and 31 warnings, unchanged from the branch baseline. No unit tests, because the frontend has no unit suite — that gap belongs to #72, and these components are covered end to end by the next change.

Refs #62
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-20 17:40:16 -05:00
co-authored by Claude Opus 5
parent 7d227507b0
commit e0ae2dcbcd
4 changed files with 103 additions and 0 deletions
+44
View File
@@ -0,0 +1,44 @@
import React from 'react';
import { ErrorContext, reportClientError } from '../errorReporting';
type ErrorBoundaryProps = Readonly<{
context: ErrorContext;
fallback: (error: Error) => React.ReactNode;
children: React.ReactNode;
}>;
interface ErrorBoundaryState {
error: Error | null;
}
// The only class component in the codebase. getDerivedStateFromError and
// componentDidCatch have no hook equivalent, so a boundary cannot be written as
// a function component.
//
// It knows nothing about antd and nothing about how reporting reaches the
// server. The fallback is the caller's business, which is what lets one
// boundary serve a full page, an inline region and a modal.
export default class ErrorBoundary extends React.Component<ErrorBoundaryProps, ErrorBoundaryState> {
state: ErrorBoundaryState = { error: null };
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
return { error };
}
componentDidCatch(error: Error, info: React.ErrorInfo): void {
reportClientError({
context: this.props.context,
message: error.message,
stack: error.stack,
componentStack: info.componentStack ?? undefined,
path: `${window.location.pathname}${window.location.search}`
});
}
render(): React.ReactNode {
if (this.state.error) {
return this.props.fallback(this.state.error);
}
return this.props.children;
}
}
+34
View File
@@ -0,0 +1,34 @@
import React from 'react';
import Result from 'antd/es/result';
import Typography from 'antd/es/typography';
const { Paragraph, Text } = Typography;
type ErrorFallbackProps = Readonly<{
error: Error;
title: string;
actions: React.ReactNode;
fullPage?: boolean;
}>;
// The single place that decides whether a customer is shown a stack trace.
// Gated on DEV so a developer sees the throw immediately while a production
// bundle cannot render it at all — one decision in one file rather than the
// same judgement repeated at three mount points, where they would drift.
export default function ErrorFallback({ error, title, actions, fullPage = false }: ErrorFallbackProps) {
return (
<Result
status="error"
title={title}
subTitle="This has been reported. Nothing you did caused it."
style={{ paddingBlock: fullPage ? 64 : 24 }}
extra={actions}
>
{import.meta.env.DEV ? (
<Paragraph>
<Text code>{error.message}</Text>
</Paragraph>
) : null}
</Result>
);
}
+24
View File
@@ -0,0 +1,24 @@
// Where a caught render error came from. Kept in step with the allowlist in
// backend/src/routes/clientErrors.ts, which refuses anything else rather than
// logging under a guess — change one and you must change the other.
export type ErrorContext = 'page' | 'catalogue' | 'modal';
export interface ClientErrorReport {
context: ErrorContext;
message: string;
stack?: string;
componentStack?: string;
path: string;
}
// Fire and forget, and deliberately swallowing — the one place in this change
// where swallowing is correct. This runs inside componentDidCatch, so a
// reporter that rejected would throw from the very thing that exists to stop
// throws, and there would be nothing left to catch it.
export function reportClientError(report: ClientErrorReport): void {
void fetch('/api/client-errors', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(report)
}).catch(() => undefined);
}
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />