Skip to content

[2026 Reliability] Cancel stale Horizon requests during account changes - #920

Open
solaawojobi00-bit wants to merge 1 commit into
Nanle-code:masterfrom
solaawojobi00-bit:fix/issue-745-cancel-stale-horizon-requests
Open

[2026 Reliability] Cancel stale Horizon requests during account changes#920
solaawojobi00-bit wants to merge 1 commit into
Nanle-code:masterfrom
solaawojobi00-bit:fix/issue-745-cancel-stale-horizon-requests

Conversation

@solaawojobi00-bit

Copy link
Copy Markdown

Cancel stale Horizon requests during account changes

Prevents a slower response for an old account or network from overwriting current state.


Problem

Account data is loaded asynchronously from Horizon. When the user switches account or network while a read is in flight, two responses race and nothing guarantees they land in request order:

t0  user connects account A       --> GET /accounts/A ...... (slow, 3s)
t1  user switches to account B    --> GET /accounts/B --> 200 (fast, 200ms)
t2  state shows B                                     OK
t3  A's response finally arrives  ------------------> 200
t4  state shows A                                     WRONG ACCOUNT DISPLAYED

The existing isActive boolean in Account.tsx covered one useEffect only. It did not protect the connect handler in ConnectPanel.tsx, did not cancel the request, and did not coordinate between components reading the same account.

Scenarios

# Scenario Before After
1 Connect A, then connect B before A responds A's late response overwrites B A is aborted; if it still lands it is discarded
2 Switch network mid-load Previous network's data repopulates state the switch just cleared All account lanes aborted on setNetwork
3 A fails slowly after B succeeds A's error banner covers B's good data Stale-lane failures reported as cancellation, stay silent
4 Old request's finally runs after new one starts Clears the live spinner, load looks finished commit() refuses; only the newest clears it
5 Switch account, old creation-date read resolves late createdAt reset to the old account's value Abort rethrown rather than swallowed as "no date"
6 Component unmounts mid-read Request continues to completion Aborted on effect cleanup

Solution

A lane-scoped coordinator implementing latest-request-wins.

A lane names a state slot; a lease is one unit of work on it. Starting new work on a lane invalidates every earlier lease there:

Mechanism What it does Guarantee
lease.signal Aborts the underlying fetch Best effort - saves bandwidth
lease.active / lease.commit() Rejects late results Correctness

The distinction is load-bearing: the Stellar SDK's CallBuilder and loadAccount() accept no AbortSignal, so those requests cannot always be stopped on the wire. The active check is what actually fixes the bug; abort is the optimisation on top.

Key design point: a lane keys on the state slot being written, not the account being read. There is one accountData in the store, so all account loads share one lane and the newest always wins. Keying by address would give A and B separate lanes that never cancel each other - the bug itself.

Changes by file

src/lib/requestCancellation.ts (new)

RequestCoordinator, RequestLease, StaleRequestError, and the isStaleRequestError / isAbortError / isCancellation predicates.

const lease = accountRequests.begin(AccountLanes.Connect);
const account = await lease.run((signal) => fetchAccount(address, network, { signal }));
lease.commit(() => setAccountData(account));

run() rejects if the lease goes stale before or while the work runs, so a superseded response can never reach the caller. Once a lease is stale, run() reports any outcome as StaleRequestError - including a genuine network failure - so an error belonging to an abandoned account cannot surface over current data.

src/lib/stellar.ts

Optional trailing HorizonRequestOptions ({ signal }) on the six account-scoped readers: fetchAccount, fetchTransactions, fetchOperations, fetchAccountOffers, fetchAccountCreationDate, fetchClaimableBalances. Each rejects with AbortError before issuing a request if already aborted, and if the signal fires mid-flight.

fetchAccountCreationDate previously swallowed every error and returned null. It now rethrows aborts - otherwise a cancelled read would resolve as "no creation date" and clear the current account's value:

} catch (error) {
  if ((error as Error | undefined)?.name === 'AbortError') throw error;
  return null;
}

fetchClaimableBalances goes through fetch, so its signal cancels on the wire.

src/components/dashboard/ConnectPanel.tsx

The whole connect flow (resolve -> account -> transactions -> operations) runs under one lease, and the network is pinned for its duration so a mid-flight switch cannot mix an address resolved on one network with data fetched from another.

src/components/dashboard/Account.tsx

