Skip to content

PoolDetailClient bypasses the shared usePools() cache entirely — direct uncached getFactoryPools()/getPoolDepositors() calls duplicate RPC traffic and never refresh #143

Description

@prodbycorne

Overview

src/app/farm/[poolId]/PoolDetailClient.tsx fetches its pool data and depositor list with two independent, hand-rolled useEffects calling sorobanService directly, entirely bypassing React Query:

useEffect(() => {
  let cancelled = false;
  setPoolLoading(true);
  sorobanService.getFactoryPools().then((pools) => {
    if (cancelled) return;
    const found = pools.find((p) => p.id === poolId) ?? null;
    setPool(found);
    ...
  })...
}, [poolId]);

useEffect(() => {
  let cancelled = false;
  setDepositorsLoading(true);
  sorobanService.getPoolDepositors(poolId, 20).then((list) => { ... })...
}, [poolId]);

Every other consumer of pool data in this app goes through usePools() (useSorobanQuery.ts:29-38), a React Query hook with staleTime: 30000/refetchInterval: 60000, meaning the Farm page's pool list is shared, cached, and kept fresh across all components that call usePools(). PoolDetailClient doesn't call usePools() at all — it calls sorobanService.getFactoryPools() directly, which is the exact same expensive operation usePools()'s queryFn performs (getAccount + simulateTransaction against the factory contract, per soroban.ts:705-741) — with two consequences: (1) Redundant RPC traffic. A user who visits /farm (populating usePools()'s fresh, 30-second-stale-tolerant cache) and then clicks into a pool's detail page triggers a completely fresh, uncached getFactoryPools() round trip, even though the exact same data was fetched moments earlier and is still sitting in the QueryClient's cache under the ['pools'] key — PoolDetailClient simply never looks there. (2) Permanent staleness. Because this is a one-shot useEffect fetch with no refetchInterval/staleTime mechanism at all, pool.dailyRate/pool.totalLocked/pool.totalUsers shown on the detail page never update again for the lifetime of that page view — unlike the Farm page's list, which silently refreshes every 60 seconds via usePools(). A user who leaves a pool detail page open (e.g. in a background tab, or just idle) can be looking at TVL/rate/user-count figures that are arbitrarily out of date, with no polling and no visual staleness indicator.

Requirements

  • Replace PoolDetailClient's direct sorobanService.getFactoryPools() call with usePools(), deriving pool via pools?.find((p) => p.id === poolId), so the detail page shares the same cache, staleTime, and refetchInterval as the Farm page's list.
  • If getPoolDepositors doesn't already have a React Query wrapper, add one (e.g. usePoolDepositors(poolId)) with a sensible staleTime/refetchInterval, replacing the current one-shot useEffect, so depositor data also refreshes over time rather than being frozen at mount.

Acceptance Criteria

  • Visiting /farm and then a pool's detail page for the same pool does not trigger a second, independent getFactoryPools() RPC round trip if the usePools() cache is still fresh (verifiable via a call-count assertion in a test with both components mounted against a shared QueryClient).
  • PoolDetailClient's displayed pool stats (dailyRate, totalLocked, totalUsers) update on the existing usePools() refetch interval, rather than remaining frozen for the lifetime of the page view.
  • getPoolDepositors is called through a React Query hook with a defined refetch policy, not a one-shot useEffect.
  • Existing behavior (pool-not-found error state, loading skeletons) is preserved once migrated.

Additional Notes

More precise references

  • src/app/farm/[poolId]/PoolDetailClient.tsx:127-148 (the getFactoryPools useEffect) and :150-167 (the getPoolDepositors useEffect) — confirmed both call sorobanService methods directly, with no usePools/useQuery import anywhere in this file (confirmed via the file's full import list, PoolDetailClient.tsx:1-41).
  • src/hooks/useSorobanQuery.ts:29-38 (usePools) — confirmed the existing, shared, cached hook this page should be using instead, including its staleTime: 30000/refetchInterval: 60000 policy.
  • src/lib/soroban.ts:705-741 (getFactoryPools) — confirmed this is the same underlying, non-trivial getAccount + simulateTransaction operation usePools()'s queryFn calls, i.e. genuinely duplicated work, not two different code paths that happen to look similar.
  • src/app/farm/page.tsx:445-450 — confirmed the Farm page's own usage of usePools(), for direct comparison of the pattern PoolDetailClient should adopt.

Additional edge cases

  • Because pool is derived by .find((p) => p.id === poolId) against a freshly-fetched array each time in the current implementation, migrating to usePools() should preserve the same not-found handling (if (!found) setError("Pool not found."), PoolDetailClient.tsx:136) — with usePools(), this becomes a useMemo derivation from pools that recomputes if the pool disappears from a later factory poll (e.g. a pool is deactivated), which is arguably more correct than today's one-shot check, but should be called out explicitly in the PR since it's a behavior change (today, a pool that's removed from the factory after the initial page load stays displayed until the user navigates away and back; after this fix, it could disappear/error live).
  • getPoolDepositors (soroban.ts:1361-1420) itself has the known event-scan-truncation limitation already tracked in Event-scan features silently truncate results instead of paginating with the RPC's continuation cursor #103/Event-scan features silently truncate results instead of paginating with the RPC's continuation cursor #73 — wrapping it in a React Query hook doesn't change that underlying limitation, just the fetching/caching behavior around it; worth noting so this fix isn't mistaken for also addressing pagination completeness.

Implementation sketch

// PoolDetailClient.tsx
const { data: pools, isLoading: poolLoading, isError: poolsError } = usePools();
const pool = useMemo(() => pools?.find((p) => p.id === poolId) ?? null, [pools, poolId]);

// new hook, useSorobanQuery.ts:
export const usePoolDepositors = (poolId: string, limit = 20) =>
  useQuery({
    queryKey: ['poolDepositors', poolId, limit],
    queryFn: () => sorobanService.getPoolDepositors(poolId, limit),
    staleTime: 30000,
    refetchInterval: 60000,
    enabled: !!poolId,
  });

Test/reproduction plan

  • Mount both FarmPage (or just usePools() directly) and PoolDetailClient under one shared QueryClient/mocked sorobanService.getFactoryPools; assert getFactoryPools is called once (cache hit for the second consumer), not twice.
  • Advance fake timers past usePools()'s refetchInterval; assert PoolDetailClient's displayed dailyRate/totalLocked update to reflect a changed mocked response, where today they would remain frozen.

Cross-references

Metadata

Metadata

Assignees

Labels

GrantFox OSSIssue tracked in GrantFox OSSMaybe RewardedIssue may be eligible for a GrantFox rewardOfficial CampaignCampaign: Official CampaignOfficial Campaign | FWC26Campaign: Official Campaign | FWC26Third CampaignCampaign: Third CampaignperformanceRendering performance, caching, or bundle sizesorobanSoroban smart-contract integration (XDR, RPC, transaction building)very hardExtremely hard — deep expertise, careful design, and significant time required

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions