You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
[$175] iOS - Add to Apple Wallet button loads infinitely after cancelling Download Wallet and retap #102403
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
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.
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.
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.
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:
In hideModal(), always clear addPassViewController. Call dismiss only if the controller is still on screen.
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.
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.
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
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):
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().
Unguarded addPassViewController reference:
In hideModal():
iflet 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.
Hang on Subsequent Tap:
On the second tap, IOSPresentAddPaymentPassView checks:
guardself.addPassViewController ==nilelse{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.
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?
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:
Upstream PR:
Submit the corresponding PR to Expensify/react-native-wallet so the upstream library can release version 0.1.26 cleanly.
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.
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.
@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?
Activity
applause-bot commentedon Sep 28, 2026
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
MelvinBot commentedon Sep 28, 2026
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.AddToWalletButtonwaits for that answer, so the spinner never stops.How it happens
IOSPresentAddPaymentPassViewsaves the add-card controller inaddPassViewControllerand presents it. Wallet is not installed, so iOS shows the "Download Wallet" prompt in its place.didFinishAddingresolves the promise ascanceled, so the button comes back. It then callshideModal(). That function clearsaddPassViewControlleronly inside thedismisscompletion. The controller is no longer on screen at this point, so theelsebranch runs, logs a message, and the saved reference stays.addPassViewControlleris still set, so the library logs "EnrollViewController is already presented." and returns. It never calls the completion handler, resolves, or rejects.AddToWalletButton/index.native.tsx:31-64never settles, soisLoadingstaystrue. The spinner lasts until the app restarts.The App code works as long as the promise settles.
WalletManager.swiftis the same at 0.1.22 and on the library's currentmain(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 inios/WalletManager.swift:hideModal(), always clearaddPassViewController. Calldismissonly if the controller is still on screen.IOSPresentAddPaymentPassView, if the saved controller is not on screen, treat it as stale and present a new one. If it really is on screen, callcompletion(.error, …)so JS always gets an answer.presentAddPaymentPassCompletionHandleronly when the library actually presents the screen.After the library releases the fix, the App bumps
@expensify/react-native-walletinpackage.json,package-lock.json, andios/Podfile.lock.What alternative solutions did you explore? (Optional)
patch-packageuntil the library releases the fix.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-walletto make the library change, then bump the version in App. Or reply@MelvinBot implement this as a patch-package stopgapto get a draft App PR.To refine:
@MelvinBot <your feedback>To reject: Explain why you are rejecting Melvin's proposal.
melvin-bot commentedon Sep 28, 2026
Triggered auto assignment to Contributor-plus team member for initial proposal review - @dmkt9 (
External)[-]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[/+]melvin-bot commentedon Sep 28, 2026
Job added to Upwork: https://www.upwork.com/jobs/~022104518177406814644
bonnhatnguyen commentedon Sep 28, 2026
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 moduleWalletManager.swiftused bysrc/libs/Wallet/index.ios.tsandsrc/components/AddToWalletButton/index.native.tsx):When the user first taps "Add to Wallet",
IOSPresentAddPaymentPassViewsaves the active view controller inself.addPassViewControllerand presents it. If the Wallet app is not installed, iOS replaces this with the system "Download Wallet" alert. When the user cancels,didFinishAddingis invoked and resolves the JS promise with status'canceled'. It then callshideModal().addPassViewControllerreference:In
hideModal():addPassViewController.isBeingPresented || addPassViewController.presentingViewController != nilevaluates tofalse. Theelsebranch executes, leavingself.addPassViewControllerpopulated with a stale pointer.On the second tap,
IOSPresentAddPaymentPassViewchecks:addPassViewControllerwas never cleared, it prints the log and immediately returns without ever resolving or rejecting the Promise or calling any completion handler.src/components/AddToWalletButton/index.native.tsx(lines 31–47),setIsLoading(true)is set when pressed, but because the returned Promise fromhandleAddCardToWalletnever 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?
Immediate In-App Fix (patch-package):
In
patches/@expensify+react-native-wallet+0.1.22.patch(or applying tonode_modules/@expensify/react-native-wallet/ios/WalletManager.swift):hideModal(), ensureself.addPassViewController = nilis executed unconditionally so stale references are always cleaned up:IOSPresentAddPaymentPassView, ifaddPassViewControlleris 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:Upstream PR:
Submit the corresponding PR to
Expensify/react-native-walletso the upstream library can release version0.1.26cleanly.In-App Safety Net (
src/components/AddToWalletButton/index.native.tsx):Listen to
AppStateactive transitions so if the user returns to the app from an external iOS prompt/modal that completed without settling, resetsetIsLoading(false)cleanly.What alternative solutions did you explore? (Optional)
setTimeoutinAddToWalletButton: 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
melvin-bot commentedon Sep 28, 2026
bonnhatnguyen commentedon Sep 28, 2026
Contributor details
Your Expensify account email: bonnhatnguyen0@gmail.com
Upwork Profile Link: https://www.upwork.com/freelancers/~0139ed4098bdc50182
melvin-bot commentedon Sep 28, 2026
✅ Contributor details stored successfully. Thank you for contributing to Expensify!
bconnnnn commentedon Sep 29, 2026
This looks like stale native state in
@expensify/react-native-wallet.IOSPresentAddPaymentPassViewrefuses to present a second controller whileaddPassViewControlleris 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 leavesaddPassViewControllerset.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.
2 remaining items
bconnnnn commentedon Sep 30, 2026
@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?
melvin-bot commentedon Oct 1, 2026
@dmkt9 Whoops! This issue is 2 days overdue. Let's get this updated quick!
melvin-bot commentedon Oct 5, 2026
@dmkt9 Still overdue 6 days?! Let's take care of this!
dmkt9 commentedon Oct 6, 2026
MelvinBot's proposal looks good to me
🎀 👀 🎀 C+
melvin-bot commentedon Oct 6, 2026
Triggered auto assignment to @francoisl, see https://stackoverflow.com/c/expensify/questions/7972 for more details.
m-natarajan commentedon Oct 8, 2026
Not repro in the Ad-hoc build
ScreenRecording_10-08-2026.20-58-46_1.MP4
dmkt9 commentedon Oct 9, 2026
@francoisl Do I need to review Expensify/react-native-wallet#95, or do I only need to review the version bump PR in App?
fix: allow Apple Wallet retries after cancellation