Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/App.css
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
button {
cursor: pointer;
padding: 30;
}
46 changes: 43 additions & 3 deletions src/components/common/TradeDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,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;
Expand All @@ -36,6 +37,7 @@ const TradeDialog: React.FC<TradeDialogProps> = ({
side,
creatorName,
availableHoldings,
isBalanceLoading = false,
keyPriceStroops,
onOpenChange,
onConfirm,
Expand All @@ -58,6 +60,14 @@ const TradeDialog: React.FC<TradeDialogProps> = ({
return Number(normalized);
}, [amountText]);

<<<<<<< HEAD
const amountValid =
Number.isFinite(parsedAmount) &&
parsedAmount > 0 &&
!isBalanceLoading &&
(side !== 'sell' ||
(availableHoldings != null && parsedAmount <= availableHoldings));
=======
const validationError = useMemo((): string | null => {
const normalized = amountText.trim();
if (!normalized) return 'Please enter an amount.';
Expand All @@ -70,6 +80,7 @@ const TradeDialog: React.FC<TradeDialogProps> = ({

const amountValid = validationError === null;
const showError = touched && validationError !== null;
>>>>>>> upstream/main

const title = side === 'buy' ? 'Buy keys' : 'Sell keys';
const confirmLabel = side === 'buy' ? 'Confirm buy' : 'Confirm sell';
Expand Down Expand Up @@ -151,11 +162,23 @@ const TradeDialog: React.FC<TradeDialogProps> = ({
)}
<div className="flex flex-wrap items-center gap-2 text-xs text-white/45">
<span
aria-label={`Current wallet holdings: ${formatNumber(availableHoldings)} keys`}
aria-busy={isBalanceLoading || undefined}
aria-label={
isBalanceLoading || availableHoldings == null
? 'Current wallet holdings loading'
: `Current wallet holdings: ${formatNumber(
availableHoldings
)} keys`
}
role="status"
>
Holdings: {formatNumber(availableHoldings)} keys
{isBalanceLoading || availableHoldings == null
? 'Holdings: Loading...'
: `Holdings: ${formatNumber(availableHoldings)} keys`}
</span>
{side === 'sell' &&
!isBalanceLoading &&
availableHoldings != null &&
availableHoldings > 0 &&
Number.isFinite(parsedAmount) &&
parsedAmount > 0 && (
Expand All @@ -170,13 +193,30 @@ const TradeDialog: React.FC<TradeDialogProps> = ({
/>
)}
</div>
<<<<<<< HEAD
<NetworkFeeHint
variant="text"
label="Approx. network fee"
fee={networkFeeCopy}
className="text-white/45"
/>
{side === 'sell' &&
!isBalanceLoading &&
availableHoldings != null &&
parsedAmount > availableHoldings && (
<div className="text-xs text-red-300">
You can’t sell more than your current holdings.
</div>
)}
=======
{side === 'buy' && (
<NetworkFeeHint
variant="text"
fee={estimatedNetworkFee}
className="text-white/45"
/>
)}
>>>>>>> upstream/main
</div>

{/*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
});
115 changes: 115 additions & 0 deletions src/hooks/__tests__/useNetworkAwareBalance.test.ts
Original file line number Diff line number Diff line change
@@ -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<T>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>(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<number>();
const secondBalance = createDeferred<number>();
const fetchFirstBalance = vi.fn(() => firstBalance.promise);
const fetchSecondBalance = vi.fn(() => secondBalance.promise);

const { result, rerender } = renderHook(
({
balanceKey,
fetchBalance,
}: {
balanceKey: string;
fetchBalance: () => Promise<number>;
}) =>
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<number>();
const currentBalance = createDeferred<number>();
const fetchStaleBalance = vi.fn(() => staleBalance.promise);
const fetchCurrentBalance = vi.fn(() => currentBalance.promise);

const { result, rerender } = renderHook(
({
balanceKey,
fetchBalance,
}: {
balanceKey: string;
fetchBalance: () => Promise<number>;
}) =>
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);
});
});
123 changes: 123 additions & 0 deletions src/hooks/useNetworkAwareBalance.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';

type BalanceStatus = 'idle' | 'loading' | 'success' | 'error';

interface BalanceState<TBalance> {
balance: TBalance | null;
balanceKey: string | null;
error: unknown;
status: BalanceStatus;
}

export interface UseNetworkAwareBalanceOptions<TBalance> {
/**
* 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<TBalance>;
}

export interface UseNetworkAwareBalanceResult<TBalance> {
balance: TBalance | null;
error: unknown;
isError: boolean;
isLoading: boolean;
refresh: () => void;
}

export function useNetworkAwareBalance<TBalance>({
balanceKey,
enabled = true,
fetchBalance,
}: UseNetworkAwareBalanceOptions<TBalance>): UseNetworkAwareBalanceResult<TBalance> {
const requestIdRef = useRef(0);
const [refreshNonce, setRefreshNonce] = useState(0);
const [state, setState] = useState<BalanceState<TBalance>>({
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,
]
);
}
Loading
Loading