diff --git a/eslint.config.js b/eslint.config.js index e3c6a77..52c823f 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -51,6 +51,8 @@ export default tseslint.config( 'sonarjs/no-ignored-return': 'warn', 'sonarjs/no-ignored-exceptions': 'warn', 'sonarjs/prefer-read-only-props': 'warn', + // React class render() legitimately returns JSX | ReactNode on different paths + 'sonarjs/function-return-type': 'warn', }, }, { diff --git a/packages/web/src/App.tsx b/packages/web/src/App.tsx index 500b147..c7fb689 100644 --- a/packages/web/src/App.tsx +++ b/packages/web/src/App.tsx @@ -1,6 +1,7 @@ import React from 'react'; import { BrowserRouter, Routes, Route, Link } from 'react-router-dom'; import { AuthProvider, useAuth } from './AuthContext'; +import ErrorBoundary from './ErrorBoundary'; import Home from './pages/Home'; import MovieDetail from './pages/MovieDetail'; import SeatSelection from './pages/SeatSelection'; @@ -38,14 +39,16 @@ function App() {
- - } /> - } /> - } /> - } /> - } /> - } /> - + + + } /> + } /> + } /> + } /> + } /> + } /> + +
diff --git a/packages/web/src/ErrorBoundary.test.tsx b/packages/web/src/ErrorBoundary.test.tsx new file mode 100644 index 0000000..1c553d9 --- /dev/null +++ b/packages/web/src/ErrorBoundary.test.tsx @@ -0,0 +1,75 @@ +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import ErrorBoundary from './ErrorBoundary' + +function Bomb(): never { + throw new Error('Test explosion') +} + +function Safe() { + return
All good
+} + +// Suppress the expected console.error noise from ErrorBoundary.componentDidCatch +beforeEach(() => { + vi.spyOn(console, 'error').mockImplementation(() => undefined) +}) + +afterEach(() => { + vi.restoreAllMocks() +}) + +describe('ErrorBoundary', () => { + it('renders children when no error occurs', () => { + render( + + + + ) + expect(screen.getByText('All good')).toBeInTheDocument() + }) + + it('renders the fallback UI when a child throws', () => { + render( + + + + ) + expect(screen.getByText('Something went wrong.')).toBeInTheDocument() + expect(screen.getByText(/unexpected error occurred/)).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Try again' })).toBeInTheDocument() + }) + + it('does not re-throw the error to the parent', () => { + expect(() => + render( + + + + ) + ).not.toThrow() + }) + + it('resets error state and re-renders children when "Try again" is clicked', async () => { + let shouldThrow = true + + function MaybeThrow() { + if (shouldThrow) throw new Error('Conditional error') + return
Recovered
+ } + + render( + + + + ) + + expect(screen.getByText('Something went wrong.')).toBeInTheDocument() + + shouldThrow = false + await userEvent.click(screen.getByRole('button', { name: 'Try again' })) + + expect(screen.getByText('Recovered')).toBeInTheDocument() + }) +}) diff --git a/packages/web/src/ErrorBoundary.tsx b/packages/web/src/ErrorBoundary.tsx new file mode 100644 index 0000000..3b35618 --- /dev/null +++ b/packages/web/src/ErrorBoundary.tsx @@ -0,0 +1,47 @@ +import React, { Component, ErrorInfo, ReactNode } from 'react'; + +interface Props { + children: ReactNode; +} + +interface State { + hasError: boolean; +} + +class ErrorBoundary extends Component { + constructor(props: Props) { + super(props); + this.state = { hasError: false }; + } + + static getDerivedStateFromError(): State { + return { hasError: true }; + } + + componentDidCatch(error: Error, info: ErrorInfo): void { + console.error('ErrorBoundary caught an error:', error, info.componentStack); + } + + render(): ReactNode { + if (this.state.hasError) { + return ( +
+

Something went wrong.

+

+ An unexpected error occurred. Please refresh the page to try again. +

+ +
+ ); + } + + return this.props.children; + } +} + +export default ErrorBoundary;