Offers and creation-date reads replace the isActive flag with leases, aborting on effect cleanup. Also fixes a latent type error: setCreatedAt received the string returned by fetchAccountCreationDate while the state is Date | null.

src/lib/store.ts

setNetwork aborts all account lanes, and clears accountLoading / txLoading / opsLoading. Those cancelled requests' own finally handlers are lease-guarded and will no longer fire, which would otherwise leave a spinner stuck on permanently. Found while reviewing the first cut of this change; covered by a regression test.

Regression tests

42 new tests. Mapped to the acceptance criteria:

Criterion Coverage File
Primary flow Sole lease commits; run() resolves; lanes independent requestCancellation.test.ts
Primary flow Readers resolve normally with and without a signal stellar.cancellation.test.ts
The race Slow A resolving after B started rejects as stale; B still delivers requestCancellation.test.ts
The race Network switch invalidates in-flight leases; stale write refused store.networkCancellation.test.ts
Boundary Already-aborted signal rejects without calling Horizon stellar.cancellation.test.ts
Boundary abort() on an idle lane is a no-op requestCancellation.test.ts
Boundary No spinner left stuck after cancelling store.networkCancellation.test.ts
Unsupported env AbortController deleted -> token-only invalidation still correct requestCancellation.test.ts
Failure path Genuine failure propagates unchanged, not masked as abort both
Failure path Stale-lane failure reported as cancellation requestCancellation.test.ts
Invalid input Empty / whitespace / non-string lane throws TypeError requestCancellation.test.ts

Testing

Targeted before/after comparison over every test file touching the changed modules:

BEFORE (changes stashed)   Test Files  1 failed | 7 passed (8)
                                Tests  3 failed | 68 passed (71)

AFTER                      Test Files  1 failed | 10 passed (11)
                                Tests  3 failed | 110 passed (113)

The failure set is identical in both runs - the same three pre-existing failures in tests/unit/lib/stellar.test.ts, which uses require() inside an ESM test file:

FAIL tests/unit/lib/stellar.test.ts > fetchAccount > should return account data from Horizon server
FAIL tests/unit/lib/stellar.test.ts > fetchAccount > should cache account data and not call server again
FAIL tests/unit/lib/stellar.test.ts > fetchAccount > should propagate errors from server
  -> TypeError: mockServer.loadAccount.mockResolvedValueOnce is not a function

+42 passing, 0 new failures.

New tests in isolation:

 PASS  tests/unit/lib/requestCancellation.test.ts       (24 tests)
 PASS  tests/unit/lib/stellar.cancellation.test.ts      (12 tests)
 PASS  tests/unit/lib/store.networkCancellation.test.ts  (6 tests)

Test Files  3 passed (3)
     Tests  42 passed (42)

Static checks:

eslint on all changed/added files    0 errors, 0 warnings
repo-wide lint errors                51 -> 51   (unchanged)
tsc --noEmit error count           1431 -> 1429 (-2, from the Account.tsx type fix)
prettier --check package.json package-lock.json    pass

Documentation

docs/REQUEST_CANCELLATION.md - the race, the lane/lease model, API reference, how to choose a lane name, error handling, plus the required compatibility, security and migration notes. Linked from docs/contributing.md under a new "Async data loading" convention.

Compatibility highlights:

  • Backwards compatible - options is optional and trailing on every reader; existing call sites are untouched.
  • No AbortController (older webviews, SSR shims): detected once and exported as supportsAbortController. Degrades to token-only invalidation - lease.signal is undefined and nothing is cancelled on the wire, but stale responses are still discarded, so correctness holds.
  • Security - cancellation is a UI-consistency control, not access control. Aborting does not guarantee the server stopped, and for SDK-backed reads the response is usually still received and discarded client-side. Cache keys stay scoped by publicKey and network, so a cancelled read for one account can never serve another.

Notes for reviewers

Prerequisite repair (please read)

master could not install at all. package.json and package-lock.json were both invalid JSON - commit 4405a8c duplicated lines without commas - so npm ci failed and every CI job was red on every open PR. optionalDependencies also declared @stellar/ledger@^1.0.0, which 404s on npm and had no lock entry, so npm ci refused as out of sync even once the JSON parsed.

This PR includes the minimum needed to make the repo installable again:

