From 3f42498c4f6052e2770ac62259120790fa3bb587 Mon Sep 17 00:00:00 2001 From: "Adebanjo Abraham.I" Date: Sat, 29 Aug 2026 12:12:43 +0100 Subject: [PATCH] fix(dashboard): wrap each screen in its own ErrorBoundary A crash in one screen (e.g. TransactionHistory) previously tore down the entire Dashboard, including the Sidebar and TopBar, because only a single root-level ErrorBoundary existed in main.tsx. Each screen rendered by Dashboard now gets its own isolate-mode ErrorBoundary with a scoped fallback that names the crashed screen and offers a Retry button, which clears the boundary and re-mounts only that screen. The root ErrorBoundary in main.tsx is unchanged and still catches anything above Dashboard as a last resort. Closes #564 --- src/screens/Dashboard.test.tsx | 70 +++++++++++++++++++++++++++++++++- src/screens/Dashboard.tsx | 62 ++++++++++++++++++++++++++++-- 2 files changed, 128 insertions(+), 4 deletions(-) diff --git a/src/screens/Dashboard.test.tsx b/src/screens/Dashboard.test.tsx index d4260fa..de37900 100644 --- a/src/screens/Dashboard.test.tsx +++ b/src/screens/Dashboard.test.tsx @@ -5,6 +5,21 @@ import type { NavSection } from "@/components/Sidebar"; import { Dashboard } from "./Dashboard"; +// Toggled from within a test to make the mocked TransactionsScreen throw on +// render, then recover once cleared — used by the per-screen ErrorBoundary +// tests below. +const { getTransactionsShouldThrow, setTransactionsShouldThrow } = vi.hoisted( + () => { + let shouldThrow = false; + return { + getTransactionsShouldThrow: () => shouldThrow, + setTransactionsShouldThrow: (value: boolean) => { + shouldThrow = value; + }, + }; + }, +); + // Dashboard composes every screen; stub the chrome and screens so these tests // cover only Dashboard's own controlled/uncontrolled section logic. vi.mock("@/components/Sidebar", () => ({ @@ -47,7 +62,12 @@ vi.mock("@/screens/AccountScreen", () => ({ AccountScreen: stubScreen("account"), })); vi.mock("@/screens/TransactionsScreen", () => ({ - TransactionsScreen: stubScreen("transactions"), + TransactionsScreen: () => { + if (getTransactionsShouldThrow()) { + throw new Error("boom"); + } + return
transactions screen
; + }, })); vi.mock("@/screens/SorobanScreen", () => ({ SorobanScreen: stubScreen("soroban"), @@ -75,6 +95,7 @@ describe("Dashboard", () => { beforeEach(() => { vi.clearAllMocks(); localStorage.clear(); + setTransactionsShouldThrow(false); }); describe("uncontrolled mode", () => { @@ -181,4 +202,51 @@ describe("Dashboard", () => { expect(screen.getByTestId("topbar-active")).toHaveTextContent("soroban"); }); }); + + describe("per-screen error boundaries (#564)", () => { + beforeEach(() => { + vi.spyOn(console, "error").mockImplementation(() => {}); + }); + + it("does not bring down the Sidebar or TopBar when a screen crashes", () => { + setTransactionsShouldThrow(true); + render(); + + expect(screen.getByLabelText("Main navigation")).toBeInTheDocument(); + expect(screen.getByTestId("topbar-active")).toBeInTheDocument(); + expect(screen.getByText(/Transactions couldn't load/)).toBeInTheDocument(); + }); + + it("shows the screen name and a Retry button in the fallback", () => { + setTransactionsShouldThrow(true); + render(); + + expect(screen.getByText("Transactions couldn't load")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Retry" })).toBeInTheDocument(); + }); + + it("recovers and re-mounts only the affected screen when Retry is clicked", async () => { + setTransactionsShouldThrow(true); + render(); + expect(screen.getByRole("button", { name: "Retry" })).toBeInTheDocument(); + + setTransactionsShouldThrow(false); + fireEvent.click(screen.getByRole("button", { name: "Retry" })); + + expect( + await screen.findByTestId("screen-transactions"), + ).toBeInTheDocument(); + // The chrome was never affected by the crash or the retry. + expect(screen.getByLabelText("Main navigation")).toBeInTheDocument(); + }); + + it("leaves other screens working while a different screen is crashed", async () => { + setTransactionsShouldThrow(true); + render(); + expect(screen.getByText("Transactions couldn't load")).toBeInTheDocument(); + + fireEvent.click(screen.getByRole("button", { name: "wallet" })); + expect(await screen.findByTestId("screen-wallet")).toBeInTheDocument(); + }); + }); }); diff --git a/src/screens/Dashboard.tsx b/src/screens/Dashboard.tsx index 317d8b4..84e017b 100644 --- a/src/screens/Dashboard.tsx +++ b/src/screens/Dashboard.tsx @@ -1,5 +1,6 @@ import { type ComponentType, lazy, Suspense, useCallback, useEffect, useState } from "react"; +import { ErrorBoundary } from "@/components/ErrorBoundary"; import { NetworkBanner } from "@/components/NetworkBanner"; import { type NavSection, Sidebar } from "@/components/Sidebar"; import { TopBar } from "@/components/TopBar"; @@ -63,6 +64,51 @@ const SCREENS: Record = { nfts: NFTScreen, }; +const SCREEN_LABELS: Record = { + wallet: "Wallet", + account: "Account", + transactions: "Transactions", + soroban: "Soroban", + network: "Network", + recovery: "Recovery", + charts: "Charts", + farming: "Yield Farming", + budget: "Budget", + nfts: "NFTs", +}; + +/** + * Fallback shown when a single screen's ErrorBoundary catches a render + * error. Scoped to the screen's own area — Sidebar, TopBar, and the other + * (hidden) screens are rendered by Dashboard outside this boundary, so a + * crash here never tears down the rest of the shell. + */ +function ScreenErrorFallback({ + screenName, + onRetry, +}: { + screenName: string; + onRetry: () => void; +}) { + return ( +
+

+ {screenName} couldn't load +

+

+ Something went wrong rendering this screen. The rest of the dashboard + is unaffected. +

+ +
+ ); +} + export interface DashboardProps { /** Max width of the main content column. Defaults to "700px". */ maxContentWidth?: string; @@ -143,9 +189,19 @@ export function Dashboard({ hidden={section !== active} data-testid={`screen-wrapper-${section}`} > - - - + ( + + )} + > + + + + ); })}