Skip to content

Commit 24a643e

Browse files
authored
Merge pull request #1262 from solaawojobi00-bit/fix/issue-1242-route-error-boundaries
Add per-route error boundaries with Sentry reporting (#1242)
2 parents d175617 + 9bf5d81 commit 24a643e

3 files changed

Lines changed: 167 additions & 17 deletions

File tree

frontend/src/App.tsx

Lines changed: 69 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,9 @@ import {
3131
} from "./lib/formDraftStorage";
3232
import ErrorBoundary from "./components/ErrorBoundary";
3333
import ErrorFallback from "./components/ErrorFallback";
34+
import RouteErrorBoundary from "./components/RouteErrorBoundary";
3435
import RouteLoadingFallback from "./components/RouteLoadingFallback";
36+
import { captureException } from "./config/sentry";
3537
import {
3638
LazyAnalytics,
3739
LazyHome,
@@ -178,40 +180,90 @@ function AppContent() {
178180
<Route
179181
path="/"
180182
element={
181-
<LazyHome
182-
walletAddress={walletAddress}
183-
usdcBalance={usdcBalance}
184-
xlmBalance={xlmBalance}
185-
/>
183+
<RouteErrorBoundary routeName="home">
184+
<LazyHome
185+
walletAddress={walletAddress}
186+
usdcBalance={usdcBalance}
187+
xlmBalance={xlmBalance}
188+
/>
189+
</RouteErrorBoundary>
186190
}
187191
/>
188192
<Route
189193
path="/portfolio"
190194
element={
191-
<LazyPortfolio
192-
walletAddress={walletAddress}
193-
/>
195+
<RouteErrorBoundary routeName="portfolio">
196+
<LazyPortfolio
197+
walletAddress={walletAddress}
198+
/>
199+
</RouteErrorBoundary>
194200
}
195201
/>
196202
<Route
197203
path="/analytics"
198204
element={
199205
<FeatureGate flag="ANALYTICS_PAGE">
200-
<LazyAnalytics />
206+
<RouteErrorBoundary routeName="analytics">
207+
<LazyAnalytics />
208+
</RouteErrorBoundary>
201209
</FeatureGate>
202210
}
203211
/>
204-
<Route path="/transactions" element={<LazyTransactionHistory walletAddress={walletAddress} />} />
205-
<Route path="/compare" element={<LazyVaultComparison />} />
206-
<Route path="/strategies/:strategyId" element={<StrategyDetail walletAddress={walletAddress} />} />
207-
<Route path="/receipt/:txHash" element={<TransactionReceipt />} />
208-
<Route path="/settings" element={<LazySettings />} />
209-
<Route path="/ui-kit" element={<LazyUIPreview />} />
212+
<Route
213+
path="/transactions"
214+
element={
215+
<RouteErrorBoundary routeName="transactions">
216+
<LazyTransactionHistory walletAddress={walletAddress} />
217+
</RouteErrorBoundary>
218+
}
219+
/>
220+
<Route
221+
path="/compare"
222+
element={
223+
<RouteErrorBoundary routeName="vault-comparison">
224+
<LazyVaultComparison />
225+
</RouteErrorBoundary>
226+
}
227+
/>
228+
<Route
229+
path="/strategies/:strategyId"
230+
element={
231+
<RouteErrorBoundary routeName="strategy-detail">
232+
<StrategyDetail walletAddress={walletAddress} />
233+
</RouteErrorBoundary>
234+
}
235+
/>
236+
<Route
237+
path="/receipt/:txHash"
238+
element={
239+
<RouteErrorBoundary routeName="transaction-receipt">
240+
<TransactionReceipt />
241+
</RouteErrorBoundary>
242+
}
243+
/>
244+
<Route
245+
path="/settings"
246+
element={
247+
<RouteErrorBoundary routeName="settings">
248+
<LazySettings />
249+
</RouteErrorBoundary>
250+
}
251+
/>
252+
<Route
253+
path="/ui-kit"
254+
element={
255+
<RouteErrorBoundary routeName="ui-preview">
256+
<LazyUIPreview />
257+
</RouteErrorBoundary>
258+
}
259+
/>
210260
<Route
211261
path="/admin"
212262
element={
213263
<ProtectedRoute role={role} allow={["admin"]}>
214-
<Admin walletAddress={walletAddress} />
264+
<RouteErrorBoundary routeName="admin">
265+
<Admin walletAddress={walletAddress} />
266+
</RouteErrorBoundary>
215267
</ProtectedRoute>
216268
}
217269
/>
@@ -256,7 +308,7 @@ function App() {
256308
)}
257309
showDialog={false}
258310
>
259-
<ErrorBoundary>
311+
<ErrorBoundary onError={(error) => captureException(error, { route: "app-root" })}>
260312
<AuthProvider>
261313
<FeatureFlagProvider>
262314
<VaultProvider>
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import { describe, it, expect, vi, beforeEach } from "vitest";
2+
import { render, screen, fireEvent } from "@testing-library/react";
3+
import RouteErrorBoundary from "./RouteErrorBoundary";
4+
5+
const captureException = vi.fn();
6+
7+
vi.mock("../config/sentry", () => ({
8+
captureException: (...args: unknown[]) => captureException(...args),
9+
}));
10+
11+
function Boom({ shouldThrow }: { shouldThrow: boolean }) {
12+
if (shouldThrow) {
13+
throw new Error("page crashed");
14+
}
15+
return <div>page content</div>;
16+
}
17+
18+
describe("RouteErrorBoundary", () => {
19+
beforeEach(() => {
20+
captureException.mockClear();
21+
});
22+
23+
it("renders the page when it does not throw", () => {
24+
render(
25+
<RouteErrorBoundary routeName="home">
26+
<Boom shouldThrow={false} />
27+
</RouteErrorBoundary>,
28+
);
29+
30+
expect(screen.getByText("page content")).toBeDefined();
31+
expect(captureException).not.toHaveBeenCalled();
32+
});
33+
34+
it("shows a user-friendly fallback and reports to Sentry when the page throws", () => {
35+
const spy = vi.spyOn(console, "error").mockImplementation(() => undefined);
36+
37+
render(
38+
<RouteErrorBoundary routeName="home">
39+
<Boom shouldThrow />
40+
</RouteErrorBoundary>,
41+
);
42+
43+
expect(screen.getByRole("alert")).toBeDefined();
44+
expect(screen.getByText("Something went wrong")).toBeDefined();
45+
expect(captureException).toHaveBeenCalledTimes(1);
46+
expect(captureException.mock.calls[0][0]).toBeInstanceOf(Error);
47+
expect(captureException.mock.calls[0][1]).toEqual({ route: "home" });
48+
49+
spy.mockRestore();
50+
});
51+
52+
it("recovers via the retry button after the underlying error is resolved", () => {
53+
const spy = vi.spyOn(console, "error").mockImplementation(() => undefined);
54+
let shouldThrow = true;
55+
56+
const { rerender } = render(
57+
<RouteErrorBoundary routeName="home">
58+
<Boom shouldThrow={shouldThrow} />
59+
</RouteErrorBoundary>,
60+
);
61+
62+
expect(screen.getByRole("alert")).toBeDefined();
63+
64+
shouldThrow = false;
65+
rerender(
66+
<RouteErrorBoundary routeName="home">
67+
<Boom shouldThrow={shouldThrow} />
68+
</RouteErrorBoundary>,
69+
);
70+
71+
fireEvent.click(screen.getByText("Try Again"));
72+
73+
expect(screen.getByText("page content")).toBeDefined();
74+
spy.mockRestore();
75+
});
76+
});
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import type { ReactNode } from "react";
2+
import ErrorBoundary from "./ErrorBoundary";
3+
import { captureException } from "../config/sentry";
4+
5+
interface RouteErrorBoundaryProps {
6+
/** Identifies which route/page crashed in error monitoring. */
7+
routeName: string;
8+
children: ReactNode;
9+
}
10+
11+
/**
12+
* Isolates a single routed page so a render error there shows a
13+
* user-friendly fallback (with retry) instead of blanking the whole app,
14+
* and reports the failure to Sentry for monitoring.
15+
*/
16+
export default function RouteErrorBoundary({ routeName, children }: RouteErrorBoundaryProps) {
17+
return (
18+
<ErrorBoundary onError={(error) => captureException(error, { route: routeName })}>
19+
{children}
20+
</ErrorBoundary>
21+
);
22+
}

0 commit comments

Comments
 (0)