From fd5e180674373980b5944e65e7ef63394bebb3df Mon Sep 17 00:00:00 2001 From: techisigu Date: Sun, 26 Jul 2026 21:33:14 +0000 Subject: [PATCH] Standardize Statistics error recovery on soft-retry pattern ErrorBoundary's statistics fallback previously called window.location.reload() on retry, a full page reload, while Statistics' own internal error UI (for fetch failures) retries via execute() with no reload. Give ErrorBoundary a reset() method and let fallback be a render function that receives it, so render-crash retries also resolve via an in-place reset rather than a hard navigation. Full reload remains the default fallback behavior for boundaries that don't opt into a custom fallback (e.g. the app-level boundary in layout.tsx), reserved for genuinely unrecoverable crashes. Co-Authored-By: Claude Sonnet 5 --- frontend/src/components/ErrorBoundary.tsx | 15 ++++++++++- frontend/src/components/LandingPage.tsx | 6 ++--- .../LandingPage.error-boundary.test.tsx | 26 ++++++++++++++++--- 3 files changed, 40 insertions(+), 7 deletions(-) diff --git a/frontend/src/components/ErrorBoundary.tsx b/frontend/src/components/ErrorBoundary.tsx index b4424a5d..f1002aba 100644 --- a/frontend/src/components/ErrorBoundary.tsx +++ b/frontend/src/components/ErrorBoundary.tsx @@ -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; } @@ -18,6 +20,7 @@ export class ErrorBoundary extends React.Component { constructor(props: Props) { super(props); this.state = { hasError: false, error: null }; + this.reset = this.reset.bind(this); } static getDerivedStateFromError(error: Error): State { @@ -29,8 +32,18 @@ export class ErrorBoundary extends React.Component { 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 || (
= ({ className }) => { {/* Statistics Section */} - (

Platform Statistics

Unable to load statistics at this time. Please try again later.

- }> + )}>
diff --git a/frontend/src/components/__tests__/LandingPage.error-boundary.test.tsx b/frontend/src/components/__tests__/LandingPage.error-boundary.test.tsx index 299c854a..6bba8a16 100644 --- a/frontend/src/components/__tests__/LandingPage.error-boundary.test.tsx +++ b/frontend/src/components/__tests__/LandingPage.error-boundary.test.tsx @@ -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
Statistics loaded
; }, }; }); @@ -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', () => { @@ -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(); + + // 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(); + }); });