diff --git a/.claude/commands/review-pr.md b/.claude/commands/review-pr.md index 5891deeb8..28f78d635 100644 --- a/.claude/commands/review-pr.md +++ b/.claude/commands/review-pr.md @@ -85,7 +85,9 @@ Fixed in abc1234 along with other review items. 7. Push changes 8. Reply to **each individual review comment** using the comment's `id`: + ```bash - gh api repos/{owner}/{repo}/pulls/comments/{comment_id}/replies -f body="Fixed in abc1234." + gh api repos/{owner}/{repo}/pulls/comments/{comment_id}/replies -X POST -f body="Fixed in abc1234." ``` - **IMPORTANT:** Always reply directly to individual comments, NOT as a general PR review comment. Use the `/pulls/comments/{id}/replies` endpoint, NOT `gh pr review --comment`. + + **CRITICAL:** You MUST include `-X POST` — without it the request defaults to GET and returns 404. Always reply directly to individual comments, NOT as a general PR review comment. Use the `/pulls/comments/{id}/replies` endpoint, NOT `gh pr review --comment`. diff --git a/src/__tests__/index.test.ts b/src/__tests__/index.test.ts index d62a48f82..3c938bd8c 100644 --- a/src/__tests__/index.test.ts +++ b/src/__tests__/index.test.ts @@ -130,7 +130,7 @@ describe('Public API (src/index.ts)', () => { const sub = IAP.purchaseUpdatedListener(listener); expect(typeof sub.remove).toBe('function'); - // Emulate native event + // Emulate native event via singleton handler const nitroPurchase = { id: 't1', productId: 'p1', @@ -140,8 +140,10 @@ describe('Public API (src/index.ts)', () => { purchaseState: 'purchased', isAutoRenewing: false, }; - const wrapped = mockIap.addPurchaseUpdatedListener.mock.calls[0][0]; - wrapped(nitroPurchase); + // Singleton: only one native handler registered + expect(mockIap.addPurchaseUpdatedListener).toHaveBeenCalledTimes(1); + const nativeHandler = mockIap.addPurchaseUpdatedListener.mock.calls[0][0]; + nativeHandler(nitroPurchase); expect(listener).toHaveBeenCalledWith( expect.objectContaining({ productId: 'p1', @@ -149,9 +151,12 @@ describe('Public API (src/index.ts)', () => { }), ); - // remove + // remove only removes from JS set, not native sub.remove(); - expect(mockIap.removePurchaseUpdatedListener).toHaveBeenCalled(); + // Verify listener no longer fires after removal + listener.mockClear(); + nativeHandler(nitroPurchase); + expect(listener).not.toHaveBeenCalled(); }); it('purchaseErrorListener forwards error objects and supports removal', () => { @@ -160,8 +165,9 @@ describe('Public API (src/index.ts)', () => { expect(typeof sub.remove).toBe('function'); const err = {code: 'E_UNKNOWN', message: 'oops'}; - const passed = mockIap.addPurchaseErrorListener.mock.calls[0][0]; - passed(err); + expect(mockIap.addPurchaseErrorListener).toHaveBeenCalledTimes(1); + const nativeHandler = mockIap.addPurchaseErrorListener.mock.calls[0][0]; + nativeHandler(err); expect(listener).toHaveBeenCalledWith( expect.objectContaining({ code: ErrorCode.Unknown, @@ -170,7 +176,10 @@ describe('Public API (src/index.ts)', () => { ); sub.remove(); - expect(mockIap.removePurchaseErrorListener).toHaveBeenCalled(); + // Verify listener no longer fires after removal + listener.mockClear(); + nativeHandler(err); + expect(listener).not.toHaveBeenCalled(); }); it('promotedProductListenerIOS warns and no-ops on non‑iOS', () => { @@ -198,13 +207,18 @@ describe('Public API (src/index.ts)', () => { }; const listener = jest.fn(); const sub = IAP.promotedProductListenerIOS(listener); - const wrapped = mockIap.addPromotedProductListenerIOS.mock.calls[0][0]; - wrapped(nitroProduct); + expect(mockIap.addPromotedProductListenerIOS).toHaveBeenCalledTimes(1); + const nativeHandler = + mockIap.addPromotedProductListenerIOS.mock.calls[0][0]; + nativeHandler(nitroProduct); expect(listener).toHaveBeenCalledWith( expect.objectContaining({id: 'sku1', platform: PLATFORM_IOS}), ); sub.remove(); - expect(mockIap.removePromotedProductListenerIOS).toHaveBeenCalled(); + // Verify listener no longer fires after removal + listener.mockClear(); + nativeHandler(nitroProduct); + expect(listener).not.toHaveBeenCalled(); }); it('purchaseUpdatedListener ignores invalid purchase payload', () => { @@ -215,13 +229,14 @@ describe('Public API (src/index.ts)', () => { expect(listener).not.toHaveBeenCalled(); }); - it('multiple purchaseUpdatedListeners all receive events', () => { + it('multiple purchaseUpdatedListeners all receive events from single native handler', () => { const listener1 = jest.fn(); const listener2 = jest.fn(); const sub1 = IAP.purchaseUpdatedListener(listener1); const sub2 = IAP.purchaseUpdatedListener(listener2); - expect(mockIap.addPurchaseUpdatedListener).toHaveBeenCalledTimes(2); + // Singleton: only one native listener registered + expect(mockIap.addPurchaseUpdatedListener).toHaveBeenCalledTimes(1); const nitroPurchase = { id: 't1', @@ -232,10 +247,9 @@ describe('Public API (src/index.ts)', () => { purchaseState: 'purchased', isAutoRenewing: false, }; - const wrapped1 = mockIap.addPurchaseUpdatedListener.mock.calls[0][0]; - const wrapped2 = mockIap.addPurchaseUpdatedListener.mock.calls[1][0]; - wrapped1(nitroPurchase); - wrapped2(nitroPurchase); + // Single native handler dispatches to all JS listeners + const nativeHandler = mockIap.addPurchaseUpdatedListener.mock.calls[0][0]; + nativeHandler(nitroPurchase); expect(listener1).toHaveBeenCalledTimes(1); expect(listener2).toHaveBeenCalledTimes(1); @@ -248,12 +262,12 @@ describe('Public API (src/index.ts)', () => { const listener1 = jest.fn(); const listener2 = jest.fn(); const sub1 = IAP.purchaseUpdatedListener(listener1); - const sub2 = IAP.purchaseUpdatedListener(listener2); + IAP.purchaseUpdatedListener(listener2); + // Remove first listener sub1.remove(); - expect(mockIap.removePurchaseUpdatedListener).toHaveBeenCalledTimes(1); - const wrapped2 = mockIap.addPurchaseUpdatedListener.mock.calls[1][0]; + const nativeHandler = mockIap.addPurchaseUpdatedListener.mock.calls[0][0]; const nitroPurchase = { id: 't2', productId: 'p2', @@ -263,26 +277,24 @@ describe('Public API (src/index.ts)', () => { purchaseState: 'purchased', isAutoRenewing: false, }; - wrapped2(nitroPurchase); + nativeHandler(nitroPurchase); + // listener2 still receives events, listener1 does not expect(listener2).toHaveBeenCalledTimes(1); expect(listener1).not.toHaveBeenCalled(); - - sub2.remove(); }); - it('multiple purchaseErrorListeners all receive errors', () => { + it('multiple purchaseErrorListeners all receive errors from single native handler', () => { const listener1 = jest.fn(); const listener2 = jest.fn(); const sub1 = IAP.purchaseErrorListener(listener1); const sub2 = IAP.purchaseErrorListener(listener2); - expect(mockIap.addPurchaseErrorListener).toHaveBeenCalledTimes(2); + // Singleton: only one native listener registered + expect(mockIap.addPurchaseErrorListener).toHaveBeenCalledTimes(1); - const wrapped1 = mockIap.addPurchaseErrorListener.mock.calls[0][0]; - const wrapped2 = mockIap.addPurchaseErrorListener.mock.calls[1][0]; + const nativeHandler = mockIap.addPurchaseErrorListener.mock.calls[0][0]; const err = {code: 'user-cancelled', message: 'User cancelled'}; - wrapped1(err); - wrapped2(err); + nativeHandler(err); expect(listener1).toHaveBeenCalledTimes(1); expect(listener2).toHaveBeenCalledTimes(1); @@ -295,16 +307,14 @@ describe('Public API (src/index.ts)', () => { const listener1 = jest.fn(); const listener2 = jest.fn(); const sub1 = IAP.purchaseErrorListener(listener1); - const sub2 = IAP.purchaseErrorListener(listener2); + IAP.purchaseErrorListener(listener2); sub1.remove(); - const wrapped2 = mockIap.addPurchaseErrorListener.mock.calls[1][0]; - wrapped2({code: 'network-error', message: 'Network error'}); + const nativeHandler = mockIap.addPurchaseErrorListener.mock.calls[0][0]; + nativeHandler({code: 'network-error', message: 'Network error'}); expect(listener2).toHaveBeenCalledTimes(1); expect(listener1).not.toHaveBeenCalled(); - - sub2.remove(); }); }); @@ -322,9 +332,10 @@ describe('Public API (src/index.ts)', () => { const listener1 = jest.fn(); const sub1 = IAP.purchaseUpdatedListener(listener1); - // Verify listener is registered + // Verify singleton native listener is registered expect(mockIap.addPurchaseUpdatedListener).toHaveBeenCalledTimes(1); - const wrapped1 = mockIap.addPurchaseUpdatedListener.mock.calls[0][0]; + const nativeHandler1 = + mockIap.addPurchaseUpdatedListener.mock.calls[0][0]; // Simulate a purchase event — listener should fire const nitroPurchase = { @@ -336,10 +347,10 @@ describe('Public API (src/index.ts)', () => { purchaseState: 'purchased', isAutoRenewing: false, }; - wrapped1(nitroPurchase); + nativeHandler1(nitroPurchase); expect(listener1).toHaveBeenCalledTimes(1); - // 2. Disconnect and remove old listener + // 2. Disconnect (endConnection resets listener state) sub1.remove(); await IAP.endConnection(); @@ -349,12 +360,13 @@ describe('Public API (src/index.ts)', () => { const listener2 = jest.fn(); const sub2 = IAP.purchaseUpdatedListener(listener2); - // New listener should be registered with native + // New singleton native listener should be registered after reset expect(mockIap.addPurchaseUpdatedListener).toHaveBeenCalledTimes(1); - const wrapped2 = mockIap.addPurchaseUpdatedListener.mock.calls[0][0]; + const nativeHandler2 = + mockIap.addPurchaseUpdatedListener.mock.calls[0][0]; // Simulate purchase event on new connection — new listener should fire - wrapped2(nitroPurchase); + nativeHandler2(nitroPurchase); expect(listener2).toHaveBeenCalledTimes(1); expect(listener2).toHaveBeenCalledWith( expect.objectContaining({productId: 'p1'}), @@ -377,9 +389,9 @@ describe('Public API (src/index.ts)', () => { const sub2 = IAP.purchaseErrorListener(errorListener2); expect(mockIap.addPurchaseErrorListener).toHaveBeenCalledTimes(1); - const wrapped = mockIap.addPurchaseErrorListener.mock.calls[0][0]; + const nativeHandler = mockIap.addPurchaseErrorListener.mock.calls[0][0]; - wrapped({code: 'user-cancelled', message: 'User cancelled'}); + nativeHandler({code: 'user-cancelled', message: 'User cancelled'}); expect(errorListener2).toHaveBeenCalledTimes(1); expect(errorListener2).toHaveBeenCalledWith( expect.objectContaining({ @@ -1777,9 +1789,10 @@ describe('Public API (src/index.ts)', () => { ); sub.remove(); - expect( - mockIap.removeDeveloperProvidedBillingListenerAndroid, - ).toHaveBeenCalled(); + // Singleton pattern: native remove is not called, JS listener is removed from Set + listener.mockClear(); + wrapped(details); + expect(listener).not.toHaveBeenCalled(); }); }); diff --git a/src/index.ts b/src/index.ts index 18acb0572..907412e5c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -198,63 +198,115 @@ const IAP = { // ============================================================================ // EVENT LISTENERS +// +// Uses a singleton native listener per event type with JS-level fan-out. +// This avoids the iOS bug where removePurchaseUpdatedListener calls +// removeAll() instead of removing a specific listener, which caused ALL +// listeners to be lost when any single useIAP instance unmounted. +// See: https://github.com/hyochan/react-native-iap/issues/3150 // ============================================================================ -const purchaseUpdatedListenerMap = new WeakMap< - (purchase: Purchase) => void, - NitroPurchaseListener ->(); -const purchaseErrorListenerMap = new WeakMap< - (error: PurchaseError) => void, - NitroPurchaseErrorListener ->(); -const promotedProductListenerMap = new WeakMap< - (product: Product) => void, - NitroPromotedProductListener ->(); +const purchaseUpdateJsListeners = new Set<(purchase: Purchase) => void>(); +let purchaseUpdateNativeAttached = false; +const purchaseUpdateNativeHandler: NitroPurchaseListener = (nitroPurchase) => { + if (validateNitroPurchase(nitroPurchase)) { + const convertedPurchase = convertNitroPurchaseToPurchase(nitroPurchase); + for (const listener of purchaseUpdateJsListeners) { + try { + listener(convertedPurchase); + } catch (e) { + RnIapConsole.error('[purchaseUpdatedListener] callback threw:', e); + } + } + } else { + RnIapConsole.error( + 'Invalid purchase data received from native — productId:', + (nitroPurchase as any)?.productId ?? 'unknown', + ); + } +}; + +const purchaseErrorJsListeners = new Set<(error: PurchaseError) => void>(); +let purchaseErrorNativeAttached = false; +const purchaseErrorNativeHandler: NitroPurchaseErrorListener = (error) => { + const normalized: PurchaseError = { + code: normalizeErrorCodeFromNative(error.code), + message: error.message, + productId: undefined, + }; + for (const listener of purchaseErrorJsListeners) { + try { + listener(normalized); + } catch (e) { + RnIapConsole.error('[purchaseErrorListener] callback threw:', e); + } + } +}; + +const promotedProductJsListeners = new Set<(product: Product) => void>(); +let promotedProductNativeAttached = false; +const promotedProductNativeHandler: NitroPromotedProductListener = ( + nitroProduct, +) => { + if (validateNitroProduct(nitroProduct)) { + const convertedProduct = convertNitroProductToProduct(nitroProduct); + for (const listener of promotedProductJsListeners) { + try { + listener(convertedProduct); + } catch (e) { + RnIapConsole.error('[promotedProductListenerIOS] callback threw:', e); + } + } + } else { + RnIapConsole.error( + 'Invalid promoted product data received from native — id:', + (nitroProduct as any)?.id ?? 'unknown', + ); + } +}; + +/** + * Reset all JS-level listener tracking state. + * Called during endConnection to ensure clean re-registration on next initConnection. + */ +export const resetListenerState = (): void => { + purchaseUpdateNativeAttached = false; + purchaseErrorNativeAttached = false; + promotedProductNativeAttached = false; + userChoiceBillingNativeAttached = false; + developerProvidedBillingNativeAttached = false; + // Clear all JS listeners since native side clears them in endConnection + purchaseUpdateJsListeners.clear(); + purchaseErrorJsListeners.clear(); + promotedProductJsListeners.clear(); + userChoiceBillingJsListeners.clear(); + developerProvidedBillingJsListeners.clear(); +}; export const purchaseUpdatedListener = ( listener: (purchase: Purchase) => void, ): EventSubscription => { - const wrappedListener: NitroPurchaseListener = (nitroPurchase) => { - if (validateNitroPurchase(nitroPurchase)) { - const convertedPurchase = convertNitroPurchaseToPurchase(nitroPurchase); - listener(convertedPurchase); - } else { - RnIapConsole.error( - 'Invalid purchase data received from native:', - nitroPurchase, - ); - } - }; + purchaseUpdateJsListeners.add(listener); - purchaseUpdatedListenerMap.set(listener, wrappedListener); - let attached = false; - try { - IAP.instance.addPurchaseUpdatedListener(wrappedListener); - attached = true; - } catch (e) { - const msg = toErrorMessage(e); - if (msg.includes('Nitro runtime not installed')) { - RnIapConsole.warn( - '[purchaseUpdatedListener] Nitro not ready yet; listener inert until initConnection()', - ); - } else { - throw e; + if (!purchaseUpdateNativeAttached) { + try { + IAP.instance.addPurchaseUpdatedListener(purchaseUpdateNativeHandler); + purchaseUpdateNativeAttached = true; + } catch (e) { + const msg = toErrorMessage(e); + if (msg.includes('Nitro runtime not installed')) { + RnIapConsole.warn( + '[purchaseUpdatedListener] Nitro not ready yet; listener inert until initConnection()', + ); + } else { + throw e; + } } } return { remove: () => { - const wrapped = purchaseUpdatedListenerMap.get(listener); - if (wrapped) { - if (attached) { - try { - IAP.instance.removePurchaseUpdatedListener(wrapped); - } catch {} - } - purchaseUpdatedListenerMap.delete(listener); - } + purchaseUpdateJsListeners.delete(listener); }, }; }; @@ -262,41 +314,27 @@ export const purchaseUpdatedListener = ( export const purchaseErrorListener = ( listener: (error: PurchaseError) => void, ): EventSubscription => { - const wrapped: NitroPurchaseErrorListener = (error) => { - listener({ - code: normalizeErrorCodeFromNative(error.code), - message: error.message, - productId: undefined, - }); - }; + purchaseErrorJsListeners.add(listener); - purchaseErrorListenerMap.set(listener, wrapped); - let attached = false; - try { - IAP.instance.addPurchaseErrorListener(wrapped); - attached = true; - } catch (e) { - const msg = toErrorMessage(e); - if (msg.includes('Nitro runtime not installed')) { - RnIapConsole.warn( - '[purchaseErrorListener] Nitro not ready yet; listener inert until initConnection()', - ); - } else { - throw e; + if (!purchaseErrorNativeAttached) { + try { + IAP.instance.addPurchaseErrorListener(purchaseErrorNativeHandler); + purchaseErrorNativeAttached = true; + } catch (e) { + const msg = toErrorMessage(e); + if (msg.includes('Nitro runtime not installed')) { + RnIapConsole.warn( + '[purchaseErrorListener] Nitro not ready yet; listener inert until initConnection()', + ); + } else { + throw e; + } } } return { remove: () => { - const stored = purchaseErrorListenerMap.get(listener); - if (stored) { - if (attached) { - try { - IAP.instance.removePurchaseErrorListener(stored); - } catch {} - } - purchaseErrorListenerMap.delete(listener); - } + purchaseErrorJsListeners.delete(listener); }, }; }; @@ -319,45 +357,27 @@ export const promotedProductListenerIOS = ( return {remove: () => {}}; } - const wrappedListener: NitroPromotedProductListener = (nitroProduct) => { - if (validateNitroProduct(nitroProduct)) { - const convertedProduct = convertNitroProductToProduct(nitroProduct); - listener(convertedProduct); - } else { - RnIapConsole.error( - 'Invalid promoted product data received from native:', - nitroProduct, - ); - } - }; + promotedProductJsListeners.add(listener); - promotedProductListenerMap.set(listener, wrappedListener); - let attached = false; - try { - IAP.instance.addPromotedProductListenerIOS(wrappedListener); - attached = true; - } catch (e) { - const msg = toErrorMessage(e); - if (msg.includes('Nitro runtime not installed')) { - RnIapConsole.warn( - '[promotedProductListenerIOS] Nitro not ready yet; listener inert until initConnection()', - ); - } else { - throw e; + if (!promotedProductNativeAttached) { + try { + IAP.instance.addPromotedProductListenerIOS(promotedProductNativeHandler); + promotedProductNativeAttached = true; + } catch (e) { + const msg = toErrorMessage(e); + if (msg.includes('Nitro runtime not installed')) { + RnIapConsole.warn( + '[promotedProductListenerIOS] Nitro not ready yet; listener inert until initConnection()', + ); + } else { + throw e; + } } } return { remove: () => { - const wrapped = promotedProductListenerMap.get(listener); - if (wrapped) { - if (attached) { - try { - IAP.instance.removePromotedProductListenerIOS(wrapped); - } catch {} - } - promotedProductListenerMap.delete(listener); - } + promotedProductJsListeners.delete(listener); }, }; }; @@ -388,10 +408,23 @@ export const promotedProductListenerIOS = ( type NitroUserChoiceBillingListener = Parameters< RnIap['addUserChoiceBillingListenerAndroid'] >[0]; -const userChoiceBillingListenerMap = new WeakMap< - (details: any) => void, - NitroUserChoiceBillingListener ->(); + +const userChoiceBillingJsListeners = new Set<(details: any) => void>(); +let userChoiceBillingNativeAttached = false; +const userChoiceBillingNativeHandler: NitroUserChoiceBillingListener = ( + details, +) => { + for (const listener of userChoiceBillingJsListeners) { + try { + listener(details); + } catch (e) { + RnIapConsole.error( + '[userChoiceBillingListenerAndroid] callback threw:', + e, + ); + } + } +}; export const userChoiceBillingListenerAndroid = ( listener: (details: any) => void, @@ -403,37 +436,29 @@ export const userChoiceBillingListenerAndroid = ( return {remove: () => {}}; } - const wrappedListener: NitroUserChoiceBillingListener = (details) => { - listener(details); - }; + userChoiceBillingJsListeners.add(listener); - userChoiceBillingListenerMap.set(listener, wrappedListener); - let attached = false; - try { - IAP.instance.addUserChoiceBillingListenerAndroid(wrappedListener); - attached = true; - } catch (e) { - const msg = toErrorMessage(e); - if (msg.includes('Nitro runtime not installed')) { - RnIapConsole.warn( - '[userChoiceBillingListenerAndroid] Nitro not ready yet; listener inert until initConnection()', + if (!userChoiceBillingNativeAttached) { + try { + IAP.instance.addUserChoiceBillingListenerAndroid( + userChoiceBillingNativeHandler, ); - } else { - throw e; + userChoiceBillingNativeAttached = true; + } catch (e) { + const msg = toErrorMessage(e); + if (msg.includes('Nitro runtime not installed')) { + RnIapConsole.warn( + '[userChoiceBillingListenerAndroid] Nitro not ready yet; listener inert until initConnection()', + ); + } else { + throw e; + } } } return { remove: () => { - const wrapped = userChoiceBillingListenerMap.get(listener); - if (wrapped) { - if (attached) { - try { - IAP.instance.removeUserChoiceBillingListenerAndroid(wrapped); - } catch {} - } - userChoiceBillingListenerMap.delete(listener); - } + userChoiceBillingJsListeners.delete(listener); }, }; }; @@ -471,10 +496,24 @@ export const userChoiceBillingListenerAndroid = ( type NitroDeveloperProvidedBillingListener = Parameters< RnIap['addDeveloperProvidedBillingListenerAndroid'] >[0]; -const developerProvidedBillingListenerMap = new WeakMap< - (details: any) => void, - NitroDeveloperProvidedBillingListener + +const developerProvidedBillingJsListeners = new Set< + (details: DeveloperProvidedBillingDetailsAndroid) => void >(); +let developerProvidedBillingNativeAttached = false; +const developerProvidedBillingNativeHandler: NitroDeveloperProvidedBillingListener = + (details) => { + for (const listener of developerProvidedBillingJsListeners) { + try { + listener(details); + } catch (e) { + RnIapConsole.error( + '[developerProvidedBillingListenerAndroid] callback threw:', + e, + ); + } + } + }; export interface DeveloperProvidedBillingDetailsAndroid { /** @@ -495,37 +534,29 @@ export const developerProvidedBillingListenerAndroid = ( return {remove: () => {}}; } - const wrappedListener: NitroDeveloperProvidedBillingListener = (details) => { - listener(details); - }; + developerProvidedBillingJsListeners.add(listener); - developerProvidedBillingListenerMap.set(listener, wrappedListener); - let attached = false; - try { - IAP.instance.addDeveloperProvidedBillingListenerAndroid(wrappedListener); - attached = true; - } catch (e) { - const msg = toErrorMessage(e); - if (msg.includes('Nitro runtime not installed')) { - RnIapConsole.warn( - '[developerProvidedBillingListenerAndroid] Nitro not ready yet; listener inert until initConnection()', + if (!developerProvidedBillingNativeAttached) { + try { + IAP.instance.addDeveloperProvidedBillingListenerAndroid( + developerProvidedBillingNativeHandler, ); - } else { - throw e; + developerProvidedBillingNativeAttached = true; + } catch (e) { + const msg = toErrorMessage(e); + if (msg.includes('Nitro runtime not installed')) { + RnIapConsole.warn( + '[developerProvidedBillingListenerAndroid] Nitro not ready yet; listener inert until initConnection()', + ); + } else { + throw e; + } } } return { remove: () => { - const wrapped = developerProvidedBillingListenerMap.get(listener); - if (wrapped) { - if (attached) { - try { - IAP.instance.removeDeveloperProvidedBillingListenerAndroid(wrapped); - } catch {} - } - developerProvidedBillingListenerMap.delete(listener); - } + developerProvidedBillingJsListeners.delete(listener); }, }; }; @@ -1170,7 +1201,9 @@ export const initConnection: MutationField<'initConnection'> = async ( export const endConnection: MutationField<'endConnection'> = async () => { try { if (!iapRef) return true; - return await IAP.instance.endConnection(); + const result = await IAP.instance.endConnection(); + resetListenerState(); + return result; } catch (error) { RnIapConsole.error('Failed to end IAP connection:', error); const parsedError = parseErrorStringToJsonObj(error);