Skip to content

fix(react-native-iap): avoid iOS listener cleanup crash after unmount - #262

Merged
hyochan merged 4 commits into
hyodotdev:mainfrom
MadeinFrance:agent/ios-iap-listener-unmount-crash
Aug 1, 2026
Merged

fix(react-native-iap): avoid iOS listener cleanup crash after unmount#262
hyochan merged 4 commits into
hyodotdev:mainfrom
MadeinFrance:agent/ios-iap-listener-unmount-crash

Conversation

@MadeinFrance

@MadeinFrance MadeinFrance commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

Summary

This keeps the iOS StoreKit-backed singleton native purchase and purchase-error listeners attached for the app session while JavaScript subscriptions are still removed normally.

useIAP keeps internal state updates mount-guarded during post-purchase refresh, but still delivers onPurchaseSuccess for a purchase event that already entered before unmount so apps can call finishTransaction.

Why

In a React Native app using useIAP from a modal-like plans screen, dismissing the screen can unmount the hook while StoreKit/Nitro purchase listener work is still in flight. The JS subscription cleanup removes the JS callback, but when it is also the last callback it previously disposed the native iOS listener immediately.

On iOS release builds this can abort during screen dismissal. Keeping the singleton native listener attached for the app session avoids that teardown race while still removing the JS callback, so unmounted screens no longer receive new purchase, purchase-error, promoted-product, or billing-issue callbacks.

For a purchase event that already entered before unmount, onPurchaseSuccess remains deliverable after the pending refresh completes. That callback is the documented finishTransaction site, and dropping it would risk leaving the purchase unfinished.

Android behavior is unchanged: native listener removal still happens when the last JS listener is removed.

Validation

  • yarn typecheck:lib in libraries/react-native-iap
  • yarn lint in libraries/react-native-iap
  • yarn lint:tsc in libraries/react-native-iap
  • yarn test:ci --watchman=false --silent in libraries/react-native-iap
  • git diff --check
  • Consumer app validation with a patch-package version of the same fix: npm run typecheck

Summary by CodeRabbit

  • Bug Fixes
    • Purchase success callbacks now remain deliverable when a related screen or component is unmounted, while preventing updates to inactive state.
    • Improved iOS purchase and error listener handling so removing a JavaScript listener does not interrupt native purchase updates.
    • Improved Android listener cleanup and re-subscription behavior after the final JavaScript listener is removed.

@coderabbitai

coderabbitai Bot commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

The pull request preserves purchase success delivery after useIAP unmounts and separates JavaScript callback removal from native listener cleanup on iOS. Non-iOS platforms retain native listener removal after the final JavaScript subscription.

Changes

Listener lifecycle safeguards

Layer / File(s) Summary
Post-unmount purchase delivery
libraries/react-native-iap/src/hooks/useIAP.ts, libraries/react-native-iap/src/__tests__/hooks/useIAP.test.ts
Purchase success callbacks remain deliverable after unmount. Internal refresh state updates remain mount-guarded. Tests cover a pending refresh that resolves after unmount.
Platform-specific listener cleanup
libraries/react-native-iap/src/index.ts, libraries/react-native-iap/src/__tests__/index.test.ts
iOS removes JavaScript purchase and error callbacks but retains the singleton native listeners. Android removes native listeners after the final JavaScript subscription and reattaches them for later subscriptions. Tests cover repeated, non-deduping, and re-subscription cases.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Sequence Diagram(s)

sequenceDiagram
  participant PurchaseEvent
  participant useIAP
  participant SubscriptionRefresh
  participant onPurchaseSuccess
  PurchaseEvent->>useIAP: deliver purchase update
  useIAP->>SubscriptionRefresh: refresh active subscriptions
  PurchaseEvent->>useIAP: unmount hook
  SubscriptionRefresh-->>useIAP: return refreshed subscriptions
  useIAP->>useIAP: guard internal state updates
  useIAP-->>onPurchaseSuccess: deliver purchase success
Loading

Suggested labels: react-native-iap

Suggested reviewers: hyochan

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes the main fix: preventing iOS listener cleanup crashes after unmount.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
libraries/react-native-iap/src/hooks/useIAP.ts (1)

588-600: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Re-check mount state after the asynchronous refreshes.

The guard at Line 588 runs before two await calls. If cleanup runs while either call is pending, execution resumes and invokes onPurchaseSuccess at Lines 598-600 after unmount. Add a second mount check after the awaits. Add a regression test that unmounts while the refresh is pending.

