From 17395881366d408ec13b15f4336d8dd1b532e1b4 Mon Sep 17 00:00:00 2001 From: Iwayemi-Kehinde Date: Sun, 31 May 2026 17:55:27 +0100 Subject: [PATCH] fix(wallet): add loading state during network balance refresh --- src/components/common/TradeDialog.tsx | 37 +++- .../__tests__/TradeDialog.focusOrder.test.tsx | 8 + .../__tests__/useNetworkAwareBalance.test.ts | 115 ++++++++++++ src/hooks/useNetworkAwareBalance.ts | 123 +++++++++++++ src/pages/LandingPage.tsx | 173 +++++++++++++----- 5 files changed, 406 insertions(+), 50 deletions(-) create mode 100644 src/hooks/__tests__/useNetworkAwareBalance.test.ts create mode 100644 src/hooks/useNetworkAwareBalance.ts diff --git a/src/components/common/TradeDialog.tsx b/src/components/common/TradeDialog.tsx index fdfdd1a..5396d94 100644 --- a/src/components/common/TradeDialog.tsx +++ b/src/components/common/TradeDialog.tsx @@ -28,7 +28,8 @@ export interface TradeDialogProps { open: boolean; side: TradeSide; creatorName: string; - availableHoldings: number; + availableHoldings: number | null; + isBalanceLoading?: boolean; /** Per-key price in stroops, shown on the buy confirmation step. */ keyPriceStroops?: number | null; onOpenChange: (open: boolean) => void; @@ -46,6 +47,7 @@ const TradeDialog: React.FC = ({ side, creatorName, availableHoldings, + isBalanceLoading = false, keyPriceStroops, onOpenChange, onConfirm, @@ -70,7 +72,9 @@ const TradeDialog: React.FC = ({ const amountValid = Number.isFinite(parsedAmount) && parsedAmount > 0 && - (side !== 'sell' || parsedAmount <= availableHoldings); + !isBalanceLoading && + (side !== 'sell' || + (availableHoldings != null && parsedAmount <= availableHoldings)); const displayCreatorName = normalizeCreatorDisplayName(creatorName) || 'Unnamed creator'; @@ -191,11 +195,23 @@ const TradeDialog: React.FC = ({ />
- Holdings: {formatNumber(availableHoldings)} keys + {isBalanceLoading || availableHoldings == null + ? 'Holdings: Loading...' + : `Holdings: ${formatNumber(availableHoldings)} keys`} {side === 'sell' && + !isBalanceLoading && + availableHoldings != null && availableHoldings > 0 && Number.isFinite(parsedAmount) && parsedAmount > 0 && ( @@ -216,11 +232,14 @@ const TradeDialog: React.FC = ({ fee={networkFeeCopy} className="text-white/45" /> - {side === 'sell' && parsedAmount > availableHoldings && ( -
- You can’t sell more than your current holdings. -
- )} + {side === 'sell' && + !isBalanceLoading && + availableHoldings != null && + parsedAmount > availableHoldings && ( +
+ You can’t sell more than your current holdings. +
+ )}
{/* diff --git a/src/components/common/__tests__/TradeDialog.focusOrder.test.tsx b/src/components/common/__tests__/TradeDialog.focusOrder.test.tsx index 1ac8448..9cb3fb7 100644 --- a/src/components/common/__tests__/TradeDialog.focusOrder.test.tsx +++ b/src/components/common/__tests__/TradeDialog.focusOrder.test.tsx @@ -130,4 +130,12 @@ describe('TradeDialog focus order', () => { ) ).toBeInTheDocument(); }); + + it('shows balance loading copy instead of stale holdings while switching networks', () => { + renderDialog({ availableHoldings: 10, isBalanceLoading: true }); + + expect(screen.getByText('Holdings: Loading...')).toBeInTheDocument(); + expect(screen.queryByText('Holdings: 10 keys')).not.toBeInTheDocument(); + expect(screen.getByTestId('trade-dialog-confirm')).toBeDisabled(); + }); }); diff --git a/src/hooks/__tests__/useNetworkAwareBalance.test.ts b/src/hooks/__tests__/useNetworkAwareBalance.test.ts new file mode 100644 index 0000000..0703def --- /dev/null +++ b/src/hooks/__tests__/useNetworkAwareBalance.test.ts @@ -0,0 +1,115 @@ +import { act, renderHook, waitFor } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import { useNetworkAwareBalance } from '@/hooks/useNetworkAwareBalance'; + +function createDeferred() { + let resolve!: (value: T) => void; + const promise = new Promise(promiseResolve => { + resolve = promiseResolve; + }); + + return { promise, resolve }; +} + +describe('useNetworkAwareBalance', () => { + it('enters loading immediately during a network switch and clears it after the new balance loads', async () => { + const firstBalance = createDeferred(); + const secondBalance = createDeferred(); + const fetchFirstBalance = vi.fn(() => firstBalance.promise); + const fetchSecondBalance = vi.fn(() => secondBalance.promise); + + const { result, rerender } = renderHook( + ({ + balanceKey, + fetchBalance, + }: { + balanceKey: string; + fetchBalance: () => Promise; + }) => + useNetworkAwareBalance({ + balanceKey, + fetchBalance, + }), + { + initialProps: { + balanceKey: 'wallet:1', + fetchBalance: fetchFirstBalance, + }, + } + ); + + expect(result.current.isLoading).toBe(true); + + await act(async () => { + firstBalance.resolve(10); + await firstBalance.promise; + }); + + await waitFor(() => expect(result.current.balance).toBe(10)); + expect(result.current.isLoading).toBe(false); + + rerender({ + balanceKey: 'wallet:2', + fetchBalance: fetchSecondBalance, + }); + + expect(result.current.balance).toBeNull(); + expect(result.current.isLoading).toBe(true); + + await act(async () => { + secondBalance.resolve(25); + await secondBalance.promise; + }); + + await waitFor(() => expect(result.current.balance).toBe(25)); + expect(result.current.isLoading).toBe(false); + }); + + it('does not expose a stale balance when an older request resolves after a network switch', async () => { + const staleBalance = createDeferred(); + const currentBalance = createDeferred(); + const fetchStaleBalance = vi.fn(() => staleBalance.promise); + const fetchCurrentBalance = vi.fn(() => currentBalance.promise); + + const { result, rerender } = renderHook( + ({ + balanceKey, + fetchBalance, + }: { + balanceKey: string; + fetchBalance: () => Promise; + }) => + useNetworkAwareBalance({ + balanceKey, + fetchBalance, + }), + { + initialProps: { + balanceKey: 'wallet:1', + fetchBalance: fetchStaleBalance, + }, + } + ); + + rerender({ + balanceKey: 'wallet:2', + fetchBalance: fetchCurrentBalance, + }); + + await act(async () => { + staleBalance.resolve(10); + await staleBalance.promise; + }); + + expect(result.current.balance).toBeNull(); + expect(result.current.isLoading).toBe(true); + + await act(async () => { + currentBalance.resolve(30); + await currentBalance.promise; + }); + + await waitFor(() => expect(result.current.balance).toBe(30)); + expect(result.current.isLoading).toBe(false); + }); +}); diff --git a/src/hooks/useNetworkAwareBalance.ts b/src/hooks/useNetworkAwareBalance.ts new file mode 100644 index 0000000..f8b0eed --- /dev/null +++ b/src/hooks/useNetworkAwareBalance.ts @@ -0,0 +1,123 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; + +type BalanceStatus = 'idle' | 'loading' | 'success' | 'error'; + +interface BalanceState { + balance: TBalance | null; + balanceKey: string | null; + error: unknown; + status: BalanceStatus; +} + +export interface UseNetworkAwareBalanceOptions { + /** + * Stable identity for the balance being fetched, usually account + chain + + * asset. Changing this key immediately hides the previous balance. + */ + balanceKey: string | null | undefined; + enabled?: boolean; + fetchBalance: () => Promise; +} + +export interface UseNetworkAwareBalanceResult { + balance: TBalance | null; + error: unknown; + isError: boolean; + isLoading: boolean; + refresh: () => void; +} + +export function useNetworkAwareBalance({ + balanceKey, + enabled = true, + fetchBalance, +}: UseNetworkAwareBalanceOptions): UseNetworkAwareBalanceResult { + const requestIdRef = useRef(0); + const [refreshNonce, setRefreshNonce] = useState(0); + const [state, setState] = useState>({ + balance: null, + balanceKey: null, + error: null, + status: 'idle', + }); + + const activeBalanceKey = enabled ? (balanceKey ?? null) : null; + const hasCurrentBalance = + state.status === 'success' && state.balanceKey === activeBalanceKey; + const isAwaitingCurrentBalance = + activeBalanceKey != null && + (state.balanceKey !== activeBalanceKey || state.status !== 'success'); + + useEffect(() => { + if (activeBalanceKey == null) { + requestIdRef.current += 1; + setState({ + balance: null, + balanceKey: null, + error: null, + status: 'idle', + }); + return; + } + + const requestId = requestIdRef.current + 1; + requestIdRef.current = requestId; + let isActive = true; + setState({ + balance: null, + balanceKey: activeBalanceKey, + error: null, + status: 'loading', + }); + + fetchBalance() + .then(balance => { + if (!isActive || requestIdRef.current !== requestId) return; + setState({ + balance, + balanceKey: activeBalanceKey, + error: null, + status: 'success', + }); + }) + .catch(error => { + if (!isActive || requestIdRef.current !== requestId) return; + setState({ + balance: null, + balanceKey: activeBalanceKey, + error, + status: 'error', + }); + }); + + return () => { + isActive = false; + }; + }, [activeBalanceKey, fetchBalance, refreshNonce]); + + const refresh = useCallback(() => { + setRefreshNonce(nonce => nonce + 1); + }, []); + + return useMemo( + () => ({ + balance: hasCurrentBalance ? state.balance : null, + error: state.balanceKey === activeBalanceKey ? state.error : null, + isError: + state.balanceKey === activeBalanceKey && state.status === 'error', + isLoading: enabled && isAwaitingCurrentBalance, + refresh, + }), + [ + activeBalanceKey, + enabled, + hasCurrentBalance, + isAwaitingCurrentBalance, + refresh, + state.balance, + state.balanceKey, + state.error, + state.status, + ] + ); +} diff --git a/src/pages/LandingPage.tsx b/src/pages/LandingPage.tsx index 5c83b95..2a96d69 100644 --- a/src/pages/LandingPage.tsx +++ b/src/pages/LandingPage.tsx @@ -1,5 +1,6 @@ -import { useEffect, useMemo, useRef, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { LayoutGroup, motion } from 'framer-motion'; +import { useAccount, useChainId } from 'wagmi'; import { courseService, type Course } from '@/services/course.service'; import SkipToContent from '@/components/common/SkipToContent'; import { cn } from '@/lib/utils'; @@ -31,6 +32,7 @@ import NetworkMismatchBanner from '@/components/common/NetworkMismatchBanner'; import StellarConnectionQualityBadge from '@/components/common/StellarConnectionQualityBadge'; import { useEthersProvider } from '@/hooks/useEthersProvider'; import { useNetworkMismatch } from '@/hooks/useNetworkMismatch'; +import { useNetworkAwareBalance } from '@/hooks/useNetworkAwareBalance'; import showToast from '@/utils/toast.util'; import { getSignatureErrorMessage } from '@/utils/errorHandling.utils'; import { formatCompactNumber, formatNumber } from '@/utils/numberFormat.utils'; @@ -180,6 +182,7 @@ const CREATOR_SCROLL_KEY = 'accesslayer.creator-scrollY'; const MAX_CREATOR_FETCH_RETRIES = 3; const BASE_RETRY_DELAY_MS = 800; const PAGE_SIZE = 6; +const WALLET_HOLDINGS_BALANCE_LOAD_DELAY_MS = 250; const FETCH_RETRY_ACTION_LABEL = 'Try again'; const FINAL_FETCH_ERROR_COPY = 'Unable to load live creators right now. Showing fallback creators.'; @@ -187,8 +190,32 @@ const FINAL_FETCH_ERROR_COPY = const getFetchRetryHelperCopy = (attempt: number, maxAttempts: number) => `We couldn't load live creators yet. Retrying automatically (attempt ${attempt} of ${maxAttempts}).`; +const fetchFeaturedWalletHoldingsBalance = async (holdings: number) => { + await new Promise(resolve => + window.setTimeout(resolve, WALLET_HOLDINGS_BALANCE_LOAD_DELAY_MS) + ); + return holdings; +}; + type SortOption = 'featured' | 'price-asc' | 'price-desc' | 'supply-desc'; +const WalletBalanceLoadingIndicator: React.FC<{ className?: string }> = ({ + className, +}) => ( + + +); + interface CreatorProfileLoadErrorProps { onRetry: () => void; isRetrying: boolean; @@ -235,6 +262,8 @@ function LandingPage() { // resolved a load yet — the staleness helper treats that as "stale" // so the warning surfaces if the load hangs. const [creatorsFetchedAt, setCreatorsFetchedAt] = useState(null); + const { address } = useAccount(); + const connectedChainId = useChainId(); const { isMismatch: isNetworkMismatch } = useNetworkMismatch(); const [isLoading, setIsLoading] = useState(true); const [isFilterLoading, setIsFilterLoading] = useState(false); @@ -245,7 +274,7 @@ function LandingPage() { const hash = window.location.hash.slice(1); return PROFILE_TABS.includes(hash) ? hash : 'overview'; }); - const [featuredHoldings, setFeaturedHoldings] = useState(3); + const [featuredHoldingsSource, setFeaturedHoldingsSource] = useState(3); const [precisionMode, setPrecisionMode] = useState('compact'); const [tradeSide, setTradeSide] = useState('buy'); const [tradeDialogOpen, setTradeDialogOpen] = useState(false); @@ -460,6 +489,36 @@ function LandingPage() { // fall back to the demo featured creator. This keeps the profile panel // reactive to backend updates (supply, price, etc.). const featuredCreator = creators.length > 0 ? creators[0] : DEMO_CREATORS[0]; + const walletBalanceKey = `${address ?? 'demo-wallet'}:${connectedChainId}:${featuredCreator.id}`; + const fetchFeaturedHoldingsBalance = useCallback( + () => fetchFeaturedWalletHoldingsBalance(featuredHoldingsSource), + [featuredHoldingsSource] + ); + const { + balance: featuredHoldings, + isLoading: isWalletBalanceLoading, + } = useNetworkAwareBalance({ + balanceKey: walletBalanceKey, + fetchBalance: fetchFeaturedHoldingsBalance, + }); + const displayedFeaturedHoldings = featuredHoldings ?? 0; + const formattedFeaturedOwnership = + featuredHoldings == null + ? '—' + : formatOwnershipPercent( + featuredHoldings, + featuredCreator?.creatorShareSupply, + { + maximumFractionDigits: precisionMode === 'compact' ? 1 : 2, + } + ); + const mobileFeaturedOwnership = + featuredHoldings == null + ? '—' + : formatOwnershipPercent( + featuredHoldings, + featuredCreator?.creatorShareSupply + ); useEffect(() => { if (pendingScrollRestoreRef.current == null) return; @@ -501,7 +560,9 @@ function LandingPage() { }; const handleConfirmTrade = async (amount: number) => { - const previousHoldings = featuredHoldings; + if (isWalletBalanceLoading || featuredHoldings == null) return; + + const previousHoldings = featuredHoldingsSource; setTradeSubmitting(true); try { @@ -513,7 +574,7 @@ function LandingPage() { await new Promise(resolve => window.setTimeout(resolve, 900)); - setFeaturedHoldings(current => + setFeaturedHoldingsSource(current => tradeSide === 'buy' ? current + amount : Math.max(0, current - amount) @@ -529,7 +590,7 @@ function LandingPage() { ); setTradeDialogOpen(false); } catch (error) { - setFeaturedHoldings(previousHoldings); + setFeaturedHoldingsSource(previousHoldings); showToast.error(getSignatureErrorMessage(error)); } finally { setTradeSubmitting(false); @@ -868,24 +929,19 @@ function LandingPage() { }, { label: 'Your holdings', - value: `${formatNumber(featuredHoldings)} keys${formatOwnershipPercent( - featuredHoldings, - featuredCreator?.creatorShareSupply, - { - maximumFractionDigits: - precisionMode === 'compact' ? 1 : 2, - } - ) !== '—' - ? ` (${formatOwnershipPercent( - featuredHoldings, - featuredCreator?.creatorShareSupply, - { - maximumFractionDigits: - precisionMode === 'compact' ? 1 : 2, - } - )})` - : '' - }`, + value: isWalletBalanceLoading ? ( + + ) : ( + `${formatNumber( + displayedFeaturedHoldings + )} keys${formattedFeaturedOwnership !== '—' + ? ` (${formattedFeaturedOwnership})` + : '' + }` + ), + helperText: isWalletBalanceLoading + ? 'Refreshing for the selected network.' + : undefined, }, ]} /> @@ -915,14 +971,21 @@ function LandingPage() {
@@ -930,7 +993,11 @@ function LandingPage() { className="rounded-xl" variant="outline" onClick={() => openTradeDialog('sell')} - disabled={isNetworkMismatch || tradeSubmitting} + disabled={ + isNetworkMismatch || + tradeSubmitting || + isWalletBalanceLoading + } > Sell @@ -957,17 +1024,29 @@ function LandingPage() {
- {formatNumber(featuredHoldings)} keys - {formatOwnershipPercent(featuredHoldings, featuredCreator?.creatorShareSupply) !== '—' && ( - - ({formatOwnershipPercent(featuredHoldings, featuredCreator?.creatorShareSupply)}) - + {isWalletBalanceLoading ? ( + + ) : ( + <> + {formatNumber(displayedFeaturedHoldings)} keys + {mobileFeaturedOwnership !== '—' && ( + + ({mobileFeaturedOwnership}) + + )} + )}
@@ -976,15 +1055,22 @@ function LandingPage() {
@@ -993,7 +1079,11 @@ function LandingPage() { size="sm" variant="outline" onClick={() => openTradeDialog('sell')} - disabled={isNetworkMismatch || tradeSubmitting} + disabled={ + isNetworkMismatch || + tradeSubmitting || + isWalletBalanceLoading + } > Sell @@ -1027,6 +1117,7 @@ function LandingPage() { side={tradeSide} creatorName="Alex Rivers" availableHoldings={featuredHoldings} + isBalanceLoading={isWalletBalanceLoading} keyPriceStroops={resolveCreatorKeyPriceStroops(featuredCreator)} isSubmitting={tradeSubmitting} networkFeeEstimateProvider={tradeFeeEstimateProvider}