Skip to content
This repository was archived by the owner on Apr 26, 2026. It is now read-only.
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
6 changes: 4 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -249,12 +249,14 @@ yarn install && yarn typecheck && yarn lint --fix
## Hook API Semantics (useIAP)

- Inside the `useIAP` hook, most methods return `Promise<void>` and update internal state. Do not design examples that expect returned data from these methods.
- Examples: `fetchProducts`, `requestProducts` (if present), `requestPurchase`, `getAvailablePurchases`.
- Examples: `fetchProducts`, `requestPurchase`, `getAvailablePurchases`.
- After calling, consume state from the hook: `products`, `subscriptions`, `availablePurchases`, etc.
- For `requestPurchase`: Use `onPurchaseSuccess` callback to receive purchase results, NOT the return value.
- Defined exceptions in the hook that DO return values:
- `getActiveSubscriptions(subscriptionIds?) => Promise<ActiveSubscription[]>` (also updates `activeSubscriptions` state)
- `hasActiveSubscriptions(subscriptionIds?) => Promise<boolean>`
- The root (index) API is value-returning and can be awaited to receive data directly. Use root API when not using React state.
- Example: `const result = await requestPurchase({...})` returns `Promise<RequestPurchaseResult | null>` (though native returns empty array by design - actual results come through event listeners).

### Common CI Fixes

Expand Down Expand Up @@ -297,7 +299,7 @@ The project uses a centralized error handling approach across all platforms:
- `getUserFriendlyErrorMessage()` - **Public helper** - Get user-friendly error messages
- `ErrorCode` enum (from types.ts) - Standardized error codes across platforms

**Android & iOS (OpenIAP)**
### Android & iOS (OpenIAP)

Both platforms use the OpenIAP library's error handling:

