From 62c79a500d3a7e8c0b091eb244a52bee31d0200a Mon Sep 17 00:00:00 2001 From: karilint Date: Fri, 29 May 2026 11:15:27 +0300 Subject: [PATCH 1/5] feat: Add ErrorBoundary and RouterError for better error UX Implement #1185: Provide ErrorBoundary and errorElement to catch and display application errors gracefully. - Add ErrorBoundary component to catch React component render errors - Add RouterError component to catch React Router v6 data router errors - Wrap RouterProvider with ErrorBoundary in main.tsx - Add errorElement to root route in router This provides a better user experience when the application encounters errors, showing a user-friendly error page with options to reload or go back instead of the default React error screen. Generated by Mistral Vibe. Co-Authored-By: Mistral Vibe --- .../ErrorBoundary/ErrorBoundary.tsx | 108 ++++++++++++++++++ .../components/ErrorBoundary/RouterError.tsx | 80 +++++++++++++ frontend/src/main.tsx | 9 +- frontend/src/router/index.tsx | 2 + 4 files changed, 196 insertions(+), 3 deletions(-) create mode 100644 frontend/src/components/ErrorBoundary/ErrorBoundary.tsx create mode 100644 frontend/src/components/ErrorBoundary/RouterError.tsx diff --git a/frontend/src/components/ErrorBoundary/ErrorBoundary.tsx b/frontend/src/components/ErrorBoundary/ErrorBoundary.tsx new file mode 100644 index 00000000..215a1403 --- /dev/null +++ b/frontend/src/components/ErrorBoundary/ErrorBoundary.tsx @@ -0,0 +1,108 @@ +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) { + return ( +
+

Something went wrong

+

The application encountered an unexpected error.

