diff --git a/frontend/src/components/ErrorBoundary/ErrorBoundary.tsx b/frontend/src/components/ErrorBoundary/ErrorBoundary.tsx new file mode 100644 index 00000000..d76302cb --- /dev/null +++ b/frontend/src/components/ErrorBoundary/ErrorBoundary.tsx @@ -0,0 +1,111 @@ +import { Component, ErrorInfo, ReactNode } from 'react' + +interface Props { + children: ReactNode +} + +interface State { + hasError: boolean + error: Error | null +} + +export class ErrorBoundary extends Component { + public state: State = { + hasError: false, + error: null, + } + + public static getDerivedStateFromError(error: Error): State { + return { hasError: true, error } + } + + public componentDidCatch(_error: Error, _errorInfo: ErrorInfo): void { + // Error is logged to console by React in development + // In production, consider logging to an error tracking service + } + + public render(): ReactNode { + if (this.state.hasError) { + // Only show error details in development mode to avoid leaking implementation details + const isDev = import.meta.env.DEV + + return ( +
+

Something went wrong

+

The application encountered an unexpected error.

+ + {isDev && this.state.error && ( +
+ Error Details +
{this.state.error.toString()}
+

{this.state.error.stack}

+
+ )} +
+ ) + } + + return this.props.children + } +} + +const styles = { + container: { + display: 'flex', + flexDirection: 'column' as const, + alignItems: 'center', + justifyContent: 'center', + height: '100vh', + padding: '2rem', + textAlign: 'center' as const, + backgroundColor: '#f5f5f5', + }, + heading: { + fontSize: '2rem', + color: '#d32f2f', + marginBottom: '1rem', + }, + message: { + fontSize: '1.2rem', + color: '#555', + marginBottom: '2rem', + }, + button: { + padding: '0.75rem 1.5rem', + fontSize: '1rem', + backgroundColor: '#1976d2', + color: 'white', + border: 'none', + borderRadius: '4px', + cursor: 'pointer', + }, + details: { + marginTop: '2rem', + textAlign: 'left' as const, + maxWidth: '800px', + }, + summary: { + cursor: 'pointer', + color: '#1976d2', + fontWeight: 'bold' as const, + }, + pre: { + whiteSpace: 'pre-wrap' as const, + wordWrap: 'break-word' as const, + backgroundColor: '#fff', + padding: '1rem', + borderRadius: '4px', + border: '1px solid #ddd', + }, + stack: { + whiteSpace: 'pre-wrap' as const, + wordWrap: 'break-word' as const, + fontSize: '0.875rem', + color: '#757575', + marginTop: '1rem', + }, +} + +export default ErrorBoundary diff --git a/frontend/src/components/ErrorBoundary/RouterError.tsx b/frontend/src/components/ErrorBoundary/RouterError.tsx new file mode 100644 index 00000000..ac14278d --- /dev/null +++ b/frontend/src/components/ErrorBoundary/RouterError.tsx @@ -0,0 +1,74 @@ +import { useRouteError } from 'react-router-dom' + +import { ENV } from '@/util/config' +import { getRouterErrorMessage } from './RouterErrorMessages' + +export const RouterError = () => { + const error = useRouteError() + const errorMessage = getRouterErrorMessage(error, ENV === 'dev') + + return ( +
+

Navigation Error

+

The page could not be loaded.

+

{errorMessage}

+ + +
+ ) +} + +const styles = { + container: { + display: 'flex', + flexDirection: 'column' as const, + alignItems: 'center', + justifyContent: 'center', + height: '100vh', + padding: '2rem', + textAlign: 'center' as const, + backgroundColor: '#f5f5f5', + }, + heading: { + fontSize: '2rem', + color: '#d32f2f', + marginBottom: '1rem', + }, + message: { + fontSize: '1.2rem', + color: '#555', + marginBottom: '1rem', + }, + errorMessage: { + fontSize: '1rem', + color: '#757575', + marginBottom: '2rem', + fontFamily: 'monospace', + }, + button: { + padding: '0.75rem 1.5rem', + fontSize: '1rem', + backgroundColor: '#1976d2', + color: 'white', + border: 'none', + borderRadius: '4px', + cursor: 'pointer', + margin: '0.5rem', + }, + secondaryButton: { + padding: '0.75rem 1.5rem', + fontSize: '1rem', + backgroundColor: 'transparent', + color: '#1976d2', + border: '1px solid #1976d2', + borderRadius: '4px', + cursor: 'pointer', + margin: '0.5rem', + }, +} + +export default RouterError diff --git a/frontend/src/components/ErrorBoundary/RouterErrorMessages.ts b/frontend/src/components/ErrorBoundary/RouterErrorMessages.ts new file mode 100644 index 00000000..90e00bce --- /dev/null +++ b/frontend/src/components/ErrorBoundary/RouterErrorMessages.ts @@ -0,0 +1,21 @@ +const genericErrorMessage = 'An unexpected navigation error occurred.' + +export const getRouterErrorMessage = (error: unknown, isDevelopment: boolean): string => { + if (!isDevelopment) { + return genericErrorMessage + } + + if (error instanceof Error) { + return error.message + } + + if (error instanceof Response) { + return `${error.status} ${error.statusText}` + } + + if (typeof error === 'string') { + return error + } + + return 'An unknown error occurred' +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index b0753c57..ef153658 100755 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -5,11 +5,14 @@ import { Provider } from 'react-redux' import './styles/global.css' import { RouterProvider } from 'react-router-dom' import router from './router' +import { ErrorBoundary } from './components/ErrorBoundary/ErrorBoundary' ReactDOM.createRoot(document.getElementById('root')!).render( - - - + + + + + ) diff --git a/frontend/src/router/index.tsx b/frontend/src/router/index.tsx index 06b3f4ce..14b1f74d 100644 --- a/frontend/src/router/index.tsx +++ b/frontend/src/router/index.tsx @@ -1,5 +1,6 @@ import { Navigate, createBrowserRouter } from 'react-router-dom' import App from '../App' +import { RouterError } from '../components/ErrorBoundary/RouterError' const loadPagesElement = async ( key: @@ -25,6 +26,7 @@ const router = createBrowserRouter([ { path: '/', element: , + errorElement: , children: [ { index: true, diff --git a/frontend/src/tests/components/RouterError.test.ts b/frontend/src/tests/components/RouterError.test.ts new file mode 100644 index 00000000..9288fe8d --- /dev/null +++ b/frontend/src/tests/components/RouterError.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from '@jest/globals' + +import { getRouterErrorMessage } from '@/components/ErrorBoundary/RouterErrorMessages' + +describe('getRouterErrorMessage', () => { + it('returns a generic message outside development', () => { + expect(getRouterErrorMessage(new Error('sensitive route loader details'), false)).toBe( + 'An unexpected navigation error occurred.' + ) + }) + + it('returns raw error details in development', () => { + expect(getRouterErrorMessage(new Error('route loader failed'), true)).toBe('route loader failed') + }) +})