Skip to content
Open
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
70 changes: 69 additions & 1 deletion src/screens/Dashboard.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => ({
Expand Down Expand Up @@ -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 <div data-testid="screen-transactions">transactions screen</div>;
},
}));
vi.mock("@/screens/SorobanScreen", () => ({
SorobanScreen: stubScreen("soroban"),
Expand Down Expand Up @@ -75,6 +95,7 @@ describe("Dashboard", () => {
beforeEach(() => {
vi.clearAllMocks();
localStorage.clear();
setTransactionsShouldThrow(false);
});

describe("uncontrolled mode", () => {
Expand Down Expand Up @@ -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(<Dashboard defaultSection="transactions" />);

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(<Dashboard defaultSection="transactions" />);

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(<Dashboard defaultSection="transactions" />);
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(<Dashboard defaultSection="transactions" />);
expect(screen.getByText("Transactions couldn't load")).toBeInTheDocument();

fireEvent.click(screen.getByRole("button", { name: "wallet" }));
expect(await screen.findByTestId("screen-wallet")).toBeInTheDocument();
});
});
});
62 changes: 59 additions & 3 deletions src/screens/Dashboard.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -63,6 +64,51 @@ const SCREENS: Record<NavSection, ComponentType> = {
nfts: NFTScreen,
};

const SCREEN_LABELS: Record<NavSection, string> = {
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 (
<div className="flex flex-col items-center gap-3 py-10 text-center">
<p className="text-[13px] font-medium text-ink">
{screenName} couldn't load
</p>
<p className="text-[12px] text-ink-3">
Something went wrong rendering this screen. The rest of the dashboard
is unaffected.
</p>
<button
onClick={onRetry}
className="inline-flex items-center h-8 px-3.5 rounded-lg bg-surface-2 border border-line hover:border-line-2 text-[12px] text-ink-2 transition-colors cursor-pointer"
>
Retry
</button>
</div>
);
}

export interface DashboardProps {
/** Max width of the main content column. Defaults to "700px". */
maxContentWidth?: string;
Expand Down Expand Up @@ -143,9 +189,19 @@ export function Dashboard({
hidden={section !== active}
data-testid={`screen-wrapper-${section}`}
>
<Suspense fallback={null}>
<Screen />
</Suspense>
<ErrorBoundary
isolate
fallback={(_error, reset) => (
<ScreenErrorFallback
screenName={SCREEN_LABELS[section]}
onRetry={reset}
/>
)}
>
<Suspense fallback={null}>
<Screen />
</Suspense>
</ErrorBoundary>
</div>
);
})}
Expand Down