Skip to content

[$175] iOS - Add to Apple Wallet button loads infinitely after cancelling Download Wallet and retap #102403

Open

Description

@applause-bot

If you haven’t already, check out our contributing guidelines for onboarding. To join our Slack channel, fill out this form.


Version Number: 9.4.94-0
Reproducible in staging?: Yes
Reproducible in production?: Yes
If this was caught during regression testing, add the test name, ID and link from BrowserStack: https://test-management.browserstack.com/projects/2219752/folder/13176890/test-cases/41237390?sort=%2Bidentifier
Email or phone of affected tester (no customers): jsdoijnodsdisojo@gmail.com
Issue reported by: Applause Internal Team
Bug source: Regression TC Execution
Device used: iPhone 17 Pro Max / IOS 27.0
App Component: User Settings

Action Performed:

Precondition:

  • Wallet app is not installed on iOS device.
  • User has assigned a virtual card to themselves.
  1. Launch Expensify app.
  2. Go to Account > Wallet.
  3. Open the virtual card.
  4. Tap Add to Apple Wallet.
  5. Tap Cancel on Download Wallet modal.
  6. Tap Add to Apple Wallet again.

Expected Result:

Download Wallet modal will reappear.

Actual Result:

Add to Apple Wallet button loads infinitely.

Workaround:

Unknown

Platforms:

  • Android: App
  • Android: mWeb Chrome
  • iOS: App
  • iOS: mWeb Safari
  • iOS: mWeb Chrome
  • Windows: Chrome
  • MacOS: Chrome Safari

Screenshots/Videos

Bug7269481_1790352485819.ScreenRecording_09-26-2026_00-03-45_1.mp4

View all open jobs on GitHub

Applause Internal Information
Exported by: Julia Ponikarchuk
Bug ID: 7269481

Upwork Automation - Do Not Edit
Issue OwnerCurrent Issue Owner: @francoisl

