diff --git a/frontend/src/components/ErrorBoundary.tsx b/frontend/src/components/ErrorBoundary.tsx new file mode 100644 index 0000000..b88389e --- /dev/null +++ b/frontend/src/components/ErrorBoundary.tsx @@ -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 { + 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; + } +} diff --git a/frontend/src/components/ErrorFallback.tsx b/frontend/src/components/ErrorFallback.tsx new file mode 100644 index 0000000..729308b --- /dev/null +++ b/frontend/src/components/ErrorFallback.tsx @@ -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 ( + + {import.meta.env.DEV ? ( + + {error.message} + + ) : null} + + ); +} diff --git a/frontend/src/errorReporting.ts b/frontend/src/errorReporting.ts new file mode 100644 index 0000000..eb9ea66 --- /dev/null +++ b/frontend/src/errorReporting.ts @@ -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); +} diff --git a/frontend/src/vite-env.d.ts b/frontend/src/vite-env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/frontend/src/vite-env.d.ts @@ -0,0 +1 @@ +///