From 21e701346ae323d4440adc0070e723ec7256c3d8 Mon Sep 17 00:00:00 2001 From: presidojay1 <88345996+presidojay1@users.noreply.github.com> Date: Mon, 17 Aug 2026 08:12:11 +0100 Subject: [PATCH 01/10] feat: add usePendingIds hook for per-row mutation loading state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A single shared useMutation's .variables is a scalar overwritten by the most recent .mutate() call, not a record of everything currently pending — so deriving a row's loading indicator from it silently clobbers another row's indicator when two actions overlap (#52). usePendingIds tracks a Set of in-flight ids instead, updated independently of any single mutation instance. --- src/hooks/usePendingIds.ts | 45 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 src/hooks/usePendingIds.ts diff --git a/src/hooks/usePendingIds.ts b/src/hooks/usePendingIds.ts new file mode 100644 index 0000000..7dfccc8 --- /dev/null +++ b/src/hooks/usePendingIds.ts @@ -0,0 +1,45 @@ +import { useCallback, useState } from 'react' + +/** + * Tracks a set of in-flight ids independently of any single mutation's + * `.variables` — which is a single scalar, overwritten by the most recent + * `.mutate()`/`.mutateAsync()` call, not a record of everything currently + * pending. Deriving a per-row loading indicator from `.variables` means a + * second concurrent action (e.g. releasing a different escrow while the + * first is still in flight) silently clobbers the first row's indicator. + * See #52. + * + * `track(id, promise)` marks `id` pending immediately and clears it once + * `promise` settles, regardless of success or failure, so each id's + * pending state is independent of what any other id is doing. + */ +export function usePendingIds() { + const [pendingIds, setPendingIds] = useState>(new Set()) + + const track = useCallback((id: string, promise: Promise): void => { + setPendingIds((prev) => { + if (prev.has(id)) return prev + const next = new Set(prev) + next.add(id) + return next + }) + + promise + .catch(() => { + // Already surfaced via the mutation's own error state (isError/ + // error); this hook only tracks pending-ness, not the outcome, and + // nothing here awaits `track`'s return, so an unhandled rejection + // would otherwise leak from this call. + }) + .finally(() => { + setPendingIds((prev) => { + if (!prev.has(id)) return prev + const next = new Set(prev) + next.delete(id) + return next + }) + }) + }, []) + + return { pendingIds, track } +} From 9ae8bf0ca48bc05046c1142c0bbf5fca0f4ec123 Mon Sep 17 00:00:00 2001 From: presidojay1 <88345996+presidojay1@users.noreply.github.com> Date: Mon, 17 Aug 2026 08:13:35 +0100 Subject: [PATCH 02/10] fix(escrow): give each row an independent release-pending indicator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit releaseMutation.variables is a single scalar overwritten by the most recent .mutate() call — releasing escrow B while escrow A's release is still in flight silently un-showed A's spinner (and re-enabled its button) even though A's request hadn't settled (#52). Escrow.tsx now tracks releasing ids via usePendingIds, and EscrowList/EscrowItem derive isReleasing from Set membership instead of scalar equality. --- src/components/Escrow/EscrowList.tsx | 8 +++++--- src/pages/Escrow.tsx | 6 ++++-- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/src/components/Escrow/EscrowList.tsx b/src/components/Escrow/EscrowList.tsx index 44bec76..2903876 100644 --- a/src/components/Escrow/EscrowList.tsx +++ b/src/components/Escrow/EscrowList.tsx @@ -14,7 +14,9 @@ interface EscrowListProps { currentPublicKey: string | null onRelease: (id: string) => void onRefund: (id: string) => void - releasingId?: string | null + // A Set of every currently in-flight id, not a single scalar — two + // different escrows can be releasing/refunding at once (#52). + releasingIds?: Set refundingId?: string | null } @@ -26,7 +28,7 @@ export function EscrowList({ currentPublicKey, onRelease, onRefund, - releasingId, + releasingIds, refundingId, }: EscrowListProps) { return ( @@ -63,7 +65,7 @@ export function EscrowList({ currentPublicKey={currentPublicKey} onRelease={onRelease} onRefund={onRefund} - isReleasing={releasingId === e.id} + isReleasing={releasingIds?.has(e.id) ?? false} isRefunding={refundingId === e.id} /> ))} diff --git a/src/pages/Escrow.tsx b/src/pages/Escrow.tsx index 483da91..8ab39a3 100644 --- a/src/pages/Escrow.tsx +++ b/src/pages/Escrow.tsx @@ -12,6 +12,7 @@ import { useReleaseEscrow, useRefundEscrow, } from '@/hooks/useEscrows' +import { usePendingIds } from '@/hooks/usePendingIds' import { useWallet } from '@/hooks/useWallet' export default function EscrowPage() { @@ -22,6 +23,7 @@ export default function EscrowPage() { const { data: escrows, isLoading, isError, refetch } = useEscrowList() const releaseMutation = useReleaseEscrow() const refundMutation = useRefundEscrow() + const { pendingIds: releasingIds, track: trackRelease } = usePendingIds() if (!isConnected) { return ( @@ -88,9 +90,9 @@ export default function EscrowPage() { isError={isError} onRetry={() => refetch()} currentPublicKey={publicKey} - onRelease={(id) => releaseMutation.mutate(id)} + onRelease={(id) => trackRelease(id, releaseMutation.mutateAsync(id))} onRefund={(id) => refundMutation.mutate(id)} - releasingId={releaseMutation.isPending ? releaseMutation.variables ?? null : null} + releasingIds={releasingIds} refundingId={refundMutation.isPending ? refundMutation.variables ?? null : null} /> From d5375a0ac9d96788b17d3e16a7c7a9d27f3799af Mon Sep 17 00:00:00 2001 From: presidojay1 <88345996+presidojay1@users.noreply.github.com> Date: Mon, 17 Aug 2026 08:14:38 +0100 Subject: [PATCH 03/10] fix(escrow): give each row an independent refund-pending indicator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Same clobbering bug as the release fix, but for refundMutation — a depositor refunding escrow B while escrow A's refund is still in flight silently un-showed A's spinner (#52). EscrowList now derives isRefunding from a Set of in-flight refunding ids too, matching the release fix. --- src/components/Escrow/EscrowList.tsx | 6 +++--- src/pages/Escrow.tsx | 5 +++-- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/src/components/Escrow/EscrowList.tsx b/src/components/Escrow/EscrowList.tsx index 2903876..67c6501 100644 --- a/src/components/Escrow/EscrowList.tsx +++ b/src/components/Escrow/EscrowList.tsx @@ -17,7 +17,7 @@ interface EscrowListProps { // A Set of every currently in-flight id, not a single scalar — two // different escrows can be releasing/refunding at once (#52). releasingIds?: Set - refundingId?: string | null + refundingIds?: Set } export function EscrowList({ @@ -29,7 +29,7 @@ export function EscrowList({ onRelease, onRefund, releasingIds, - refundingId, + refundingIds, }: EscrowListProps) { return ( @@ -66,7 +66,7 @@ export function EscrowList({ onRelease={onRelease} onRefund={onRefund} isReleasing={releasingIds?.has(e.id) ?? false} - isRefunding={refundingId === e.id} + isRefunding={refundingIds?.has(e.id) ?? false} /> ))} diff --git a/src/pages/Escrow.tsx b/src/pages/Escrow.tsx index 8ab39a3..65efe3c 100644 --- a/src/pages/Escrow.tsx +++ b/src/pages/Escrow.tsx @@ -24,6 +24,7 @@ export default function EscrowPage() { const releaseMutation = useReleaseEscrow() const refundMutation = useRefundEscrow() const { pendingIds: releasingIds, track: trackRelease } = usePendingIds() + const { pendingIds: refundingIds, track: trackRefund } = usePendingIds() if (!isConnected) { return ( @@ -91,9 +92,9 @@ export default function EscrowPage() { onRetry={() => refetch()} currentPublicKey={publicKey} onRelease={(id) => trackRelease(id, releaseMutation.mutateAsync(id))} - onRefund={(id) => refundMutation.mutate(id)} + onRefund={(id) => trackRefund(id, refundMutation.mutateAsync(id))} releasingIds={releasingIds} - refundingId={refundMutation.isPending ? refundMutation.variables ?? null : null} + refundingIds={refundingIds} /> From 87a1231745361541d334471c8ec78f497586dd32 Mon Sep 17 00:00:00 2001 From: presidojay1 <88345996+presidojay1@users.noreply.github.com> Date: Mon, 17 Aug 2026 08:15:59 +0100 Subject: [PATCH 04/10] fix(subscriptions): give each row an independent cancel-pending indicator Identical shape to the escrow release/refund bug: cancelMutation was shared across the whole SubscriptionList, so cancelling subscription B while subscription A's cancellation was still in flight silently un-showed A's spinner (#52). Subscriptions.tsx now tracks cancelling ids via usePendingIds, matching the escrow fix. --- src/components/Subscriptions/SubscriptionList.tsx | 8 +++++--- src/pages/Subscriptions.tsx | 6 ++++-- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/src/components/Subscriptions/SubscriptionList.tsx b/src/components/Subscriptions/SubscriptionList.tsx index 2f1822a..4260556 100644 --- a/src/components/Subscriptions/SubscriptionList.tsx +++ b/src/components/Subscriptions/SubscriptionList.tsx @@ -12,7 +12,9 @@ interface SubscriptionListProps { isError: boolean onRetry: () => void onCancel: (id: string) => void - cancellingId?: string | null + // A Set of every currently in-flight id, not a single scalar — two + // different subscriptions can be cancelling at once (#52). + cancellingIds?: Set } export function SubscriptionList({ @@ -21,7 +23,7 @@ export function SubscriptionList({ isError, onRetry, onCancel, - cancellingId, + cancellingIds, }: SubscriptionListProps) { return ( @@ -53,7 +55,7 @@ export function SubscriptionList({ key={s.id} subscription={s} onCancel={onCancel} - isCancelling={cancellingId === s.id} + isCancelling={cancellingIds?.has(s.id) ?? false} /> ))} diff --git a/src/pages/Subscriptions.tsx b/src/pages/Subscriptions.tsx index 728443c..a08f848 100644 --- a/src/pages/Subscriptions.tsx +++ b/src/pages/Subscriptions.tsx @@ -11,6 +11,7 @@ import { useSubscriptionList, useCancelSubscription, } from '@/hooks/useSubscriptions' +import { usePendingIds } from '@/hooks/usePendingIds' import { useWallet } from '@/hooks/useWallet' export default function Subscriptions() { @@ -27,6 +28,7 @@ export default function Subscriptions() { const { data: subscriptions, isLoading, isError, refetch } = useSubscriptionList() const cancelMutation = useCancelSubscription() + const { pendingIds: cancellingIds, track: trackCancel } = usePendingIds() if (!isConnected) { return ( @@ -93,8 +95,8 @@ export default function Subscriptions() { isLoading={isLoading} isError={isError} onRetry={() => refetch()} - onCancel={(id) => cancelMutation.mutate(id)} - cancellingId={cancelMutation.isPending ? cancelMutation.variables ?? null : null} + onCancel={(id) => trackCancel(id, cancelMutation.mutateAsync(id))} + cancellingIds={cancellingIds} /> From 1782e57dc05c94af9318e8858421a8339ed9002f Mon Sep 17 00:00:00 2001 From: presidojay1 <88345996+presidojay1@users.noreply.github.com> Date: Mon, 17 Aug 2026 08:17:26 +0100 Subject: [PATCH 05/10] test: cover usePendingIds tracking, isolation, and cleanup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two ids tracked concurrently stay independently pending; resolving one never clears a different, still-pending id — the exact clobbering this hook exists to prevent (#52). Also covers rejection (cleared without an unhandled rejection), same-id re-tracking, and track's referential stability across renders. --- src/hooks/usePendingIds.test.ts | 133 ++++++++++++++++++++++++++++++++ 1 file changed, 133 insertions(+) create mode 100644 src/hooks/usePendingIds.test.ts diff --git a/src/hooks/usePendingIds.test.ts b/src/hooks/usePendingIds.test.ts new file mode 100644 index 0000000..1828160 --- /dev/null +++ b/src/hooks/usePendingIds.test.ts @@ -0,0 +1,133 @@ +import { act, renderHook } from '@testing-library/react' +import { describe, it, expect, vi } from 'vitest' +import { usePendingIds } from './usePendingIds' + +function deferred() { + let resolve!: (value: T) => void + let reject!: (reason?: unknown) => void + const promise = new Promise((res, rej) => { + resolve = res + reject = rej + }) + return { promise, resolve, reject } +} + +describe('usePendingIds', () => { + it('marks an id pending immediately when tracked', () => { + const { result } = renderHook(() => usePendingIds()) + const { promise } = deferred() + + act(() => { + result.current.track('a', promise) + }) + + expect(result.current.pendingIds.has('a')).toBe(true) + }) + + it('two different ids tracked concurrently stay independently pending', () => { + const { result } = renderHook(() => usePendingIds()) + const a = deferred() + const b = deferred() + + act(() => { + result.current.track('a', a.promise) + result.current.track('b', b.promise) + }) + + expect(result.current.pendingIds.has('a')).toBe(true) + expect(result.current.pendingIds.has('b')).toBe(true) + }) + + it('resolving one id does not clear a different, still-pending id (#52)', async () => { + const { result } = renderHook(() => usePendingIds()) + const a = deferred() + const b = deferred() + + act(() => { + result.current.track('a', a.promise) + result.current.track('b', b.promise) + }) + + await act(async () => { + b.resolve() + await b.promise + }) + + expect(result.current.pendingIds.has('a')).toBe(true) + expect(result.current.pendingIds.has('b')).toBe(false) + }) + + it('clears the id once its promise resolves', async () => { + const { result } = renderHook(() => usePendingIds()) + const { promise, resolve } = deferred() + + act(() => { + result.current.track('a', promise) + }) + expect(result.current.pendingIds.has('a')).toBe(true) + + await act(async () => { + resolve() + await promise + }) + + expect(result.current.pendingIds.has('a')).toBe(false) + }) + + it('clears the id even when its promise rejects, without an unhandled rejection', async () => { + const { result } = renderHook(() => usePendingIds()) + const { promise, reject } = deferred() + + act(() => { + result.current.track('a', promise) + }) + + await act(async () => { + reject(new Error('boom')) + await promise.catch(() => {}) + }) + + expect(result.current.pendingIds.has('a')).toBe(false) + }) + + it('tracking the same id twice while the first is still pending is a no-op on entry', () => { + const { result } = renderHook(() => usePendingIds()) + const a = deferred() + const b = deferred() + + act(() => { + result.current.track('a', a.promise) + result.current.track('a', b.promise) + }) + + expect(result.current.pendingIds.has('a')).toBe(true) + expect(result.current.pendingIds.size).toBe(1) + }) + + it('track is referentially stable across renders', () => { + const { result, rerender } = renderHook(() => usePendingIds()) + const first = result.current.track + rerender() + expect(result.current.track).toBe(first) + }) + + it('does not produce an unhandled rejection warning for a rejected promise', async () => { + const onUnhandledRejection = vi.fn() + process.on('unhandledRejection', onUnhandledRejection) + + const { result } = renderHook(() => usePendingIds()) + const { promise, reject } = deferred() + + act(() => { + result.current.track('a', promise) + }) + + await act(async () => { + reject(new Error('boom')) + await new Promise((r) => setTimeout(r, 0)) + }) + + expect(onUnhandledRejection).not.toHaveBeenCalled() + process.off('unhandledRejection', onUnhandledRejection) + }) +}) From 11ebf0d272d1d8be0d70ffcd2403926fb8b7cd34 Mon Sep 17 00:00:00 2001 From: presidojay1 <88345996+presidojay1@users.noreply.github.com> Date: Mon, 17 Aug 2026 08:18:54 +0100 Subject: [PATCH 06/10] test: cover EscrowList's Set-based per-row releasing state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two different rows in releasingIds show as releasing at the same time without either one's state overwriting the other's — the UI symptom of #52. Also covers undefined releasingIds rendering nothing as pending. --- src/components/Escrow/EscrowList.test.tsx | 92 +++++++++++++++++++++++ 1 file changed, 92 insertions(+) create mode 100644 src/components/Escrow/EscrowList.test.tsx diff --git a/src/components/Escrow/EscrowList.test.tsx b/src/components/Escrow/EscrowList.test.tsx new file mode 100644 index 0000000..cbffa8e --- /dev/null +++ b/src/components/Escrow/EscrowList.test.tsx @@ -0,0 +1,92 @@ +import { render, screen } from '@testing-library/react' +import { describe, it, expect, vi } from 'vitest' +import { EscrowList } from './EscrowList' +import type { Escrow } from '@/types' + +const BENEFICIARY = 'GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN' + +function makeEscrow(overrides: Partial = {}): Escrow { + return { + id: 'esc_1', + depositorPublicKey: 'GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5', + beneficiaryPublicKey: BENEFICIARY, + arbiterPublicKey: null, + assetCode: 'XLM', + assetIssuer: null, + amount: '100', + unlockTime: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(), + status: 'funded', + createdAt: new Date().toISOString(), + ...overrides, + } +} + +const noop = vi.fn() + +describe('EscrowList per-row pending state (#52)', () => { + it('shows a row as releasing only when its own id is in releasingIds', () => { + const escrows = [ + makeEscrow({ id: 'escrowA', amount: '10' }), + makeEscrow({ id: 'escrowB', amount: '20' }), + ] + render( + , + ) + + const releaseButtons = screen.getAllByRole('button', { name: /release/i }) + expect(releaseButtons).toHaveLength(2) + // escrowA (10 XLM) not releasing, escrowB (20 XLM) is. + expect(releaseButtons[0]).not.toBeDisabled() + expect(releaseButtons[1]).toBeDisabled() + }) + + it('shows two different rows as releasing simultaneously without clobbering each other (#52)', () => { + const escrows = [ + makeEscrow({ id: 'escrowA', amount: '10' }), + makeEscrow({ id: 'escrowB', amount: '20' }), + makeEscrow({ id: 'escrowC', amount: '30' }), + ] + render( + , + ) + + const releaseButtons = screen.getAllByRole('button', { name: /release/i }) + expect(releaseButtons[0]).toBeDisabled() // escrowA + expect(releaseButtons[1]).not.toBeDisabled() // escrowB + expect(releaseButtons[2]).toBeDisabled() // escrowC + }) + + it('treats an undefined releasingIds as nothing pending', () => { + render( + , + ) + + expect(screen.getByRole('button', { name: /release/i })).not.toBeDisabled() + }) +}) From 0e2167cac609b06df2c7dad44643e2dd2f2391e9 Mon Sep 17 00:00:00 2001 From: presidojay1 <88345996+presidojay1@users.noreply.github.com> Date: Mon, 17 Aug 2026 08:20:17 +0100 Subject: [PATCH 07/10] test: cover SubscriptionList's Set-based per-row cancelling state Mirrors the EscrowList coverage: two different rows in cancellingIds show as cancelling at the same time without either one's state overwriting the other's (#52). --- .../Subscriptions/SubscriptionList.test.tsx | 84 +++++++++++++++++++ 1 file changed, 84 insertions(+) create mode 100644 src/components/Subscriptions/SubscriptionList.test.tsx diff --git a/src/components/Subscriptions/SubscriptionList.test.tsx b/src/components/Subscriptions/SubscriptionList.test.tsx new file mode 100644 index 0000000..2a1a834 --- /dev/null +++ b/src/components/Subscriptions/SubscriptionList.test.tsx @@ -0,0 +1,84 @@ +import { render, screen } from '@testing-library/react' +import { describe, it, expect, vi } from 'vitest' +import { SubscriptionList } from './SubscriptionList' +import type { Subscription } from '@/types' + +function makeSubscription(overrides: Partial = {}): Subscription { + return { + id: 'sub_1', + sourcePublicKey: 'GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5', + destinationAddress: 'GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN', + asset: { code: 'XLM', issuer: null, name: 'Stellar Lumens', decimals: 7 }, + amount: '10', + interval: 'monthly', + startDate: new Date().toISOString(), + nextRunAt: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(), + status: 'active', + createdAt: new Date().toISOString(), + runCount: 0, + ...overrides, + } +} + +const noop = vi.fn() + +describe('SubscriptionList per-row pending state (#52)', () => { + it('shows a row as cancelling only when its own id is in cancellingIds', () => { + const subscriptions = [ + makeSubscription({ id: 'subA', amount: '10' }), + makeSubscription({ id: 'subB', amount: '20' }), + ] + render( + , + ) + + const cancelButtons = screen.getAllByRole('button', { name: /cancel/i }) + expect(cancelButtons).toHaveLength(2) + expect(cancelButtons[0]).not.toBeDisabled() + expect(cancelButtons[1]).toBeDisabled() + }) + + it('shows two different rows as cancelling simultaneously without clobbering each other (#52)', () => { + const subscriptions = [ + makeSubscription({ id: 'subA', amount: '10' }), + makeSubscription({ id: 'subB', amount: '20' }), + makeSubscription({ id: 'subC', amount: '30' }), + ] + render( + , + ) + + const cancelButtons = screen.getAllByRole('button', { name: /cancel/i }) + expect(cancelButtons[0]).toBeDisabled() // subA + expect(cancelButtons[1]).not.toBeDisabled() // subB + expect(cancelButtons[2]).toBeDisabled() // subC + }) + + it('treats an undefined cancellingIds as nothing pending', () => { + render( + , + ) + + expect(screen.getByRole('button', { name: /cancel/i })).not.toBeDisabled() + }) +}) From 17cb49dc3f64b9a0f8a466afd6193319f79918e4 Mon Sep 17 00:00:00 2001 From: presidojay1 <88345996+presidojay1@users.noreply.github.com> Date: Mon, 17 Aug 2026 08:21:53 +0100 Subject: [PATCH 08/10] test: reproduce #52's release-clobbering scenario end-to-end MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Combines the real useReleaseEscrow mutation with usePendingIds (mocking only the API/wallet boundary), following the issue's own suggested testing strategy: releasing escrow B while escrow A's release is still in flight must not affect A's pending state. Explicitly asserts releaseMutation.variables becomes 'escrowB' at that point — the old, wrong source of truth — while pendingIds correctly still has both. --- src/hooks/useEscrows.test.tsx | 116 ++++++++++++++++++++++++++++++++++ 1 file changed, 116 insertions(+) create mode 100644 src/hooks/useEscrows.test.tsx diff --git a/src/hooks/useEscrows.test.tsx b/src/hooks/useEscrows.test.tsx new file mode 100644 index 0000000..b68105c --- /dev/null +++ b/src/hooks/useEscrows.test.tsx @@ -0,0 +1,116 @@ +import React from 'react' +import { act, renderHook, waitFor } from '@testing-library/react' +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import type { Escrow } from '@/types' + +const apiMocks = vi.hoisted(() => ({ + escrowApi: { + buildReleaseTransaction: vi.fn(), + release: vi.fn(), + buildRefundTransaction: vi.fn(), + refund: vi.fn(), + }, +})) +vi.mock('@/lib/api', () => apiMocks) + +const walletMocks = vi.hoisted(() => ({ + signTransaction: vi.fn(async (xdr: string) => `signed:${xdr}`), + refreshAccount: vi.fn(async () => {}), +})) +vi.mock('./useWallet', () => ({ useWallet: () => walletMocks })) + +import { useReleaseEscrow } from './useEscrows' +import { usePendingIds } from './usePendingIds' + +function deferred() { + let resolve!: (value: T) => void + const promise = new Promise((res) => { + resolve = res + }) + return { promise, resolve } +} + +function makeEscrow(overrides: Partial = {}): Escrow { + return { + id: 'esc_1', + depositorPublicKey: 'GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5', + beneficiaryPublicKey: 'GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN', + arbiterPublicKey: null, + assetCode: 'XLM', + assetIssuer: null, + amount: '100', + unlockTime: new Date().toISOString(), + status: 'funded', + createdAt: new Date().toISOString(), + ...overrides, + } +} + +function wrapper({ children }: { children: React.ReactNode }) { + const client = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }) + return {children} +} + +function useReleaseHarness() { + const releaseMutation = useReleaseEscrow() + const { pendingIds, track } = usePendingIds() + return { releaseMutation, pendingIds, track } +} + +beforeEach(() => { + apiMocks.escrowApi.buildReleaseTransaction.mockReset() + apiMocks.escrowApi.release.mockReset() + apiMocks.escrowApi.buildReleaseTransaction.mockImplementation(async (id: string) => ({ + xdr: `unsigned-xdr-${id}`, + fee: '100', + })) +}) + +describe('useReleaseEscrow + usePendingIds: concurrent releases (#52)', () => { + it('releasing a second escrow while the first is still in flight keeps both independently pending', async () => { + const releaseA = deferred() + const releaseB = deferred() + apiMocks.escrowApi.release.mockImplementation((id: string) => + id === 'escrowA' ? releaseA.promise : releaseB.promise, + ) + + const { result } = renderHook(() => useReleaseHarness(), { wrapper }) + + act(() => { + result.current.track('escrowA', result.current.releaseMutation.mutateAsync('escrowA')) + }) + await waitFor(() => expect(result.current.pendingIds.has('escrowA')).toBe(true)) + + // Escrow A's release is still outstanding when B's is started — the + // exact scenario from #52's repro steps. + act(() => { + result.current.track('escrowB', result.current.releaseMutation.mutateAsync('escrowB')) + }) + await waitFor(() => expect(result.current.pendingIds.has('escrowB')).toBe(true)) + + // Both pending at once. The old bug derived "is A releasing" from + // releaseMutation.variables, which is now 'escrowB' — proving that + // scalar alone is the wrong source of truth, while pendingIds still + // has both. + expect(result.current.releaseMutation.variables).toBe('escrowB') + expect(result.current.pendingIds.has('escrowA')).toBe(true) + expect(result.current.pendingIds.has('escrowB')).toBe(true) + + // Resolving B first must not clear A (this is what the bug broke). + await act(async () => { + releaseB.resolve(makeEscrow({ id: 'escrowB', status: 'released' })) + await releaseB.promise + }) + await waitFor(() => expect(result.current.pendingIds.has('escrowB')).toBe(false)) + expect(result.current.pendingIds.has('escrowA')).toBe(true) + + await act(async () => { + releaseA.resolve(makeEscrow({ id: 'escrowA', status: 'released' })) + await releaseA.promise + }) + await waitFor(() => expect(result.current.pendingIds.has('escrowA')).toBe(false)) + }) +}) From ae4e96bd64a7e9fe88390be764f0d3fb26bf700e Mon Sep 17 00:00:00 2001 From: presidojay1 <88345996+presidojay1@users.noreply.github.com> Date: Mon, 17 Aug 2026 08:23:33 +0100 Subject: [PATCH 09/10] test: reproduce #52's refund-clobbering scenario end-to-end Mirrors the release test for useRefundEscrow: refunding escrow B while escrow A's refund is still in flight must not affect A's pending state, proven against the real mutation rather than a mock of usePendingIds itself. --- src/hooks/useEscrows.test.tsx | 53 ++++++++++++++++++++++++++++++++++- 1 file changed, 52 insertions(+), 1 deletion(-) diff --git a/src/hooks/useEscrows.test.tsx b/src/hooks/useEscrows.test.tsx index b68105c..324122a 100644 --- a/src/hooks/useEscrows.test.tsx +++ b/src/hooks/useEscrows.test.tsx @@ -20,7 +20,7 @@ const walletMocks = vi.hoisted(() => ({ })) vi.mock('./useWallet', () => ({ useWallet: () => walletMocks })) -import { useReleaseEscrow } from './useEscrows' +import { useReleaseEscrow, useRefundEscrow } from './useEscrows' import { usePendingIds } from './usePendingIds' function deferred() { @@ -60,13 +60,25 @@ function useReleaseHarness() { return { releaseMutation, pendingIds, track } } +function useRefundHarness() { + const refundMutation = useRefundEscrow() + const { pendingIds, track } = usePendingIds() + return { refundMutation, pendingIds, track } +} + beforeEach(() => { apiMocks.escrowApi.buildReleaseTransaction.mockReset() apiMocks.escrowApi.release.mockReset() + apiMocks.escrowApi.buildRefundTransaction.mockReset() + apiMocks.escrowApi.refund.mockReset() apiMocks.escrowApi.buildReleaseTransaction.mockImplementation(async (id: string) => ({ xdr: `unsigned-xdr-${id}`, fee: '100', })) + apiMocks.escrowApi.buildRefundTransaction.mockImplementation(async (id: string) => ({ + xdr: `unsigned-refund-xdr-${id}`, + fee: '100', + })) }) describe('useReleaseEscrow + usePendingIds: concurrent releases (#52)', () => { @@ -114,3 +126,42 @@ describe('useReleaseEscrow + usePendingIds: concurrent releases (#52)', () => { await waitFor(() => expect(result.current.pendingIds.has('escrowA')).toBe(false)) }) }) + +describe('useRefundEscrow + usePendingIds: concurrent refunds (#52)', () => { + it('refunding a second escrow while the first is still in flight keeps both independently pending', async () => { + const refundA = deferred() + const refundB = deferred() + apiMocks.escrowApi.refund.mockImplementation((id: string) => + id === 'escrowA' ? refundA.promise : refundB.promise, + ) + + const { result } = renderHook(() => useRefundHarness(), { wrapper }) + + act(() => { + result.current.track('escrowA', result.current.refundMutation.mutateAsync('escrowA')) + }) + await waitFor(() => expect(result.current.pendingIds.has('escrowA')).toBe(true)) + + act(() => { + result.current.track('escrowB', result.current.refundMutation.mutateAsync('escrowB')) + }) + await waitFor(() => expect(result.current.pendingIds.has('escrowB')).toBe(true)) + + expect(result.current.refundMutation.variables).toBe('escrowB') + expect(result.current.pendingIds.has('escrowA')).toBe(true) + expect(result.current.pendingIds.has('escrowB')).toBe(true) + + await act(async () => { + refundB.resolve(makeEscrow({ id: 'escrowB', status: 'refunded' })) + await refundB.promise + }) + await waitFor(() => expect(result.current.pendingIds.has('escrowB')).toBe(false)) + expect(result.current.pendingIds.has('escrowA')).toBe(true) + + await act(async () => { + refundA.resolve(makeEscrow({ id: 'escrowA', status: 'refunded' })) + await refundA.promise + }) + await waitFor(() => expect(result.current.pendingIds.has('escrowA')).toBe(false)) + }) +}) From 1beed3561b3a36d0b0f9a11b2473ae54bb097003 Mon Sep 17 00:00:00 2001 From: presidojay1 <88345996+presidojay1@users.noreply.github.com> Date: Mon, 17 Aug 2026 08:25:36 +0100 Subject: [PATCH 10/10] test: reproduce #52's cancel-clobbering scenario end-to-end Same shape as the escrow release/refund tests but for useCancelSubscription: cancelling subscription B while A's cancellation is still in flight must not affect A's pending state. Completes coverage for all three mutations named in #52. --- src/hooks/useSubscriptions.test.tsx | 107 ++++++++++++++++++++++++++++ 1 file changed, 107 insertions(+) create mode 100644 src/hooks/useSubscriptions.test.tsx diff --git a/src/hooks/useSubscriptions.test.tsx b/src/hooks/useSubscriptions.test.tsx new file mode 100644 index 0000000..be6a88c --- /dev/null +++ b/src/hooks/useSubscriptions.test.tsx @@ -0,0 +1,107 @@ +import React from 'react' +import { act, renderHook, waitFor } from '@testing-library/react' +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import type { Subscription } from '@/types' + +const apiMocks = vi.hoisted(() => ({ + subscriptionApi: { + buildCancelTransaction: vi.fn(), + cancel: vi.fn(), + }, +})) +vi.mock('@/lib/api', () => apiMocks) + +const walletMocks = vi.hoisted(() => ({ + signTransaction: vi.fn(async (xdr: string) => `signed:${xdr}`), + refreshAccount: vi.fn(async () => {}), +})) +vi.mock('./useWallet', () => ({ useWallet: () => walletMocks })) + +import { useCancelSubscription } from './useSubscriptions' +import { usePendingIds } from './usePendingIds' + +function deferred() { + let resolve!: (value: T) => void + const promise = new Promise((res) => { + resolve = res + }) + return { promise, resolve } +} + +function makeSubscription(overrides: Partial = {}): Subscription { + return { + id: 'sub_1', + sourcePublicKey: 'GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5', + destinationAddress: 'GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN', + asset: { code: 'XLM', issuer: null, name: 'Stellar Lumens', decimals: 7 }, + amount: '10', + interval: 'monthly', + startDate: new Date().toISOString(), + nextRunAt: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(), + status: 'active', + createdAt: new Date().toISOString(), + runCount: 0, + ...overrides, + } +} + +function wrapper({ children }: { children: React.ReactNode }) { + const client = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }) + return {children} +} + +function useCancelHarness() { + const cancelMutation = useCancelSubscription() + const { pendingIds, track } = usePendingIds() + return { cancelMutation, pendingIds, track } +} + +beforeEach(() => { + apiMocks.subscriptionApi.buildCancelTransaction.mockReset() + apiMocks.subscriptionApi.cancel.mockReset() + // No on-chain authorization needed for these ids — purely a backend flag + // flip, matching useCancelSubscription's documented fallback path. + apiMocks.subscriptionApi.buildCancelTransaction.mockResolvedValue({ xdr: undefined }) +}) + +describe('useCancelSubscription + usePendingIds: concurrent cancellations (#52)', () => { + it('cancelling a second subscription while the first is still in flight keeps both independently pending', async () => { + const cancelA = deferred() + const cancelB = deferred() + apiMocks.subscriptionApi.cancel.mockImplementation((id: string) => + id === 'subA' ? cancelA.promise : cancelB.promise, + ) + + const { result } = renderHook(() => useCancelHarness(), { wrapper }) + + act(() => { + result.current.track('subA', result.current.cancelMutation.mutateAsync('subA')) + }) + await waitFor(() => expect(result.current.pendingIds.has('subA')).toBe(true)) + + act(() => { + result.current.track('subB', result.current.cancelMutation.mutateAsync('subB')) + }) + await waitFor(() => expect(result.current.pendingIds.has('subB')).toBe(true)) + + expect(result.current.cancelMutation.variables).toBe('subB') + expect(result.current.pendingIds.has('subA')).toBe(true) + expect(result.current.pendingIds.has('subB')).toBe(true) + + await act(async () => { + cancelB.resolve(makeSubscription({ id: 'subB', status: 'cancelled' })) + await cancelB.promise + }) + await waitFor(() => expect(result.current.pendingIds.has('subB')).toBe(false)) + expect(result.current.pendingIds.has('subA')).toBe(true) + + await act(async () => { + cancelA.resolve(makeSubscription({ id: 'subA', status: 'cancelled' })) + await cancelA.promise + }) + await waitFor(() => expect(result.current.pendingIds.has('subA')).toBe(false)) + }) +})