Activity

  1. added
    BugSomething is broken. Auto assigns a BugZero manager.
    on Sep 28, 2026
  2. applause-bot commented on Sep 28, 2026

    @applause-bot
    Author

    While Applause doesn't embed attachments for this bug, here are the links for quick view:
    https://platform.applause.com/services/links/v1/external/007da34ce5f7a91a8169d96b2d912c15793eaa415f219e8b3578e000b0ffc030

  3. MelvinBot commented on Sep 28, 2026

    @MelvinBot
    Contributor

    Proposal

    What is the root cause of that problem?

    The iOS side of @expensify/react-native-wallet (the App uses 0.1.22) does not clear its saved add-card screen after you cancel the first time. On the second tap, the library thinks that screen is still open. It shows nothing and never answers the App. AddToWalletButton waits for that answer, so the spinner never stops.

    How it happens
    1. First tap. IOSPresentAddPaymentPassView saves the add-card controller in addPassViewController and presents it. Wallet is not installed, so iOS shows the "Download Wallet" prompt in its place.
    2. Cancel. didFinishAdding resolves the promise as canceled, so the button comes back. It then calls hideModal(). That function clears addPassViewController only inside the dismiss completion. The controller is no longer on screen at this point, so the else branch runs, logs a message, and the saved reference stays.
    3. Second tap. addPassViewController is still set, so the library logs "EnrollViewController is already presented." and returns. It never calls the completion handler, resolves, or rejects.
    4. Result. The promise in AddToWalletButton/index.native.tsx:31-64 never settles, so isLoading stays true. The spinner lasts until the app restarts.

    The App code works as long as the promise settles. WalletManager.swift is the same at 0.1.22 and on the library's current main (0.1.25), so a version bump alone won't fix this.

    What changes do you think we should make in order to solve the problem?

    This fix belongs in Expensify/react-native-wallet, not in the App repo. Make these changes in ios/WalletManager.swift:

    1. In hideModal(), always clear addPassViewController. Call dismiss only if the controller is still on screen.
    2. In IOSPresentAddPaymentPassView, if the saved controller is not on screen, treat it as stale and present a new one. If it really is on screen, call completion(.error, …) so JS always gets an answer.
    3. Save presentAddPaymentPassCompletionHandler only when the library actually presents the screen.

    After the library releases the fix, the App bumps @expensify/react-native-wallet in package.json, package-lock.json, and ios/Podfile.lock.

    What alternative solutions did you explore? (Optional)

    • Stopgap patch. Apply the same Swift change in the App with patch-package until the library releases the fix.
    • Rejected: timeout in AddToWalletButton. A JS timeout would hide the hang, but people can stay in Apple's add-card sheet for a long time. It would also leave the library in a stale state.

    I did not reproduce this. The bug only happens in the native iOS app, and this run could only test on web.


    Next Steps for Contributor+ team:
    To accept: The fix is outside the App repo. Ask an Expensify engineer who maintains react-native-wallet to make the library change, then bump the version in App. Or reply @MelvinBot implement this as a patch-package stopgap to get a draft App PR.
    To refine: @MelvinBot <your feedback>
    To reject: Explain why you are rejecting Melvin's proposal.

  4. added
    ExternalAdded to denote the issue can be worked on by a contributor
    on Sep 28, 2026
  5. melvin-bot commented on Sep 28, 2026

    @melvin-bot

    Triggered auto assignment to Contributor-plus team member for initial proposal review - @dmkt9 (External)

  6. changed the title [-]iOS - Add to Apple Wallet button loads infinitely after cancelling Download Wallet and retap[/-] [+][$175] iOS - Add to Apple Wallet button loads infinitely after cancelling Download Wallet and retap[/+] on Sep 28, 2026
  7. melvin-bot commented on Sep 28, 2026

    @melvin-bot
  8. bonnhatnguyen commented on Sep 28, 2026

    @bonnhatnguyen
    ## Proposal

    Please re-state the problem that we are trying to solve in this issue.

    On iOS, tapping the "Add to Apple Wallet" button prompts the system sheet to download the Wallet app (if not installed). If the user cancels that sheet and then taps the "Add to Apple Wallet" button a second time, the button enters an infinite loading state (activity indicator spins indefinitely) and never recovers.


    What is the root cause of that problem?

    The root cause lies in @expensify/react-native-wallet (native iOS module WalletManager.swift used by src/libs/Wallet/index.ios.ts and src/components/AddToWalletButton/index.native.tsx):

    1. State Leak on Cancellation:
      When the user first taps "Add to Wallet", IOSPresentAddPaymentPassView saves the active view controller in self.addPassViewController and presents it. If the Wallet app is not installed, iOS replaces this with the system "Download Wallet" alert. When the user cancels, didFinishAdding is invoked and resolves the JS promise with status 'canceled'. It then calls hideModal().
    2. Unguarded addPassViewController reference:
      In hideModal():
      if let addPassViewController = self.addPassViewController, addPassViewController.isBeingPresented || addPassViewController.presentingViewController != nil {
          addPassViewController.dismiss(animated: true) {
              self.addPassViewController = nil
          }
      } else {
          print("Pass view controller is not being presented.")
      }
      Because the view was already dismissed by iOS, addPassViewController.isBeingPresented || addPassViewController.presentingViewController != nil evaluates to false. The else branch executes, leaving self.addPassViewController populated with a stale pointer.
    3. Hang on Subsequent Tap:
      On the second tap, IOSPresentAddPaymentPassView checks:
      guard self.addPassViewController == nil else {
          print("EnrollViewController is already presented.")
          return
      }
      Because addPassViewController was never cleared, it prints the log and immediately returns without ever resolving or rejecting the Promise or calling any completion handler.
    4. In src/components/AddToWalletButton/index.native.tsx (lines 31–47), setIsLoading(true) is set when pressed, but because the returned Promise from handleAddCardToWallet never settles, setIsLoading(false) is never reached, leaving the button spinning forever.

    What changes do you think we should make in order to solve the problem?

    1. Immediate In-App Fix (patch-package):
      In patches/@expensify+react-native-wallet+0.1.22.patch (or applying to node_modules/@expensify/react-native-wallet/ios/WalletManager.swift):

      • In hideModal(), ensure self.addPassViewController = nil is executed unconditionally so stale references are always cleaned up:
      --- a/node_modules/@expensify/react-native-wallet/ios/WalletManager.swift
      +++ b/node_modules/@expensify/react-native-wallet/ios/WalletManager.swift
      @@ -201,8 +201,9 @@ class WalletManager: NSObject, PKAddPaymentPassViewControllerDelegate {
               if let addPassViewController = self.addPassViewController, addPassViewController.isBeingPresented || addPassViewController.presentingViewController != nil {
                   addPassViewController.dismiss(animated: true) {
                       self.addPassViewController = nil
                   }
               } else {
      +            self.addPassViewController = nil
                   print("Pass view controller is not being presented.")
               }
      • In IOSPresentAddPaymentPassView, if addPassViewController is not currently presented on screen, treat it as stale, reset it, and allow presenting the new controller. If it is truly still presented, reject/call completion with an error so the JS promise always settles:
      @@ -123,7 +124,8 @@ class WalletManager: NSObject, PKAddPaymentPassViewControllerDelegate {
           @objc(IOSPresentAddPaymentPassView:cardData:completion:)
           func IOSPresentAddPaymentPassView(signature: String, cardData: [String: Any], completion: @escaping RCTResponseSenderBlock) {
               guard self.addPassViewController == nil else {
      +            completion([["error": "EnrollViewController is already presented."]])
                   print("EnrollViewController is already presented.")
                   return
               }
    2. Upstream PR:
      Submit the corresponding PR to Expensify/react-native-wallet so the upstream library can release version 0.1.26 cleanly.

    3. In-App Safety Net (src/components/AddToWalletButton/index.native.tsx):
      Listen to AppState active transitions so if the user returns to the app from an external iOS prompt/modal that completed without settling, reset setIsLoading(false) cleanly.


    What alternative solutions did you explore? (Optional)

    • Setting an arbitrary setTimeout in AddToWalletButton: Rejected because user interaction in Apple Pay sheets can take variable time and does not fix the underlying stale state in the native controller.

    Contributor details
    Your Expensify account email: bonnhatnguyen0@gmail.com
    Upwork Profile Link: https://www.upwork.com/freelancers/~0139ed4098bdc50182

  9. melvin-bot commented on Sep 28, 2026

    @melvin-bot

    ⚠️ Invalid email. Please make sure to create an Expensify account with this email first here.

  10. bonnhatnguyen commented on Sep 28, 2026

    @bonnhatnguyen

    Contributor details
    Your Expensify account email: bonnhatnguyen0@gmail.com
    Upwork Profile Link: https://www.upwork.com/freelancers/~0139ed4098bdc50182

  11. melvin-bot commented on Sep 28, 2026

    @melvin-bot

    ✅ Contributor details stored successfully. Thank you for contributing to Expensify!

  12. bconnnnn commented on Sep 29, 2026

    @bconnnnn

    This looks like stale native state in @expensify/react-native-wallet.

    IOSPresentAddPaymentPassView refuses to present a second controller while addPassViewController is non-nil.

    The problem is that hideModal() only clears that reference inside the dismiss completion. If iOS has already dismissed/replaced the PassKit controller while showing the Download Wallet flow, the controller is no longer considered presented, so the function just logs and leaves addPassViewController set.

    On the second tap, WalletManager thinks the old controller is still open, presents nothing, and never completes the new JS request. That explains the infinite spinner.

    I’d make hideModal() always release the stored controller reference, and only make the actual dismiss conditional on whether it’s still presented. I’d also add coverage for cancel -> retry so the second call can present normally and its completion is still reachable.

    I wouldn’t fix this with a JS timeout because that would only hide the spinner while leaving the native state broken.

  13. 2 remaining items

  14. bconnnnn commented on Sep 30, 2026

    @bconnnnn

    @MelvinBot @dmkt9 I added the native controller-cleanup change and the cancel → retry iOS test plan to my proposal above. I’m available to take the dependency-side fix if this proposal is accepted. Could you review it?

  15. melvin-bot commented on Oct 1, 2026

    @melvin-bot

    @dmkt9 Whoops! This issue is 2 days overdue. Let's get this updated quick!

  16. melvin-bot commented on Oct 5, 2026

    @melvin-bot

    @dmkt9 Still overdue 6 days?! Let's take care of this!

  17. dmkt9 commented on Oct 6, 2026

    @dmkt9
    Contributor

    MelvinBot's proposal looks good to me

    🎀 👀 🎀 C+

  18. melvin-bot commented on Oct 6, 2026

    @melvin-bot

    Triggered auto assignment to @francoisl, see https://stackoverflow.com/c/expensify/questions/7972 for more details.

  19. m-natarajan commented on Oct 8, 2026

    @m-natarajan

    Not repro in the Ad-hoc build

    ScreenRecording_10-08-2026.20-58-46_1.MP4
  20. dmkt9 commented on Oct 9, 2026

    @dmkt9
    Contributor

    @francoisl Do I need to review Expensify/react-native-wallet#95, or do I only need to review the version bump PR in App?

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

Metadata

Metadata

Labels

BugSomething is broken. Auto assigns a BugZero manager.ExternalAdded to denote the issue can be worked on by a contributorReviewingHas a PR in reviewWeeklyKSv2

Type

No type

Projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions