Skip to content

a1e7909f - Separate the action POST from the detail refresh in the compliance stop/resume flow - #1263

Merged
TaprootFreak merged 4 commits into
developfrom
fix/compliance-action-refresh-error-handling
Aug 4, 2026
Merged

a1e7909f - Separate the action POST from the detail refresh in the compliance stop/resume flow#1263
TaprootFreak merged 4 commits into
developfrom
fix/compliance-action-refresh-error-handling

Conversation

@TaprootFreak

@TaprootFreak TaprootFreak commented Aug 4, 2026

Copy link
Copy Markdown
Collaborator

Closes #1256.

Two robustness gaps in the compliance transactions tab, both pre-existing in the stop flow and mirrored by the resume flow added in #1254.

1. A failing detail refresh no longer reports the action as failed

confirmStop / confirmResume ran the action POST and the follow-up getTransactionByUid refresh in one try. When only the refresh failed after a successful POST, the UI showed Stop failed / Resume failed, kept the dialog open, skipped onStatusChanged and left the stale cache entry in place — so the action was offered again although it had already succeeded server-side.

The POST now has its own try/catch and returns early on failure. On success the dialog closes and onStatusChanged fires immediately; the refresh runs afterwards through a new refreshTxDetail helper that, on failure, drops the cached detail and reports its own error instead of masking it as an action failure.

2. Escape and backdrop clicks are blocked while a request is running

ConfirmDialog passed onCancel straight through as the Modal onClose, and Modal invokes it on Escape and backdrop click without looking at isLoading — only the Cancel button was disabled. The dialog could therefore be dismissed mid-request and another one opened, leaking the shared stoppingTxId / resumingTxId loading state into the wrong dialog instance.

ConfirmDialog now forwards onClose only when it is not loading, and each dialog's isLoading is bound to the transaction it was opened for.

3. Two shared states that this made reachable

Because an action for a second transaction can now legitimately be started while the first one is still refreshing, two states shared across transactions needed the same scoping:

  • Loading state. stoppingTxId / resumingTxId hold a single id, so the finally of the first action reset it while the POST of the second was still in flight and released that dialog early. Each action now clears the state only if it is still its own.
  • Detail error. txDetailError was a single slot shared with handleUidClick, so a refresh settling late wiped or replaced the error of a transaction the user had opened in the meantime, leaving its detail row blank. Detail errors are now kept in a Map keyed by uid — mirroring the detail cache next to it — and every path only touches the entry of the transaction it belongs to.

Tests

src/__tests__/compliance-transaction-action-refresh.test.tsx:

  • a refresh that fails after a successful resume/stop keeps the result — no action error, dialog closed, onStatusChanged fired — and the refresh error is asserted to render inside the transaction detail row rather than in the action error field;
  • the refresh is driven by a controlled promise, so the assertions run while it is still pending and therefore prove the ordering, not just the end state;
  • a failing POST still reports the error and keeps the dialog open;
  • with two transactions: the dialog for the second one is neither disabled nor locked shut while the refresh for the first is running; it keeps its own loading state when that refresh settles; and a detail error belonging to it survives both a succeeding and a failing refresh of the first.

src/__tests__/confirm-dialog-loading.test.tsx: Escape and backdrop click are ignored while loading and still close the dialog when idle.

Both suites were checked against deliberate mutations of the production code — loading state unscoped, onClose passed through unguarded, stale cache kept, dialog closed only after the refresh, refresh error routed into the action error field, finally clearing a foreign loading state, and the detail error cleared or overwritten across transactions — and each one is caught by the tests that cover it.

…op/resume flow

A refresh failure after a successful stop or resume is no longer reported as a
failed action: the dialog closes and onStatusChanged fires right after the POST,
and a failing getTransactionByUid only invalidates the cached detail and surfaces
its own error instead of keeping a stale entry.

ConfirmDialog no longer forwards onClose while a request is running, so Escape and
backdrop clicks cannot dismiss the dialog mid-request, and each dialog's loading
state is bound to the transaction it was opened for.
The layout context mock referenced `document` inside the jest.mock factory, which
Jest rejects, so both new suites failed to run at all. The root ref now comes from
a mock-prefixed holder that beforeEach fills in.

The refresh is driven by a controlled promise, so the tests now prove that the
dialog closes and onStatusChanged fires while the refresh is still pending, and
that a refresh error is rendered in the transaction detail row instead of the
action error field. A new case with two transactions covers the per-transaction
loading state, and the interactions are wrapped in act to keep the console clean.
…saction

Two overlap races remained once an action for a second transaction could be
started while the first one was still refreshing.

The loading state is a single id, so the finally block of the first action reset
it while the POST of the second was still in flight, releasing that dialog early.
Each action now clears the state only if it is still its own.

The detail error was a single string shared with handleUidClick, so a refresh
that settled late wiped or replaced the error belonging to a transaction the user
had opened in the meantime, leaving its detail row blank. The error now carries
the uid it belongs to and is only cleared, set and rendered for that transaction.
Binding the single error slot to a uid only covered the success path: when a
refresh failed, it still replaced whatever error was on screen, so the detail row
of a transaction the user had opened in the meantime went blank — the very case
the previous commit set out to fix.

Detail errors are now kept in a map keyed by uid, mirroring the detail cache next
to it. Each path only touches the entry of the transaction it belongs to, and the
row renders its own error or nothing.
@TaprootFreak

Copy link
Copy Markdown
Collaborator Author

Four review passes were needed to reach a clean state, each one covering both conformance and logic:

  1. deepened the tests: a controlled promise now proves that the dialog closes and onStatusChanged fires while the refresh is still pending, plus coverage for the per-transaction loading state (the suites also had a jest.mock scope error that kept them from running at all);
  2. found that the finally of one action cleared the loading state of another, and that the shared detail error slot could be wiped across transactions;
  3. found that binding the error slot to a uid only covered the success path — the failure path still displaced a foreign error, so the errors moved into a map keyed by uid;
  4. clean on both dimensions.

Beyond the automated checks, both suites were run against eight deliberate mutations of the production code — loading state unscoped, onClose passed through unguarded, stale cache kept, dialog closed only after the refresh, refresh error routed into the action error field, finally clearing a foreign loading state, detail errors cleared globally, detail errors overwritten across transactions. Each mutation is caught by the tests that cover it, and the control run is green again afterwards.

The last pass surfaced three further races in neighbouring scalar states (action tracking overwritten by a second action, txDetailLoading dropping a later spinner, and a late chargeback detail fetch undoing a cache invalidation). None of them is merge-blocking — the backend rejects a repeated stop/resume with a conflict — so they are tracked in #1264.

@TaprootFreak
TaprootFreak marked this pull request as ready for review August 4, 2026 11:14
@TaprootFreak
TaprootFreak merged commit 3199d35 into develop Aug 4, 2026
6 checks passed
@TaprootFreak
TaprootFreak deleted the fix/compliance-action-refresh-error-handling branch August 4, 2026 14:09
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.

Stop/Resume flow: success reported as failure when the detail refresh fails; dialogs closable during loading

1 participant