Skip to content

fix(frontend): Escape leaves stale purchase buckets for the next modal submission #331

Closed

Description

@cristim

Summary

Closing the purchase modal with Escape bypasses purchase-state cleanup. A later single-bucket modal can submit the previous fan-out buckets instead of the newly displayed recommendation.

Discovered during independent Claude Fable 5.1 review of LeanerCloud/cloud-commitments-cli#2071. This predates that PR and remains present on main at eac9a62a88d49cbb30abf1dc3943037dd1d58b0b.

Current behavior and reproduction

  1. Select recommendations forming at least two supported buckets and open Purchase.
  2. Press Escape without submitting.
  3. Select a different single-bucket recommendation and open Purchase again.
  4. Click Send for Approval and inspect the confirmation/request payloads using a fixture backend, not real cloud purchases.

frontend/src/modal.ts:107-110 calls only closeModal(el) on Escape. The explicit close-button handler in frontend/src/app.ts:245-249 additionally calls clearPurchaseModalRecommendations() and clearFanOutBuckets(). openPurchaseModal sets single-row state without clearing the old fan-out state. handleExecutePurchase checks getFanOutBuckets() first, so the old buckets win over the visible single-row selection.

Expected behavior

Every supported close path must discard purchase-specific state. Reopening a modal must submit exactly the selection displayed in that modal.

Proposed fix and verification

Route Escape and explicit close through the same purchase cleanup without changing unrelated modal behavior. Add a regression that opens fan-out, presses Escape through the real keyboard handler, opens a different single-bucket selection, and asserts on the actual intercepted executePurchase payload. Verify it fails before the fix and passes afterward.

Also make the submit router distinguish no fan-out state (null) from an all-skipped fan-out ([]) introduced by LeanerCloud/cloud-commitments-cli#2071. The latter must not fall through into stale single-row state. This second path is currently prevented by a disabled button; do not describe it as a confirmed live exploit.

Evidence and limits

Static review confirmed the control flow against the committed code. This follow-up has not yet been reproduced in a browser. Approval is still required for fan-out submissions, so the demonstrated code path concerns unintended approval requests, not an observed cloud charge.

References: LeanerCloud/cloud-commitments-cli#2071, LeanerCloud/cloud-commitments-cli#1903, LeanerCloud/cloud-commitments-cli#1904, and LeanerCloud/cloud-commitments-cli#2071 (comment).

Severity: high when stale purchase intent is submitted; limited audience and an explicit-close workaround. Kept separate from the in-flight button regression being repaired in LeanerCloud/cloud-commitments-cli#2071.

Activity

  1. cristim commented on Sep 14, 2026

    @cristim
    MemberAuthor

    Independent Astra review of LeanerCloud/cloud-commitments-cli#2071 identified another reproduction of this existing modal-lifecycle bug: start a single-selection bulk purchase with its account-override request deferred; change selection and open a multi-bucket purchase; let that newer request finish; then release the older request. The old single-row content replaces purchase-details, but getFanOutBuckets still contains the newer buckets, so app.ts submission selects those buckets instead of the displayed row. Static comparison confirms both asynchronous entry paths are already independent on 39003684be16542ec606b0d83b0a6e13364176e4, before the current legacy-payment and capacity fixes. Please include both cross-mode ordering directions and delayed completion after close in this issue’s regression coverage. This is tracked here rather than broadening LeanerCloud/cloud-commitments-cli#2071 into a cancellation redesign. No real purchase was executed; independent local fixture reproduction is being refreshed.

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

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions