From 209a7c69227dae8eb3812f2aff77db91b20a8317 Mon Sep 17 00:00:00 2001 From: DeborahOlaboye Date: Mon, 31 Aug 2026 07:45:41 +0100 Subject: [PATCH 1/3] fix: collapse duplicate signal ternaries in stream.ts mutating wrappers (#399) Replace the 8 duplicate 'signal ? f(..., {signal}) : f(...)' patterns in withdraw, cancel, forceCancel, pause, resume, topUp, clawback, and transferRecipient with a single internal 'mutate' helper function. This reduces code duplication and eliminates a source where the {signal} option could be accidentally forgotten in future wrappers. --- lib/stream.ts | 63 +++++++++++++++++++++++++-------------------------- 1 file changed, 31 insertions(+), 32 deletions(-) diff --git a/lib/stream.ts b/lib/stream.ts index 16f4fef..17e8d09 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); } From 421b86e442f4331f9f0efaae71494dc45d3bb246 Mon Sep 17 00:00:00 2001 From: DeborahOlaboye Date: Mon, 31 Aug 2026 07:45:55 +0100 Subject: [PATCH 2/3] fix: RateTicker performance and accessibility (#396, #397) - #396: Change setInterval from 100ms to 1000ms aligned to whole seconds. Previously the 100ms interval fired 10 times per second but the displayed value only changed once per second due to elapsed flooring. This caused 9 no-op renders per visible update. On the dashboard/streams list with 20+ StreamCard instances, this reduces unnecessary renders from ~200/sec to ~20/sec. - #397: Remove 'aria-live="polite"' and 'aria-atomic="true"' from the ticker. An aria-live region that updates every second made screen readers announce the balance aloud continuously, rendering the interface unusable with assistive tech. The ticker is decorative motion; the balance can be accessed via other UI elements. --- components/stream/RateTicker.tsx | 25 +++++++++++++++++++++---- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/components/stream/RateTicker.tsx b/components/stream/RateTicker.tsx index 9992468..3a66109 100644 --- a/components/stream/RateTicker.tsx +++ b/components/stream/RateTicker.tsx @@ -29,16 +29,33 @@ export function RateTicker({ ratePerSecond, startBalance, decimals = 7 }: RateTi }, [startBalance]); useEffect(() => { - const id = setInterval(() => { + // 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); + + // 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)); - }, 100); - return () => clearInterval(id); + + // 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); + + return () => clearInterval(id); + }, msUntilNextSecond); + + return () => clearTimeout(alignmentTimer); }, [ratePerSecond, decimals]); return ( - + {display} ); From 30592867cbdd0d8c576592f2c97011d54c4758b2 Mon Sep 17 00:00:00 2001 From: DeborahOlaboye Date: Mon, 31 Aug 2026 07:46:30 +0100 Subject: [PATCH 3/3] refactor: replace manual debounce with useDebounce hook in create page (#395) Replace the hand-rolled recipient-check debouncing (using debounceRef, setTimeout, and clearTimeout) with the existing useDebounce hook from hooks/useDebounce.ts. This reduces code complexity, eliminates one fewer place to leak timers, and improves maintainability by using a single, proven abstraction instead of duplicating debounce logic. --- app/create/page.tsx | 39 +++++++++++++++------------------------ 1 file changed, 15 insertions(+), 24 deletions(-) diff --git a/app/create/page.tsx b/app/create/page.tsx index 4dc565e..248d8ed 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 } from '@/lib/stellar-address'; @@ -78,12 +79,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, formState: { errors } } = useForm({ resolver: zodResolver(schema), @@ -95,7 +90,10 @@ export default function CreatePage() { const token = watch('token'); const recipient = watch('recipient'); - // 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. @@ -109,10 +107,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'); @@ -121,40 +116,36 @@ export default function CreatePage() { setRecipientStatus('checking'); - if (debounceRef.current) clearTimeout(debounceRef.current); - const controller = new AbortController(); + let isMounted = true; - debounceRef.current = setTimeout(async () => { + (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); try { // Add a 10-second timeout to prevent an infinite loading state (#123) - const exists = await checkRecipientExists(recipient, { timeoutMs: 10_000 }); - if (!isCurrent()) return; + const exists = await checkRecipientExists(debouncedRecipient, { timeoutMs: 10_000 }); + if (!isMounted) return; setRecipientStatus(exists ? 'valid' : 'not-found'); } catch (err) { // Network / RPC error — don't block the user, but surface a warning. - if (!isCurrent()) return; + if (!isMounted) return; console.error('Recipient check failed:', err); setRecipientStatus('error'); } finally { clearTimeout(timeoutId); } - }, 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