Skip to content

Commit a51ab16

Browse files
authored
Merge pull request Junirezz#998 from ToryMic/fix/912-frontend-add-frontend-error-boundary-with-user-safe-fallback-ui
[912] Frontend: Add frontend error boundary with user-safe fallback UI
2 parents 5c40afd + 780cb0a commit a51ab16

8 files changed

Lines changed: 226 additions & 31 deletions

File tree

frontend/SENTRY_GUIDE.md

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -42,15 +42,19 @@ main.tsx
4242

4343
### Error Boundary (`src/App.tsx`)
4444

45-
The root `<Sentry.ErrorBoundary>` wraps the entire application:
45+
The app uses a layered boundary so users never see a blank screen or raw stack traces:
4646

4747
```tsx
48-
<Sentry.ErrorBoundary fallback={<ErrorFallback />} showDialog>
49-
<App />
48+
<Sentry.ErrorBoundary fallback={} showDialog={false}>
49+
<ErrorBoundary>
50+
<App />
51+
</ErrorBoundary>
5052
</Sentry.ErrorBoundary>
5153
```
5254

53-
This automatically captures and reports any unhandled React render errors.
55+
- `ErrorBoundary` (`src/components/ErrorBoundary.tsx`) catches render errors even when Sentry is not configured.
56+
- `ErrorFallback` shows a fixed, user-safe message with **Try Again** / Reload / Go Home. Technical `error.message` values are not shown in production.
57+
- Sentry’s native crash dialog is disabled (`showDialog={false}`) to keep the fallback UI consistent.
5458

5559
### Router Integration (`src/App.tsx`)
5660

frontend/src/App.tsx

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import {
2323
loadVaultFormDraft,
2424
type VaultFormDraft,
2525
} from "./lib/formDraftStorage";
26+
import ErrorBoundary from "./components/ErrorBoundary";
2627
import ErrorFallback from "./components/ErrorFallback";
2728
import RouteLoadingFallback from "./components/RouteLoadingFallback";
2829
import {
@@ -227,15 +228,17 @@ function App() {
227228
resetError={props.resetError}
228229
/>
229230
)}
230-
showDialog
231+
showDialog={false}
231232
>
232-
<AuthProvider>
233-
<FeatureFlagProvider>
234-
<VaultProvider>
235-
<AppContent />
236-
</VaultProvider>
237-
</FeatureFlagProvider>
238-
</AuthProvider>
233+
<ErrorBoundary>
234+
<AuthProvider>
235+
<FeatureFlagProvider>
236+
<VaultProvider>
237+
<AppContent />
238+
</VaultProvider>
239+
</FeatureFlagProvider>
240+
</AuthProvider>
241+
</ErrorBoundary>
239242
</Sentry.ErrorBoundary>
240243
);
241244
}
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import { describe, it, expect, vi } from "vitest";
2+
import { render, screen, fireEvent } from "@testing-library/react";
3+
import { ErrorBoundary } from "./ErrorBoundary";
4+
5+
function Boom({ shouldThrow }: { shouldThrow: boolean }) {
6+
if (shouldThrow) {
7+
throw new Error("TypeError: boom");
8+
}
9+
return <div>ok</div>;
10+
}
11+
12+
describe("ErrorBoundary", () => {
13+
it("renders children when there is no error", () => {
14+
render(
15+
<ErrorBoundary>
16+
<div>healthy</div>
17+
</ErrorBoundary>,
18+
);
19+
expect(screen.getByText("healthy")).toBeDefined();
20+
});
21+
22+
it("shows user-safe fallback when a child throws", () => {
23+
const spy = vi.spyOn(console, "error").mockImplementation(() => undefined);
24+
25+
render(
26+
<ErrorBoundary>
27+
<Boom shouldThrow />
28+
</ErrorBoundary>,
29+
);
30+
31+
expect(screen.getByRole("alert")).toBeDefined();
32+
expect(screen.getByText("Something went wrong")).toBeDefined();
33+
expect(screen.queryByText(/TypeError: boom/)).toBeNull();
34+
35+
spy.mockRestore();
36+
});
37+
38+
it("recovers when Try Again is clicked after the child stops throwing", () => {
39+
const spy = vi.spyOn(console, "error").mockImplementation(() => undefined);
40+
let shouldThrow = true;
41+
42+
const { rerender } = render(
43+
<ErrorBoundary>
44+
<Boom shouldThrow={shouldThrow} />
45+
</ErrorBoundary>,
46+
);
47+
48+
expect(screen.getByRole("alert")).toBeDefined();
49+
50+
shouldThrow = false;
51+
rerender(
52+
<ErrorBoundary>
53+
<Boom shouldThrow={shouldThrow} />
54+
</ErrorBoundary>,
55+
);
56+
// Boundary still holds error state until reset
57+
expect(screen.getByRole("alert")).toBeDefined();
58+
fireEvent.click(screen.getByText("Try Again"));
59+
60+
expect(screen.getByText("ok")).toBeDefined();
61+
spy.mockRestore();
62+
});
63+
64+
it("invokes onError when a child throws", () => {
65+
const spy = vi.spyOn(console, "error").mockImplementation(() => undefined);
66+
const onError = vi.fn();
67+
68+
render(
69+
<ErrorBoundary onError={onError}>
70+
<Boom shouldThrow />
71+
</ErrorBoundary>,
72+
);
73+
74+
expect(onError).toHaveBeenCalled();
75+
expect(onError.mock.calls[0][0]).toBeInstanceOf(Error);
76+
spy.mockRestore();
77+
});
78+
});
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import { Component, type ErrorInfo, type ReactNode } from "react";
2+
import ErrorFallback from "./ErrorFallback";
3+
4+
export interface ErrorBoundaryProps {
5+
children: ReactNode;
6+
fallback?: (props: { error: Error; resetError: () => void }) => ReactNode;
7+
onError?: (error: Error, info: ErrorInfo) => void;
8+
}
9+
10+
interface ErrorBoundaryState {
11+
error: Error | null;
12+
}
13+
14+
/**
15+
* React error boundary with a user-safe fallback UI.
16+
* Works without Sentry so render failures never blank the app.
17+
*/
18+
export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
19+
state: ErrorBoundaryState = { error: null };
20+
21+
static getDerivedStateFromError(error: unknown): ErrorBoundaryState {
22+
return {
23+
error: error instanceof Error ? error : new Error(String(error)),
24+
};
25+
}
26+
27+
componentDidCatch(error: Error, info: ErrorInfo): void {
28+
this.props.onError?.(error, info);
29+
}
30+
31+
resetError = (): void => {
32+
this.setState({ error: null });
33+
};
34+
35+
render(): ReactNode {
36+
const { error } = this.state;
37+
if (!error) {
38+
return this.props.children;
39+
}
40+
41+
if (this.props.fallback) {
42+
return this.props.fallback({ error, resetError: this.resetError });
43+
}
44+
45+
return <ErrorFallback error={error} resetError={this.resetError} />;
46+
}
47+
}
48+
49+
export default ErrorBoundary;

