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)} />