fix: a crashing page now says so, and the in-progress and stats cards are gone

/quizzes rendered a white screen — no navbar, no footer, nothing to read or
report. I could not reproduce it from the data shape the API returns, and that
is the point: an unhandled render error unmounts the whole tree and leaves
nobody, reader or developer, anything to work from.

There is now an error boundary around the routed page. The navbar and footer
survive, the message and the component trail reach the console, and the reader
gets a reload and a way out. It is keyed by path, so navigating away clears it.

Also removed, both superseded by the analysis page: the dashboard's in-progress
list and its three stat cards.

Still open on /quizzes: I have not found the underlying throw. With the boundary
in place the next visit will name it rather than showing a blank page, which is
the thing I actually needed and did not have.

249 frontend tests green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
This commit is contained in:
Daniel 2026-09-11 03:48:12 +02:00
parent 1f77d421c7
commit 6993c06998
4 changed files with 87 additions and 2 deletions

View file

@ -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 (
<>
<Navbar />
<div className="container">
<Outlet />
<ErrorBoundary key={location.pathname}>
<Outlet />
</ErrorBoundary>
</div>
<SiteFooter />
</>

View file

@ -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; }

View file

@ -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 (
<div className="eb-card" role="alert">
<h1>This page failed to load</h1>
<p>The rest of the app is still working the error was on this screen.</p>
<pre>{String(this.state.error?.message || this.state.error)}</pre>
<div className="eb-actions">
<button className="btn btn-primary" onClick={() => window.location.reload()}>Reload</button>
<a className="btn btn-secondary" href="/">Dashboard</a>
</div>
</div>
)
}
}

View file

@ -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(<ErrorBoundary><Boom /></ErrorBoundary>)
// 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(<ErrorBoundary><p>All fine</p></ErrorBoundary>)
expect(screen.getByText('All fine')).toBeInTheDocument()
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
})
})