diff --git a/src/app/farm/[poolId]/PoolDetailClient.test.tsx b/src/app/farm/[poolId]/PoolDetailClient.test.tsx new file mode 100644 index 0000000..4f4a933 --- /dev/null +++ b/src/app/farm/[poolId]/PoolDetailClient.test.tsx @@ -0,0 +1,230 @@ +import { act, render, screen, waitFor } from "@testing-library/react"; +import { ChakraProvider } from "@chakra-ui/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { sorobanService } from "@/lib/soroban"; +import type { PoolInfo } from "@/lib/soroban-parsers"; +import { usePools } from "@/hooks/useSorobanQuery"; +import PoolDetailClient from "./PoolDetailClient"; + +vi.mock("@/components/TvlChart/TvlChart", () => ({ + default: () => null, +})); + +vi.mock("@/hooks/useLockFlow", () => ({ + useLockFlow: vi.fn(() => ({ + step: "idle", + record: null, + error: null, + isPending: false, + execute: vi.fn(), + reset: vi.fn(), + })), +})); + +vi.mock("@/context/StellarWalletContext", () => ({ + useStellarWallet: vi.fn(() => ({ + publicKey: null, + walletApi: null, + isConnected: false, + isNetworkMismatch: false, + })), +})); + +vi.mock("@/context/OwnConnectButtonContext", () => ({ + useOwnConnectButton: vi.fn(() => vi.fn()), +})); + +function makePool(overrides: Partial = {}): PoolInfo { + return { + id: "pool-xlm", + contractAddress: "CPOOL", + asset: { code: "XLM", isNative: true }, + dailyRate: "0.5%", + minLockPeriod: 604800, + totalLocked: "10000", + totalUsers: 42, + isActive: true, + createdAt: Date.now(), + ...overrides, + }; +} + +function renderDetail(poolId: string, client: QueryClient) { + return render( + + + + + , + ); +} + +beforeEach(() => { + vi.spyOn(sorobanService, "getPoolDepositors").mockResolvedValue([]); + + // jsdom has no matchMedia implementation; Chakra's responsive Flex/Modal + // components call useMediaQuery internally, which needs one. + Object.defineProperty(window, "matchMedia", { + writable: true, + value: vi.fn().mockImplementation((query: string) => ({ + matches: false, + media: query, + onchange: null, + addListener: vi.fn(), + removeListener: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + })), + }); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("PoolDetailClient shares the usePools() cache (#143)", () => { + it("does not trigger a second getFactoryPools() call when the Farm page already populated the cache", async () => { + const getFactoryPoolsSpy = vi + .spyOn(sorobanService, "getFactoryPools") + .mockResolvedValue([makePool()]); + + const client = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + + // Simulates visiting /farm first: a bare usePools() consumer populates + // the shared cache. + function FarmPageProbe() { + usePools(); + return null; + } + render( + + + , + ); + await waitFor(() => expect(getFactoryPoolsSpy).toHaveBeenCalledTimes(1)); + + // Now navigate into the pool detail page for the same pool, sharing + // the same QueryClient — the fresh (staleTime: 30s) cache should be + // reused, not re-fetched. + renderDetail("pool-xlm", client); + await screen.findByText("XLM Pool"); + + expect(getFactoryPoolsSpy).toHaveBeenCalledTimes(1); + }); +}); + +describe("PoolDetailClient refreshes stats on usePools()'s interval (#143)", () => { + it("updates the displayed dailyRate after refetchInterval elapses, instead of staying frozen", async () => { + vi.useFakeTimers(); + try { + const getFactoryPoolsSpy = vi + .spyOn(sorobanService, "getFactoryPools") + .mockResolvedValueOnce([makePool({ dailyRate: "0.5%" })]) + .mockResolvedValue([makePool({ dailyRate: "0.9%" })]); + + const client = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + + renderDetail("pool-xlm", client); + + await act(async () => { + await vi.advanceTimersByTimeAsync(0); + }); + expect(screen.getByText("0.5%")).toBeTruthy(); + expect(getFactoryPoolsSpy).toHaveBeenCalledTimes(1); + + // Fire the refetchInterval's timer callback (fake timers), then hand + // off to real timers so the resulting fetch promise and React's + // scheduler settle normally — React's scheduler doesn't reliably + // flush under advanceTimersByTimeAsync alone. Pre-fix, + // PoolDetailClient had no polling mechanism at all and this figure + // would stay frozen at "0.5%" for the lifetime of the page view. + act(() => { + vi.advanceTimersByTime(60000); + }); + vi.useRealTimers(); + + await waitFor(() => expect(getFactoryPoolsSpy).toHaveBeenCalledTimes(2)); + await waitFor(() => expect(screen.getByText("0.9%")).toBeTruthy()); + } finally { + vi.useRealTimers(); + } + }); +}); + +describe("PoolDetailClient preserves loading/not-found behavior after migration (#143)", () => { + it("shows loading skeletons while usePools() is still pending", async () => { + let resolvePools!: (pools: PoolInfo[]) => void; + vi.spyOn(sorobanService, "getFactoryPools").mockReturnValue( + new Promise((resolve) => { + resolvePools = resolve; + }), + ); + + const client = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + renderDetail("pool-xlm", client); + + // Breadcrumb falls back to a truncated poolId while loading, not the + // pool's asset code (which isn't known yet). + expect(screen.getByText("pool-xlm…")).toBeTruthy(); + expect(screen.queryByText("XLM Pool")).toBeNull(); + + await act(async () => { + resolvePools([makePool()]); + await Promise.resolve(); + }); + }); + + it('shows "Pool not found." when usePools() resolves without the requested poolId', async () => { + vi.spyOn(sorobanService, "getFactoryPools").mockResolvedValue([ + makePool({ id: "some-other-pool" }), + ]); + + const client = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + renderDetail("pool-xlm", client); + + expect(await screen.findByText("Pool not found.")).toBeTruthy(); + expect(screen.getByRole("link", { name: /back to farm/i })).toBeTruthy(); + // The stats/detail layout isn't rendered in the error state. + expect(screen.queryByText("Daily Rate")).toBeNull(); + }); + + it('shows "Failed to load pool data." when the pools fetch itself fails', async () => { + // usePools() hard-codes retry: 3 with exponential backoff, so exhausting + // it before isError flips takes several real seconds — fake timers, + // advanced until nothing is pending, cover the whole retry chain fast. + vi.useFakeTimers(); + try { + vi.spyOn(sorobanService, "getFactoryPools").mockRejectedValue( + new Error("RPC unreachable"), + ); + + const client = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + renderDetail("pool-xlm", client); + + // retryDelay is min(1000 * 2^attempt, 30000) for 3 retries: + // 1000 + 2000 + 4000 = 7000ms until isError flips. Advancing in a + // bounded step (rather than runAllTimersAsync, which never + // terminates here — usePools()/usePoolDepositors' refetchInterval + // keeps rescheduling) covers the whole chain without hanging. + await act(async () => { + await vi.advanceTimersByTimeAsync(8000); + }); + + expect(screen.getByText("Failed to load pool data.")).toBeTruthy(); + } finally { + vi.useRealTimers(); + } + }); +}); diff --git a/src/app/farm/[poolId]/PoolDetailClient.tsx b/src/app/farm/[poolId]/PoolDetailClient.tsx index ef4aad7..ee8b7b2 100644 --- a/src/app/farm/[poolId]/PoolDetailClient.tsx +++ b/src/app/farm/[poolId]/PoolDetailClient.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import NextLink from "next/link"; import { Alert, @@ -29,11 +29,14 @@ import { Tr, useDisclosure, } from "@chakra-ui/react"; -import { formatCredits, sorobanService } from "@/lib/soroban"; -import type { PoolInfo } from "@/lib/soroban"; +import { formatCredits } from "@/lib/soroban"; import TvlChart from "@/components/TvlChart/TvlChart"; import { useLockFlow } from "@/hooks/useLockFlow"; -import { useStellarBalance } from "@/hooks/useSorobanQuery"; +import { + usePoolDepositors, + usePools, + useStellarBalance, +} from "@/hooks/useSorobanQuery"; import { useStellarWallet } from "@/context/StellarWalletContext"; import ConnectWalletButton from "@/components/ConnectWalletButton/ConnectWalletButton"; import { useOwnConnectButton } from "@/context/OwnConnectButtonContext"; @@ -87,13 +90,29 @@ function StatCard({ } export default function PoolDetailClient({ poolId }: { poolId: string }) { - const [pool, setPool] = useState(null); - const [depositors, setDepositors] = useState([]); - const [poolLoading, setPoolLoading] = useState(true); - const [depositorsLoading, setDepositorsLoading] = useState(true); - const [error, setError] = useState(null); const [rawAmount, setRawAmount] = useState("0"); + // Shares the same cache/staleTime/refetchInterval as the Farm page's + // pool list instead of an independent, uncached getFactoryPools() call + // — visiting /farm then a pool's detail page no longer re-fetches the + // same data, and stats now refresh on usePools()'s interval instead of + // being frozen at mount (#143). Deriving `pool` this way is arguably + // more correct than the old one-shot check (it recomputes if the pool + // later disappears from a factory poll), but is a behavior change worth + // calling out: previously a pool removed from the factory after initial + // load stayed displayed until the user navigated away and back. + const { data: pools, isLoading: poolLoading, isError: poolsError } = usePools(); + const pool = useMemo( + () => pools?.find((p) => p.id === poolId) ?? null, + [pools, poolId], + ); + const notFound = !poolLoading && !poolsError && !pool; + const error = poolsError + ? "Failed to load pool data." + : notFound + ? "Pool not found." + : null; + const { isOpen, onOpen, onClose } = useDisclosure(); const { publicKey, walletApi, isConnected, isNetworkMismatch } = useStellarWallet(); @@ -124,47 +143,9 @@ export default function PoolDetailClient({ poolId }: { poolId: string }) { walletApi, }); - useEffect(() => { - let cancelled = false; - setPoolLoading(true); - sorobanService - .getFactoryPools() - .then((pools) => { - if (cancelled) return; - const found = pools.find((p) => p.id === poolId) ?? null; - setPool(found); - if (!found) setError("Pool not found."); - setPoolLoading(false); - }) - .catch(() => { - if (!cancelled) { - setError("Failed to load pool data."); - setPoolLoading(false); - } - }); - return () => { - cancelled = true; - }; - }, [poolId]); - - useEffect(() => { - let cancelled = false; - setDepositorsLoading(true); - sorobanService - .getPoolDepositors(poolId, 20) - .then((list) => { - if (!cancelled) { - setDepositors(list); - setDepositorsLoading(false); - } - }) - .catch(() => { - if (!cancelled) setDepositorsLoading(false); - }); - return () => { - cancelled = true; - }; - }, [poolId]); + const { data: depositorsData, isLoading: depositorsLoading } = + usePoolDepositors(poolId, 20); + const depositors: Depositor[] = depositorsData ?? []; const handleModalClose = () => { if (isDepositPending(flow.step)) return; diff --git a/src/hooks/useSorobanQuery.test.ts b/src/hooks/useSorobanQuery.test.ts new file mode 100644 index 0000000..a713d02 --- /dev/null +++ b/src/hooks/useSorobanQuery.test.ts @@ -0,0 +1,60 @@ +import { createElement, type ReactNode } from "react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { waitFor } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { renderHook } from "@/test/renderHook"; +import { sorobanService } from "@/lib/soroban"; +import { usePoolDepositors } from "./useSorobanQuery"; + +function wrapper({ children }: { children: ReactNode }) { + const client = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + return createElement(QueryClientProvider, { client }, children); +} + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("usePoolDepositors (#143)", () => { + it("fetches depositors for the given pool through sorobanService", async () => { + const depositors = [ + { address: "GDEP1", amount: "100", credits: "10" }, + { address: "GDEP2", amount: "50", credits: "5" }, + ]; + const spy = vi + .spyOn(sorobanService, "getPoolDepositors") + .mockResolvedValue(depositors); + + const { result } = renderHook(() => usePoolDepositors("pool-xlm", 20), { + wrapper, + }); + + await waitFor(() => expect(result.current.data).toEqual(depositors)); + + expect(spy).toHaveBeenCalledWith("pool-xlm", 20); + }); + + it("does not fetch when poolId is empty", async () => { + const spy = vi + .spyOn(sorobanService, "getPoolDepositors") + .mockResolvedValue([]); + + renderHook(() => usePoolDepositors("", 20), { wrapper }); + + // Give any accidental fetch a chance to fire before asserting it didn't. + await new Promise((r) => setTimeout(r, 0)); + expect(spy).not.toHaveBeenCalled(); + }); + + it("defaults limit to 20 when not provided", async () => { + const spy = vi + .spyOn(sorobanService, "getPoolDepositors") + .mockResolvedValue([]); + + renderHook(() => usePoolDepositors("pool-xlm"), { wrapper }); + + await waitFor(() => expect(spy).toHaveBeenCalledWith("pool-xlm", 20)); + }); +}); diff --git a/src/hooks/useSorobanQuery.ts b/src/hooks/useSorobanQuery.ts index b7bc59b..2df2086 100644 --- a/src/hooks/useSorobanQuery.ts +++ b/src/hooks/useSorobanQuery.ts @@ -37,6 +37,23 @@ export const usePools = () => { }); }; +/** + * Hook to fetch a pool's top depositors, replacing the one-shot + * getPoolDepositors() useEffect PoolDetailClient used to call directly + * (#143) — this way depositor data also refreshes on an interval instead + * of being frozen at mount for the lifetime of the page view. + */ +export const usePoolDepositors = (poolId: string, limit: number = 20) => { + return useQuery({ + queryKey: ['poolDepositors', poolId, limit], + queryFn: () => sorobanService.getPoolDepositors(poolId, limit), + enabled: !!poolId, + staleTime: 30000, + refetchInterval: 60000, + retry: 2, + }); +}; + /** * Hook to fetch user position for a specific pool */