From 6d025b07744431d23ccbda00b74126fbe7799203 Mon Sep 17 00:00:00 2001 From: hyochan Date: Wed, 28 Jan 2026 06:57:44 +0900 Subject: [PATCH 1/5] fix(hooks): change useIAP requestPurchase return type to void - Change requestPurchase return type from Promise to Promise - This aligns with hook design where purchase results come through onPurchaseSuccess callback - Update CLAUDE.md documentation to clarify hook vs root API semantics - Fix markdownlint warning in CLAUDE.md Co-Authored-By: Claude Opus 4.5 --- CLAUDE.md | 6 ++++-- src/hooks/useIAP.ts | 9 ++++----- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 8ed60b563..cebfea165 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -249,12 +249,14 @@ yarn install && yarn typecheck && yarn lint --fix ## Hook API Semantics (useIAP) - Inside the `useIAP` hook, most methods return `Promise` 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` (also updates `activeSubscriptions` state) - `hasActiveSubscriptions(subscriptionIds?) => Promise` - 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` (though native returns empty array by design - actual results come through event listeners). ### Common CI Fixes @@ -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: diff --git a/src/hooks/useIAP.ts b/src/hooks/useIAP.ts index dab920e7c..11351df59 100644 --- a/src/hooks/useIAP.ts +++ b/src/hooks/useIAP.ts @@ -33,7 +33,6 @@ import {ErrorCode} from '../types'; import type { ProductQueryType, RequestPurchaseProps, - RequestPurchaseResult, AlternativeBillingModeAndroid, BillingProgramAndroid, UserChoiceBillingDetails, @@ -69,9 +68,7 @@ type UseIap = { skus: string[]; type?: ProductQueryType | null; }) => Promise; - requestPurchase: ( - params: RequestPurchaseProps, - ) => Promise; + requestPurchase: (params: RequestPurchaseProps) => Promise; /** * @deprecated Use `verifyPurchase` instead. This function will be removed in a future version. */ @@ -330,7 +327,9 @@ export function useIAP(options?: UseIapOptions): UseIap { ); const requestPurchase = useCallback( - (requestObj: RequestPurchaseProps) => requestPurchaseInternal(requestObj), + async (requestObj: RequestPurchaseProps): Promise => { + await requestPurchaseInternal(requestObj); + }, [], ); From f41e5ce05d93a3df0f731d5b44e5867ff82d8486 Mon Sep 17 00:00:00 2001 From: hyochan Date: Wed, 18 Feb 2026 21:55:16 +0900 Subject: [PATCH 2/5] fix: reset listener state on endConnection for proper reconnection Reset `listenersAttached` (Android) and `isInitializing` (iOS) flags during endConnection so that listeners can be re-registered after a subsequent initConnection call. Add reconnection listener tests. Co-Authored-By: Claude Opus 4.6 --- .../java/com/margelo/nitro/iap/HybridRnIap.kt | 1 + ios/HybridRnIap.swift | 4 +- src/__tests__/index.test.ts | 75 +++++++++++++++++++ 3 files changed, 78 insertions(+), 2 deletions(-) diff --git a/android/src/main/java/com/margelo/nitro/iap/HybridRnIap.kt b/android/src/main/java/com/margelo/nitro/iap/HybridRnIap.kt index 1ca48a591..7b58143b3 100644 --- a/android/src/main/java/com/margelo/nitro/iap/HybridRnIap.kt +++ b/android/src/main/java/com/margelo/nitro/iap/HybridRnIap.kt @@ -254,6 +254,7 @@ class HybridRnIap : HybridRnIapSpec() { runCatching { openIap.endConnection() } productTypeBySku.clear() isInitialized = false + listenersAttached = false initDeferred = null RnIapLog.result("endConnection", true) true diff --git a/ios/HybridRnIap.swift b/ios/HybridRnIap.swift index 7c8dc6a4a..64cfc7516 100644 --- a/ios/HybridRnIap.swift +++ b/ios/HybridRnIap.swift @@ -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") diff --git a/src/__tests__/index.test.ts b/src/__tests__/index.test.ts index c8b1bc4e0..242233d0f 100644 --- a/src/__tests__/index.test.ts +++ b/src/__tests__/index.test.ts @@ -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', () => { From 2e6a92c49b5f03f4869dc3a613673d82d199d60f Mon Sep 17 00:00:00 2001 From: hyochan Date: Wed, 18 Feb 2026 22:39:07 +0900 Subject: [PATCH 3/5] fix(android): clear listener lists on endConnection for parity with iOS Clear all internal listener lists (purchaseUpdated, purchaseError, promotedProduct, userChoiceBilling, developerProvidedBilling) in endConnection to match iOS cleanupExistingState behavior and prevent duplicate event delivery after reconnection. Co-Authored-By: Claude Opus 4.6 --- android/src/main/java/com/margelo/nitro/iap/HybridRnIap.kt | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/android/src/main/java/com/margelo/nitro/iap/HybridRnIap.kt b/android/src/main/java/com/margelo/nitro/iap/HybridRnIap.kt index 7b58143b3..2ffec2352 100644 --- a/android/src/main/java/com/margelo/nitro/iap/HybridRnIap.kt +++ b/android/src/main/java/com/margelo/nitro/iap/HybridRnIap.kt @@ -255,6 +255,11 @@ class HybridRnIap : HybridRnIapSpec() { productTypeBySku.clear() isInitialized = false listenersAttached = false + purchaseUpdatedListeners.clear() + purchaseErrorListeners.clear() + promotedProductListenersIOS.clear() + userChoiceBillingListenersAndroid.clear() + developerProvidedBillingListenersAndroid.clear() initDeferred = null RnIapLog.result("endConnection", true) true From fd464e98f1e617474c8215691b1e4839e5acde0c Mon Sep 17 00:00:00 2001 From: hyochan Date: Wed, 18 Feb 2026 22:53:47 +0900 Subject: [PATCH 4/5] test(hooks): add requestPurchase coverage for useIAP Add test verifying useIAP's requestPurchase calls root API and returns void, covering the async/await wrapper changed in the requestPurchase return type update. Co-Authored-By: Claude Opus 4.6 --- src/__tests__/hooks/useIAP.test.ts | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/src/__tests__/hooks/useIAP.test.ts b/src/__tests__/hooks/useIAP.test.ts index eb22a1b11..8f977d187 100644 --- a/src/__tests__/hooks/useIAP.test.ts +++ b/src/__tests__/hooks/useIAP.test.ts @@ -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 as any); + + 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'); From dd3dd1c212df3f383fe99c73bd4a00dac48e452b Mon Sep 17 00:00:00 2001 From: hyochan Date: Wed, 18 Feb 2026 23:18:11 +0900 Subject: [PATCH 5/5] fix(test): use null instead of null as any for requestPurchase mock The root API requestPurchase returns Promise, so null is the correct mock value without needing a type cast. Co-Authored-By: Claude Opus 4.6 --- src/__tests__/hooks/useIAP.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/__tests__/hooks/useIAP.test.ts b/src/__tests__/hooks/useIAP.test.ts index 8f977d187..27e883040 100644 --- a/src/__tests__/hooks/useIAP.test.ts +++ b/src/__tests__/hooks/useIAP.test.ts @@ -121,7 +121,7 @@ describe('hooks/useIAP (renderer)', () => { it('requestPurchase calls root API and returns void', async () => { const mockRequestPurchase = jest .spyOn(IAP, 'requestPurchase') - .mockResolvedValue(null as any); + .mockResolvedValue(null); let api: any; const Harness = () => {