diff --git a/src/components/ErrorBoundary.test.tsx b/src/components/ErrorBoundary.test.tsx
index 2df1d7b..e4eb91e 100644
--- a/src/components/ErrorBoundary.test.tsx
+++ b/src/components/ErrorBoundary.test.tsx
@@ -171,6 +171,70 @@ describe("ErrorBoundary", () => {
expect(screen.getByText("Mounted successfully")).toBeInTheDocument();
});
+ it("calls onRetry when the user clicks try again", () => {
+ vi.spyOn(console, "error").mockImplementation(() => {});
+ const onRetry = vi.fn();
+
+ render(
+
+
+
+ );
+
+ expect(onRetry).not.toHaveBeenCalled();
+
+ fireEvent.click(screen.getByRole("button", { name: /try again/i }));
+
+ expect(onRetry).toHaveBeenCalledTimes(1);
+ });
+
+ it("calls onRetry from a custom fallback's reset callback", () => {
+ vi.spyOn(console, "error").mockImplementation(() => {});
+ const onRetry = vi.fn();
+
+ render(
+ (
+
+ )}
+ >
+
+
+ );
+
+ fireEvent.click(screen.getByText("Reset Custom"));
+
+ expect(onRetry).toHaveBeenCalledTimes(1);
+ });
+
+ it("runs onRetry before children re-mount so the retry uses fresh state", () => {
+ vi.spyOn(console, "error").mockImplementation(() => {});
+
+ let clientReady = false;
+ const reinitialise = vi.fn(() => {
+ clientReady = true;
+ });
+
+ const NeedsClient = () => {
+ if (!clientReady) throw new Error("Client not initialized");
+ return
Client ready
;
+ };
+
+ render(
+
+
+
+ );
+
+ expect(screen.getByText("Something went wrong")).toBeInTheDocument();
+
+ fireEvent.click(screen.getByRole("button", { name: /try again/i }));
+
+ expect(reinitialise).toHaveBeenCalledTimes(1);
+ expect(screen.getByTestId("ready")).toBeInTheDocument();
+ });
+
it("applies scoped container styling when isolate is true", () => {
vi.spyOn(console, "error").mockImplementation(() => {});
diff --git a/src/components/ErrorBoundary.tsx b/src/components/ErrorBoundary.tsx
index 0b4c729..7cb4e0b 100644
--- a/src/components/ErrorBoundary.tsx
+++ b/src/components/ErrorBoundary.tsx
@@ -10,6 +10,13 @@ interface Props {
fallback?: (error: Error, reset: () => void) => ReactNode;
/** Called when the boundary catches an error. */
onError?: (error: Error, info: ErrorInfo) => void;
+ /**
+ * Called when the user retries, before the boundary clears its error state.
+ * Use it to re-attempt the work that failed — e.g. re-initialising the
+ * Sorokit client after `createSorokitClient()` threw — so the retried render
+ * has a working dependency instead of throwing again immediately.
+ */
+ onRetry?: () => void;
/** Render fallback content as an in-page scoped panel instead of a full-page state. */
isolate?: boolean;
/** Optional support URL shown in the default fallback. */
@@ -42,11 +49,16 @@ export class ErrorBoundary extends Component {
}
}
- reset = () =>
+ reset = () => {
+ // Runs before the state clears so a re-initialisation attempt is already
+ // done by the time children re-mount under the new resetKey.
+ this.props.onRetry?.();
this.setState((state) => ({
error: null,
resetKey: state.resetKey + 1,
+ componentStack: null,
}));
+ };
render() {
const { error, resetKey, componentStack } = this.state;
diff --git a/src/main.tsx b/src/main.tsx
index af13911..9c9445a 100644
--- a/src/main.tsx
+++ b/src/main.tsx
@@ -1,17 +1,34 @@
import './index.css'
-import React from 'react'
+import React, { useCallback,useState } from 'react'
import ReactDOM from 'react-dom/client'
import App from './App.tsx'
+import { ErrorBoundary } from './components/ErrorBoundary'
import type { SorokitClient } from './lib/client.ts'
import { createMockClient } from './lib/mock-client'
// Initialize mock client for development
-const client = createMockClient() as SorokitClient
+const createClient = (): SorokitClient => createMockClient() as SorokitClient
+
+function Root() {
+ const [client, setClient] = useState(createClient)
+
+ // Retrying re-attempts client creation, so a boundary tripped by a failed
+ // initialisation comes back with a fresh client instead of the broken one.
+ const handleRetry = useCallback(() => {
+ setClient(createClient())
+ }, [])
+
+ return (
+
+
+
+ )
+}
ReactDOM.createRoot(document.getElementById('root')!).render(
-
+
,
)
diff --git a/src/screens/Dashboard.test.tsx b/src/screens/Dashboard.test.tsx
new file mode 100644
index 0000000..7f38c06
--- /dev/null
+++ b/src/screens/Dashboard.test.tsx
@@ -0,0 +1,155 @@
+import { fireEvent, render, screen } from "@testing-library/react";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+import type { NavSection } from "@/components/Sidebar";
+
+import { Dashboard } from "./Dashboard";
+
+// 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", () => ({
+ Sidebar: ({
+ active,
+ onNavigate,
+ }: {
+ active: NavSection;
+ onNavigate: (s: NavSection) => void;
+ }) => (
+
+ ),
+}));
+vi.mock("@/components/TopBar", () => ({
+ TopBar: ({ active }: { active: NavSection }) => (
+ {active}
+ ),
+}));
+vi.mock("@/components/NetworkBanner", () => ({
+ NetworkBanner: () => null,
+}));
+
+function stubScreen(name: string) {
+ return () => {name} screen
;
+}
+
+vi.mock("@/screens/WalletScreen", () => ({
+ WalletScreen: stubScreen("wallet"),
+}));
+vi.mock("@/screens/AccountScreen", () => ({
+ AccountScreen: stubScreen("account"),
+}));
+vi.mock("@/screens/TransactionsScreen", () => ({
+ TransactionsScreen: stubScreen("transactions"),
+}));
+vi.mock("@/screens/SorobanScreen", () => ({
+ SorobanScreen: stubScreen("soroban"),
+}));
+vi.mock("@/screens/NetworkScreen", () => ({
+ NetworkScreen: stubScreen("network"),
+}));
+vi.mock("@/screens/RecoveryScreen", () => ({
+ RecoveryScreen: stubScreen("recovery"),
+}));
+vi.mock("@/screens/ChartingScreen", () => ({
+ ChartingScreen: stubScreen("charts"),
+}));
+vi.mock("@/screens/YieldFarmingScreen", () => ({
+ YieldFarmingScreen: stubScreen("farming"),
+}));
+vi.mock("@/screens/BudgetScreen", () => ({
+ BudgetScreen: stubScreen("budget"),
+}));
+vi.mock("@/screens/NFTScreen", () => ({
+ NFTScreen: stubScreen("nfts"),
+}));
+
+describe("Dashboard", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ localStorage.clear();
+ });
+
+ describe("uncontrolled mode", () => {
+ it("starts on the Wallet screen by default", () => {
+ render();
+ expect(screen.getByTestId("screen-wallet")).toBeInTheDocument();
+ });
+
+ it("initialises to defaultSection when provided", () => {
+ render();
+ expect(screen.getByTestId("screen-soroban")).toBeInTheDocument();
+ expect(screen.queryByTestId("screen-wallet")).not.toBeInTheDocument();
+ });
+
+ it("changes the rendered screen when a nav item is clicked", () => {
+ render();
+ fireEvent.click(screen.getByRole("button", { name: "transactions" }));
+
+ expect(screen.getByTestId("screen-transactions")).toBeInTheDocument();
+ expect(screen.queryByTestId("screen-wallet")).not.toBeInTheDocument();
+ });
+
+ it("still reports navigation through onSectionChange", () => {
+ const onSectionChange = vi.fn();
+ render();
+
+ fireEvent.click(screen.getByRole("button", { name: "network" }));
+
+ expect(onSectionChange).toHaveBeenCalledWith("network");
+ expect(screen.getByTestId("screen-network")).toBeInTheDocument();
+ });
+ });
+
+ describe("controlled mode", () => {
+ it("renders the screen named by activeSection", () => {
+ render();
+ expect(screen.getByTestId("screen-transactions")).toBeInTheDocument();
+ expect(screen.queryByTestId("screen-wallet")).not.toBeInTheDocument();
+ });
+
+ it("fires onSectionChange but does not change the screen itself", () => {
+ const onSectionChange = vi.fn();
+ render(
+ ,
+ );
+
+ fireEvent.click(screen.getByRole("button", { name: "soroban" }));
+
+ expect(onSectionChange).toHaveBeenCalledWith("soroban");
+ // The parent owns the state, so the view is unchanged until it updates.
+ expect(screen.getByTestId("screen-transactions")).toBeInTheDocument();
+ expect(screen.queryByTestId("screen-soroban")).not.toBeInTheDocument();
+ });
+
+ it("follows the parent when activeSection changes", () => {
+ const { rerender } = render();
+ expect(screen.getByTestId("screen-wallet")).toBeInTheDocument();
+
+ rerender();
+ expect(screen.getByTestId("screen-network")).toBeInTheDocument();
+ expect(screen.queryByTestId("screen-wallet")).not.toBeInTheDocument();
+ });
+
+ it("ignores defaultSection when activeSection is set", () => {
+ render();
+ expect(screen.getByTestId("screen-wallet")).toBeInTheDocument();
+ });
+
+ it("passes the active section down to the chrome", () => {
+ render();
+ expect(screen.getByTestId("sidebar-active")).toHaveTextContent("soroban");
+ expect(screen.getByTestId("topbar-active")).toHaveTextContent("soroban");
+ });
+ });
+});
diff --git a/src/screens/Dashboard.tsx b/src/screens/Dashboard.tsx
index 3c16ce1..1de775d 100644
--- a/src/screens/Dashboard.tsx
+++ b/src/screens/Dashboard.tsx
@@ -1,18 +1,18 @@
-import { type ComponentType,useState } from "react";
+import { type ComponentType,useCallback,useState } from "react";
import { NetworkBanner } from "@/components/NetworkBanner";
import { type NavSection,Sidebar } from "@/components/Sidebar";
import { TopBar } from "@/components/TopBar";
import { AccountScreen } from "@/screens/AccountScreen";
-import { NFTScreen } from "@/screens/NFTScreen";
+import { BudgetScreen } from "@/screens/BudgetScreen";
+import { ChartingScreen } from "@/screens/ChartingScreen";
import { NetworkScreen } from "@/screens/NetworkScreen";
+import { NFTScreen } from "@/screens/NFTScreen";
+import { RecoveryScreen } from "@/screens/RecoveryScreen";
import { SorobanScreen } from "@/screens/SorobanScreen";
import { TransactionsScreen } from "@/screens/TransactionsScreen";
import { WalletScreen } from "@/screens/WalletScreen";
-import { RecoveryScreen } from "@/screens/RecoveryScreen";
-import { ChartingScreen } from "@/screens/ChartingScreen";
import { YieldFarmingScreen } from "@/screens/YieldFarmingScreen";
-import { BudgetScreen } from "@/screens/BudgetScreen";
const SCREENS: Record = {
wallet: WalletScreen,
@@ -27,17 +27,47 @@ const SCREENS: Record = {
nfts: NFTScreen,
};
-export function Dashboard() {
- const [active, setActive] = useState("wallet");
+export interface DashboardProps {
+ /**
+ * Controlled active section. When provided, `Dashboard` renders this section
+ * and never changes it internally — the parent owns the state and should
+ * update it from `onSectionChange`.
+ */
+ activeSection?: NavSection;
+ /** Fired whenever a nav item is chosen, in both controlled and uncontrolled mode. */
+ onSectionChange?: (section: NavSection) => void;
+ /** Initial section in uncontrolled mode. Ignored when `activeSection` is set. */
+ defaultSection?: NavSection;
+}
+
+export function Dashboard({
+ activeSection,
+ onSectionChange,
+ defaultSection = "wallet",
+}: DashboardProps = {}) {
+ const isControlled = activeSection !== undefined;
+ const [internalActive, setInternalActive] =
+ useState(defaultSection);
const [sidebarOpen, setSidebarOpen] = useState(false);
- const ActiveScreen = SCREENS[active];
+ const active = isControlled ? activeSection : internalActive;
+
+ const handleNavigate = useCallback(
+ (section: NavSection) => {
+ // In controlled mode the parent decides what renders next; only report.
+ if (!isControlled) setInternalActive(section);
+ onSectionChange?.(section);
+ },
+ [isControlled, onSectionChange],
+ );
+
+ const ActiveScreen = SCREENS[active] ?? SCREENS.wallet;
return (
setSidebarOpen(false)}
/>