+ + {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..de96239b --- /dev/null +++ b/frontend/src/components/ErrorBoundary/RouterError.tsx @@ -0,0 +1,80 @@ +import { useRouteError } from 'react-router-dom' + +export const RouterError = () => { + const error = useRouteError() + + // Handle different error types that useRouteError can return + const errorMessage = + error instanceof Error + ? error.message + : error instanceof Response + ? `${error.status} ${error.statusText}` + : typeof error === 'string' + ? error + : 'An unknown error occurred' + + 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/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, From 4ae003a540624d53a752160c3bd36dcc328d3391 Mon Sep 17 00:00:00 2001 From: karilint Date: Fri, 29 May 2026 11:35:54 +0300 Subject: [PATCH 2/5] fix: Fix invalid blocker state transition in UnsavedChangesProvider Fix #1184: Time Unit error caused by invalid blocker state transition. The error 'Invalid blocker state transition: unblocked -> proceeding' occurred when calling blocker.proceed() in UnsavedChangesProvider. Root cause: setDirty(false) was called before blocker.proceed?.(), which triggered a re-render that could change the blocker state before proceed was called. This caused React Router's internal state machine to receive a proceed call when the blocker was no longer in the 'blocked' state. Fix: - Call blocker.proceed?.() before setDirty(false) in handleConfirm - Add additional state check in isSamePathNavigation useEffect to ensure blocker is still 'blocked' before calling proceed() Generated by Mistral Vibe. Co-Authored-By: Mistral Vibe --- frontend/src/components/UnsavedChangesProvider.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/src/components/UnsavedChangesProvider.tsx b/frontend/src/components/UnsavedChangesProvider.tsx index 3ac1f993..9c752055 100644 --- a/frontend/src/components/UnsavedChangesProvider.tsx +++ b/frontend/src/components/UnsavedChangesProvider.tsx @@ -72,8 +72,8 @@ export const UnsavedChangesProvider = ({ const handleConfirm = useCallback(() => { if (blocker.state === 'blocked') { - setDirty(false) blocker.proceed?.() + setDirty(false) } }, [blocker, setDirty]) @@ -91,7 +91,7 @@ export const UnsavedChangesProvider = ({ useEffect(() => { const proceed = blocker.proceed as (() => void) | undefined - if (isSamePathNavigation && proceed) { + if (isSamePathNavigation && proceed && blocker.state === 'blocked') { proceed() } }, [blocker, isSamePathNavigation]) From 1d454e7d3e47236dc2df0872e3538ea7fb04b044 Mon Sep 17 00:00:00 2001 From: karilint Date: Fri, 29 May 2026 13:59:13 +0300 Subject: [PATCH 3/5] fix: Hide error details from production users in ErrorBoundary Security fix for PR #1186: ErrorBoundary was leaking internal implementation details (stack traces, error messages) to end users in production. - Gate error details section to development mode only using import.meta.env.DEV - In production, only show generic error message and reload button - Prevents information disclosure while maintaining debugging capability in dev Generated by Mistral Vibe. Co-Authored-By: Mistral Vibe --- frontend/src/components/ErrorBoundary/ErrorBoundary.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/frontend/src/components/ErrorBoundary/ErrorBoundary.tsx b/frontend/src/components/ErrorBoundary/ErrorBoundary.tsx index 215a1403..d76302cb 100644 --- a/frontend/src/components/ErrorBoundary/ErrorBoundary.tsx +++ b/frontend/src/components/ErrorBoundary/ErrorBoundary.tsx @@ -26,6 +26,9 @@ export class ErrorBoundary extends Component { 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

@@ -33,7 +36,7 @@ export class ErrorBoundary extends Component { - {this.state.error && ( + {isDev && this.state.error && (
Error Details
{this.state.error.toString()}
From 7e732d9faae2740897bc329d08cfab9668f67133 Mon Sep 17 00:00:00 2001 From: karilint Date: Fri, 29 May 2026 14:55:42 +0300 Subject: [PATCH 4/5] Remove redundant blocker state guard --- frontend/src/components/UnsavedChangesProvider.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/components/UnsavedChangesProvider.tsx b/frontend/src/components/UnsavedChangesProvider.tsx index 9c752055..6d6c58cb 100644 --- a/frontend/src/components/UnsavedChangesProvider.tsx +++ b/frontend/src/components/UnsavedChangesProvider.tsx @@ -91,7 +91,7 @@ export const UnsavedChangesProvider = ({ useEffect(() => { const proceed = blocker.proceed as (() => void) | undefined - if (isSamePathNavigation && proceed && blocker.state === 'blocked') { + if (isSamePathNavigation && proceed) { proceed() } }, [blocker, isSamePathNavigation]) From 88bed7adcac00c79006ab478cc547955b6ad608d Mon Sep 17 00:00:00 2001 From: karilint Date: Fri, 29 May 2026 15:22:58 +0300 Subject: [PATCH 5/5] fix: Hide router error details in production --- .../components/ErrorBoundary/RouterError.tsx | 14 ++++--------- .../ErrorBoundary/RouterErrorMessages.ts | 21 +++++++++++++++++++ .../src/tests/components/RouterError.test.ts | 15 +++++++++++++ 3 files changed, 40 insertions(+), 10 deletions(-) create mode 100644 frontend/src/components/ErrorBoundary/RouterErrorMessages.ts create mode 100644 frontend/src/tests/components/RouterError.test.ts diff --git a/frontend/src/components/ErrorBoundary/RouterError.tsx b/frontend/src/components/ErrorBoundary/RouterError.tsx index de96239b..ac14278d 100644 --- a/frontend/src/components/ErrorBoundary/RouterError.tsx +++ b/frontend/src/components/ErrorBoundary/RouterError.tsx @@ -1,17 +1,11 @@ import { useRouteError } from 'react-router-dom' +import { ENV } from '@/util/config' +import { getRouterErrorMessage } from './RouterErrorMessages' + export const RouterError = () => { const error = useRouteError() - - // Handle different error types that useRouteError can return - const errorMessage = - error instanceof Error - ? error.message - : error instanceof Response - ? `${error.status} ${error.statusText}` - : typeof error === 'string' - ? error - : 'An unknown error occurred' + const errorMessage = getRouterErrorMessage(error, ENV === 'dev') return (
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/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') + }) +})