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;