Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 61 additions & 0 deletions libraries/react-native-iap/src/__tests__/hooks/useIAP.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down Expand Up @@ -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<typeof TestRenderer.create>;
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')
Expand Down
94 changes: 86 additions & 8 deletions libraries/react-native-iap/src/__tests__/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Comment thread
hyochan marked this conversation as resolved.
const listener1 = jest.fn();
const listener2 = jest.fn();
const sub1 = IAP.purchaseUpdatedListener(listener1);
Expand All @@ -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();
Comment thread
MadeinFrance marked this conversation as resolved.
});

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', () => {
Expand Down Expand Up @@ -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];
Expand Down
5 changes: 5 additions & 0 deletions libraries/react-native-iap/src/hooks/useIAP.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
21 changes: 20 additions & 1 deletion libraries/react-native-iap/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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') {
Comment thread
MadeinFrance marked this conversation as resolved.
return;
}

const token = receiveDuplicateTransactionUpdatesIOS
? purchaseUpdateDuplicateNativeToken
: purchaseUpdateNativeToken;
Expand Down Expand Up @@ -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;
Expand Down