🛡️ Sentinel: [MEDIUM] Add Global Error Boundary #59
23
src/components/layout/ErrorBoundary.module.css
Normal file
23
src/components/layout/ErrorBoundary.module.css
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
.container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 100vh;
|
||||||
|
width: 100%;
|
||||||
|
text-align: center;
|
||||||
|
padding: 2rem;
|
||||||
|
background-color: var(--md-sys-color-background);
|
||||||
|
color: var(--md-sys-color-on-background);
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
font-size: 2rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
color: var(--md-sys-color-error);
|
||||||
|
}
|
||||||
|
|
||||||
|
.message {
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
color: var(--md-sys-color-on-surface-variant);
|
||||||
|
}
|
||||||
49
src/components/layout/ErrorBoundary.tsx
Normal file
49
src/components/layout/ErrorBoundary.tsx
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import { Component, type ErrorInfo, type ReactNode } from 'react';
|
||||||
|
import { Button } from '../ui';
|
||||||
|
import styles from './ErrorBoundary.module.css';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface State {
|
||||||
|
hasError: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ErrorBoundary extends Component<Props, State> {
|
||||||
|
constructor(props: Props) {
|
||||||
|
super(props);
|
||||||
|
this.state = { hasError: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
static getDerivedStateFromError(_: Error): State {
|
||||||
|
return { hasError: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
|
||||||
|
// Log error securely - do not expose to user
|
||||||
|
console.error('Uncaught error:', error, errorInfo);
|
||||||
|
}
|
||||||
|
|
||||||
|
handleReload = () => {
|
||||||
|
window.location.reload();
|
||||||
|
};
|
||||||
|
|
||||||
|
render() {
|
||||||
|
if (this.state.hasError) {
|
||||||
|
return (
|
||||||
|
<div className={styles.container} role="alert">
|
||||||
|
<h1 className={styles.title}>Something went wrong</h1>
|
||||||
|
<p className={styles.message}>
|
||||||
|
We apologize for the inconvenience. Please try reloading the page.
|
||||||
|
</p>
|
||||||
|
<Button onClick={this.handleReload} variant="primary">
|
||||||
|
Reload Page
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.props.children;
|
||||||
|
}
|
||||||
|
}
|
||||||
44
src/components/layout/__tests__/ErrorBoundary.test.tsx
Normal file
44
src/components/layout/__tests__/ErrorBoundary.test.tsx
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
import { render, screen, cleanup } from '@testing-library/react';
|
||||||
|
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||||
|
import { ErrorBoundary } from '../ErrorBoundary';
|
||||||
|
|
||||||
|
// Mock component that throws
|
||||||
|
const ThrowError = () => {
|
||||||
|
throw new Error('Test Error');
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('ErrorBoundary', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders children when there is no error', () => {
|
||||||
|
render(
|
||||||
|
<ErrorBoundary>
|
||||||
|
<div>Content</div>
|
||||||
|
</ErrorBoundary>
|
||||||
|
);
|
||||||
|
expect(screen.getByText('Content')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders fallback UI when there is an error', () => {
|
||||||
|
// Prevent console.error from cluttering output during this test
|
||||||
|
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||||
|
|
||||||
|
render(
|
||||||
|
<ErrorBoundary>
|
||||||
|
<ThrowError />
|
||||||
|
</ErrorBoundary>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText('Something went wrong')).toBeTruthy();
|
||||||
|
expect(screen.getByRole('button', { name: /reload page/i })).toBeTruthy();
|
||||||
|
|
||||||
|
// Ensure stack trace/error message is NOT rendered (security check)
|
||||||
|
expect(screen.queryByText('Test Error')).toBeNull();
|
||||||
|
|
||||||
|
consoleSpy.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2,3 +2,4 @@ export { Navbar } from './Navbar';
|
|||||||
export { Footer } from './Footer';
|
export { Footer } from './Footer';
|
||||||
export { FancyCursor } from './FancyCursor';
|
export { FancyCursor } from './FancyCursor';
|
||||||
export { ScrollToTop } from './ScrollToTop';
|
export { ScrollToTop } from './ScrollToTop';
|
||||||
|
export { ErrorBoundary } from './ErrorBoundary';
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
import { StrictMode } from 'react';
|
import { StrictMode } from 'react';
|
||||||
import { createRoot } from 'react-dom/client';
|
import { createRoot } from 'react-dom/client';
|
||||||
import { App } from './App';
|
import { App } from './App';
|
||||||
|
import { ErrorBoundary } from './components/layout';
|
||||||
|
|
||||||
createRoot(document.getElementById('root')!).render(
|
createRoot(document.getElementById('root')!).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
|
<ErrorBoundary>
|
||||||
<App />
|
<App />
|
||||||
|
</ErrorBoundary>
|
||||||
</StrictMode>
|
</StrictMode>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user