diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 35d47f8f..f4c7a31b 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,5 +1,5 @@ -import { Routes, Route, Navigate } from 'react-router-dom'; -import { useEffect } from 'react'; +import { Routes, Route, Navigate, useLocation } from 'react-router-dom'; +import { useEffect, type ReactNode } from 'react'; import Home from './pages/Home'; import Debugger from './pages/Debugger'; import PayrollScheduler from './pages/PayrollScheduler'; @@ -25,6 +25,20 @@ import AuthCallback from './pages/AuthCallback'; import { useTranslation } from 'react-i18next'; import { contractService } from './services/contracts'; +/** + * Wraps a route element in an ErrorBoundary that auto-resets when the user + * navigates to a different route path. This ensures a crash on one route + * doesn't permanently break navigation to another route. + */ +function RouteBoundary({ children, fallback }: { children: ReactNode; fallback?: ReactNode }) { + const location = useLocation(); + return ( + }> + {children} + + ); +} + function App() { const { t } = useTranslation(); @@ -41,7 +55,7 @@ function App() { - + } /> - + } /> - + } /> - } + } > - + } /> }> + - + } /> - + } /> - + } /> }> + - + } /> {}} />}> + - + } /> {}} />}> + - + } /> {}} />}> + - + } /> {}} />}> + - + } /> {}} />}> + - + } /> {}} />}> + - + } /> {}} />}> + - + } /> {}} />}> + - + } /> {}} />}> + - + } /> - } /> - } /> + + + + } + /> + + + + } + /> } /> ); } -export default App; +export default App; \ No newline at end of file diff --git a/frontend/src/components/ErrorBoundary.tsx b/frontend/src/components/ErrorBoundary.tsx index c57a9ff7..d88ce395 100644 --- a/frontend/src/components/ErrorBoundary.tsx +++ b/frontend/src/components/ErrorBoundary.tsx @@ -4,12 +4,28 @@ import * as Sentry from '@sentry/react'; type ErrorBoundaryProps = { fallback: React.ReactNode; children: React.ReactNode; + /** Callback invoked after the boundary resets (e.g. when the user clicks "Try Again"). */ + onReset?: () => void; + /** + * When any of these values change between renders, the boundary is reset + * (and `onReset` is fired). Useful for keying the boundary to the current + * route so navigation away from a crashed route recovers automatically. + */ + resetKeys?: ReadonlyArray; }; type ErrorBoundaryState = { hasError: boolean; }; +/** + * Error boundary that isolates render errors to the subtree it wraps. It + * renders `fallback` when a child throws, optionally injecting an `onReset` + * prop into the fallback so "Try Again" buttons can clear the error state. + * + * When `resetKeys` change between renders, the boundary recovers + * automatically (useful for route-based reset). + */ export default class ErrorBoundary extends React.Component { state: ErrorBoundaryState = { hasError: false, @@ -19,7 +35,27 @@ export default class ErrorBoundary extends React.Component 0) { + const prevKeys = prevProps.resetKeys; + if ( + !prevKeys || + prevKeys.length !== resetKeys.length || + resetKeys.some((key, index) => !Object.is(key, prevKeys[index])) + ) { + this.setState({ hasError: false }); + onReset?.(); + } + } + } + componentDidCatch(error: unknown, errorInfo: React.ErrorInfo) { + // Log to Sentry when configured, and always surface the component stack + // in the console for dev-mode debugging. + console.error('Uncaught error caught by ErrorBoundary', error, errorInfo); Sentry.captureException(error, { extra: { componentStack: errorInfo.componentStack, @@ -27,11 +63,28 @@ export default class ErrorBoundary extends React.Component { + this.setState({ hasError: false }); + this.props.onReset?.(); + }; + render() { if (this.state.hasError) { - return this.props.fallback; + const { fallback } = this.props; + // If the fallback accepts an onReset handler, wire it up so the + // "Try Again" button actually clears the error state. + if (React.isValidElement(fallback)) { + const fallbackProps = fallback.props as { onReset?: unknown }; + if (typeof fallbackProps.onReset === 'undefined') { + return React.cloneElement( + fallback as React.ReactElement<{ onReset?: () => void }>, + { onReset: this.handleReset }, + ); + } + } + return fallback; } return this.props.children; } -} +} \ No newline at end of file diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index f84f1879..47820627 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -35,7 +35,7 @@ ReactDOM.createRoot(document.getElementById('root')!).render( - {}} />}> + }>