diff --git a/frontend/src/components/dashboard/dashboard-view.test.tsx b/frontend/src/components/dashboard/dashboard-view.test.tsx
new file mode 100644
index 00000000..fe9503e1
--- /dev/null
+++ b/frontend/src/components/dashboard/dashboard-view.test.tsx
@@ -0,0 +1,213 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
+import { render, screen, waitFor } from "@testing-library/react";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import React from "react";
+
+// ─── Mocks ──────────────────────────────────────────────────────────────────
+//
+// dashboard-view.tsx pulls in a lot of feature components (the stream
+// creation wizard, modals, SSE status indicator, etc.) that aren't relevant
+// to the "does switching tabs/routes re-trigger a network fetch within
+// staleTime" question this file is about. They're stubbed out below so the
+// test can focus on the useDashboard/query-cache wiring.
+
+vi.mock("@/hooks/useStreamEvents", () => ({
+ useStreamEvents: () => ({
+ events: [],
+ connected: true,
+ reconnecting: false,
+ error: null,
+ }),
+}));
+
+vi.mock("react-hot-toast", () => ({
+ default: {
+ success: vi.fn(),
+ error: vi.fn(),
+ loading: vi.fn(() => "toast-id"),
+ },
+}));
+
+vi.mock("@/lib/soroban", () => ({
+ createStream: vi.fn(),
+ topUpStream: vi.fn(),
+ cancelStream: vi.fn(),
+ withdrawFromStream: vi.fn(),
+ toBaseUnits: vi.fn((v: string) => BigInt(v)),
+ toDurationSeconds: vi.fn(() => 0),
+ getTokenAddress: vi.fn((symbol: string) => symbol),
+ toSorobanErrorMessage: vi.fn((e) => String(e)),
+}));
+
+vi.mock("@/lib/stellar", () => ({
+ isValidStellarPublicKey: vi.fn(() => true),
+}));
+
+vi.mock("../IncomingStreams", () => ({
+ default: () =>
,
+}));
+
+vi.mock("./SSEStatusIndicator", () => ({
+ SSEStatusIndicator: () => ,
+}));
+
+vi.mock("../stream-creation/StreamCreationWizard", () => ({
+ StreamCreationWizard: () => ,
+}));
+
+vi.mock("../stream-creation/TopUpModal", () => ({
+ TopUpModal: () => ,
+}));
+
+vi.mock("../stream-creation/CancelConfirmModal", () => ({
+ CancelConfirmModal: () => ,
+}));
+
+vi.mock("./StreamDetailsModal", () => ({
+ StreamDetailsModal: () => ,
+}));
+
+vi.mock("../ui/Button", () => ({
+ Button: ({
+ children,
+ onClick,
+ disabled,
+ ...rest
+ }: React.ButtonHTMLAttributes & {
+ glow?: boolean;
+ variant?: string;
+ size?: string;
+ children?: React.ReactNode;
+ }) => (
+
+ ),
+}));
+
+import { DashboardView } from "./dashboard-view";
+import type { WalletSession } from "@/lib/wallet";
+
+const PUBLIC_KEY = "GABCDEFPUBLICKEY000000000000000000000000000000000000000";
+
+const session: WalletSession = {
+ walletId: "freighter",
+ walletName: "Freighter",
+ publicKey: PUBLIC_KEY,
+ connectedAt: new Date().toISOString(),
+ network: "TESTNET",
+ mocked: false,
+};
+
+function jsonResponse(body: unknown) {
+ return {
+ ok: true,
+ status: 200,
+ json: async () => body,
+ } as Response;
+}
+
+describe("DashboardView + useDashboard cache integration", () => {
+ beforeEach(() => {
+ vi.stubGlobal("fetch", vi.fn().mockResolvedValue(jsonResponse([])));
+ });
+
+ afterEach(() => {
+ vi.unstubAllGlobals();
+ });
+
+ it("does not issue a duplicate network fetch when the dashboard is unmounted and remounted within staleTime (e.g. switching tabs away and back)", async () => {
+ // Mirror the app's real QueryClient defaults (see
+ // frontend/src/components/providers/query-provider.tsx) so this proves
+ // the behavior users actually get: staleTime 10s means a remount within
+ // that window should be served from cache, not refetched.
+ const queryClient = new QueryClient({
+ defaultOptions: {
+ queries: { staleTime: 10_000, refetchOnWindowFocus: false, retry: 1 },
+ },
+ });
+
+ const renderDashboard = () =>
+ render(
+
+
+ ,
+ );
+
+ const first = renderDashboard();
+ await waitFor(() =>
+ expect(screen.getByText(/start your first stream/i)).toBeInTheDocument(),
+ );
+ // One request each for outgoing ("sender") and incoming ("recipient") streams.
+ expect(fetch).toHaveBeenCalledTimes(2);
+
+ // Simulate navigating/switching away from the dashboard and back within
+ // the 10s staleTime window.
+ first.unmount();
+
+ render(
+
+
+ ,
+ );
+
+ await waitFor(() =>
+ expect(screen.getByText(/start your first stream/i)).toBeInTheDocument(),
+ );
+
+ // Still just the 2 calls from the first mount — the remounted dashboard
+ // was served from the React Query cache instead of refetching.
+ expect(fetch).toHaveBeenCalledTimes(2);
+ }, 20000);
+
+ it("shows the loading skeleton, then renders content once useDashboard resolves", async () => {
+ const queryClient = new QueryClient({
+ defaultOptions: { queries: { retry: false } },
+ });
+
+ render(
+
+
+ ,
+ );
+
+ expect(screen.getByLabelText(/loading dashboard/i)).toBeInTheDocument();
+
+ await waitFor(() =>
+ expect(screen.getByText(/start your first stream/i)).toBeInTheDocument(),
+ );
+ }, 20000);
+
+ it("shows the error state with a retry button when the fetch fails, and retrying refetches", async () => {
+ vi.stubGlobal(
+ "fetch",
+ vi.fn().mockRejectedValue(new Error("network down")),
+ );
+ const queryClient = new QueryClient({
+ defaultOptions: { queries: { retry: false } },
+ });
+
+ render(
+
+
+ ,
+ );
+
+ await waitFor(() =>
+ expect(screen.getByText(/failed to load streams/i)).toBeInTheDocument(),
+ );
+
+ const callsBeforeRetry = vi.mocked(fetch).mock.calls.length;
+
+ vi.mocked(fetch).mockResolvedValue(jsonResponse([]));
+ screen.getByRole("button", { name: /retry/i }).click();
+
+ await waitFor(() =>
+ expect(screen.getByText(/start your first stream/i)).toBeInTheDocument(),
+ );
+
+ expect(vi.mocked(fetch).mock.calls.length).toBeGreaterThan(
+ callsBeforeRetry,
+ );
+ }, 20000);
+});
diff --git a/frontend/src/components/dashboard/dashboard-view.tsx b/frontend/src/components/dashboard/dashboard-view.tsx
index 6a0c252c..6e6c000c 100644
--- a/frontend/src/components/dashboard/dashboard-view.tsx
+++ b/frontend/src/components/dashboard/dashboard-view.tsx
@@ -17,7 +17,7 @@ import toast from "react-hot-toast";
import {
getDashboardAnalytics,
- fetchDashboardData,
+ useDashboard,
dashboardQueryKey,
type DashboardSnapshot,
type Stream,
@@ -511,11 +511,20 @@ export function DashboardView({ session, onDisconnect }: DashboardViewProps) {
const [showWizard, setShowWizard] = React.useState(false);
const [modal, setModal] = React.useState(null);
- const [snapshot, setSnapshot] = React.useState(
- null,
- );
- const [isSnapshotLoading, setIsSnapshotLoading] = React.useState(true);
- const [snapshotError, setSnapshotError] = React.useState(null);
+ const {
+ data: snapshotData,
+ isLoading: isSnapshotLoading,
+ isError: isSnapshotError,
+ error: snapshotErrorObj,
+ refetch: refetchSnapshot,
+ } = useDashboard(session.publicKey);
+
+ const snapshot: DashboardSnapshot | null = snapshotData ?? null;
+ const snapshotError = isSnapshotError
+ ? snapshotErrorObj instanceof Error
+ ? snapshotErrorObj.message
+ : "Failed to fetch dashboard data."
+ : null;
const {
events: streamEvents,
@@ -541,15 +550,9 @@ export function DashboardView({ session, onDisconnect }: DashboardViewProps) {
"resumed",
];
if (relevantTypes.includes(latestEvent.type)) {
- fetchDashboardData(session.publicKey)
- .then(setSnapshot)
- .catch((err) => {
- setSnapshotError(
- err instanceof Error
- ? err.message
- : "Failed to refresh dashboard",
- );
- });
+ void queryClient.invalidateQueries({
+ queryKey: dashboardQueryKey(session.publicKey),
+ });
}
}
}
@@ -619,51 +622,6 @@ export function DashboardView({ session, onDisconnect }: DashboardViewProps) {
persistTemplates(templates);
}, [templates, templatesHydrated]);
- // ── Load dashboard snapshot ───────────────────────────────────────────────
-
- const loadSnapshot = React.useCallback(async () => {
- setIsSnapshotLoading(true);
- setSnapshotError(null);
- try {
- const next = await fetchDashboardData(session.publicKey);
- setSnapshot(next);
- } catch (err) {
- setSnapshot(null);
- setSnapshotError(
- err instanceof Error ? err.message : "Failed to fetch dashboard data.",
- );
- } finally {
- setIsSnapshotLoading(false);
- }
- }, [session.publicKey, setIsSnapshotLoading, setSnapshotError, setSnapshot]);
-
- React.useEffect(() => {
- let cancelled = false;
- const run = async () => {
- setIsSnapshotLoading(true);
- setSnapshotError(null);
- try {
- const next = await fetchDashboardData(session.publicKey);
- if (!cancelled) setSnapshot(next);
- } catch (err) {
- if (!cancelled) {
- setSnapshot(null);
- setSnapshotError(
- err instanceof Error
- ? err.message
- : "Failed to fetch dashboard data.",
- );
- }
- } finally {
- if (!cancelled) setIsSnapshotLoading(false);
- }
- };
- void run();
- return () => {
- cancelled = true;
- };
- }, [session.publicKey]);
-
// ── Template handlers ─────────────────────────────────────────────────────
const updateStreamForm = (field: keyof StreamFormValues, value: string) => {
@@ -779,15 +737,18 @@ export function DashboardView({ session, onDisconnect }: DashboardViewProps) {
};
const topUpStreamLocally = (streamId: string, amount: number) => {
- setSnapshot((prev) => {
- if (!prev) return prev;
- return {
- ...prev,
- outgoingStreams: prev.outgoingStreams.map((s) =>
- s.id === streamId ? { ...s, deposited: s.deposited + amount } : s,
- ),
- };
- });
+ queryClient.setQueryData(
+ dashboardQueryKey(session.publicKey),
+ (prev) => {
+ if (!prev) return prev;
+ return {
+ ...prev,
+ outgoingStreams: prev.outgoingStreams.map((s) =>
+ s.id === streamId ? { ...s, deposited: s.deposited + amount } : s,
+ ),
+ };
+ },
+ );
};
const addStreamLocally = (data: StreamFormData) => {
@@ -804,14 +765,17 @@ export function DashboardView({ session, onDisconnect }: DashboardViewProps) {
lastUpdateTime: Math.floor(Date.now() / 1000),
isActive: true,
};
- setSnapshot((prev) => {
- if (!prev) return prev;
- return {
- ...prev,
- outgoingStreams: [newStream, ...prev.outgoingStreams],
- activeStreamsCount: prev.activeStreamsCount + 1,
- };
- });
+ queryClient.setQueryData(
+ dashboardQueryKey(session.publicKey),
+ (prev) => {
+ if (!prev) return prev;
+ return {
+ ...prev,
+ outgoingStreams: [newStream, ...prev.outgoingStreams],
+ activeStreamsCount: prev.activeStreamsCount + 1,
+ };
+ },
+ );
};
// ── Contract handlers ─────────────────────────────────────────────────────
@@ -872,8 +836,7 @@ export function DashboardView({ session, onDisconnect }: DashboardViewProps) {
await sorobanWithdraw(session, {
streamId: BigInt(stream.id.replace(/\D/g, "") || "0"),
});
- const refreshed = await fetchDashboardData(session.publicKey);
- setSnapshot(refreshed);
+ await refetchSnapshot();
toast.success("Withdrawal successful!", { id: toastId });
} catch (err) {
toast.error(toSorobanErrorMessage(err), { id: toastId });
@@ -958,7 +921,12 @@ export function DashboardView({ session, onDisconnect }: DashboardViewProps) {
// ── Error state ───────────────────────────────────────────────────────
if (snapshotError) {
- return ;
+ return (
+ void refetchSnapshot()}
+ />
+ );
}
// ── First-time / completely empty wallet ──────────────────────────────
diff --git a/frontend/src/lib/dashboard.test.ts b/frontend/src/lib/dashboard.test.ts
index bb1cf02e..93832bee 100644
--- a/frontend/src/lib/dashboard.test.ts
+++ b/frontend/src/lib/dashboard.test.ts
@@ -1,8 +1,12 @@
-import { describe, it, expect } from "vitest";
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
+import { renderHook, waitFor } from "@testing-library/react";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import React from "react";
import {
mapBackendStreamToFrontend,
getDashboardAnalytics,
dashboardQueryKey,
+ useDashboard,
type DashboardSnapshot,
} from "./dashboard";
import type { BackendStream } from "./api-types";
@@ -181,3 +185,103 @@ describe("getDashboardAnalytics", () => {
expect(volume30d.value).toBe(50); // only the recent activity
});
});
+
+// ── useDashboard ─────────────────────────────────────────────────────────────
+
+describe("useDashboard", () => {
+ const PUBLIC_KEY = "GABCDEFPUBLICKEY000000000000000000000000000000000000000";
+
+ function jsonResponse(body: unknown) {
+ return {
+ ok: true,
+ status: 200,
+ json: async () => body,
+ } as Response;
+ }
+
+ function makeWrapper(queryClient: QueryClient) {
+ return function Wrapper({ children }: { children: React.ReactNode }) {
+ return React.createElement(
+ QueryClientProvider,
+ { client: queryClient },
+ children,
+ );
+ };
+ }
+
+ beforeEach(() => {
+ vi.stubGlobal("fetch", vi.fn());
+ });
+
+ afterEach(() => {
+ vi.unstubAllGlobals();
+ });
+
+ it("does not fetch when publicKey is empty (disabled query)", () => {
+ const queryClient = new QueryClient({
+ defaultOptions: { queries: { retry: false } },
+ });
+ const { result } = renderHook(() => useDashboard(""), {
+ wrapper: makeWrapper(queryClient),
+ });
+
+ expect(result.current.fetchStatus).toBe("idle");
+ expect(fetch).not.toHaveBeenCalled();
+ });
+
+ it("fetches and returns a mapped dashboard snapshot for a given publicKey", async () => {
+ vi.mocked(fetch).mockResolvedValue(jsonResponse([]));
+ const queryClient = new QueryClient({
+ defaultOptions: { queries: { retry: false } },
+ });
+
+ const { result } = renderHook(() => useDashboard(PUBLIC_KEY), {
+ wrapper: makeWrapper(queryClient),
+ });
+
+ await waitFor(() => expect(result.current.isSuccess).toBe(true));
+
+ expect(result.current.data).toEqual({
+ totalSent: 0,
+ totalReceived: 0,
+ totalValueLocked: 0,
+ activeStreamsCount: 0,
+ recentActivity: [],
+ outgoingStreams: [],
+ incomingStreams: [],
+ });
+ // one request for outgoing ("sender") streams, one for incoming ("recipient")
+ expect(fetch).toHaveBeenCalledTimes(2);
+ });
+
+ it("does not refetch within staleTime when remounted with the same QueryClient (no duplicate fetch on tab switch)", async () => {
+ vi.mocked(fetch).mockResolvedValue(jsonResponse([]));
+ // Mirror the app's real QueryClient defaults (frontend/src/components/providers/query-provider.tsx)
+ // so this test proves the behavior users actually get.
+ const queryClient = new QueryClient({
+ defaultOptions: {
+ queries: { staleTime: 10_000, refetchOnWindowFocus: false, retry: 1 },
+ },
+ });
+ const wrapper = makeWrapper(queryClient);
+
+ const first = renderHook(() => useDashboard(PUBLIC_KEY), { wrapper });
+ await waitFor(() => expect(first.result.current.isSuccess).toBe(true));
+ expect(fetch).toHaveBeenCalledTimes(2);
+
+ // Simulate navigating away (unmount) and back (remount) within the
+ // 10s staleTime window, sharing the same QueryClient instance the way
+ // the app's QueryProvider keeps one alive across route/tab changes.
+ first.unmount();
+
+ const second = renderHook(() => useDashboard(PUBLIC_KEY), { wrapper });
+ await waitFor(() =>
+ expect(second.result.current.data).toBeDefined(),
+ );
+
+ // Cache hit: still just the 2 calls from the initial mount — no
+ // duplicate network fetch for switching away and back within staleTime.
+ expect(fetch).toHaveBeenCalledTimes(2);
+ expect(second.result.current.isFetching).toBe(false);
+ });
+});