Skip to content
This repository was archived by the owner on Apr 26, 2026. It is now read-only.

Commit 6343b15

Browse files
hyochanclaude
andauthored
fix: reset listener state on endConnection for proper reconnection (#3151)
## Summary - Reset `listenersAttached` flag (Android) and `isInitializing` flag (iOS) during `endConnection` so listeners can be re-registered after reconnection - Add tests verifying purchase and error listeners work correctly after `endConnection → initConnection` cycle ## Changes ### Android (`HybridRnIap.kt`) - Reset `listenersAttached = false` in `endConnection` to allow re-attachment on next `initConnection` ### iOS (`HybridRnIap.swift`) - Reset `isInitializing = false` in `endConnection` to allow re-initialization - Clean up extra blank lines ### Tests (`index.test.ts`) - Add reconnection test: purchase updated listeners work after `endConnection → initConnection` - Add reconnection test: error listeners work after `endConnection → initConnection` ## Test plan - [x] `yarn typecheck` passes - [x] `yarn lint` passes - [x] `yarn test` passes (new reconnection tests included) 🤖 Generated with [Claude Code](https://claude.ai/code) <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **Documentation** * Clarified Hook API semantics: removed certain examples, added guidance that purchase results arrive via callbacks (onPurchaseSuccess), and adjusted header formatting. * **Bug Fixes** * Improved connection cleanup to fully reset listeners and initialization state for reliable reconnection. * **Breaking Changes** * requestPurchase now returns Promise<void>; purchase results are delivered via callbacks. * **Tests** * Added reconnection and purchase-request tests verifying listeners and void return behavior. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
1 parent a524fdd commit 6343b15

6 files changed

Lines changed: 115 additions & 9 deletions

File tree

CLAUDE.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -249,12 +249,14 @@ yarn install && yarn typecheck && yarn lint --fix
249249
## Hook API Semantics (useIAP)
250250

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

259261
### Common CI Fixes
260262

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

300-
**Android & iOS (OpenIAP)**
302+
### Android & iOS (OpenIAP)
301303

302304
Both platforms use the OpenIAP library's error handling:
303305

android/src/main/java/com/margelo/nitro/iap/HybridRnIap.kt

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -254,6 +254,12 @@ class HybridRnIap : HybridRnIapSpec() {
254254
runCatching { openIap.endConnection() }
255255
productTypeBySku.clear()
256256
isInitialized = false
257+
listenersAttached = false
258+
purchaseUpdatedListeners.clear()
259+
purchaseErrorListeners.clear()
260+
promotedProductListenersIOS.clear()
261+
userChoiceBillingListenersAndroid.clear()
262+
developerProvidedBillingListenersAndroid.clear()
257263
initDeferred = null
258264
RnIapLog.result("endConnection", true)
259265
true

ios/HybridRnIap.swift

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1034,8 +1034,8 @@ class HybridRnIap: HybridRnIapSpec {
10341034
updateListenerTask?.cancel()
10351035
updateListenerTask = nil
10361036
isInitialized = false
1037-
1038-
1037+
isInitializing = false
1038+
10391039
// Remove OpenIAP listeners & end connection
10401040
if let sub = purchaseUpdatedSub {
10411041
RnIapLog.payload("removeListener", "purchaseUpdated")

src/__tests__/hooks/useIAP.test.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,30 @@ describe('hooks/useIAP (renderer)', () => {
118118
expect(IAP.finishTransaction).toBeDefined();
119119
});
120120

121+
it('requestPurchase calls root API and returns void', async () => {
122+
const mockRequestPurchase = jest
123+
.spyOn(IAP, 'requestPurchase')
124+
.mockResolvedValue(null);
125+
126+
let api: any;
127+
const Harness = () => {
128+
api = useIAP();
129+
return null;
130+
};
131+
132+
await act(async () => {
133+
TestRenderer.create(React.createElement(Harness));
134+
});
135+
await act(async () => {});
136+
137+
await act(async () => {
138+
const result = await api.requestPurchase({sku: 'product1'});
139+
expect(result).toBeUndefined();
140+
});
141+
142+
expect(mockRequestPurchase).toHaveBeenCalledWith({sku: 'product1'});
143+
});
144+
121145
describe('onError callback', () => {
122146
it('calls onError when fetchProducts fails', async () => {
123147
const fetchError = new Error('Network error fetching products');

src/__tests__/index.test.ts

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -223,6 +223,81 @@ describe('Public API (src/index.ts)', () => {
223223
expect(mockIap.initConnection).toHaveBeenCalled();
224224
expect(mockIap.endConnection).toHaveBeenCalled();
225225
});
226+
227+
it('listeners work after endConnection → initConnection reconnection', async () => {
228+
// 1. Initial connection + listener
229+
await IAP.initConnection();
230+
const listener1 = jest.fn();
231+
const sub1 = IAP.purchaseUpdatedListener(listener1);
232+
233+
// Verify listener is registered
234+
expect(mockIap.addPurchaseUpdatedListener).toHaveBeenCalledTimes(1);
235+
const wrapped1 = mockIap.addPurchaseUpdatedListener.mock.calls[0][0];
236+
237+
// Simulate a purchase event — listener should fire
238+
const nitroPurchase = {
239+
id: 't1',
240+
productId: 'p1',
241+
transactionDate: Date.now(),
242+
platform: 'ios',
243+
quantity: 1,
244+
purchaseState: 'purchased',
245+
isAutoRenewing: false,
246+
};
247+
wrapped1(nitroPurchase);
248+
expect(listener1).toHaveBeenCalledTimes(1);
249+
250+
// 2. Disconnect and remove old listener
251+
sub1.remove();
252+
await IAP.endConnection();
253+
254+
// 3. Reconnect and register new listener
255+
jest.clearAllMocks();
256+
await IAP.initConnection();
257+
const listener2 = jest.fn();
258+
const sub2 = IAP.purchaseUpdatedListener(listener2);
259+
260+
// New listener should be registered with native
261+
expect(mockIap.addPurchaseUpdatedListener).toHaveBeenCalledTimes(1);
262+
const wrapped2 = mockIap.addPurchaseUpdatedListener.mock.calls[0][0];
263+
264+
// Simulate purchase event on new connection — new listener should fire
265+
wrapped2(nitroPurchase);
266+
expect(listener2).toHaveBeenCalledTimes(1);
267+
expect(listener2).toHaveBeenCalledWith(
268+
expect.objectContaining({productId: 'p1'}),
269+
);
270+
271+
sub2.remove();
272+
});
273+
274+
it('error listeners work after endConnection → initConnection reconnection', async () => {
275+
await IAP.initConnection();
276+
const errorListener1 = jest.fn();
277+
const sub1 = IAP.purchaseErrorListener(errorListener1);
278+
sub1.remove();
279+
await IAP.endConnection();
280+
281+
// Reconnect and register new error listener
282+
jest.clearAllMocks();
283+
await IAP.initConnection();
284+
const errorListener2 = jest.fn();
285+
const sub2 = IAP.purchaseErrorListener(errorListener2);
286+
287+
expect(mockIap.addPurchaseErrorListener).toHaveBeenCalledTimes(1);
288+
const wrapped = mockIap.addPurchaseErrorListener.mock.calls[0][0];
289+
290+
wrapped({code: 'user-cancelled', message: 'User cancelled'});
291+
expect(errorListener2).toHaveBeenCalledTimes(1);
292+
expect(errorListener2).toHaveBeenCalledWith(
293+
expect.objectContaining({
294+
code: ErrorCode.UserCancelled,
295+
message: 'User cancelled',
296+
}),
297+
);
298+
299+
sub2.remove();
300+
});
226301
});
227302

228303
describe('fetchProducts', () => {

src/hooks/useIAP.ts

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,6 @@ import {ErrorCode} from '../types';
3333
import type {
3434
ProductQueryType,
3535
RequestPurchaseProps,
36-
RequestPurchaseResult,
3736
AlternativeBillingModeAndroid,
3837
BillingProgramAndroid,
3938
UserChoiceBillingDetails,
@@ -69,9 +68,7 @@ type UseIap = {
6968
skus: string[];
7069
type?: ProductQueryType | null;
7170
}) => Promise<void>;
72-
requestPurchase: (
73-
params: RequestPurchaseProps,
74-
) => Promise<RequestPurchaseResult | null>;
71+
requestPurchase: (params: RequestPurchaseProps) => Promise<void>;
7572
/**
7673
* @deprecated Use `verifyPurchase` instead. This function will be removed in a future version.
7774
*/
@@ -330,7 +327,9 @@ export function useIAP(options?: UseIapOptions): UseIap {
330327
);
331328

332329
const requestPurchase = useCallback(
333-
(requestObj: RequestPurchaseProps) => requestPurchaseInternal(requestObj),
330+
async (requestObj: RequestPurchaseProps): Promise<void> => {
331+
await requestPurchaseInternal(requestObj);
332+
},
334333
[],
335334
);
336335

0 commit comments

Comments
 (0)