Expand Down
6 changes: 6 additions & 0 deletions android/src/main/java/com/margelo/nitro/iap/HybridRnIap.kt
Original file line number Diff line number Diff line change
Expand Up @@ -254,6 +254,12 @@ class HybridRnIap : HybridRnIapSpec() {
runCatching { openIap.endConnection() }
productTypeBySku.clear()
isInitialized = false
listenersAttached = false

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

While resetting listenersAttached is necessary to allow re-attaching the bridge listeners to the underlying openIap module, you should also clear the internal listener lists (purchaseUpdatedListeners, purchaseErrorListeners, etc.) in endConnection.

On iOS, cleanupExistingState (called by endConnection) clears these lists. On Android, if you don't clear them, any listeners registered from the JS side before endConnection will persist. When initConnection is called again, a new bridge listener is attached to openIap, but the old JS listeners are still in the lists. This will result in duplicate notifications for the same event after reconnection.

Clearing these lists ensures consistency between platforms and prevents duplicate event delivery.

            listenersAttached = false
            purchaseUpdatedListeners.clear()
            purchaseErrorListeners.clear()
            promotedProductListenersIOS.clear()
            userChoiceBillingListenersAndroid.clear()
            developerProvidedBillingListenersAndroid.clear()

purchaseUpdatedListeners.clear()
purchaseErrorListeners.clear()
promotedProductListenersIOS.clear()
userChoiceBillingListenersAndroid.clear()
developerProvidedBillingListenersAndroid.clear()
initDeferred = null
RnIapLog.result("endConnection", true)
true
Expand Down
4 changes: 2 additions & 2 deletions ios/HybridRnIap.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1034,8 +1034,8 @@ class HybridRnIap: HybridRnIapSpec {
updateListenerTask?.cancel()
updateListenerTask = nil
isInitialized = false

isInitializing = false

// Remove OpenIAP listeners & end connection
if let sub = purchaseUpdatedSub {
RnIapLog.payload("removeListener", "purchaseUpdated")
Expand Down
24 changes: 24 additions & 0 deletions src/__tests__/hooks/useIAP.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,30 @@ describe('hooks/useIAP (renderer)', () => {
expect(IAP.finishTransaction).toBeDefined();
});

it('requestPurchase calls root API and returns void', async () => {
const mockRequestPurchase = jest
.spyOn(IAP, 'requestPurchase')
.mockResolvedValue(null);

let api: any;
const Harness = () => {
api = useIAP();
return null;
};

await act(async () => {
TestRenderer.create(React.createElement(Harness));
});
await act(async () => {});

await act(async () => {
const result = await api.requestPurchase({sku: 'product1'});
expect(result).toBeUndefined();
});

expect(mockRequestPurchase).toHaveBeenCalledWith({sku: 'product1'});
});

describe('onError callback', () => {
it('calls onError when fetchProducts fails', async () => {
const fetchError = new Error('Network error fetching products');
Expand Down
75 changes: 75 additions & 0 deletions src/__tests__/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,81 @@ describe('Public API (src/index.ts)', () => {
expect(mockIap.initConnection).toHaveBeenCalled();
expect(mockIap.endConnection).toHaveBeenCalled();
});

it('listeners work after endConnection → initConnection reconnection', async () => {
// 1. Initial connection + listener
await IAP.initConnection();
const listener1 = jest.fn();
const sub1 = IAP.purchaseUpdatedListener(listener1);

// Verify listener is registered
expect(mockIap.addPurchaseUpdatedListener).toHaveBeenCalledTimes(1);
const wrapped1 = mockIap.addPurchaseUpdatedListener.mock.calls[0][0];

// Simulate a purchase event — listener should fire
const nitroPurchase = {
id: 't1',
productId: 'p1',
transactionDate: Date.now(),
platform: 'ios',
quantity: 1,
purchaseState: 'purchased',
isAutoRenewing: false,
};
wrapped1(nitroPurchase);
expect(listener1).toHaveBeenCalledTimes(1);

// 2. Disconnect and remove old listener
sub1.remove();
await IAP.endConnection();

// 3. Reconnect and register new listener
jest.clearAllMocks();
await IAP.initConnection();
const listener2 = jest.fn();
const sub2 = IAP.purchaseUpdatedListener(listener2);

// New listener should be registered with native
expect(mockIap.addPurchaseUpdatedListener).toHaveBeenCalledTimes(1);
const wrapped2 = mockIap.addPurchaseUpdatedListener.mock.calls[0][0];

// Simulate purchase event on new connection — new listener should fire
wrapped2(nitroPurchase);
expect(listener2).toHaveBeenCalledTimes(1);
expect(listener2).toHaveBeenCalledWith(
expect.objectContaining({productId: 'p1'}),
);

sub2.remove();
});

it('error listeners work after endConnection → initConnection reconnection', async () => {
await IAP.initConnection();
const errorListener1 = jest.fn();
const sub1 = IAP.purchaseErrorListener(errorListener1);
sub1.remove();
await IAP.endConnection();

// Reconnect and register new error listener
jest.clearAllMocks();
await IAP.initConnection();
const errorListener2 = jest.fn();
const sub2 = IAP.purchaseErrorListener(errorListener2);

expect(mockIap.addPurchaseErrorListener).toHaveBeenCalledTimes(1);
const wrapped = mockIap.addPurchaseErrorListener.mock.calls[0][0];

wrapped({code: 'user-cancelled', message: 'User cancelled'});
expect(errorListener2).toHaveBeenCalledTimes(1);
expect(errorListener2).toHaveBeenCalledWith(
expect.objectContaining({
code: ErrorCode.UserCancelled,
message: 'User cancelled',
}),
);

sub2.remove();
});
});

describe('fetchProducts', () => {
Expand Down
9 changes: 4 additions & 5 deletions src/hooks/useIAP.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,6 @@ import {ErrorCode} from '../types';
import type {
ProductQueryType,
RequestPurchaseProps,
RequestPurchaseResult,
AlternativeBillingModeAndroid,
BillingProgramAndroid,
UserChoiceBillingDetails,
Expand Down Expand Up @@ -69,9 +68,7 @@ type UseIap = {
skus: string[];
type?: ProductQueryType | null;
}) => Promise<void>;
requestPurchase: (
params: RequestPurchaseProps,
) => Promise<RequestPurchaseResult | null>;
requestPurchase: (params: RequestPurchaseProps) => Promise<void>;
/**
* @deprecated Use `verifyPurchase` instead. This function will be removed in a future version.
*/
Expand Down Expand Up @@ -330,7 +327,9 @@ export function useIAP(options?: UseIapOptions): UseIap {
);

const requestPurchase = useCallback(
(requestObj: RequestPurchaseProps) => requestPurchaseInternal(requestObj),
async (requestObj: RequestPurchaseProps): Promise<void> => {
await requestPurchaseInternal(requestObj);
},
[],
);

Expand Down