-
Notifications
You must be signed in to change notification settings - Fork 0
feat: add ErrorBoundary around route tree #4
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
e3db5bb
dc61d34
a9e46d5
458b392
473c88a
6541a69
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 <div>All good</div> | ||
| } | ||
|
|
||
| // 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( | ||
| <ErrorBoundary> | ||
| <Safe /> | ||
| </ErrorBoundary> | ||
| ) | ||
| expect(screen.getByText('All good')).toBeInTheDocument() | ||
| }) | ||
|
|
||
| it('renders the fallback UI when a child throws', () => { | ||
| render( | ||
| <ErrorBoundary> | ||
| <Bomb /> | ||
| </ErrorBoundary> | ||
| ) | ||
| 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( | ||
| <ErrorBoundary> | ||
| <Bomb /> | ||
| </ErrorBoundary> | ||
| ) | ||
| ).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 <div>Recovered</div> | ||
| } | ||
|
|
||
| render( | ||
| <ErrorBoundary> | ||
| <MaybeThrow /> | ||
| </ErrorBoundary> | ||
| ) | ||
|
|
||
| expect(screen.getByText('Something went wrong.')).toBeInTheDocument() | ||
|
|
||
| shouldThrow = false | ||
| await userEvent.click(screen.getByRole('button', { name: 'Try again' })) | ||
|
|
||
| expect(screen.getByText('Recovered')).toBeInTheDocument() | ||
| }) | ||
| }) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,47 @@ | ||
| import React, { Component, ErrorInfo, ReactNode } from 'react'; | ||
|
|
||
| interface Props { | ||
| children: ReactNode; | ||
| } | ||
|
|
||
| interface State { | ||
| hasError: boolean; | ||
| } | ||
|
|
||
| class ErrorBoundary extends Component<Props, State> { | ||
| 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 ( | ||
| <div style={{ textAlign: 'center', padding: '60px 20px' }}> | ||
| <h2>Something went wrong.</h2> | ||
| <p style={{ color: '#666', marginTop: '8px' }}> | ||
| An unexpected error occurred. Please refresh the page to try again. | ||
| </p> | ||
| <button | ||
| style={{ marginTop: '20px' }} | ||
| onClick={() => this.setState({ hasError: false })} | ||
| > | ||
|
Comment on lines
+25
to
+36
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. For better maintainability and separation of concerns, it's recommended to avoid inline styles. Consider moving these styles to a dedicated CSS or CSS-in-JS file and using class names instead. This makes styles more reusable and easier to manage, especially as the application grows. For example, you could create an ErrorBoundary.css .error-boundary-fallback {
text-align: center;
padding: 60px 20px;
}
.error-message {
color: #666;
margin-top: 8px;
}
.retry-button {
margin-top: 20px;
}ErrorBoundary.tsx import './ErrorBoundary.css';
// ...
<div className="error-boundary-fallback">
<h2>Something went wrong.</h2>
<p className="error-message">
{this.state.error?.message ?? 'An unexpected error occurred.'}
</p>
<button
onClick={() => this.setState({ hasError: false, error: null })}
className="retry-button"
>
Try again
</button>
</div> |
||
| Try again | ||
| </button> | ||
| </div> | ||
| ); | ||
| } | ||
|
|
||
| return this.props.children; | ||
| } | ||
| } | ||
|
|
||
| export default ErrorBoundary; | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
While
getDerivedStateFromErroris great for updating state to render a fallback UI, it's also crucial to log the error for debugging and monitoring purposes. You should implementcomponentDidCatchto handle side-effects like logging the error to the console or an external service. Without this, errors caught by this boundary will not be reported, making it difficult to track issues in production.