Repository navigation
ENG-704: keypad no longer toasts ERR_MULTI_REQ after backing out of a card charge - #91
Merged
Merged
Conversation
Another screen's still-pending NFC request makes the native module refuse the keypad's requestTechnology with "You can only issue one request at a time". runSession toasted that literal string. Classify it with a new isMultiRequestError and return 'busy': no toast, and the loop re-arms after the existing breather. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WxkKbrg2Xohf3mQj7BLrs6
A popped card screen (CashuCardCharge, CashuCardSpend, CashuCardDebug, Invoice) cancels its request only on unmount, after its close transition. On the JS stack the keypad regains focus first, so its arm hit the live request and the native module refused it with ERR_MULTI_REQ, which the keypad toasted. Await a generation-guarded cancelCardSession() ahead of armLoop in the loop chain. The previous loop has already ended (the blur cancelled the keypad's own request), so this never cancels the keypad's own request; with nothing pending it is a no-op. The departing screen's late unmount cancel then hits the keypad's fresh request, which runSession reads as 'cancelled' and re-arms at once, silently; the new test locks that in. The ENG-630 write-ordering test now picks the session-close cancel as the first cancel after the arm, since the focus flush is the first cancel overall. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WxkKbrg2Xohf3mQj7BLrs6
…unmount Review follow-up on ENG-704. A popped card screen cancelled its request only on unmount, at the end of the close transition. By then the keypad had armed its own request, and a card could be mid-read: the late cancel closed the keypad's tag under its transceive and toasted a card failure. useCardSessionRelease cancels as the stack lets the screen go, before the keypad's focus effect runs, and skips the unmount cancel once released (whatever is pending then belongs to the next screen). A removal with no beforeRemove still cancels at unmount. CashuCardCharge calls release from its own listener's let-through branches only, so a refused pop (held or settled) keeps the charge's session. Spend, Debug and Invoice use the hook's listener. The keypad's focus flush stays as the backstop. Its test no longer promises "never", and a new test pins what the merchant sees if a foreign cancel ever cuts a read mid-flight: a failed-read toast, then a re-arm. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WxkKbrg2Xohf3mQj7BLrs6
Review follow-up on ENG-704. docs/13-cashu-card.md still told screens to cancel their session on unmount, which 5bd7099 replaced: an unmount cancel lands after the keypad underneath has armed, and closes its tag mid-read. Point screens at useCardSessionRelease, say why, and note the CashuCardCharge pattern for a listener that can refuse the pop. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WxkKbrg2Xohf3mQj7BLrs6
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
On Android, backing out of a card charge (sub-cent sat amount →
CashuCardCharge) to the keypad could toast "You can only issue one request at a time".The keypad arms a card request as soon as it regains focus. On the JS stack navigator, that focus fires before the popped charge screen unmounts, and the charge screen only cancelled its own pending request in its unmount cleanup. So the keypad's
requestTechnologylanded on a live request. react-native-nfc-manager rejected it withERR_MULTI_REQ(NfcManager.java:61), andrunSessiontoasted the literal string. The loop re-armed 750 ms later and worked, so the toast was pure noise.CashuCardSpend,CashuCardDebugandInvoicehad the same unmount-only cancel.The unmount cancel had a second, worse effect. It lands at the end of the close transition (about 350 to 400 ms), after the keypad has armed. If a card is mid-read on the keypad by then,
cancelCardSessioncloses the keypad's tag under its transceive and the merchant sees a card-failure toast.Change
Three layers. Layer 0 removes the cause at the card screens; layers 1 and 2 in the keypad are the backstop for any screen that still leaves a request behind.
beforeRemove(newsrc/hooks/useCardSessionRelease.ts).releasecancels the screen's request as the stack lets it go, before the keypad's focus effect runs, so the keypad arms against a clear radio. Once released, the hook's unmount cleanup does nothing: whatever is pending by then belongs to the next screen. A screen removed with nobeforeRemove(a parent unmounting, a test) still cancels at unmount.CashuCardSpend,CashuCardDebugandInvoicepassnavigationand use the hook's own listener. Spend and Debug set theircancelledRefinonRelease, so the read they cut is treated as their own cancel.CashuCardChargehas its ownbeforeRemovelistener that can refuse the pop (held during the burn, or settled and routed to Done). It callsreleaseonly from the let-through branches, so a refused pop keeps the charge's session. A plain pop before the burn also setscancelledRef, so the cut read is never painted as an error on the way out.docs/13-cashu-card.mdnow tells new card screens to use the hook instead of an unmount cancel.src/hooks/useKeypadCardReader.ts,useFocusEffect), now a backstop. On Android, an awaited, generation-guardedcancelCardSession()runs ahead ofarmLoopin theloopRefchain. It catches a request that layer 0 did not release (a screen removed without the event, a session opened in the gap). The chain still waits for the previous loop to end (the blur's cancel), so the flush never cancels the keypad's own request, and the "never arms twice" guard holds. When nothing is pending, the flush is a no-op.ERR_MULTI_REQas busy (src/services/cashuCardNfc.tsaddsMULTI_REQUEST_MESSAGE+isMultiRequestError;runSessionreturns'busy'for it). No toast; the loop re-arms after the existingREARM_AFTER_ERROR_MSbreather, and thefinallycancel clears the stray request. Every other error still toasts.Decided, and pinned by a test: the keypad cannot tell a foreign cancel that cuts a read mid-flight from a card leaving the field. Both close the tag under the transceive, so the keypad reports a failed read and re-arms. Layer 0 is what keeps card screens from causing that.
iOS is untouched (no focus arm). No user-facing copy changed.
How it was tested
New tests:
__tests__/hooks/useCardSessionRelease.test.tsx(layer 0): cancels onbeforeRemoveand not again at unmount; still cancels at unmount with nobeforeRemove; runsonReleasebefore the cancel; with nonavigation, installs no listener and handsreleaseto the screen; stops listening once unmounted.__tests__/screens/CashuCardCharge.test.tsx(layer 0):beforeRemove, no error pill when the cut read rejects, none more at unmount.__tests__/screens/CashuCardDebug.test.tsx› "cancels an in-flight read as the stack lets the screen go, and not again at unmount".__tests__/screens/CashuCardSpend.test.tsx› "CashuCardSpend session release (ENG-704)" › "cancels as the stack lets the screen go, and not again at unmount".__tests__/screens/Invoice.test.tsx› "releases the card session as the stack lets the screen go, and not again at unmount" and "still cancels the card session at unmount when no beforeRemove came first".__tests__/support/navigationStub.ts: a shared stub withemitBeforeRemovefor the Debug, Spend and Invoice tests.__tests__/services/cashuCardNfc.test.ts›isMultiRequestError(layer 2): pins the native string; true forNfcErrorBase(MULTI_REQUEST_MESSAGE)andError(MULTI_REQUEST_MESSAGE); false forUserCancel,TagConnectionLost,NfcErrorBase('no tech request available'), a string,null.__tests__/hooks/useKeypadCardReader.test.tsx:requestTechnologyscoped to this test: refuses withERR_MULTI_REQwhile a request is pending. Asserts the flush cancel precedes the keypad's arm and no toast. It also asserts that a late unmount cancel from a screen with nobeforeRemoveleads to an immediate silent re-arm while no card is in the field.describeCardFailuretoast, no navigation, then a re-arm after the breather.Mutation checks (source reverted, new tests kept):
d491bc5, hook file kept), run at5bd7099: 5 failures, the new Charge (2 of 3), Debug, Spend and Invoice release tests. "A refused pop releases nothing" and Invoice's no-beforeRemovefallback still pass, as they should: the old unmount-only code never cancelled on a refused pop and did cancel at unmount.d491bc5(the hook change since then is comment-only):718c700: 7 failures. All 4isMultiRequestErrortests and both layer-1/2 hook tests fail, plus "says quietly that a card without the applet…". That last one is collateral: the failing ERR_MULTI_REQ test leaves its loop's breather running into the next test, and it passes alone on718c700.718c700only: both layer-1/2 hook tests fail.Adjusted existing test: "writes the owed change onto a PIN-less card in the same session and re-reads the balance" (ENG-630) took
cancelTechnologyRequest.mock.invocationCallOrder[0]as the session close. With the flush, the first cancel is now the focus flush, so the test counted a cancel on focus. It now takes the first cancel after the keypad's arm as the session close; the "write before close" assertion is unchanged in strength.CI commands at
a8cef6f, all green:yarn lint --max-warnings 99999: 0 errors, 1 pre-existing warning (src/hooks/useSatPrice.tsx)yarn typecheck: cleanyarn test --runInBand: 72 suites passed (1 skipped), 1558 tests passed, 9 skippednode_modules/.bin/prettier --checkon the changed source and test files: clean (docs/13-cashu-card.mdhas pre-existing table drift outside the edited paragraph)typoson the added lines: cleanFollow-ups
isMultiRequestErrormatches a third-party literal. If an upgrade of react-native-nfc-manager rewords it, layer 2 goes quiet. Layers 0 and 1 still prevent the race, and the classifier test pins the string.🤖 Generated with Claude Code
https://claude.ai/code/session_01WxkKbrg2Xohf3mQj7BLrs6