As per coding guidelines, follow React Native best practices for component structure and lifecycle management.

Proposed fix
           await getActiveSubscriptionsInternal();
           await getAvailablePurchasesInternal();
+          if (!isMountedRef.current) {
+            return;
+          }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@libraries/react-native-iap/src/hooks/useIAP.ts` around lines 588 - 600, In
the post-purchase refresh flow around getActiveSubscriptionsInternal and
getAvailablePurchasesInternal, re-check isMountedRef.current after both awaited
refreshes and before invoking optionsRef.current?.onPurchaseSuccess, returning
when the hook has unmounted. Add a regression test that unmounts the hook while
refresh is pending and verifies the success callback is not called.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@libraries/react-native-iap/src/hooks/useIAP.ts`:
- Around line 588-600: In the post-purchase refresh flow around
getActiveSubscriptionsInternal and getAvailablePurchasesInternal, re-check
isMountedRef.current after both awaited refreshes and before invoking
optionsRef.current?.onPurchaseSuccess, returning when the hook has unmounted.
Add a regression test that unmounts the hook while refresh is pending and
verifies the success callback is not called.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: d016f82e-901a-413e-bef6-3f08b1b1d8a4

📥 Commits

Reviewing files that changed from the base of the PR and between 1b2d3bb and 6dd403f.

📒 Files selected for processing (2)
  • libraries/react-native-iap/src/hooks/useIAP.ts
  • libraries/react-native-iap/src/index.ts