frontend/src/components/ErrorFallback.test.tsx

Lines changed: 39 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -7,33 +7,58 @@ describe('ErrorFallback', () => {
77
const mockError = new Error('Test error message');
88
const mockResetError = vi.fn();
99

10-
it('renders error message', () => {
11-
render(<ErrorFallback error={mockError} resetError={mockResetError} />);
12-
10+
it('renders a user-safe message without exposing technical errors by default', () => {
11+
render(
12+
<ErrorFallback
13+
error={new Error('TypeError: Cannot read properties of null')}
14+
resetError={mockResetError}
15+
showErrorDetail={false}
16+
/>,
17+
);
18+
19+
expect(screen.getByRole('alert')).toBeDefined();
1320
expect(screen.getByText('Something went wrong')).toBeDefined();
14-
expect(screen.getByText('Test error message')).toBeDefined();
21+
expect(screen.queryByTestId('error-fallback-detail')).toBeNull();
22+
expect(screen.queryByText(/TypeError/)).toBeNull();
23+
});
24+
25+
it('calls resetError when Try Again is clicked', () => {
26+
render(<ErrorFallback error={mockError} resetError={mockResetError} showErrorDetail={false} />);
27+
28+
fireEvent.click(screen.getByText('Try Again'));
29+
expect(mockResetError).toHaveBeenCalled();
1530
});
1631

1732
it('calls reload when reload button is clicked', () => {
1833
const reloadSpy = vi.spyOn(ErrorNavigation, 'reloadPage').mockImplementation(() => undefined);
1934

20-
render(<ErrorFallback error={mockError} resetError={mockResetError} onReload={reloadSpy} />);
21-
22-
const reloadButton = screen.getByText('Reload Page');
23-
fireEvent.click(reloadButton);
24-
35+
render(
36+
<ErrorFallback
37+
error={mockError}
38+
resetError={mockResetError}
39+
onReload={reloadSpy}
40+
showErrorDetail={false}
41+
/>,
42+
);
43+
44+
fireEvent.click(screen.getByText('Reload Page'));
2545
expect(reloadSpy).toHaveBeenCalled();
2646
reloadSpy.mockRestore();
2747
});
2848

2949
it('navigates to home when Go Home button is clicked', () => {
3050
const assignSpy = vi.spyOn(ErrorNavigation, 'goHome').mockImplementation(() => undefined);
3151

32-
render(<ErrorFallback error={mockError} resetError={mockResetError} onGoHome={assignSpy} />);
33-
34-
const homeButton = screen.getByText('Go Home');
35-
fireEvent.click(homeButton);
36-
52+
render(
53+
<ErrorFallback
54+
error={mockError}
55+
resetError={mockResetError}
56+
onGoHome={assignSpy}
57+
showErrorDetail={false}
58+
/>,
59+
);
60+
61+
fireEvent.click(screen.getByText('Go Home'));
3762
expect(assignSpy).toHaveBeenCalled();
3863
assignSpy.mockRestore();
3964
});

frontend/src/components/ErrorFallback.tsx

Lines changed: 39 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,16 +8,37 @@ interface ErrorFallbackProps {
88
resetError: () => void;
99
onReload?: () => void;
1010
onGoHome?: () => void;
11+
/** When true, show a sanitized one-line detail (dev only by default). */
12+
showErrorDetail?: boolean;
13+
}
14+
15+
function isSafeUserFacingDetail(message: string): boolean {
16+
const trimmed = message.trim();
17+
if (!trimmed || trimmed.length > 160) return false;
18+
// Block stack-like / path / secret-looking content
19+
if (/[/\\]|\.tsx?\b|\.jsx?\b|at\s+\S+|Error:|TypeError|ReferenceError|http/i.test(trimmed)) {
20+
return false;
21+
}
22+
return true;
1123
}
1224

1325
const ErrorFallback: React.FC<ErrorFallbackProps> = ({
1426
error,
27+
resetError,
1528
onReload = reloadPage,
1629
onGoHome = goHome,
30+
showErrorDetail = import.meta.env.DEV,
1731
}) => {
1832
const { t } = useTranslation();
33+
const detail =
34+
showErrorDetail && error?.message && isSafeUserFacingDetail(error.message)
35+
? error.message
36+
: null;
37+
1938
return (
2039
<div
40+
role="alert"
41+
aria-live="assertive"
2142
style={{
2243
display: "flex",
2344
alignItems: "center",
@@ -51,7 +72,7 @@ const ErrorFallback: React.FC<ErrorFallbackProps> = ({
5172
marginBottom: "8px",
5273
}}
5374
>
54-
<AlertOctagon size={48} />
75+
<AlertOctagon size={48} aria-hidden="true" />
5576
</div>
5677

5778
<div>
@@ -71,8 +92,9 @@ const ErrorFallback: React.FC<ErrorFallbackProps> = ({
7192
>
7293
{t("errorFallback.message")}
7394
</p>
74-
{error?.message && (
95+
{detail && (
7596
<div
97+
data-testid="error-fallback-detail"
7698
style={{
7799
background: "rgba(0,0,0,0.2)",
78100
border: "1px solid var(--border-glass)",
@@ -87,7 +109,7 @@ const ErrorFallback: React.FC<ErrorFallbackProps> = ({
87109
fontFamily: "monospace",
88110
}}
89111
>
90-
{error.message}
112+
{detail}
91113
</div>
92114
)}
93115
</div>
@@ -102,20 +124,32 @@ const ErrorFallback: React.FC<ErrorFallbackProps> = ({
102124
}}
103125
>
104126
<button
127+
type="button"
105128
className="btn btn-primary"
129+
onClick={resetError}
130+
style={{ width: "100%", padding: "14px" }}
131+
>
132+
<RefreshCw size={18} aria-hidden="true" />
133+
{t("errorFallback.tryAgain")}
134+
</button>
135+
136+
<button
137+
type="button"
138+
className="btn btn-outline"
106139
onClick={onReload}
107140
style={{ width: "100%", padding: "14px" }}
108141
>
109-
<RefreshCw size={18} />
142+
<RefreshCw size={18} aria-hidden="true" />
110143
{t("errorFallback.reload")}
111144
</button>
112145

113146
<button
147+
type="button"
114148
className="btn btn-outline"
115149
onClick={onGoHome}
116150
style={{ width: "100%", padding: "14px" }}
117151
>
118-
<Home size={18} />
152+
<Home size={18} aria-hidden="true" />
119153
{t("errorFallback.goHome")}
120154
</button>
121155
</div>

frontend/src/i18n/locales/en.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,7 @@ export const en = {
182182
errorFallback: {
183183
title: "Something went wrong",
184184
message: "We've encountered an unexpected issue. Our team has been notified and is working on it.",
185+
tryAgain: "Try Again",
185186
reload: "Reload Page",
186187
goHome: "Go Home",
187188
},

frontend/src/i18n/locales/es.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,7 @@ export const es = {
182182
errorFallback: {
183183
title: "Algo salió mal",
184184
message: "Encontramos un problema inesperado. Nuestro equipo ha sido notificado y está trabajando en ello.",
185+
tryAgain: "Intentar de nuevo",
185186
reload: "Recargar página",
186187
goHome: "Ir al inicio",
187188
},

0 commit comments

Comments
 (0)