Skip to content

Commit e3ee015

Browse files
MadeinFrancehyochanclaude
authored
fix(react-native-iap): avoid iOS listener cleanup crash after unmount (#262)
## 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` <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## 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. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: Hyo <hyo@hyo.dev> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent d724875 commit e3ee015

4 files changed

Lines changed: 172 additions & 9 deletions

File tree

libraries/react-native-iap/src/__tests__/hooks/useIAP.test.ts

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ describe('hooks/useIAP (renderer)', () => {
5050
let mockSyncIOS: jest.SpyInstance;
5151

5252
beforeEach(() => {
53+
capturedPurchaseListener = undefined;
5354
jest.spyOn(IAP, 'initConnection').mockResolvedValue(true as any);
5455
mockGetAvailablePurchases = jest
5556
.spyOn(IAP, 'getAvailablePurchases')
@@ -121,6 +122,66 @@ describe('hooks/useIAP (renderer)', () => {
121122
expect(IAP.finishTransaction).toBeDefined();
122123
});
123124

125+
it('still delivers purchase success when unmounted while refresh is pending', async () => {
126+
let resolveActiveSubscriptions: (() => void) | undefined;
127+
mockGetActiveSubscriptions.mockImplementation(
128+
() =>
129+
new Promise((resolve) => {
130+
resolveActiveSubscriptions = () => resolve([]);
131+
}) as any,
132+
);
133+
134+
const onPurchaseSuccess = jest.fn();
135+
const Harness = () => {
136+
useIAP({onPurchaseSuccess});
137+
return null;
138+
};
139+
140+
let renderer: ReturnType<typeof TestRenderer.create>;
141+
await act(async () => {
142+
renderer = TestRenderer.create(React.createElement(Harness));
143+
});
144+
await act(async () => {});
145+
146+
const purchase = {
147+
id: 't1',
148+
productId: 'p1',
149+
transactionDate: Date.now(),
150+
platform: 'ios',
151+
store: 'apple',
152+
quantity: 1,
153+
purchaseState: 'purchased',
154+
isAutoRenewing: false,
155+
};
156+
if (!capturedPurchaseListener) {
157+
throw new Error('purchase listener was not initialized');
158+
}
159+
160+
const purchaseUpdate = capturedPurchaseListener(purchase);
161+
162+
if (!resolveActiveSubscriptions) {
163+
throw new Error('active subscriptions resolver was not initialized');
164+
}
165+
const resolvePendingActiveSubscriptions = resolveActiveSubscriptions;
166+
167+
await act(async () => {
168+
renderer.unmount();
169+
});
170+
171+
await act(async () => {
172+
resolvePendingActiveSubscriptions();
173+
await purchaseUpdate;
174+
});
175+
176+
// The event entered while mounted, so the success callback still fires:
177+
// it is where apps call finishTransaction, and a dropped event has no
178+
// in-session redelivery.
179+
expect(onPurchaseSuccess).toHaveBeenCalledTimes(1);
180+
expect(onPurchaseSuccess).toHaveBeenCalledWith(
181+
expect.objectContaining({id: 't1', productId: 'p1'}),
182+
);
183+
});
184+
124185
it('requestPurchase calls root API and returns void', async () => {
125186
const mockRequestPurchase = jest
126187
.spyOn(IAP, 'requestPurchase')

libraries/react-native-iap/src/__tests__/index.test.ts

Lines changed: 86 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -214,7 +214,7 @@ describe('Public API (src/index.ts)', () => {
214214
expect(duplicateListener).toHaveBeenCalledTimes(1);
215215
});
216216

217-
it('removes purchase updated native listener by token after the last JS listener is removed', () => {
217+
it('removes iOS purchase updated JS listeners without removing the native listener', () => {
218218
const listener1 = jest.fn();
219219
const listener2 = jest.fn();
220220
const sub1 = IAP.purchaseUpdatedListener(listener1);
@@ -226,23 +226,100 @@ describe('Public API (src/index.ts)', () => {
226226

227227
sub2.remove();
228228
sub2.remove();
229-
expect(mockIap.removePurchaseUpdatedListener).toHaveBeenCalledTimes(1);
230-
expect(mockIap.removePurchaseUpdatedListener).toHaveBeenCalledWith(1);
229+
expect(mockIap.removePurchaseUpdatedListener).not.toHaveBeenCalled();
231230
});
232231

233-
it('removes non-deduping purchase updated native listener by its own token', () => {
232+
it('removes iOS non-deduping purchase updated JS listener without removing the native listener', () => {
234233
const defaultSub = IAP.purchaseUpdatedListener(jest.fn());
235-
const duplicateSub = IAP.purchaseUpdatedListener(jest.fn(), {
234+
const duplicateListener = jest.fn();
235+
const duplicateSub = IAP.purchaseUpdatedListener(duplicateListener, {
236236
dedupeTransactionIOS: false,
237237
});
238238

239239
expect(mockIap.addPurchaseUpdatedListener).toHaveBeenCalledTimes(2);
240+
const duplicateNativeHandler = mockIap.addPurchaseUpdatedListener.mock
241+
.calls[1][0];
242+
const nitroPurchase = {
243+
id: 't1',
244+
transactionId: 't1',
245+
productId: 'p1',
246+
transactionDate: Date.now(),
247+
store: 'apple',
248+
quantity: 1,
249+
purchaseState: 'purchased',
250+
isAutoRenewing: false,
251+
};
252+
253+
duplicateNativeHandler(nitroPurchase);
254+
expect(duplicateListener).toHaveBeenCalledTimes(1);
255+
240256
duplicateSub.remove();
241-
expect(mockIap.removePurchaseUpdatedListener).toHaveBeenCalledWith(2);
257+
expect(mockIap.removePurchaseUpdatedListener).not.toHaveBeenCalled();
258+
259+
duplicateNativeHandler(nitroPurchase);
260+
expect(duplicateListener).toHaveBeenCalledTimes(1);
242261

243262
defaultSub.remove();
263+
expect(mockIap.removePurchaseUpdatedListener).not.toHaveBeenCalled();
264+
});
265+
266+
it('reuses the retained iOS native listener when re-subscribing after full removal', () => {
267+
const first = jest.fn();
268+
const sub = IAP.purchaseUpdatedListener(first);
269+
const nativeHandler = mockIap.addPurchaseUpdatedListener.mock.calls[0][0];
270+
sub.remove();
271+
272+
const second = jest.fn();
273+
IAP.purchaseUpdatedListener(second);
274+
expect(mockIap.addPurchaseUpdatedListener).toHaveBeenCalledTimes(1);
275+
276+
nativeHandler({
277+
id: 't1',
278+
transactionId: 't1',
279+
productId: 'p1',
280+
transactionDate: Date.now(),
281+
store: 'apple',
282+
quantity: 1,
283+
purchaseState: 'purchased',
284+
isAutoRenewing: false,
285+
});
286+
expect(first).not.toHaveBeenCalled();
287+
expect(second).toHaveBeenCalledTimes(1);
288+
});
289+
290+
it('removes the Android native listener by token and re-attaches on next subscribe', () => {
291+
(Platform as any).OS = 'android';
292+
const sub1 = IAP.purchaseUpdatedListener(jest.fn());
293+
const sub2 = IAP.purchaseUpdatedListener(jest.fn());
294+
295+
expect(mockIap.addPurchaseUpdatedListener).toHaveBeenCalledTimes(1);
296+
sub1.remove();
297+
expect(mockIap.removePurchaseUpdatedListener).not.toHaveBeenCalled();
298+
299+
sub2.remove();
300+
expect(mockIap.removePurchaseUpdatedListener).toHaveBeenCalledTimes(1);
244301
expect(mockIap.removePurchaseUpdatedListener).toHaveBeenCalledWith(1);
245-
expect(mockIap.removePurchaseUpdatedListener).toHaveBeenCalledTimes(2);
302+
303+
IAP.purchaseUpdatedListener(jest.fn());
304+
expect(mockIap.addPurchaseUpdatedListener).toHaveBeenCalledTimes(2);
305+
});
306+
307+
it('keeps the iOS native purchase error listener attached across removal and re-subscribe', () => {
308+
const first = jest.fn();
309+
const sub = IAP.purchaseErrorListener(first);
310+
expect(mockIap.addPurchaseErrorListener).toHaveBeenCalledTimes(1);
311+
const nativeHandler = mockIap.addPurchaseErrorListener.mock.calls[0][0];
312+
313+
sub.remove();
314+
expect(mockIap.removePurchaseErrorListener).not.toHaveBeenCalled();
315+
316+
const second = jest.fn();
317+
IAP.purchaseErrorListener(second);
318+
expect(mockIap.addPurchaseErrorListener).toHaveBeenCalledTimes(1);
319+
320+
nativeHandler({code: 'network-error', message: 'offline'});
321+
expect(first).not.toHaveBeenCalled();
322+
expect(second).toHaveBeenCalledTimes(1);
246323
});
247324

248325
it('purchaseErrorListener forwards error objects and supports removal', () => {
@@ -435,7 +512,8 @@ describe('Public API (src/index.ts)', () => {
435512
expect(listener1).not.toHaveBeenCalled();
436513
});
437514

438-
it('detaches the native error listener after the last JS listener is removed', () => {
515+
it('detaches the Android native error listener after the last JS listener is removed', () => {
516+
(Platform as any).OS = 'android';
439517
const sub1 = IAP.purchaseErrorListener(jest.fn());
440518
const sub2 = IAP.purchaseErrorListener(jest.fn());
441519
const nativeHandler = mockIap.addPurchaseErrorListener.mock.calls[0][0];

libraries/react-native-iap/src/hooks/useIAP.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -591,6 +591,11 @@ export function useIAP(options?: UseIapOptions): UseIap {
591591
} catch (e) {
592592
RnIapConsole.warn('[useIAP] post-purchase refresh failed:', e);
593593
}
594+
595+
// Deliver even if the hook unmounted while the refresh was pending:
596+
// onPurchaseSuccess is where apps call finishTransaction, and a
597+
// dropped event has no in-session redelivery. Internal setState is
598+
// mount-guarded inside the refresh helpers.
594599
if (optionsRef.current?.onPurchaseSuccess) {
595600
optionsRef.current.onPurchaseSuccess(purchase);
596601
}

libraries/react-native-iap/src/index.ts

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -451,6 +451,13 @@ export const purchaseUpdatedListener = (
451451
return;
452452
}
453453

454+
// StoreKit-backed Nitro listener disposal can abort in iOS release builds
455+
// when a native modal is being popped. Keep the singleton native listener
456+
// attached for the app session and only remove the JS callback above.
457+
if (Platform.OS === 'ios') {
458+
return;
459+
}
460+
454461
const token = receiveDuplicateTransactionUpdatesIOS
455462
? purchaseUpdateDuplicateNativeToken
456463
: purchaseUpdateNativeToken;
@@ -498,7 +505,19 @@ export const purchaseErrorListener = (
498505
return {
499506
remove: () => {
500507
purchaseErrorJsListeners.delete(listener);
501-
if (purchaseErrorJsListeners.size === 0 && purchaseErrorNativeAttached) {
508+
if (purchaseErrorJsListeners.size > 0) {
509+
return;
510+
}
511+
512+
// Same iOS policy as purchaseUpdatedListener: releasing the stored
513+
// native callback from a JS unsubscribe can race an in-flight dispatch
514+
// snapshot on another thread. Keep the singleton attached; endConnection
515+
// owns native disposal.
516+
if (Platform.OS === 'ios') {
517+
return;
518+
}
519+
520+
if (purchaseErrorNativeAttached) {
502521
try {
503522
IAP.instance.removePurchaseErrorListener(purchaseErrorNativeHandler);
504523
purchaseErrorNativeAttached = false;

0 commit comments

Comments
 (0)