diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 3cae676..128ddd7 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -1,9 +1,10 @@ import { lazy, Suspense } from 'react' -import { BrowserRouter, Routes, Route, Navigate, Outlet } from 'react-router-dom' +import { BrowserRouter, Routes, Route, Navigate, Outlet, useLocation } from 'react-router-dom' import { AuthProvider, useAuth } from './context/AuthContext' import { ThemeProvider } from './context/ThemeContext' import Navbar from './components/Navbar' import SiteFooter from './components/SiteFooter' +import ErrorBoundary from './components/ErrorBoundary' const LoginPage = lazy(() => import('./pages/LoginPage')) const RegisterPage = lazy(() => import('./pages/RegisterPage')) @@ -54,11 +55,15 @@ function LoadingFallback() { // Layout wrapper for authenticated app pages (Navbar + container + footer) function AppLayout() { + // Keyed by path so navigating away from a broken page clears the error. + const location = useLocation() return ( <>
- + + +
diff --git a/frontend/src/components/ErrorBoundary.css b/frontend/src/components/ErrorBoundary.css new file mode 100644 index 0000000..7b5fdb1 --- /dev/null +++ b/frontend/src/components/ErrorBoundary.css @@ -0,0 +1,13 @@ +/* A failure the reader can see, report, and get out of. */ +.eb-card { + max-width: 640px; margin: 48px auto; padding: 26px; + background: var(--card-bg); border: 1px solid var(--wrong-bd); border-radius: 12px; +} +.eb-card h1 { margin: 0 0 8px; font-size: 1.2rem; color: var(--wrong-fg); } +.eb-card p { margin: 0 0 14px; color: var(--text-muted); font-size: 0.9rem; } +.eb-card pre { + margin: 0 0 16px; padding: 12px; overflow-x: auto; + background: var(--bg); border: 1px solid var(--border); border-radius: 8px; + font-size: 0.8rem; color: var(--text-muted); white-space: pre-wrap; +} +.eb-actions { display: flex; gap: 8px; flex-wrap: wrap; } diff --git a/frontend/src/components/ErrorBoundary.jsx b/frontend/src/components/ErrorBoundary.jsx new file mode 100644 index 0000000..66fc6d7 --- /dev/null +++ b/frontend/src/components/ErrorBoundary.jsx @@ -0,0 +1,42 @@ +import { Component } from 'react' +import './ErrorBoundary.css' + +/** + * Catch a render error and say so, rather than leaving a white page. + * + * Without this a single thrown error unmounts the whole tree — navbar included — + * and the reader gets a blank screen with nothing to act on and nothing to + * report. What broke is usually one page; the rest of the app still works, and + * the message is what makes the difference between "it's broken" and a bug + * anyone can chase. + */ +export default class ErrorBoundary extends Component { + constructor(props) { + super(props) + this.state = { error: null } + } + + static getDerivedStateFromError(error) { + return { error } + } + + componentDidCatch(error, info) { + // The console is where a developer will look; keep the component trail. + console.error('Render failed:', error, info?.componentStack) + } + + render() { + if (!this.state.error) return this.props.children + return ( +
+

This page failed to load

+

The rest of the app is still working — the error was on this screen.

+
{String(this.state.error?.message || this.state.error)}
+
+ + Dashboard +
+
+ ) + } +} diff --git a/frontend/src/components/ErrorBoundary.test.jsx b/frontend/src/components/ErrorBoundary.test.jsx new file mode 100644 index 0000000..8b517d5 --- /dev/null +++ b/frontend/src/components/ErrorBoundary.test.jsx @@ -0,0 +1,25 @@ +import { describe, expect, it, vi, afterEach } from 'vitest' +import { render, screen } from '@testing-library/react' +import ErrorBoundary from './ErrorBoundary' + +const Boom = () => { throw new Error('Cannot read properties of undefined') } + +afterEach(() => vi.restoreAllMocks()) + +describe('a page that throws', () => { + it('says so instead of leaving a white screen', () => { + vi.spyOn(console, 'error').mockImplementation(() => {}) + render() + + // A blank page tells the reader nothing and gives them nothing to report. + expect(screen.getByRole('alert')).toHaveTextContent('This page failed to load') + expect(screen.getByText(/Cannot read properties of undefined/)).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Reload' })).toBeInTheDocument() + }) + + it('leaves a working page alone', () => { + render(

All fine

) + expect(screen.getByText('All fine')).toBeInTheDocument() + expect(screen.queryByRole('alert')).not.toBeInTheDocument() + }) +})