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}`}
>
-
-
-
+ (
+
+ )}
+ >
+
+
+
+
);
})}