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
6 changes: 3 additions & 3 deletions src/app/reputation/ReputationPageContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import React, { Suspense } from 'react';
import EmptyState from '../../components/EmptyState';
import ReputationProfile from '../../components/ReputationProfile';
import ReputationSummaryCard from '../../components/ReputationSummaryCard';
import SafeBoundary from '../../components/SafeBoundary';
import ReputationErrorBoundary from '../../components/ReputationErrorBoundary';
import type { Reputation } from '@/types/domain';

export type ReputationPageContentProps = {
Expand All @@ -20,7 +20,7 @@ export function ReputationPageContent({
const hasReputation = typeof score === 'number' && score >= 0;

return (
<SafeBoundary>
<ReputationErrorBoundary>
{!reputationData || !hasReputation ? (
<main className="min-h-screen p-8">
<h1 className="text-2xl font-bold mb-6">Reputation</h1>
Expand Down Expand Up @@ -49,7 +49,7 @@ export function ReputationPageContent({
</Suspense>
</main>
)}
</SafeBoundary>
</ReputationErrorBoundary>
);
}

40 changes: 20 additions & 20 deletions src/app/reputation/__tests__/page.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -383,7 +383,7 @@ describe('ReputationPageContent', () => {
mockShouldThrowInProfile = true;
rerender(<ReputationPageContent reputationData={data} userName="Fail" />);

expect(screen.getByText('This section failed to load.')).toBeInTheDocument();
expect(screen.getByText(/the reputation section couldn.t load/i)).toBeInTheDocument();
expect(screen.queryByTestId('reputation-profile')).not.toBeInTheDocument();
consoleSpy.mockRestore();
});
Expand All @@ -394,15 +394,15 @@ describe('ReputationPageContent', () => {
const data = { score: 85, level: 'Trusted', history: [{ id: '1', type: 'Review', summary: 'Great work', date: '2026-04-24' }] };

const { rerender } = render(<ReputationPageContent reputationData={data} userName="Recover" />);
expect(screen.getByText('This section failed to load.')).toBeInTheDocument();
expect(screen.getByText(/the reputation section couldn.t load/i)).toBeInTheDocument();

mockShouldThrowInProfile = false;
rerender(<ReputationPageContent reputationData={data} userName="Recover" />);

// Error boundary requires explicit Retry click to reset
fireEvent.click(screen.getByText('Retry'));

expect(screen.queryByText('This section failed to load.')).not.toBeInTheDocument();
expect(screen.queryByText(/the reputation section couldn.t load/i)).not.toBeInTheDocument();
expect(screen.getByTestId('reputation-profile')).toBeInTheDocument();
consoleSpy.mockRestore();
});
Expand All @@ -412,13 +412,13 @@ describe('ReputationPageContent', () => {

const { rerender } = render(<ReputationPageContent reputationData={null} />);
expect(screen.getByText('No reputation yet')).toBeInTheDocument();
expect(screen.queryByText('This section failed to load.')).not.toBeInTheDocument();
expect(screen.queryByText(/the reputation section couldn.t load/i)).not.toBeInTheDocument();

mockShouldThrowInProfile = true;
const data = { score: 85, level: 'Trusted', history: [{ id: '1', type: 'Review', summary: 'Great work', date: '2026-04-24' }] };
rerender(<ReputationPageContent reputationData={data} userName="Fail" />);

expect(screen.getByText('This section failed to load.')).toBeInTheDocument();
expect(screen.getByText(/the reputation section couldn.t load/i)).toBeInTheDocument();
expect(screen.queryByText('No reputation yet')).not.toBeInTheDocument();
expect(screen.queryByTestId('reputation-profile')).not.toBeInTheDocument();
consoleSpy.mockRestore();
Expand All @@ -430,17 +430,17 @@ describe('ReputationPageContent', () => {
const data = { score: 85, level: 'Trusted', history: [{ id: '1', type: 'Review', summary: 'Great work', date: '2026-04-24' }] };

const { rerender } = render(<ReputationPageContent reputationData={data} userName="ToEmpty" />);
expect(screen.getByText('This section failed to load.')).toBeInTheDocument();
expect(screen.getByText(/the reputation section couldn.t load/i)).toBeInTheDocument();

mockShouldThrowInProfile = false;
rerender(<ReputationPageContent reputationData={null} />);

// Error boundary still has error, requires explicit Retry click to reset
expect(screen.getByText('This section failed to load.')).toBeInTheDocument();
expect(screen.getByText(/the reputation section couldn.t load/i)).toBeInTheDocument();

fireEvent.click(screen.getByText('Retry'));

expect(screen.queryByText('This section failed to load.')).not.toBeInTheDocument();
expect(screen.queryByText(/the reputation section couldn.t load/i)).not.toBeInTheDocument();
expect(screen.getByText('No reputation yet')).toBeInTheDocument();
expect(screen.queryByTestId('reputation-profile')).not.toBeInTheDocument();
consoleSpy.mockRestore();
Expand All @@ -465,7 +465,7 @@ describe('ReputationPageContent', () => {
expect(screen.getByTestId('reputation-profile')).toBeInTheDocument();
expect(screen.queryByRole('status')).not.toBeInTheDocument();
expect(screen.queryByText('No reputation yet')).not.toBeInTheDocument();
expect(screen.queryByText('This section failed to load.')).not.toBeInTheDocument();
expect(screen.queryByText(/the reputation section couldn.t load/i)).not.toBeInTheDocument();
});

it('transitions from loading to empty when no reputation data arrives', () => {
Expand All @@ -479,7 +479,7 @@ describe('ReputationPageContent', () => {
expect(screen.getByText('No reputation yet')).toBeInTheDocument();
expect(screen.queryByRole('status')).not.toBeInTheDocument();
expect(screen.queryByTestId('reputation-profile')).not.toBeInTheDocument();
expect(screen.queryByText('This section failed to load.')).not.toBeInTheDocument();
expect(screen.queryByText(/the reputation section couldn.t load/i)).not.toBeInTheDocument();
});

it('transitions from loading to error when content fails', () => {
Expand All @@ -498,7 +498,7 @@ describe('ReputationPageContent', () => {
};
render(<ReputationPageContent reputationData={data} userName="FailLoad" />);

expect(screen.getByText('This section failed to load.')).toBeInTheDocument();
expect(screen.getByText(/the reputation section couldn.t load/i)).toBeInTheDocument();
expect(screen.queryByRole('status')).not.toBeInTheDocument();
expect(screen.queryByTestId('reputation-profile')).not.toBeInTheDocument();
expect(screen.queryByText('No reputation yet')).not.toBeInTheDocument();
Expand Down Expand Up @@ -576,15 +576,15 @@ describe('ReputationPageContent', () => {
const data = { score: 75, level: 'Contributor', history: [] };
render(<ReputationPageContent reputationData={data} />);

expect(screen.getByText('This section failed to load.')).toBeInTheDocument();
expect(screen.getByText(/the reputation section couldn.t load/i)).toBeInTheDocument();
expect(screen.getByText('Retry')).toBeInTheDocument();
expect(screen.getByText('Go Home')).toBeInTheDocument();
expect(screen.queryByText('Go Home')).not.toBeInTheDocument();
expect(screen.queryByText('No reputation yet')).not.toBeInTheDocument();
expect(screen.queryByTestId('reputation-profile')).not.toBeInTheDocument();
});
});

describe('Error State — SafeBoundary fallback', () => {
describe('Error State — ReputationErrorBoundary fallback', () => {
let consoleSpy: jest.SpyInstance;

beforeEach(() => {
Expand All @@ -600,7 +600,7 @@ describe('ReputationPageContent', () => {
const data = { score: 100, level: 'Expert', history: [{ id: '1', type: 'Test', summary: 'Test', date: '2026-04-24' }] };
render(<ReputationPageContent reputationData={data} userName="ErrorUser" />);

expect(screen.getByText('This section failed to load.')).toBeInTheDocument();
expect(screen.getByText(/the reputation section couldn.t load/i)).toBeInTheDocument();
});

it('renders Retry button in error fallback', () => {
Expand All @@ -611,20 +611,20 @@ describe('ReputationPageContent', () => {
expect(screen.getByText('Retry')).toBeInTheDocument();
});

it('renders Go Home link in error fallback', () => {
it('does not render Go Home link in error fallback', () => {
mockShouldThrowInProfile = true;
const data = { score: 100, level: 'Expert', history: [] };
render(<ReputationPageContent reputationData={data} />);

expect(screen.getByText('Go Home')).toBeInTheDocument();
expect(screen.queryByText('Go Home')).not.toBeInTheDocument();
});

it('does not render empty state or profile content when in error', () => {
mockShouldThrowInProfile = true;
const data = { score: 100, level: 'Expert', history: [] };
render(<ReputationPageContent reputationData={data} />);

expect(screen.getByText('This section failed to load.')).toBeInTheDocument();
expect(screen.getByText(/the reputation section couldn.t load/i)).toBeInTheDocument();
expect(screen.queryByText('No reputation yet')).not.toBeInTheDocument();
expect(screen.queryByTestId('reputation-profile')).not.toBeInTheDocument();
expect(screen.queryByText(/Your reputation will be built/i)).not.toBeInTheDocument();
Expand All @@ -635,13 +635,13 @@ describe('ReputationPageContent', () => {
const data = { score: 85, level: 'Trusted', history: [{ id: '1', type: 'Review', summary: 'Great work', date: '2026-04-24' }] };
render(<ReputationPageContent reputationData={data} userName="RetryUser" />);

expect(screen.getByText('This section failed to load.')).toBeInTheDocument();
expect(screen.getByText(/the reputation section couldn.t load/i)).toBeInTheDocument();

// Resolve the error and click Retry to reset the error boundary
mockShouldThrowInProfile = false;
fireEvent.click(screen.getByText('Retry'));

expect(screen.queryByText('This section failed to load.')).not.toBeInTheDocument();
expect(screen.queryByText(/the reputation section couldn.t load/i)).not.toBeInTheDocument();
expect(screen.getByTestId('reputation-profile')).toBeInTheDocument();
});
});
Expand Down
142 changes: 142 additions & 0 deletions src/components/ReputationErrorBoundary.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
'use client';

import React, { Component, type ReactNode } from 'react';
import { reportError } from '@/lib/errorReporter';

// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------

export interface ReputationErrorBoundaryProps {
/** Content to protect. */
children: ReactNode;
/**
* Optional override for the fallback UI. When provided it replaces the
* built-in accessible fallback entirely — the consumer is responsible for
* rendering a retry affordance if desired.
*/
fallback?: ReactNode;
/**
* Optional callback fired after an error is caught, in addition to the
* internal `reportError` call. Useful for testing or custom instrumentation.
*/
onError?: (error: Error, info: React.ErrorInfo) => void;
}

interface State {
hasError: boolean;
error: Error | null;
}

// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------

/**
* ReputationErrorBoundary
*
* An error boundary scoped to the reputation section. When a descendant
* throws during render, the boundary:
* 1. Reports the error via `reportError` (the existing error-reporter seam).
* 2. Renders an accessible fallback UI with a visible "Retry" button.
* 3. Resets its own state on retry so the reputation section re-mounts.
*
* Accessibility:
* - The fallback container uses `role="alert"` so assistive-technology users
* are immediately informed that the section failed.
* - The "Retry" button receives focus automatically (via `autoFocus`)
* when the fallback renders, providing a clear keyboard entry point.
* - All interactive elements meet WCAG 2.4.7 focus-visible requirements.
*/
export default class ReputationErrorBoundary extends Component<
ReputationErrorBoundaryProps,
State
> {
state: State = { hasError: false, error: null };

// ------------------------------------------------------------------
// Lifecycle
// ------------------------------------------------------------------

static getDerivedStateFromError(error: Error): State {
return { hasError: true, error };
}

componentDidCatch(error: Error, info: React.ErrorInfo): void {
reportError(error, 'ReputationErrorBoundary', 'error', {
componentStack: info.componentStack ?? undefined,
});
this.props.onError?.(error, info);
}

// ------------------------------------------------------------------
// Handlers
// ------------------------------------------------------------------

/** Clears the error state so the children are re-mounted on the next render. */
handleRetry = (): void => {
this.setState({ hasError: false, error: null });
};

// ------------------------------------------------------------------
// Render
// ------------------------------------------------------------------

render(): ReactNode {
const { hasError, error } = this.state;
const { children, fallback } = this.props;

if (!hasError) {
return children;
}

// Custom fallback supplied by the consumer takes full precedence.
if (fallback !== undefined) {
return fallback;
}

// ----------------------------------------------------------------
// Built-in accessible fallback
// ----------------------------------------------------------------
return (
<div
role="alert"
aria-live="assertive"
aria-atomic="true"
className="rounded-2xl border border-red-200 bg-red-50 p-6 text-center shadow-sm"
>
<p className="text-base font-semibold text-red-800">
The reputation section couldn&rsquo;t load.
</p>

{error?.message && (
<p
className="mt-1 text-sm text-red-600"
data-testid="reputation-error-message"
>
{error.message}
</p>
)}

<p className="mt-2 text-sm text-red-700">
This is likely a temporary issue. Use the button below to try again.
</p>

<button
type="button"
autoFocus
onClick={this.handleRetry}
className={[
'mt-4 inline-flex items-center gap-2 rounded-xl',
'bg-red-700 px-4 py-2 text-sm font-semibold text-white',
'transition hover:bg-red-800',
'focus-visible:outline focus-visible:outline-4',
'focus-visible:outline-offset-2 focus-visible:outline-red-600',
].join(' ')}
>
Retry
</button>
</div>
);
}
}
Loading
Loading