@MadeinFrance
MadeinFrance force-pushed the agent/ios-iap-listener-unmount-crash branch from 6dd403f to b813f62 Compare August 1, 2026 07:08

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@libraries/react-native-iap/src/__tests__/hooks/useIAP.test.ts`:
- Around line 155-164: Update the test setup around the captured purchase
listener and active-subscriptions resolver to assert both handles are
initialized before calling renderer.unmount(). After those assertions, invoke
capturedPurchaseListener and resolveActiveSubscriptions directly without
optional chaining, preserving the existing await sequence so the test fails when
either event path is not initialized.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 86b4f5d1-a080-470d-949b-6e88bdc1f316

📥 Commits

Reviewing files that changed from the base of the PR and between 6dd403f and b813f62.

📒 Files selected for processing (3)
  • libraries/react-native-iap/src/__tests__/hooks/useIAP.test.ts
  • libraries/react-native-iap/src/hooks/useIAP.ts
  • libraries/react-native-iap/src/index.ts
🚧 Files skipped from review as they are similar to previous changes (2)
  • libraries/react-native-iap/src/index.ts
  • libraries/react-native-iap/src/hooks/useIAP.ts

Comment thread libraries/react-native-iap/src/__tests__/hooks/useIAP.test.ts Outdated
@MadeinFrance
MadeinFrance force-pushed the agent/ios-iap-listener-unmount-crash branch from b813f62 to 8ee6518 Compare August 1, 2026 07:15
@MadeinFrance
MadeinFrance force-pushed the agent/ios-iap-listener-unmount-crash branch from 8ee6518 to 7a5f95e Compare August 1, 2026 07:19
Comment thread libraries/react-native-iap/src/__tests__/index.test.ts

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
libraries/react-native-iap/src/__tests__/index.test.ts (1)

232-243: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Assert removal of the non-deduping JavaScript callback.

Lines [240] and [243] only verify that native removal does not occur. A stale duplicate callback would still pass this test. Capture the duplicate callback and native handler, dispatch a valid purchase before and after duplicateSub.remove(), and assert that the callback is not invoked after removal.

As per coding guidelines, cover both callback removal and native listener retention for this IAP operation.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@libraries/react-native-iap/src/__tests__/index.test.ts` around lines 232 -
243, Strengthen the test around IAP.purchaseUpdatedListener by capturing the
duplicate callback and native purchase-update handler, dispatching a valid
purchase before and after duplicateSub.remove(), and asserting the duplicate
callback runs only before removal. Retain the existing assertions that native
removal is not called, including after defaultSub.remove(), to cover both
JavaScript callback removal and native listener retention.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@libraries/react-native-iap/src/__tests__/index.test.ts`:
- Around line 232-243: Strengthen the test around IAP.purchaseUpdatedListener by
capturing the duplicate callback and native purchase-update handler, dispatching
a valid purchase before and after duplicateSub.remove(), and asserting the
duplicate callback runs only before removal. Retain the existing assertions that
native removal is not called, including after defaultSub.remove(), to cover both
JavaScript callback removal and native listener retention.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: d80d6a8a-51c7-40a6-bc1c-e356b192f601

📥 Commits

Reviewing files that changed from the base of the PR and between b813f62 and 522d244.

📒 Files selected for processing (4)
  • libraries/react-native-iap/src/__tests__/hooks/useIAP.test.ts
  • libraries/react-native-iap/src/__tests__/index.test.ts
  • libraries/react-native-iap/src/hooks/useIAP.ts
  • libraries/react-native-iap/src/index.ts
🚧 Files skipped from review as they are similar to previous changes (3)
  • libraries/react-native-iap/src/tests/hooks/useIAP.test.ts
  • libraries/react-native-iap/src/index.ts
  • libraries/react-native-iap/src/hooks/useIAP.ts

@hyochan hyochan added 🐛 bug Something isn't working 📱 iOS Related to iOS labels Aug 1, 2026

@hyochan hyochan left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the thorough fix and the clear PR description. I did a deep pass over the listener lifecycle, the hook semantics, tests, and cross-SDK impact. Overall the approach is sound, and it actually converges with how expo-iap already works: there, native OpenIAP listeners attach once at module creation and are only disposed at module destroy, while JS remove() detaches only the JS callback. So keeping the iOS singleton bridge attached for the session moves react-native-iap toward the sibling SDK's architecture, and it matches StoreKit 2 guidance to keep a Transaction.updates observer alive for the app's lifetime.

Verified sound (no action needed):

  • Native listener count stays bounded: purchaseUpdateNativeAttached/token stay set after the iOS early return, so re-subscribes never double-attach, and delivery to new JS callbacks keeps working through the retained handler.
  • endConnection still cleans everything up (resetListenerState() mirrors the native detachConnectionState), so nothing outlives the connection.
  • Zero-JS-listener dispatch is a safe no-op, and events arriving with zero listeners were dropped natively before this PR too — no new event-loss class.
  • Android and the other platforms keep the exact pre-PR removal path; docs never promised native disposal on remove(), so no docs contradiction.

Findings are in the inline comments. The one I'd like resolved before merge is the silent onPurchaseSuccess drop (major); the rest are test-coverage gaps and one clarification on the crash mechanism.

One parity note for a follow-up (not this PR): expo-iap's useIAP has the same post-unmount onPurchaseSuccess bug this PR fixes — its purchase-update callback awaits refreshSubscriptionStatus(...) and then calls optionsRef.current.onPurchaseSuccess(purchase) with no mount guard (libraries/expo-iap/src/useIAP.ts:653-667, and the re-subscribe path at 777-789). Worth a mirrored fix there once the semantics are settled here.

Comment thread libraries/react-native-iap/src/hooks/useIAP.ts Outdated
Comment thread libraries/react-native-iap/src/index.ts
Comment thread libraries/react-native-iap/src/__tests__/index.test.ts
Comment thread libraries/react-native-iap/src/__tests__/hooks/useIAP.test.ts Outdated
Deliver onPurchaseSuccess even when the hook unmounts while the
post-purchase refresh is pending: it is where apps call
finishTransaction, and a dropped event has no in-session redelivery
(Android auto-refunds unacknowledged purchases). Drop the sync-path
mount guards, which were unreachable: unmount cleanup removes the JS
callback from the fan-out set synchronously, so callbacks only enter
while mounted. Internal setState stays mount-guarded in the refresh
helpers.

Extend the iOS keep-attached policy to purchaseErrorListener.remove()
so the same modal-dismiss teardown cannot release the stored native
callback while a dispatch snapshot is in flight; endConnection still
owns native disposal.

Tests: pin delivery-after-unmount, restore Android token-removal
coverage, pin the iOS re-subscribe-after-removal invariant, and reset
capturedPurchaseListener between tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@hyochan

hyochan commented Aug 1, 2026

Copy link
Copy Markdown
Member

@MadeinFrance Thanks again for this fix — the core insight (keep the iOS singleton native listener attached for the session; the JS callback set is the real subscription surface) is right, and it converges with how expo-iap already works. To speed things up I pushed b922850 using maintainer edits, addressing everything from my review in one pass:

  • Semantics change (please review): onPurchaseSuccess is now delivered even when the hook unmounts while the post-purchase refresh is pending. It is the documented finishTransaction site and a dropped event has no in-session redelivery (Android auto-refunds unacknowledged purchases after ~3 days). Internal setState remains mount-guarded, so the unmount-safety motivation of your guards is preserved where it matters. The now-redundant sync-path guards were removed — unmount cleanup already removes the JS callbacks from the fan-out sets synchronously, so those paths cannot run after unmount. If you saw an actual post-unmount invocation through one of the sync paths in the wild, tell me and we will dig into that repro instead.
  • purchaseErrorListener.remove() now applies the same iOS keep-attached policy, closing the remaining JS-unsubscribe → native-release path in the same modal-dismiss flow. If your crash log clearly points at OpenIapModule.removeListener rather than the general closure release, we can narrow this back out.
  • Tests: Android token-removal coverage restored, iOS re-subscribe invariant pinned, delivery-after-unmount pinned, capturedPurchaseListener reset between tests. Full suite: 411 passing, typecheck and lint clean.

The PR description's line about re-checking mounted state before onPurchaseSuccess no longer matches the code — could you update it when you get a chance? Happy to revert any part of this if you disagree with the direction.

@hyodotdev hyodotdev deleted a comment from gemini-code-assist Bot Aug 1, 2026
@hyodotdev hyodotdev deleted a comment from gemini-code-assist Bot Aug 1, 2026
@hyodotdev hyodotdev deleted a comment from coderabbitai Bot Aug 1, 2026
@hyochan

hyochan commented Aug 1, 2026

Copy link
Copy Markdown
Member

@MadeinFrance The review cycle on this branch is complete: CI is green, the full jest suite (411 tests) passes, and an independent verification round over b922850 came back clean — including mutation checks confirming each new test genuinely pins its behavior (delivery-after-unmount, Android token removal, iOS re-subscribe reuse, iOS error-listener retention).

Since you have the only real reproduction environment (the consumer app you validated with patch-package), could you re-test the updated branch against the original scenario before we merge?

  1. iOS release build: dismiss the plans/paywall modal while purchase-listener work is in flight — confirm the original abort is gone.
  2. Complete a purchase and dismiss the screen immediately, while the post-purchase refresh is still running — with the updated semantics onPurchaseSuccess should still arrive (late) so finishTransaction runs; please confirm nothing downstream in your app assumes the callback can no longer fire after the screen closed.
  3. A quick purchase-error flow after remount — the error listener now also stays attached on iOS, so errors should keep flowing to newly mounted screens.

And if your crash log points specifically at OpenIapModule.removeListener rather than the general Nitro closure release, note it in the open thread on index.ts and we can narrow the error-listener change back out. Thanks!

@MadeinFrance

Copy link
Copy Markdown
Contributor Author

@hyochan Thanks for the edits, the new semantics make sense to me.

Keeping onPurchaseSuccess deliverable after an already-entered purchase event is the safer behavior if that’s the documented finishTransaction site. The mount-guarded internal state updates preserve the original unmount-safety concern.

I also agree with extending the iOS keep-attached policy to purchaseErrorListener.remove(). The crash I’m seeing points to the modal-dismiss/native listener cleanup path generally, not a repro that would require narrowing this back yet.

I updated the PR description.

@MadeinFrance

Copy link
Copy Markdown
Contributor Author

@hyochan Tested in the Abandoned World app release build.

Everything works correctly with the updated branch:

  • Dismissing the plans/paywall modal no longer triggers the original abort.
  • Completing a purchase and dismissing immediately still works; onPurchaseSuccess arrives and our finishTransaction flow runs as expected.
  • Re-opening/remounting the flow still receives purchase/error listener events correctly.
  • I don’t have evidence that the crash was specifically isolated to OpenIapModule.removeListener, so the conservative iOS keep-attached policy for both purchase and error listeners makes sense to me.

Looks good from the consumer app side.

@MadeinFrance MadeinFrance left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

@MadeinFrance
MadeinFrance requested a review from hyochan August 1, 2026 11:10
@hyochan
hyochan merged commit e3ee015 into hyodotdev:main Aug 1, 2026
14 checks passed
@hyochan

hyochan commented Aug 1, 2026

Copy link
Copy Markdown
Member

@MadeinFrance Deployment is complete — thank you again for the fix and for validating the final behavior in the consumer app release build.

No further action is required for this PR.

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

Labels

🐛 bug Something isn't working 📱 iOS Related to iOS

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants