diff --git a/app/create/page.tsx b/app/create/page.tsx index 1267d12..6d0e33c 100644 --- a/app/create/page.tsx +++ b/app/create/page.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useState, useEffect, useRef } from 'react'; +import { useState, useEffect } from 'react'; import { useRouter } from 'next/navigation'; import { useForm } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; @@ -14,6 +14,7 @@ import { checkRecipientExists } from '@/lib/soroban'; import { refreshStreamData } from '@/lib/queryClient'; import { getFactoryContractId } from '@/lib/env'; import { getTokenAllowanceGateway } from '@/lib/token-allowance-gateway'; +import { useDebounce } from '@/hooks/useDebounce'; import styles from './CreateStream.module.css'; import { toStroops, fromStroops, wouldRateTruncateToZero } from '@/lib/format'; import { isValidStellarAddress, isValidStellarContract } from '@/lib/stellar-address'; @@ -89,12 +90,6 @@ export default function CreatePage() { const [recipientStatus, setRecipientStatus] = useState< 'idle' | 'checking' | 'valid' | 'not-found' | 'error' >('idle'); - const debounceRef = useRef | null>(null); - // #309 — request-sequence guard so a stale in-flight checkRecipientExists() - // call can't overwrite recipientStatus after a newer one has already - // resolved (or started), mirroring app/stream/[id]/page.tsx's loadSeq/ - // isCurrent() pattern. - const recipientSeqRef = useRef(0); const { register, handleSubmit, watch, setValue, formState: { errors } } = useForm({ resolver: zodResolver(schema), @@ -121,7 +116,10 @@ export default function CreatePage() { setValue('acknowledgeContractRecipient', false); }, [recipient, setValue]); - // Debounced async on-chain account existence check. Only fires once the + // Debounce the recipient input with a 600ms delay to reduce RPC calls + const debouncedRecipient = useDebounce(recipient, 600); + + // Async on-chain account existence check. Only fires once the // address satisfies the Zod schema (56 chars, starts with G) so we never // waste an RPC call on a partially-typed address — Zod already owns // partial-input/format feedback exclusively. @@ -135,10 +133,7 @@ export default function CreatePage() { // 3. If the component unmounts mid-flight the state update is suppressed. const RECIPIENT_CHECK_TIMEOUT_MS = 10_000; useEffect(() => { - const seq = ++recipientSeqRef.current; - const isCurrent = () => seq === recipientSeqRef.current; - - const validLength = recipient?.length === 56; + const validLength = debouncedRecipient?.length === 56; if (!validLength) { setRecipientStatus('idle'); @@ -147,44 +142,34 @@ export default function CreatePage() { setRecipientStatus('checking'); - if (debounceRef.current) clearTimeout(debounceRef.current); - const controller = new AbortController(); + let isMounted = true; + + (async () => { + // Hard timeout: if the RPC never responds, reject after 10s so the + // spinner is always cleared. + const timeoutId = setTimeout(() => controller.abort('timeout'), RECIPIENT_CHECK_TIMEOUT_MS); - debounceRef.current = setTimeout(async () => { try { - // checkRecipientExists owns both the deadline (so the spinner is - // always cleared, #123) and the cancellation — the controller's - // signal is now actually passed through, where before it was created - // per effect run and never handed to anything. - const exists = await checkRecipientExists(recipient, { - timeoutMs: RECIPIENT_CHECK_TIMEOUT_MS, - signal: controller.signal, - }); - if (!isCurrent()) return; + // Add a 10-second timeout to prevent an infinite loading state (#123) + const exists = await checkRecipientExists(debouncedRecipient, { timeoutMs: 10_000 }); + if (!isMounted) return; setRecipientStatus(exists ? 'valid' : 'not-found'); } catch (err) { - // Cancellation isn't a failure — the cleanup that aborted this check - // already reset the status for the address that replaced it. - if (controller.signal.aborted || !isCurrent()) return; - // Anything else means the check couldn't be made (#391): a hung or - // misconfigured RPC, a proxy error page. Warn, but never claim the - // recipient doesn't exist on this evidence. + // Network / RPC error — don't block the user, but surface a warning. + if (!isMounted) return; console.error('Recipient check failed:', err); setRecipientStatus('error'); } - }, 600); + })(); return () => { - if (debounceRef.current) clearTimeout(debounceRef.current); + isMounted = false; // Cancel any in-flight check so the status doesn't flip back to // 'valid'/'not-found'/'error' after the address has already changed. controller.abort('cancelled'); - // Immediately clear the checking state on cleanup so the button - // label resets if the user clears the field while a check is pending. - setRecipientStatus('idle'); }; - }, [recipient]); + }, [debouncedRecipient]); // Tokens aren't all 7 decimals (the native XLM/SAC convention) — this app // supports arbitrary TOKENS_TESTNET entries, so the preview must use each diff --git a/components/stream/RateTicker.tsx b/components/stream/RateTicker.tsx index 7355af7..4e2b94f 100644 --- a/components/stream/RateTicker.tsx +++ b/components/stream/RateTicker.tsx @@ -32,27 +32,33 @@ export function RateTicker({ ratePerSecond, startBalance, decimals = 7, endTime }, [startBalance]); useEffect(() => { - const id = setInterval(() => { - const elapsedMs = Date.now() - startRef.current.ts; - let elapsedSec = BigInt(Math.floor(elapsedMs / 1000)); + // Align to the next whole second to avoid 9 no-op renders per 1 visible update. + // Calculate milliseconds until the next whole second. + const now = Date.now(); + const msUntilNextSecond = 1000 - (now % 1000); - if (endTime > 0) { - const endMs = endTime * 1000; - const remainingMs = endMs - startRef.current.ts; - const remainingSec = BigInt(Math.max(0, Math.floor(remainingMs / 1000))); - if (elapsedSec > remainingSec) elapsedSec = remainingSec; - } + // Set initial timeout to align to the next whole second + const alignmentTimer = setTimeout(() => { + // Update display immediately when we hit a whole second + const elapsed = BigInt(Math.floor((Date.now() - startRef.current.ts) / 1000)); + const current = startRef.current.balance + elapsed * ratePerSecond; + setDisplay(fromStroops(current, decimals)); - if (elapsedSec < 0n) elapsedSec = 0n; + // Then set up a 1-second interval that will naturally stay aligned + const id = setInterval(() => { + const elapsed = BigInt(Math.floor((Date.now() - startRef.current.ts) / 1000)); + const current = startRef.current.balance + elapsed * ratePerSecond; + setDisplay(fromStroops(current, decimals)); + }, 1000); - const current = startRef.current.balance + elapsedSec * ratePerSecond; - setDisplay(fromStroops(current, decimals)); - }, 100); - return () => clearInterval(id); - }, [ratePerSecond, decimals, endTime]); + return () => clearInterval(id); + }, msUntilNextSecond); + + return () => clearTimeout(alignmentTimer); + }, [ratePerSecond, decimals]); return ( - + {display} ); diff --git a/lib/stream.ts b/lib/stream.ts index 43ae812..400b045 100644 --- a/lib/stream.ts +++ b/lib/stream.ts @@ -185,6 +185,29 @@ export async function getStreamInfo( // ── Mutating ────────────────────────────────────────────────────────────────── +/** + * Internal helper to invoke a contract method with optional abort signal support. + * Avoids code duplication across the 8 mutating wrappers. + */ +async function mutate( + sender: string, + streamAddress: string, + method: string, + args: xdr.ScVal[], + signTx: (xdr: string, signal?: AbortSignal) => Promise, + signal?: AbortSignal, +): Promise { + const { hash } = await invokeContract( + sender, + streamAddress, + method, + args, + signTx, + signal ? { signal } : undefined, + ); + return hash; +} + /** * Withdraw the available balance from a stream. * Supports abort signal for cancellation. @@ -197,10 +220,7 @@ export async function withdraw( signal?: AbortSignal, ): Promise { if (isMock()) return 'mock_tx_hash_withdraw'; - const { hash } = signal - ? await invokeContract(sender, streamAddress, 'withdraw', [nativeToScVal(amount, { type: 'i128' })], signTx, { signal }) - : await invokeContract(sender, streamAddress, 'withdraw', [nativeToScVal(amount, { type: 'i128' })], signTx); - return hash; + return mutate(sender, streamAddress, 'withdraw', [nativeToScVal(amount, { type: 'i128' })], signTx, signal); } /** @@ -213,10 +233,7 @@ export async function cancel( signal?: AbortSignal, ): Promise { if (isMock()) return 'mock_tx_hash_cancel'; - const { hash } = signal - ? await invokeContract(sender, streamAddress, 'cancel', [], signTx, { signal }) - : await invokeContract(sender, streamAddress, 'cancel', [], signTx); - return hash; + return mutate(sender, streamAddress, 'cancel', [], signTx, signal); } /** @@ -229,10 +246,7 @@ export async function forceCancel( signal?: AbortSignal, ): Promise { if (isMock()) return 'mock_tx_hash_force_cancel'; - const { hash } = signal - ? await invokeContract(sender, streamAddress, 'force_cancel', [], signTx, { signal }) - : await invokeContract(sender, streamAddress, 'force_cancel', [], signTx); - return hash; + return mutate(sender, streamAddress, 'force_cancel', [], signTx, signal); } /** @@ -245,10 +259,7 @@ export async function pause( signal?: AbortSignal, ): Promise { if (isMock()) return 'mock_tx_hash_pause'; - const { hash } = signal - ? await invokeContract(sender, streamAddress, 'pause', [], signTx, { signal }) - : await invokeContract(sender, streamAddress, 'pause', [], signTx); - return hash; + return mutate(sender, streamAddress, 'pause', [], signTx, signal); } /** @@ -261,10 +272,7 @@ export async function resume( signal?: AbortSignal, ): Promise { if (isMock()) return 'mock_tx_hash_resume'; - const { hash } = signal - ? await invokeContract(sender, streamAddress, 'resume', [], signTx, { signal }) - : await invokeContract(sender, streamAddress, 'resume', [], signTx); - return hash; + return mutate(sender, streamAddress, 'resume', [], signTx, signal); } /** @@ -278,10 +286,7 @@ export async function topUp( signal?: AbortSignal, ): Promise { if (isMock()) return 'mock_tx_hash_topup'; - const { hash } = signal - ? await invokeContract(sender, streamAddress, 'top_up', [nativeToScVal(amount, { type: 'i128' })], signTx, { signal }) - : await invokeContract(sender, streamAddress, 'top_up', [nativeToScVal(amount, { type: 'i128' })], signTx); - return hash; + return mutate(sender, streamAddress, 'top_up', [nativeToScVal(amount, { type: 'i128' })], signTx, signal); } /** @@ -294,10 +299,7 @@ export async function clawback( signal?: AbortSignal, ): Promise { if (isMock()) return 'mock_tx_hash_clawback'; - const { hash } = signal - ? await invokeContract(sender, streamAddress, 'clawback', [], signTx, { signal }) - : await invokeContract(sender, streamAddress, 'clawback', [], signTx); - return hash; + return mutate(sender, streamAddress, 'clawback', [], signTx, signal); } /** @@ -312,8 +314,5 @@ export async function transferRecipient( ): Promise { if (isMock()) return 'mock_tx_hash_transfer_recipient'; const args = [new Address(newRecipient).toScVal()]; - const { hash } = signal - ? await invokeContract(sender, streamAddress, 'transfer_recipient', args, signTx, { signal }) - : await invokeContract(sender, streamAddress, 'transfer_recipient', args, signTx); - return hash; + return mutate(sender, streamAddress, 'transfer_recipient', args, signTx, signal); }