Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 14 additions & 1 deletion frontend/src/components/ErrorBoundary.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,11 @@

import React, { ReactNode, ReactElement } from 'react';

type FallbackRenderer = (reset: () => void) => ReactElement;

interface Props {
children: ReactNode;
fallback?: ReactElement;
fallback?: ReactElement | FallbackRenderer;
onError?: (error: Error, errorInfo: React.ErrorInfo) => void;
section?: string;
}
Expand All @@ -18,6 +20,7 @@ export class ErrorBoundary extends React.Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = { hasError: false, error: null };
this.reset = this.reset.bind(this);
}

static getDerivedStateFromError(error: Error): State {
Expand All @@ -29,8 +32,18 @@ export class ErrorBoundary extends React.Component<Props, State> {
this.props.onError?.(error, errorInfo);
}

// Soft-resets the boundary so children remount and retry on their own,
// instead of forcing a full page reload for recoverable errors.
reset() {
this.setState({ hasError: false, error: null });
}

render() {
if (this.state.hasError) {
if (typeof this.props.fallback === 'function') {
return this.props.fallback(this.reset);
}

return (
this.props.fallback || (
<div
Expand Down
6 changes: 3 additions & 3 deletions frontend/src/components/LandingPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -214,21 +214,21 @@ export const LandingPage: React.FC<LandingPageProps> = ({ className }) => {
</section>

{/* Statistics Section */}
<ErrorBoundary section="statistics" fallback={
<ErrorBoundary section="statistics" fallback={(reset) => (
<section className="statistics" aria-labelledby="statistics-heading">
<h2 id="statistics-heading">Platform Statistics</h2>
<div className="error-message" role="alert">
<p>Unable to load statistics at this time. Please try again later.</p>
<button
className="retry-button"
onClick={() => window.location.reload()}
onClick={reset}
aria-label="Retry loading statistics"
>
Retry
</button>
</div>
</section>
}>
)}>
<Statistics />
</ErrorBoundary>

Expand Down
Original file line number Diff line number Diff line change
@@ -1,12 +1,18 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import { render, screen, fireEvent } from '@testing-library/react';
import { LandingPage } from '../LandingPage';

// Mock the Statistics component to throw an error
// Mock the Statistics component. Controlled by `mockShouldThrow` so tests can
// simulate recovery after a retry (jest hoists jest.mock factories, so the
// controlling variable must be prefixed with "mock" to be referenced here).
let mockShouldThrow = true;
jest.mock('../Statistics', () => {
return {
Statistics: () => {
throw new Error('Failed to load statistics');
if (mockShouldThrow) {
throw new Error('Failed to load statistics');
}
return <div>Statistics loaded</div>;
},
};
});
Expand All @@ -32,6 +38,7 @@ jest.mock('../../lib/hooks/useDarkMode', () => ({
describe('LandingPage with ErrorBoundary', () => {
beforeEach(() => {
jest.clearAllMocks();
mockShouldThrow = true;
});

it('should render error fallback when Statistics throws', () => {
Expand Down Expand Up @@ -63,4 +70,17 @@ describe('LandingPage with ErrorBoundary', () => {
expect(heading).toBeInTheDocument();
expect(heading.tagName).toBe('H2');
});

it('retries via a soft reset instead of reloading the page', () => {
render(<LandingPage />);

// Once Statistics recovers, the next render succeeds. A real
// window.location.reload() would be a no-op in jsdom and could never
// produce this text, so seeing it proves the boundary was reset in
// place rather than the page being reloaded.
mockShouldThrow = false;
fireEvent.click(screen.getByRole('button', { name: /retry loading statistics/i }));

expect(screen.getByText('Statistics loaded')).toBeInTheDocument();
});
});
Loading