Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
111 changes: 111 additions & 0 deletions frontend/src/components/ErrorBoundary/ErrorBoundary.tsx
Original file line number Diff line number Diff line change
@@ -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<Props, State> {
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 (
<div style={styles.container}>
<h1 style={styles.heading}>Something went wrong</h1>
<p style={styles.message}>The application encountered an unexpected error.</p>
<button style={styles.button} onClick={() => window.location.reload()}>
Reload Application
</button>
{isDev && this.state.error && (
<details style={styles.details}>
<summary style={styles.summary}>Error Details</summary>
<pre style={styles.pre}>{this.state.error.toString()}</pre>
<p style={styles.stack}>{this.state.error.stack}</p>
</details>
)}
</div>
)
}

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
74 changes: 74 additions & 0 deletions frontend/src/components/ErrorBoundary/RouterError.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div style={styles.container}>
<h1 style={styles.heading}>Navigation Error</h1>
<p style={styles.message}>The page could not be loaded.</p>
<p style={styles.errorMessage}>{errorMessage}</p>
<button style={styles.button} onClick={() => window.location.reload()}>
Reload Application
</button>
<button style={styles.secondaryButton} onClick={() => window.history.back()}>
Go Back
</button>
</div>
)
}

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
21 changes: 21 additions & 0 deletions frontend/src/components/ErrorBoundary/RouterErrorMessages.ts
Original file line number Diff line number Diff line change
@@ -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'
}
9 changes: 6 additions & 3 deletions frontend/src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<React.StrictMode>
<Provider store={store}>
<RouterProvider router={router} />
</Provider>
<ErrorBoundary>
<Provider store={store}>
<RouterProvider router={router} />
</Provider>
</ErrorBoundary>
</React.StrictMode>
)
2 changes: 2 additions & 0 deletions frontend/src/router/index.tsx
Original file line number Diff line number Diff line change
@@ -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:
Expand All @@ -25,6 +26,7 @@ const router = createBrowserRouter([
{
path: '/',
element: <App />,
errorElement: <RouterError />,
children: [
{
index: true,
Expand Down
15 changes: 15 additions & 0 deletions frontend/src/tests/components/RouterError.test.ts
Original file line number Diff line number Diff line change
@@ -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')
})
})
Loading