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..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,6 +122,66 @@ describe('hooks/useIAP (renderer)', () => { expect(IAP.finishTransaction).toBeDefined(); }); + it('still delivers 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; + }); + + // 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 () => { 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..00c326ef4 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,23 +226,100 @@ 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(), { + 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).toHaveBeenCalledWith(2); + expect(mockIap.removePurchaseUpdatedListener).not.toHaveBeenCalled(); + + duplicateNativeHandler(nitroPurchase); + expect(duplicateListener).toHaveBeenCalledTimes(1); defaultSub.remove(); + 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); - expect(mockIap.removePurchaseUpdatedListener).toHaveBeenCalledTimes(2); + + 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', () => { @@ -435,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 2100331ab..104b6916e 100644 --- a/libraries/react-native-iap/src/hooks/useIAP.ts +++ b/libraries/react-native-iap/src/hooks/useIAP.ts @@ -591,6 +591,11 @@ export function useIAP(options?: UseIapOptions): UseIap { } catch (e) { RnIapConsole.warn('[useIAP] post-purchase refresh failed:', e); } + + // 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); } diff --git a/libraries/react-native-iap/src/index.ts b/libraries/react-native-iap/src/index.ts index 2def7d335..8b7b27b4a 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; @@ -498,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;