From d430ec99fa9454380cb57e74c0162915ce01b23b Mon Sep 17 00:00:00 2001 From: hyochan Date: Wed, 11 Mar 2026 02:00:10 +0900 Subject: [PATCH 1/3] fix(listeners): use singleton native listener to prevent iOS removeAll() bug MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When multiple useIAP instances were active (or a single instance unmounted/remounted during navigation), iOS native removePurchaseUpdatedListener called removeAll() instead of removing only the specific listener. This silently wiped ALL registered listeners, causing onPurchaseSuccess to never fire — users paid but the app never acknowledged the purchase. The fix replaces per-listener native registration with a singleton native handler per event type and JS-level Set-based fan-out. remove() now only deletes from the JS Set, so other listeners remain intact regardless of iOS native behavior. Applied to all 5 listener types: purchaseUpdated, purchaseError, promotedProduct, userChoiceBilling, developerProvidedBilling. Closes #3150 Co-Authored-By: Claude Opus 4.6 --- src/__tests__/index.test.ts | 105 +++++++----- src/index.ts | 330 ++++++++++++++++++------------------ 2 files changed, 227 insertions(+), 208 deletions(-) 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..b2bca70de 100644 --- a/src/index.ts +++ b/src/index.ts @@ -198,63 +198,103 @@ 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) { + listener(convertedPurchase); + } + } else { + RnIapConsole.error( + 'Invalid purchase data received from native:', + nitroPurchase, + ); + } +}; + +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) { + listener(normalized); + } +}; + +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) { + listener(convertedProduct); + } + } else { + RnIapConsole.error( + 'Invalid promoted product data received from native:', + nitroProduct, + ); + } +}; + +/** + * 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 +302,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 +345,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 +396,16 @@ 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) { + listener(details); + } +}; export const userChoiceBillingListenerAndroid = ( listener: (details: any) => void, @@ -403,37 +417,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 +477,17 @@ 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) { + listener(details); + } + }; export interface DeveloperProvidedBillingDetailsAndroid { /** @@ -495,37 +508,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,6 +1175,7 @@ export const initConnection: MutationField<'initConnection'> = async ( export const endConnection: MutationField<'endConnection'> = async () => { try { if (!iapRef) return true; + resetListenerState(); return await IAP.instance.endConnection(); } catch (error) { RnIapConsole.error('Failed to end IAP connection:', error); From 60f2092ac9c05dad80eac6711790d776b7c57ee0 Mon Sep 17 00:00:00 2001 From: hyochan Date: Wed, 11 Mar 2026 02:50:20 +0900 Subject: [PATCH 2/3] fix(listeners): isolate fan-out callbacks and reset state after endConnection Address PR review feedback: - Wrap each listener callback in try/catch so one throwing subscriber cannot prevent other listeners from receiving the event - Move resetListenerState() after native endConnection() succeeds, so JS listener state is preserved if endConnection fails - Sanitize error logs to avoid leaking full purchase objects Co-Authored-By: Claude Opus 4.6 --- src/index.ts | 47 +++++++++++++++++++++++++++++++++++++---------- 1 file changed, 37 insertions(+), 10 deletions(-) diff --git a/src/index.ts b/src/index.ts index b2bca70de..907412e5c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -212,12 +212,16 @@ const purchaseUpdateNativeHandler: NitroPurchaseListener = (nitroPurchase) => { if (validateNitroPurchase(nitroPurchase)) { const convertedPurchase = convertNitroPurchaseToPurchase(nitroPurchase); for (const listener of purchaseUpdateJsListeners) { - listener(convertedPurchase); + try { + listener(convertedPurchase); + } catch (e) { + RnIapConsole.error('[purchaseUpdatedListener] callback threw:', e); + } } } else { RnIapConsole.error( - 'Invalid purchase data received from native:', - nitroPurchase, + 'Invalid purchase data received from native — productId:', + (nitroPurchase as any)?.productId ?? 'unknown', ); } }; @@ -231,7 +235,11 @@ const purchaseErrorNativeHandler: NitroPurchaseErrorListener = (error) => { productId: undefined, }; for (const listener of purchaseErrorJsListeners) { - listener(normalized); + try { + listener(normalized); + } catch (e) { + RnIapConsole.error('[purchaseErrorListener] callback threw:', e); + } } }; @@ -243,12 +251,16 @@ const promotedProductNativeHandler: NitroPromotedProductListener = ( if (validateNitroProduct(nitroProduct)) { const convertedProduct = convertNitroProductToProduct(nitroProduct); for (const listener of promotedProductJsListeners) { - listener(convertedProduct); + try { + listener(convertedProduct); + } catch (e) { + RnIapConsole.error('[promotedProductListenerIOS] callback threw:', e); + } } } else { RnIapConsole.error( - 'Invalid promoted product data received from native:', - nitroProduct, + 'Invalid promoted product data received from native — id:', + (nitroProduct as any)?.id ?? 'unknown', ); } }; @@ -403,7 +415,14 @@ const userChoiceBillingNativeHandler: NitroUserChoiceBillingListener = ( details, ) => { for (const listener of userChoiceBillingJsListeners) { - listener(details); + try { + listener(details); + } catch (e) { + RnIapConsole.error( + '[userChoiceBillingListenerAndroid] callback threw:', + e, + ); + } } }; @@ -485,7 +504,14 @@ let developerProvidedBillingNativeAttached = false; const developerProvidedBillingNativeHandler: NitroDeveloperProvidedBillingListener = (details) => { for (const listener of developerProvidedBillingJsListeners) { - listener(details); + try { + listener(details); + } catch (e) { + RnIapConsole.error( + '[developerProvidedBillingListenerAndroid] callback threw:', + e, + ); + } } }; @@ -1175,8 +1201,9 @@ export const initConnection: MutationField<'initConnection'> = async ( export const endConnection: MutationField<'endConnection'> = async () => { try { if (!iapRef) return true; + const result = await IAP.instance.endConnection(); resetListenerState(); - return await IAP.instance.endConnection(); + return result; } catch (error) { RnIapConsole.error('Failed to end IAP connection:', error); const parsedError = parseErrorStringToJsonObj(error); From a268bf68f22bb2b035a7719ccb0a34edd256c988 Mon Sep 17 00:00:00 2001 From: hyochan Date: Wed, 11 Mar 2026 03:43:20 +0900 Subject: [PATCH 3/3] fix(claude): add -X POST to review-pr reply command Without -X POST, gh api defaults to GET on the replies endpoint which returns 404. Co-Authored-By: Claude Opus 4.6 --- .claude/commands/review-pr.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) 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`.