Change Reason
Remove duplicated @ledgerhq/hw-transport-webusb line (both files) Invalid JSON
Remove duplicate hoist-non-react-statics / react-is / truncated html-encoding-sniffer block from the lock Invalid JSON; all three already exist intact earlier in the file
Remove @stellar/ledger from optionalDependencies Package does not exist on npm; blocks npm ci
Remove duplicate docs:validate-drift and @tensorflow/tfjs-node keys esbuild warns on every build; last-wins already selected the surviving value, so no behaviour change

npm ci now succeeds (1301 packages). Happy to split this into its own PR if you would rather keep #745 scope-pure.

Pre-existing breakage this PR does not fix

So CI results are not misread as regressions from this change:

  • lint - 51 pre-existing errors, e.g. DataSourceBadge is rendered at Account.tsx:188 but never imported. format:check also fails on eslint.config.js, tsconfig.json, ci.yml and .prettierrc.json, none of which this PR touches.
  • type-check - 1429 pre-existing errors across the codebase.
  • docs - scripts/validate-docs-drift.mjs is two complete, different implementations concatenated into one 331-line file, so it is a syntax error and the workflow cannot run. Deciding which implementation survives is a maintainer call, so it is left untouched. The new doc was validated by hand instead (relative links resolve; no references to nonexistent scripts).
  • npm test - hangs indefinitely on master somewhere in tests/unit/lib (killed at 20+ min, twice). This is why verification above uses a scoped before/after comparison rather than a whole-suite number.

Each of these is happy to become its own issue if useful.

Review pointers

  • The lane-naming rule is the subtle part - docs/REQUEST_CANCELLATION.md section Choosing a lane name explains why lanes must not be keyed by address.
  • withAbort() in stellar.ts detaches the caller from an SDK promise that cannot itself be cancelled. The HTTP request may still complete; the point is that its result can no longer reach state.
  • The setNetwork loading-flag reset is easy to miss but necessary - without it, cancelling an in-flight connect leaves the spinner on forever.

Closes #745

Slower responses for a previously selected account or network could resolve
after a newer request and overwrite current state, showing the wrong account's
data. Guarding with a local isActive flag only covered a single useEffect and
left the connect handler and cross-component reads unprotected.

Add a lane-scoped request coordinator implementing latest-request-wins. Each
lane names a state slot; starting new work on a lane aborts the previous lease
and marks it inactive, so a superseded response is both cancelled where the
transport allows and discarded if it still arrives.

- src/lib/requestCancellation.ts: RequestCoordinator, RequestLease,
  StaleRequestError and cancellation predicates. Degrades to token-only
  invalidation where AbortController is unavailable, preserving correctness.
- src/lib/stellar.ts: optional trailing HorizonRequestOptions ({ signal }) on
  fetchAccount, fetchTransactions, fetchOperations, fetchAccountOffers,
  fetchAccountCreationDate and fetchClaimableBalances. Backwards compatible.
  fetchAccountCreationDate now rethrows aborts instead of resolving null.
- ConnectPanel: the whole connect flow runs under one lease and pins the
  network, so a mid-flight switch cannot mix networks.
- Account: offers and creation-date reads use leases and abort on cleanup.
- store.setNetwork: aborts in-flight account reads and clears the loading
  flags those cancelled requests can no longer clear themselves.

Repository repair required to run any of this: package.json and
package-lock.json were both invalid JSON, and optionalDependencies declared
@stellar/ledger@^1.0.0, which does not exist on npm and had no lock entry, so
npm ci failed for every job. Removes the duplicated lines, the phantom
dependency and two duplicate keys.

Tests: 42 new tests covering the primary flow, boundary cases (already-aborted
signals, unsupported environments, idle lanes) and failure paths (genuine
errors versus cancellations, invalid lane input).

Docs: docs/REQUEST_CANCELLATION.md with compatibility, security and migration
notes, linked from docs/contributing.md.
@drips-wave

drips-wave Bot commented Aug 26, 2026

Copy link
Copy Markdown

@solaawojobi00-bit Great news! 🎉 Based on an automated assessment of this PR, the linked Wave issue(s) no longer count against your application limits.

You can now already apply to more issues while waiting for a review of this PR. Keep up the great work! 🚀

Learn more about application limits

@vercel

vercel Bot commented Aug 26, 2026

Copy link
Copy Markdown

@solaawojobi00-bit is attempting to deploy a commit to the nanle-code's projects Team on Vercel.

A member of the Team first needs to authorize it.

@Manuelshub

Copy link
Copy Markdown
Collaborator

@solaawojobi00-bit please fix all CI checks failure

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[2026 Reliability] Cancel stale Horizon requests during account changes

2 participants