From 7a5f95ed95e2aa9b20bca6cede7e946acac0d4bb Mon Sep 17 00:00:00 2001 From: Maxime Gerbe Date: Sat, 1 Aug 2026 08:54:07 +0200 Subject: [PATCH 1/3] fix iOS IAP listener cleanup crash --- .../src/__tests__/hooks/useIAP.test.ts | 54 +++++++++++++++++++ .../src/__tests__/index.test.ts | 12 ++--- .../react-native-iap/src/hooks/useIAP.ts | 21 ++++++++ libraries/react-native-iap/src/index.ts | 7 +++ 4 files changed, 87 insertions(+), 7 deletions(-) diff --git a/libraries/react-native-iap/src/__tests__/hooks/useIAP.test.ts b/libraries/react-native-iap/src/__tests__/hooks/useIAP.test.ts index 203eb2637..befe9bf81 100644 --- a/libraries/react-native-iap/src/__tests__/hooks/useIAP.test.ts +++ b/libraries/react-native-iap/src/__tests__/hooks/useIAP.test.ts @@ -121,6 +121,60 @@ describe('hooks/useIAP (renderer)', () => { expect(IAP.finishTransaction).toBeDefined(); }); + it('skips purchase success when unmounted while refresh is pending', async () => { + let resolveActiveSubscriptions: (() => void) | undefined; + mockGetActiveSubscriptions.mockImplementation( + () => + new Promise((resolve) => { + resolveActiveSubscriptions = () => resolve([]); + }) as any, + ); + + const onPurchaseSuccess = jest.fn(); + const Harness = () => { + useIAP({onPurchaseSuccess}); + return null; + }; + + let renderer: ReturnType; + await act(async () => { + renderer = TestRenderer.create(React.createElement(Harness)); + }); + await act(async () => {}); + + const purchase = { + id: 't1', + productId: 'p1', + transactionDate: Date.now(), + platform: 'ios', + store: 'apple', + quantity: 1, + purchaseState: 'purchased', + isAutoRenewing: false, + }; + if (!capturedPurchaseListener) { + throw new Error('purchase listener was not initialized'); + } + + const purchaseUpdate = capturedPurchaseListener(purchase); + + if (!resolveActiveSubscriptions) { + throw new Error('active subscriptions resolver was not initialized'); + } + const resolvePendingActiveSubscriptions = resolveActiveSubscriptions; + + await act(async () => { + renderer.unmount(); + }); + + await act(async () => { + resolvePendingActiveSubscriptions(); + await purchaseUpdate; + }); + + expect(onPurchaseSuccess).not.toHaveBeenCalled(); + }); + it('requestPurchase calls root API and returns void', async () => { const mockRequestPurchase = jest .spyOn(IAP, 'requestPurchase') diff --git a/libraries/react-native-iap/src/__tests__/index.test.ts b/libraries/react-native-iap/src/__tests__/index.test.ts index 1a786ebe3..137508a84 100644 --- a/libraries/react-native-iap/src/__tests__/index.test.ts +++ b/libraries/react-native-iap/src/__tests__/index.test.ts @@ -214,7 +214,7 @@ describe('Public API (src/index.ts)', () => { expect(duplicateListener).toHaveBeenCalledTimes(1); }); - it('removes purchase updated native listener by token after the last JS listener is removed', () => { + it('removes iOS purchase updated JS listeners without removing the native listener', () => { const listener1 = jest.fn(); const listener2 = jest.fn(); const sub1 = IAP.purchaseUpdatedListener(listener1); @@ -226,11 +226,10 @@ describe('Public API (src/index.ts)', () => { sub2.remove(); sub2.remove(); - expect(mockIap.removePurchaseUpdatedListener).toHaveBeenCalledTimes(1); - expect(mockIap.removePurchaseUpdatedListener).toHaveBeenCalledWith(1); + expect(mockIap.removePurchaseUpdatedListener).not.toHaveBeenCalled(); }); - it('removes non-deduping purchase updated native listener by its own token', () => { + it('removes iOS non-deduping purchase updated JS listener without removing the native listener', () => { const defaultSub = IAP.purchaseUpdatedListener(jest.fn()); const duplicateSub = IAP.purchaseUpdatedListener(jest.fn(), { dedupeTransactionIOS: false, @@ -238,11 +237,10 @@ describe('Public API (src/index.ts)', () => { expect(mockIap.addPurchaseUpdatedListener).toHaveBeenCalledTimes(2); duplicateSub.remove(); - expect(mockIap.removePurchaseUpdatedListener).toHaveBeenCalledWith(2); + expect(mockIap.removePurchaseUpdatedListener).not.toHaveBeenCalled(); defaultSub.remove(); - expect(mockIap.removePurchaseUpdatedListener).toHaveBeenCalledWith(1); - expect(mockIap.removePurchaseUpdatedListener).toHaveBeenCalledTimes(2); + expect(mockIap.removePurchaseUpdatedListener).not.toHaveBeenCalled(); }); it('purchaseErrorListener forwards error objects and supports removal', () => { diff --git a/libraries/react-native-iap/src/hooks/useIAP.ts b/libraries/react-native-iap/src/hooks/useIAP.ts index 2100331ab..99cdc73df 100644 --- a/libraries/react-native-iap/src/hooks/useIAP.ts +++ b/libraries/react-native-iap/src/hooks/useIAP.ts @@ -585,12 +585,21 @@ export function useIAP(options?: UseIapOptions): UseIap { if (!subscriptionsRef.current.purchaseUpdate) { subscriptionsRef.current.purchaseUpdate = purchaseUpdatedListener( async (purchase: Purchase) => { + if (!isMountedRef.current) { + return; + } + try { await getActiveSubscriptionsInternal(); await getAvailablePurchasesInternal(); } catch (e) { RnIapConsole.warn('[useIAP] post-purchase refresh failed:', e); } + + if (!isMountedRef.current) { + return; + } + if (optionsRef.current?.onPurchaseSuccess) { optionsRef.current.onPurchaseSuccess(purchase); } @@ -602,6 +611,10 @@ export function useIAP(options?: UseIapOptions): UseIap { if (!subscriptionsRef.current.purchaseError) { subscriptionsRef.current.purchaseError = purchaseErrorListener( (error) => { + if (!isMountedRef.current) { + return; + } + if ( error.code === ErrorCode.InitConnection && !connectedRef.current @@ -618,6 +631,10 @@ export function useIAP(options?: UseIapOptions): UseIap { if (isStandardIOS() && !subscriptionsRef.current.promotedProductIOS) { subscriptionsRef.current.promotedProductIOS = promotedProductListenerIOS( (product: Product) => { + if (!isMountedRef.current) { + return; + } + setPromotedProductIOS(product); if (optionsRef.current?.onPromotedProductIOS) { optionsRef.current.onPromotedProductIOS(product); @@ -655,6 +672,10 @@ export function useIAP(options?: UseIapOptions): UseIap { if (!subscriptionsRef.current.subscriptionBillingIssue) { subscriptionsRef.current.subscriptionBillingIssue = subscriptionBillingIssueListener((purchase: Purchase) => { + if (!isMountedRef.current) { + return; + } + optionsRef.current?.onSubscriptionBillingIssue?.(purchase); }); } diff --git a/libraries/react-native-iap/src/index.ts b/libraries/react-native-iap/src/index.ts index 2def7d335..a9fc18978 100644 --- a/libraries/react-native-iap/src/index.ts +++ b/libraries/react-native-iap/src/index.ts @@ -451,6 +451,13 @@ export const purchaseUpdatedListener = ( return; } + // StoreKit-backed Nitro listener disposal can abort in iOS release builds + // when a native modal is being popped. Keep the singleton native listener + // attached for the app session and only remove the JS callback above. + if (Platform.OS === 'ios') { + return; + } + const token = receiveDuplicateTransactionUpdatesIOS ? purchaseUpdateDuplicateNativeToken : purchaseUpdateNativeToken; From b4745ae878afe0fd18b0fa742c2d2aea5484cdd7 Mon Sep 17 00:00:00 2001 From: Maxime Gerbe Date: Sat, 1 Aug 2026 10:14:03 +0200 Subject: [PATCH 2/3] test: cover iOS purchase listener callback removal --- .../src/__tests__/index.test.ts | 22 ++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/libraries/react-native-iap/src/__tests__/index.test.ts b/libraries/react-native-iap/src/__tests__/index.test.ts index 137508a84..34b43f9f9 100644 --- a/libraries/react-native-iap/src/__tests__/index.test.ts +++ b/libraries/react-native-iap/src/__tests__/index.test.ts @@ -231,14 +231,34 @@ describe('Public API (src/index.ts)', () => { it('removes iOS non-deduping purchase updated JS listener without removing the native listener', () => { const defaultSub = IAP.purchaseUpdatedListener(jest.fn()); - const duplicateSub = IAP.purchaseUpdatedListener(jest.fn(), { + const duplicateListener = jest.fn(); + const duplicateSub = IAP.purchaseUpdatedListener(duplicateListener, { dedupeTransactionIOS: false, }); expect(mockIap.addPurchaseUpdatedListener).toHaveBeenCalledTimes(2); + const duplicateNativeHandler = mockIap.addPurchaseUpdatedListener.mock + .calls[1][0]; + const nitroPurchase = { + id: 't1', + transactionId: 't1', + productId: 'p1', + transactionDate: Date.now(), + store: 'apple', + quantity: 1, + purchaseState: 'purchased', + isAutoRenewing: false, + }; + + duplicateNativeHandler(nitroPurchase); + expect(duplicateListener).toHaveBeenCalledTimes(1); + duplicateSub.remove(); expect(mockIap.removePurchaseUpdatedListener).not.toHaveBeenCalled(); + duplicateNativeHandler(nitroPurchase); + expect(duplicateListener).toHaveBeenCalledTimes(1); + defaultSub.remove(); expect(mockIap.removePurchaseUpdatedListener).not.toHaveBeenCalled(); }); From b922850b239b1dd4968136ff9e2f428adde4bc6e Mon Sep 17 00:00:00 2001 From: hyochan Date: Sat, 1 Aug 2026 17:48:42 +0900 Subject: [PATCH 3/3] fix(react-native-iap): refine unmount semantics 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 --- .../src/__tests__/hooks/useIAP.test.ts | 11 +++- .../src/__tests__/index.test.ts | 62 ++++++++++++++++++- .../react-native-iap/src/hooks/useIAP.ts | 24 ++----- libraries/react-native-iap/src/index.ts | 14 ++++- 4 files changed, 87 insertions(+), 24 deletions(-) diff --git a/libraries/react-native-iap/src/__tests__/hooks/useIAP.test.ts b/libraries/react-native-iap/src/__tests__/hooks/useIAP.test.ts index befe9bf81..3fd1da2c1 100644 --- a/libraries/react-native-iap/src/__tests__/hooks/useIAP.test.ts +++ b/libraries/react-native-iap/src/__tests__/hooks/useIAP.test.ts @@ -50,6 +50,7 @@ describe('hooks/useIAP (renderer)', () => { let mockSyncIOS: jest.SpyInstance; beforeEach(() => { + capturedPurchaseListener = undefined; jest.spyOn(IAP, 'initConnection').mockResolvedValue(true as any); mockGetAvailablePurchases = jest .spyOn(IAP, 'getAvailablePurchases') @@ -121,7 +122,7 @@ describe('hooks/useIAP (renderer)', () => { expect(IAP.finishTransaction).toBeDefined(); }); - it('skips purchase success when unmounted while refresh is pending', async () => { + it('still delivers purchase success when unmounted while refresh is pending', async () => { let resolveActiveSubscriptions: (() => void) | undefined; mockGetActiveSubscriptions.mockImplementation( () => @@ -172,7 +173,13 @@ describe('hooks/useIAP (renderer)', () => { await purchaseUpdate; }); - expect(onPurchaseSuccess).not.toHaveBeenCalled(); + // The event entered while mounted, so the success callback still fires: + // it is where apps call finishTransaction, and a dropped event has no + // in-session redelivery. + expect(onPurchaseSuccess).toHaveBeenCalledTimes(1); + expect(onPurchaseSuccess).toHaveBeenCalledWith( + expect.objectContaining({id: 't1', productId: 'p1'}), + ); }); it('requestPurchase calls root API and returns void', async () => { diff --git a/libraries/react-native-iap/src/__tests__/index.test.ts b/libraries/react-native-iap/src/__tests__/index.test.ts index 34b43f9f9..00c326ef4 100644 --- a/libraries/react-native-iap/src/__tests__/index.test.ts +++ b/libraries/react-native-iap/src/__tests__/index.test.ts @@ -263,6 +263,65 @@ describe('Public API (src/index.ts)', () => { expect(mockIap.removePurchaseUpdatedListener).not.toHaveBeenCalled(); }); + it('reuses the retained iOS native listener when re-subscribing after full removal', () => { + const first = jest.fn(); + const sub = IAP.purchaseUpdatedListener(first); + const nativeHandler = mockIap.addPurchaseUpdatedListener.mock.calls[0][0]; + sub.remove(); + + const second = jest.fn(); + IAP.purchaseUpdatedListener(second); + expect(mockIap.addPurchaseUpdatedListener).toHaveBeenCalledTimes(1); + + nativeHandler({ + id: 't1', + transactionId: 't1', + productId: 'p1', + transactionDate: Date.now(), + store: 'apple', + quantity: 1, + purchaseState: 'purchased', + isAutoRenewing: false, + }); + expect(first).not.toHaveBeenCalled(); + expect(second).toHaveBeenCalledTimes(1); + }); + + it('removes the Android native listener by token and re-attaches on next subscribe', () => { + (Platform as any).OS = 'android'; + const sub1 = IAP.purchaseUpdatedListener(jest.fn()); + const sub2 = IAP.purchaseUpdatedListener(jest.fn()); + + expect(mockIap.addPurchaseUpdatedListener).toHaveBeenCalledTimes(1); + sub1.remove(); + expect(mockIap.removePurchaseUpdatedListener).not.toHaveBeenCalled(); + + sub2.remove(); + expect(mockIap.removePurchaseUpdatedListener).toHaveBeenCalledTimes(1); + expect(mockIap.removePurchaseUpdatedListener).toHaveBeenCalledWith(1); + + IAP.purchaseUpdatedListener(jest.fn()); + expect(mockIap.addPurchaseUpdatedListener).toHaveBeenCalledTimes(2); + }); + + it('keeps the iOS native purchase error listener attached across removal and re-subscribe', () => { + const first = jest.fn(); + const sub = IAP.purchaseErrorListener(first); + expect(mockIap.addPurchaseErrorListener).toHaveBeenCalledTimes(1); + const nativeHandler = mockIap.addPurchaseErrorListener.mock.calls[0][0]; + + sub.remove(); + expect(mockIap.removePurchaseErrorListener).not.toHaveBeenCalled(); + + const second = jest.fn(); + IAP.purchaseErrorListener(second); + expect(mockIap.addPurchaseErrorListener).toHaveBeenCalledTimes(1); + + nativeHandler({code: 'network-error', message: 'offline'}); + expect(first).not.toHaveBeenCalled(); + expect(second).toHaveBeenCalledTimes(1); + }); + it('purchaseErrorListener forwards error objects and supports removal', () => { const listener = jest.fn(); const sub = IAP.purchaseErrorListener(listener); @@ -453,7 +512,8 @@ describe('Public API (src/index.ts)', () => { expect(listener1).not.toHaveBeenCalled(); }); - it('detaches the native error listener after the last JS listener is removed', () => { + it('detaches the Android native error listener after the last JS listener is removed', () => { + (Platform as any).OS = 'android'; const sub1 = IAP.purchaseErrorListener(jest.fn()); const sub2 = IAP.purchaseErrorListener(jest.fn()); const nativeHandler = mockIap.addPurchaseErrorListener.mock.calls[0][0]; diff --git a/libraries/react-native-iap/src/hooks/useIAP.ts b/libraries/react-native-iap/src/hooks/useIAP.ts index 99cdc73df..104b6916e 100644 --- a/libraries/react-native-iap/src/hooks/useIAP.ts +++ b/libraries/react-native-iap/src/hooks/useIAP.ts @@ -585,10 +585,6 @@ export function useIAP(options?: UseIapOptions): UseIap { if (!subscriptionsRef.current.purchaseUpdate) { subscriptionsRef.current.purchaseUpdate = purchaseUpdatedListener( async (purchase: Purchase) => { - if (!isMountedRef.current) { - return; - } - try { await getActiveSubscriptionsInternal(); await getAvailablePurchasesInternal(); @@ -596,10 +592,10 @@ export function useIAP(options?: UseIapOptions): UseIap { RnIapConsole.warn('[useIAP] post-purchase refresh failed:', e); } - if (!isMountedRef.current) { - return; - } - + // Deliver even if the hook unmounted while the refresh was pending: + // onPurchaseSuccess is where apps call finishTransaction, and a + // dropped event has no in-session redelivery. Internal setState is + // mount-guarded inside the refresh helpers. if (optionsRef.current?.onPurchaseSuccess) { optionsRef.current.onPurchaseSuccess(purchase); } @@ -611,10 +607,6 @@ export function useIAP(options?: UseIapOptions): UseIap { if (!subscriptionsRef.current.purchaseError) { subscriptionsRef.current.purchaseError = purchaseErrorListener( (error) => { - if (!isMountedRef.current) { - return; - } - if ( error.code === ErrorCode.InitConnection && !connectedRef.current @@ -631,10 +623,6 @@ export function useIAP(options?: UseIapOptions): UseIap { if (isStandardIOS() && !subscriptionsRef.current.promotedProductIOS) { subscriptionsRef.current.promotedProductIOS = promotedProductListenerIOS( (product: Product) => { - if (!isMountedRef.current) { - return; - } - setPromotedProductIOS(product); if (optionsRef.current?.onPromotedProductIOS) { optionsRef.current.onPromotedProductIOS(product); @@ -672,10 +660,6 @@ export function useIAP(options?: UseIapOptions): UseIap { if (!subscriptionsRef.current.subscriptionBillingIssue) { subscriptionsRef.current.subscriptionBillingIssue = subscriptionBillingIssueListener((purchase: Purchase) => { - if (!isMountedRef.current) { - return; - } - optionsRef.current?.onSubscriptionBillingIssue?.(purchase); }); } diff --git a/libraries/react-native-iap/src/index.ts b/libraries/react-native-iap/src/index.ts index a9fc18978..8b7b27b4a 100644 --- a/libraries/react-native-iap/src/index.ts +++ b/libraries/react-native-iap/src/index.ts @@ -505,7 +505,19 @@ export const purchaseErrorListener = ( return { remove: () => { purchaseErrorJsListeners.delete(listener); - if (purchaseErrorJsListeners.size === 0 && purchaseErrorNativeAttached) { + if (purchaseErrorJsListeners.size > 0) { + return; + } + + // Same iOS policy as purchaseUpdatedListener: releasing the stored + // native callback from a JS unsubscribe can race an in-flight dispatch + // snapshot on another thread. Keep the singleton attached; endConnection + // owns native disposal. + if (Platform.OS === 'ios') { + return; + } + + if (purchaseErrorNativeAttached) { try { IAP.instance.removePurchaseErrorListener(purchaseErrorNativeHandler); purchaseErrorNativeAttached = false;