Repository navigation
Escrow release/refund (and Subscription cancel) share one mutation instance across the whole list — concurrent actions on different rows clobber each other's loading state #52
Description
Problem
Escrow.tsx creates exactly one useReleaseEscrow() and one useRefundEscrow() mutation instance for the entire escrow list, and derives each row's loading indicator from that single shared mutation's .variables — the argument of the most recent .mutate() call. If the user triggers the same action on two different escrows before the first one settles, the second call's id silently overwrites the first row's loading indicator.
// src/pages/Escrow.tsx:22-24, 91-94
const releaseMutation = useReleaseEscrow()
const refundMutation = useRefundEscrow()
...
<EscrowList
...
onRelease={(id) => releaseMutation.mutate(id)}
onRefund={(id) => refundMutation.mutate(id)}
releasingId={releaseMutation.isPending ? releaseMutation.variables ?? null : null}
refundingId={refundMutation.isPending ? refundMutation.variables ?? null : null}
/>EscrowItem uses that single shared id to decide which row shows a spinner:
// src/components/Escrow/EscrowList.tsx:59-68 → src/components/Escrow/EscrowItem.tsx:33-41,83-92
isReleasing={releasingId === e.id}
...
<Button loading={isReleasing} onClick={() => onRelease(escrow.id)}>Release</Button>TanStack Query's useMutation does not serialize successive .mutate() calls — calling .mutate(idB) while a call with idA is still in flight starts a second, concurrent execution, and mutation.variables is simply overwritten to reflect the latest call's argument. It is not a map of "all currently-pending variables"; it's a single scalar.
Subscriptions.tsx has the identical shape for cancellation (cancelMutation = useCancelSubscription(), shared across the whole SubscriptionList, cancellingId={cancelMutation.isPending ? cancelMutation.variables ?? null : null}).
Why it matters
If a user with multiple escrows clicks "Release" on escrow A, and — while that Freighter signature/submission is still pending — clicks "Release" on escrow B (a completely ordinary thing to do if the app doesn't visibly prevent it, and nothing here disables the other rows' buttons while one is pending), then:
releaseMutation.variablesbecomesidB.EscrowItemfor escrow A now computesisReleasing = (releasingId === 'idA')→false, even though escrow A's release call may still genuinely be in flight (its Freighter signature prompt could still be open, or itsescrowApi.releaserequest could still be in transit).- Escrow A's "Release" button re-enables (
Button's only pending-state gate is theloadingprop derived from this now-staleisReleasing), inviting the user to click "Release" on the same escrow A a second time while the first request is genuinely still outstanding — risking a second Freighter signature prompt and a secondescrowApi.releasecall racing the first for the same escrow. - Meanwhile escrow B correctly shows its own spinner, so the UI looks entirely normal and non-buggy at a glance — nothing errors, nothing visibly breaks, it just silently mis-attributes which row is "loading" whenever two release/refund/cancel actions overlap in time.
This is exactly the kind of concurrency bug that a mouse-driven, one-thing-at-a-time manual test pass would never surface (you'd have to deliberately click two different rows' action buttons in quick succession), but that a user with several escrows genuinely could hit by accident.
Reproduction
- Render
EscrowListwith two funded escrows, both releasable by the current wallet. Click "Release" on escrow A (triggeringreleaseMutation.mutate('escrowA'), still pending), then — before it resolves — click "Release" on escrow B (releaseMutation.mutate('escrowB')). ObservereleaseMutation.variablesis now'escrowB', soEscrowItemfor escrow A re-renders withisReleasing: false— its "Release" button is enabled again — despite escrow A's original release call not having settled yet.
Suggested fix
- Track pending ids as a
Set/array of in-flight ids (e.g. local component state updated inonMutate/onSettled) rather than relying on a single shared mutation's.variables, so each row's loading state is independent of what any other row is doing. - Alternatively, disable the entire list's action buttons while any release/refund/cancel mutation for that list is pending, if concurrent actions across rows aren't meant to be supported — but that should be an explicit, visible constraint, not an accidental one produced by clobbered state.
Additional Notes
src/pages/Escrow.tsx:22-24,91-94,src/components/Escrow/EscrowList.tsx(prop plumbing),src/components/Escrow/EscrowItem.tsx:33-41,83-92; identical shape insrc/pages/Subscriptions.tsx(cancelMutation,cancellingId) andsrc/components/Subscriptions/SubscriptionList.tsx/SubscriptionItem.tsx.- Testing strategy: a test that starts two
useReleaseEscrow/useRefundEscrow-style mutations for two different ids in quick succession (mocking the underlying API call to stay pending until manually resolved) and asserts both rows' derived loading state stay independently correct throughout — this fails today because both derive from the same.variablesscalar.
Activity
Hollujay commentedon Aug 16, 2026
Kindly assign the issues to me, I will surely get them done @abayomicornelius
presidojay1 commentedon Aug 16, 2026
Escrow.tsxhas a concurrency bug in its per-row loading state because the entire escrow list shares a single mutation instance for each action.The component creates one
useReleaseEscrow()and oneuseRefundEscrow()mutation, then determines whether a particular row is loading from that mutation's.variables. That value represents the arguments from the most recentmutate()call, not every mutation currently in flight.Consequently, if a user triggers the same action on escrow A and then escrow B before A settles, B's mutation variables replace A's in the shared mutation state. The UI can stop showing A as loading even though A's transaction is still pending, while showing B as loading. This makes the row-level progress indicator inaccurate during concurrent operations.
I have experience reviewing React Query mutation state where multiple independent resources share a mutation hook. The loading state should be tracked per escrow—either by using mutation state that can represent multiple concurrent calls, deriving pending state by escrow ID, or maintaining separate mutation instances per row. The key invariant is that starting an operation on one escrow must not erase the pending indicator for another escrow.
grantfox-oss commentedon Aug 16, 2026
🦊 GrantFox — @presidojay1 has been assigned to this issue as part of the Third Campaign campaign!
Next steps:
Closes #52)Good luck! Track your progress on GrantFox.
feat: add usePendingIds hook for per-row mutation loading state
fix(escrow): give each row an independent release-pending indicator
fix(escrow): give each row an independent refund-pending indicator
fix(subscriptions): give each row an independent cancel-pending indic…
test: cover usePendingIds tracking, isolation, and cleanup
grantfox-oss commentedon Aug 17, 2026
🎉 This issue has been marked as completed on GrantFox as part of the Third Campaign campaign!
@presidojay1's PR #65 was approved and merged by @abayomicornelius.
🏆 @presidojay1: You earned 35 FoxPoints for this contribution! Your current tier: Explorer (640 total points). Track your full progress on GrantFox.
👏 Great work, @presidojay1! Keep contributing to StellarSend.
test: cover EscrowList's Set-based per-row releasing state
test: cover SubscriptionList's Set-based per-row cancelling state
test: reproduce #52's release-clobbering scenario end-to-end
test: reproduce #52's refund-clobbering scenario end-to-end
test: reproduce #52's cancel-clobbering scenario end-to-end