Skip to content

Fix race condition in async requests (#882) - #884

Merged
vincentmakes merged 6 commits into
mainfrom
claude/issue-882-plan-q6zajv
Jul 27, 2026
Merged

Fix race condition in async requests (#882)#884
vincentmakes merged 6 commits into
mainfrom
claude/issue-882-plan-q6zajv

Conversation

@vincentmakes

Copy link
Copy Markdown
Owner

Summary

Fixes a critical race condition where slower API responses could overwrite newer ones, causing stale data to appear in the UI. Implements request serialization with abort signals and generation tokens to guarantee that only the most recent request may write state.

Type

  • fix
  • refactor

Changes

  • New hooks:

    • useLatestRequest(): Serializes overlapping requests, aborting predecessors and using generation tokens to ensure only the newest response writes state. Handles both imperative calls (InventoryPage) and effect-based patterns.
    • useAbortableEffect(): Effect wrapper around useLatestRequest() for dependency-driven refetches.
    • useApiQuery(): Drop-in replacement for useEffect + api.get + state management, with built-in race condition protection and optional select transform.
    • useDebouncedValue(): Debounces rapidly-changing values (search inputs) before triggering expensive operations.
  • API client enhancements:

    • Added GetOptions interface with signal?: AbortSignal support.
    • Added isAbortError() helper to distinguish abort rejections from real errors.
    • Updated api.get() and api.getRaw() to forward abort signals to fetch.
  • Affected pages (all now protected against [bug] Race condition caused by overlapping asynchronous filter requests #882):

    • InventoryPage: Uses useLatestRequest() for imperative loadData() calls; debounces search input.
    • CostReport, PortfolioReport, DependencyReport, CapabilityMapReport, PpmPortfolio, TodosPage, LifecycleReport: Migrated to useAbortableEffect().
    • CreateCardDialog, EolLinkSection, SurveyBuilder, CalculationsAdmin, SurveyRespond: Debounced search with abort signals.
    • MatrixReport: Migrated to useApiQuery().
    • CardSearch: Fixed to refetch on filter changes instead of silently dropping them.
  • Test coverage:

Test Plan

  • All new unit tests pass (useLatestRequest, useAbortableEffect, useApiQuery, useDebouncedValue).

  • InventoryPage tests verify that clearing a filter (slow whole-repository fetch) followed by picking a type doesn't let the slow response overwrite the grid.

  • CardSearch tests verify that changing the query mid-flight refetches instead of dropping the new query.

  • Existing InventoryPage, PortfolioReport, and LifecycleReport tests updated to expect abort signals in api.get() calls.

  • Manual verification: typing in search boxes no longer fires multiple requests; switching filters/pickers shows correct data even when responses arrive out of order.

  • All CI checks pass (backend lint, backend tests, frontend lint, frontend build, frontend tests)

  • Manually tested the affected feature

  • Added/updated tests for new or changed behavior

Checklist

  • My changes follow the conventions in CLAUDE.md
  • I did not introduce hardcoded card types or fields (metamodel is data-driven)
  • I bumped /VERSION and added a CHANGELOG.md entry (user-facing fix)

https://claude.ai/code/session_01KuDnkVPhKwF6iqBG1xRrYR

claude added 6 commits July 27, 2026 19:01
Filter-driven GETs had no ordering guarantee. Clearing the inventory type
filter fetches the whole repository; picking a type straight afterwards
returned first, then the slow response landed and overwrote the grid, so the
grid listed every card while the sidebar said "Application".

- api.get/getRaw accept an optional AbortSignal, and isAbortError() lets
  callers tell a superseded request from a real failure. Deliberately not
  offered on the mutating helpers: aborting a POST does not undo it.
- New shared hooks: useLatestRequest (+useAbortableEffect), useApiQuery and
  useDebouncedValue. They abort the predecessor and compare a generation
  token; the token is what actually guarantees ordering, since a resolved
  response cannot be aborted.
- InventoryPage: cards and relations both run through useLatestRequest, so
  the file carries one idiom instead of two. Only the winning request clears
  the loading flag. Search is debounced 300ms while type and status toggles
  stay instant, and the grid stays in its loading state through the debounce
  window so it never presents stale rows as settled. A failed load now
  surfaces a retryable alert instead of an unhandled rejection.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KuDnkVPhKwF6iqBG1xRrYR
The two behavioural tests fail against a build with the generation guard
disabled and pass with it, so they pin the fix rather than merely exercising
it: a stale whole-repository response landing after a narrower one must not
replace the rows, and a superseded request must not clear the loading state
while the winner is still in flight.

The AG Grid stub now exposes `loading`, mirroring its existing
`select-all-rows` escape hatch, so the loading semantics are assertable.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KuDnkVPhKwF6iqBG1xRrYR
…#882)

Matrix drew the previous axes' data underneath the newly-selected axis
labels — a complete, convincing matrix with nothing on screen to say it was
wrong, because `data` was never cleared and the labels render from the
current selection. It now goes through useApiQuery, which discards a
superseded response, and clears on switch so the existing spinner covers the
gap rather than a mismatched grid.

Portfolio (which also backs the Flexible Portfolio) already discarded stale
responses correctly; its defect was the missing catch. Since it clears the
chart on every type switch, a failed request left it on the spinner forever.
It now reports the error, and aborts a superseded request instead of
downloading a payload it will discard.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KuDnkVPhKwF6iqBG1xRrYR
Cost, Dependency, Capability Map, Lifecycle, Todos and the PPM portfolio all
refetched on a picker change with nothing to order the responses, so the
request for the previous selection could land last and replace the view.
Each now runs through useAbortableEffect: the multi-state and Promise.all
cases keep their exact shape, they just check isCurrent() before writing and
pass the signal to every leg.

Dependency and PPM additionally leaked their loading flag — cleared only on
the success path, or by whichever request finished first. Both now clear it
only when the request is still the current one.

useCardSearch dropped a query change that arrived while a request was in
flight and never retried it, so the picker kept showing results for a query
the user had already replaced. Only a duplicate scroll-sentinel loadMore is
dropped now; a new page-1 request supersedes. This engine backs CardPicker,
VendorField and the diagram card sidebar, so it was reachable from nearly
every picker in the app.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KuDnkVPhKwF6iqBG1xRrYR
Seven search-as-you-type call sites debounced with setTimeout and cleaned up
with clearTimeout. That cancels the timer, but once a request has been
dispatched nothing stops its response, so suggestions for a half-typed query
could land after — and replace — the ones for what the user actually typed.

Each now pairs useDebouncedValue with useAbortableEffect, so the pending
request is aborted and a superseded response discarded.

Two of them also leaked their spinner: SurveyRespond set loading outside the
timer and cleared it inside, so a cancelled timer left it spinning forever.
Where a spinner exists it now covers the debounce window as well as the
request, so the field never looks idle while the query is still settling.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KuDnkVPhKwF6iqBG1xRrYR
Records the rule in CLAUDE.md so a fourth stale-guard idiom doesn't appear
next to the three that had already been invented independently, including
the two details that are easy to miss in review: guard every setState with
isCurrent(), and clear the loading flag only when isCurrent().

UI_GUIDELINES gains the user-visible half in § 3.8 — the loading indicator
covers the debounce window, a failed fetch shows an alert rather than an
endless spinner, and when labels come from the current selection but the body
comes from the response, a spinner beats a silent mismatch.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KuDnkVPhKwF6iqBG1xRrYR
@vincentmakes
vincentmakes merged commit a002955 into main Jul 27, 2026
20 checks passed
@vincentmakes
vincentmakes deleted the claude/issue-882-plan-q6zajv branch July 27, 2026 19:45
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.

[bug] Race condition caused by overlapping asynchronous filter requests

2 participants