Skip to content

Commit 7a5f95e

Browse files
committed
fix iOS IAP listener cleanup crash
1 parent 1b2d3bb commit 7a5f95e

4 files changed

Lines changed: 87 additions & 7 deletions

File tree

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

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,60 @@ describe('hooks/useIAP (renderer)', () => {
121121
expect(IAP.finishTransaction).toBeDefined();
122122
});
123123

124+
it('skips purchase success when unmounted while refresh is pending', async () => {
125+
let resolveActiveSubscriptions: (() => void) | undefined;
126+
mockGetActiveSubscriptions.mockImplementation(
127+
() =>
128+
new Promise((resolve) => {
129+
resolveActiveSubscriptions = () => resolve([]);
130+
}) as any,
131+
);
132+
133+
const onPurchaseSuccess = jest.fn();
134+
const Harness = () => {
135+
useIAP({onPurchaseSuccess});
136+
return null;
137+
};
138+
139+
let renderer: ReturnType<typeof TestRenderer.create>;
140+
await act(async () => {
141+
renderer = TestRenderer.create(React.createElement(Harness));
142+
});
143+
await act(async () => {});
144+
145+
const purchase = {
146+
id: 't1',
147+
productId: 'p1',
148+
transactionDate: Date.now(),
149+
platform: 'ios',
150+
store: 'apple',
151+
quantity: 1,
152+
purchaseState: 'purchased',
153+
isAutoRenewing: false,
154+
};
155+
if (!capturedPurchaseListener) {
156+
throw new Error('purchase listener was not initialized');
157+
}
158+
159+
const purchaseUpdate = capturedPurchaseListener(purchase);
160+
161+
if (!resolveActiveSubscriptions) {
162+
throw new Error('active subscriptions resolver was not initialized');
163+
}
164+
const resolvePendingActiveSubscriptions = resolveActiveSubscriptions;
165+
166+
await act(async () => {
167+
renderer.unmount();
168+
});
169+
170+
await act(async () => {
171+
resolvePendingActiveSubscriptions();
172+
await purchaseUpdate;
173+
});
174+
175+
expect(onPurchaseSuccess).not.toHaveBeenCalled();
176+
});
177+
124178
it('requestPurchase calls root API and returns void', async () => {
125179
const mockRequestPurchase = jest
126180
.spyOn(IAP, 'requestPurchase')

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

Lines changed: 5 additions & 7 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,21 @@ 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());
235234
const duplicateSub = IAP.purchaseUpdatedListener(jest.fn(), {
236235
dedupeTransactionIOS: false,
237236
});
238237

239238
expect(mockIap.addPurchaseUpdatedListener).toHaveBeenCalledTimes(2);
240239
duplicateSub.remove();
241-
expect(mockIap.removePurchaseUpdatedListener).toHaveBeenCalledWith(2);
240+
expect(mockIap.removePurchaseUpdatedListener).not.toHaveBeenCalled();
242241

243242
defaultSub.remove();
244-
expect(mockIap.removePurchaseUpdatedListener).toHaveBeenCalledWith(1);
245-
expect(mockIap.removePurchaseUpdatedListener).toHaveBeenCalledTimes(2);
243+
expect(mockIap.removePurchaseUpdatedListener).not.toHaveBeenCalled();
246244
});
247245

248246
it('purchaseErrorListener forwards error objects and supports removal', () => {

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

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -585,12 +585,21 @@ export function useIAP(options?: UseIapOptions): UseIap {
585585
if (!subscriptionsRef.current.purchaseUpdate) {
586586
subscriptionsRef.current.purchaseUpdate = purchaseUpdatedListener(
587587
async (purchase: Purchase) => {
588+
if (!isMountedRef.current) {
589+
return;
590+
}
591+
588592
try {
589593
await getActiveSubscriptionsInternal();
590594
await getAvailablePurchasesInternal();
591595
} catch (e) {
592596
RnIapConsole.warn('[useIAP] post-purchase refresh failed:', e);
593597
}
598+
599+
if (!isMountedRef.current) {
600+
return;
601+
}
602+
594603
if (optionsRef.current?.onPurchaseSuccess) {
595604
optionsRef.current.onPurchaseSuccess(purchase);
596605
}
@@ -602,6 +611,10 @@ export function useIAP(options?: UseIapOptions): UseIap {
602611
if (!subscriptionsRef.current.purchaseError) {
603612
subscriptionsRef.current.purchaseError = purchaseErrorListener(
604613
(error) => {
614+
if (!isMountedRef.current) {
615+
return;
616+
}
617+
605618
if (
606619
error.code === ErrorCode.InitConnection &&
607620
!connectedRef.current
@@ -618,6 +631,10 @@ export function useIAP(options?: UseIapOptions): UseIap {
618631
if (isStandardIOS() && !subscriptionsRef.current.promotedProductIOS) {
619632
subscriptionsRef.current.promotedProductIOS = promotedProductListenerIOS(
620633
(product: Product) => {
634+
if (!isMountedRef.current) {
635+
return;
636+
}
637+
621638
setPromotedProductIOS(product);
622639
if (optionsRef.current?.onPromotedProductIOS) {
623640
optionsRef.current.onPromotedProductIOS(product);
@@ -655,6 +672,10 @@ export function useIAP(options?: UseIapOptions): UseIap {
655672
if (!subscriptionsRef.current.subscriptionBillingIssue) {
656673
subscriptionsRef.current.subscriptionBillingIssue =
657674
subscriptionBillingIssueListener((purchase: Purchase) => {
675+
if (!isMountedRef.current) {
676+
return;
677+
}
678+
658679
optionsRef.current?.onSubscriptionBillingIssue?.(purchase);
659680
});
660681
}

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

Lines changed: 7 additions & 0 deletions
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;

0 commit comments

Comments
 (0)