Skip to content

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

Closed

Description

@abayomicornelius

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.variables becomes idB.
  • EscrowItem for escrow A now computes isReleasing = (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 its escrowApi.release request could still be in transit).
  • Escrow A's "Release" button re-enables (Button's only pending-state gate is the loading prop derived from this now-stale isReleasing), 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 second escrowApi.release call 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 EscrowList with two funded escrows, both releasable by the current wallet. Click "Release" on escrow A (triggering releaseMutation.mutate('escrowA'), still pending), then — before it resolves — click "Release" on escrow B (releaseMutation.mutate('escrowB')). Observe releaseMutation.variables is now 'escrowB', so EscrowItem for escrow A re-renders with isReleasing: 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 in onMutate/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 in src/pages/Subscriptions.tsx (cancelMutation, cancellingId) and src/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 .variables scalar.

Activity

  1. added
    bugSomething isn't working
    very hardVery difficult / senior-level bounty issue
    Maybe RewardedIssue may be eligible for a GrantFox reward
    GrantFox OSSIssue tracked in GrantFox OSS
    on Aug 15, 2026
  2. Hollujay commented on Aug 16, 2026

    @Hollujay

    Kindly assign the issues to me, I will surely get them done @abayomicornelius

  3. presidojay1 commented on Aug 16, 2026

    @presidojay1
    Contributor

    Escrow.tsx has 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 one useRefundEscrow() mutation, then determines whether a particular row is loading from that mutation's .variables. That value represents the arguments from the most recent mutate() 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.

  4. grantfox-oss commented on Aug 16, 2026

    @grantfox-oss

    🦊 GrantFox — @presidojay1 has been assigned to this issue as part of the Third Campaign campaign!

    Next steps:

    1. Open a Pull Request referencing this issue (e.g., Closes #52)
    2. Your PR will be reviewed by the StellarSend maintainers

    Good luck! Track your progress on GrantFox.

  5. grantfox-oss commented on Aug 17, 2026

    @grantfox-oss

    🎉 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.

  6. added 5 commits that reference this issue on Aug 17, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

GrantFox OSSIssue tracked in GrantFox OSSMaybe RewardedIssue may be eligible for a GrantFox rewardOfficial Campaign | FWC26Campaign: Official Campaign | FWC26Third CampaignCampaign: Third CampaignbugSomething isn't workingvery hardVery difficult / senior-level bounty issue

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions