From 5ac0028ce80133fb7abe32c17790b1cb04a4bdf1 Mon Sep 17 00:00:00 2001 From: hyochan Date: Wed, 25 Mar 2026 16:05:21 +0900 Subject: [PATCH 1/4] feat(useIAP): add reconnect method for manual connection retry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the initial auto-connect fails (e.g., Play Store not ready at mount time on Android), there was no way to retry the connection through the hook — connected would permanently stay false. This adds a reconnect() method that re-runs initConnection, updates the connected state, and re-registers event listeners if they were cleaned up during a previous failure. Related to hyochan/expo-iap#328 --- src/hooks/useIAP.ts | 92 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 92 insertions(+) diff --git a/src/hooks/useIAP.ts b/src/hooks/useIAP.ts index 11351df59..529de4415 100644 --- a/src/hooks/useIAP.ts +++ b/src/hooks/useIAP.ts @@ -90,6 +90,12 @@ type UseIap = { subscriptionIds?: string[], ) => Promise; hasActiveSubscriptions: (subscriptionIds?: string[]) => Promise; + /** + * Manually retry the store connection. + * Useful when the initial auto-connect fails (e.g., Play Store not ready at mount time). + * Updates the `connected` state on success. + */ + reconnect: () => Promise; // Alternative billing (Android) checkAlternativeBillingAvailabilityAndroid?: () => Promise; showAlternativeBillingDialogAndroid?: () => Promise; @@ -476,6 +482,91 @@ export function useIAP(options?: UseIapOptions): UseIap { invokeOnError, ]); + const reconnect = useCallback(async (): Promise => { + let config: + | { + enableBillingProgramAndroid?: BillingProgramAndroid; + alternativeBillingModeAndroid?: AlternativeBillingModeAndroid; + } + | undefined; + + if (Platform.OS === 'android') { + if (optionsRef.current?.enableBillingProgramAndroid) { + config = { + enableBillingProgramAndroid: + optionsRef.current.enableBillingProgramAndroid, + }; + } else if (optionsRef.current?.alternativeBillingModeAndroid) { + config = { + alternativeBillingModeAndroid: + optionsRef.current.alternativeBillingModeAndroid, + }; + } + } + + try { + const result = await initConnection(config); + setConnected(result); + + if (result) { + // Re-register purchase listener if not already active + if (!subscriptionsRef.current.purchaseUpdate) { + subscriptionsRef.current.purchaseUpdate = purchaseUpdatedListener( + async (purchase: Purchase) => { + try { + await getActiveSubscriptionsInternal(); + await getAvailablePurchasesInternal(); + } catch (e) { + RnIapConsole.warn('[useIAP] post-purchase refresh failed:', e); + } + if (optionsRef.current?.onPurchaseSuccess) { + optionsRef.current.onPurchaseSuccess(purchase); + } + }, + ); + } + + // Re-register error listener if not already active + if (!subscriptionsRef.current.purchaseError) { + subscriptionsRef.current.purchaseError = purchaseErrorListener( + (error) => { + if ( + error.code === ErrorCode.InitConnection && + !connectedRef.current + ) { + return; + } + if (optionsRef.current?.onPurchaseError) { + optionsRef.current.onPurchaseError(error); + } + }, + ); + } + + // iOS promoted products listener + if (isStandardIOS() && !subscriptionsRef.current.promotedProductIOS) { + subscriptionsRef.current.promotedProductIOS = + promotedProductListenerIOS((product: Product) => { + setPromotedProductIOS(product); + if (optionsRef.current?.onPromotedProductIOS) { + optionsRef.current.onPromotedProductIOS(product); + } + }); + } + } + + return result; + } catch (error) { + RnIapConsole.error('[useIAP] reconnect failed:', error); + invokeOnError(error); + return false; + } + }, [ + getActiveSubscriptionsInternal, + getAvailablePurchasesInternal, + invokeOnError, + ]); + useEffect(() => { isMountedRef.current = true; initIapWithSubscriptions(); @@ -519,6 +610,7 @@ export function useIAP(options?: UseIapOptions): UseIap { requestPurchaseOnPromotedProductIOS, getActiveSubscriptions: getActiveSubscriptionsInternal, hasActiveSubscriptions: hasActiveSubscriptionsInternal, + reconnect, // Alternative billing (Android only) ...(Platform.OS === 'android' ? { From 8542e599af00912b8207f2fa429fe940b49e56bd Mon Sep 17 00:00:00 2001 From: hyochan Date: Wed, 25 Mar 2026 17:30:46 +0900 Subject: [PATCH 2/4] fix: address PR review comments - Add isMountedRef check after async initConnection in reconnect - Add userChoiceBillingAndroid listener re-registration in reconnect - Add reconnect success/failure tests Co-Authored-By: Claude Opus 4.6 (1M context) --- src/__tests__/hooks/useIAP.test.ts | 53 ++++++++++++++++++++++++++++++ src/hooks/useIAP.ts | 20 +++++++++++ 2 files changed, 73 insertions(+) diff --git a/src/__tests__/hooks/useIAP.test.ts b/src/__tests__/hooks/useIAP.test.ts index 27e883040..45919e589 100644 --- a/src/__tests__/hooks/useIAP.test.ts +++ b/src/__tests__/hooks/useIAP.test.ts @@ -373,4 +373,57 @@ describe('hooks/useIAP (renderer)', () => { expect(initConnectionSpy).toHaveBeenCalled(); }); }); + + describe('reconnect', () => { + it('reconnects and returns true on success', async () => { + let api: any; + const Harness = () => { + api = useIAP(); + return null; + }; + + await act(async () => { + TestRenderer.create(React.createElement(Harness)); + }); + await act(async () => {}); + + // Reset mock to track reconnect call + (IAP.initConnection as jest.Mock).mockClear(); + jest.spyOn(IAP, 'initConnection').mockResolvedValue(true as any); + + let result: boolean | undefined; + await act(async () => { + result = await api.reconnect(); + }); + + expect(result).toBe(true); + expect(api.connected).toBe(true); + }); + + it('calls onError and returns false when reconnect fails', async () => { + const reconnectError = new Error('Reconnect failed'); + + let api: any; + const onError = jest.fn(); + const Harness = () => { + api = useIAP({onError}); + return null; + }; + + await act(async () => { + TestRenderer.create(React.createElement(Harness)); + }); + await act(async () => {}); + + jest.spyOn(IAP, 'initConnection').mockRejectedValueOnce(reconnectError); + + let result: boolean | undefined; + await act(async () => { + result = await api.reconnect(); + }); + + expect(result).toBe(false); + expect(onError).toHaveBeenCalledWith(reconnectError); + }); + }); }); diff --git a/src/hooks/useIAP.ts b/src/hooks/useIAP.ts index 529de4415..fd57fce35 100644 --- a/src/hooks/useIAP.ts +++ b/src/hooks/useIAP.ts @@ -506,6 +506,12 @@ export function useIAP(options?: UseIapOptions): UseIap { try { const result = await initConnection(config); + + // Check if component unmounted during async initConnection + if (!isMountedRef.current) { + return false; + } + setConnected(result); if (result) { @@ -553,6 +559,20 @@ export function useIAP(options?: UseIapOptions): UseIap { } }); } + + // Re-register user choice billing listener for Android + if ( + Platform.OS === 'android' && + optionsRef.current?.onUserChoiceBillingAndroid && + !subscriptionsRef.current.userChoiceBillingAndroid + ) { + subscriptionsRef.current.userChoiceBillingAndroid = + userChoiceBillingListenerAndroid((details) => { + if (optionsRef.current?.onUserChoiceBillingAndroid) { + optionsRef.current.onUserChoiceBillingAndroid(details); + } + }); + } } return result; From 2b5eab1d1f024491d561567c512386a52d228ba6 Mon Sep 17 00:00:00 2001 From: hyochan Date: Wed, 25 Mar 2026 17:50:15 +0900 Subject: [PATCH 3/4] refactor(hooks): extract shared helpers to eliminate reconnect duplication - Extract buildAndroidConfig, registerListeners, cleanupListeners from initIapWithSubscriptions and reconnect into shared useCallback helpers - Add isMountedRef check in reconnect after async initConnection - Add userChoiceBillingAndroid listener re-registration - Add tests for reconnect success/failure, Android billing config, and userChoiceBillingAndroid listener registration Co-Authored-By: Claude Opus 4.6 (1M context) --- src/__tests__/hooks/useIAP.android.test.ts | 51 +++++ src/__tests__/hooks/useIAP.test.ts | 76 ++++++++ src/hooks/useIAP.ts | 210 +++++++-------------- 3 files changed, 193 insertions(+), 144 deletions(-) diff --git a/src/__tests__/hooks/useIAP.android.test.ts b/src/__tests__/hooks/useIAP.android.test.ts index e6367974d..8bdde57a9 100644 --- a/src/__tests__/hooks/useIAP.android.test.ts +++ b/src/__tests__/hooks/useIAP.android.test.ts @@ -125,4 +125,55 @@ describe('hooks/useIAP Android', () => { enableBillingProgramAndroid: 'external-offer', }); }); + + it('registers userChoiceBillingAndroid listener when callback is provided', async () => { + const mockUserChoiceBillingListener = jest + .spyOn(IAP, 'userChoiceBillingListenerAndroid' as any) + .mockImplementation(() => ({remove: jest.fn()})); + + let api: any; + const onUserChoiceBilling = jest.fn(); + const Harness = () => { + api = useIAP({ + onUserChoiceBillingAndroid: onUserChoiceBilling, + }); + return null; + }; + + await act(async () => { + TestRenderer.create(React.createElement(Harness)); + }); + await act(async () => {}); + + expect(api.connected).toBe(true); + expect(mockUserChoiceBillingListener).toHaveBeenCalled(); + }); + + it('reconnect uses Android billing config', async () => { + let api: any; + const Harness = () => { + api = useIAP({ + enableBillingProgramAndroid: 'user-choice-billing', + }); + return null; + }; + + await act(async () => { + TestRenderer.create(React.createElement(Harness)); + }); + await act(async () => {}); + + (IAP.initConnection as jest.Mock).mockClear(); + jest.spyOn(IAP, 'initConnection').mockResolvedValueOnce(true as any); + + let result: boolean | undefined; + await act(async () => { + result = await api.reconnect(); + }); + + expect(result).toBe(true); + expect(IAP.initConnection).toHaveBeenCalledWith({ + enableBillingProgramAndroid: 'user-choice-billing', + }); + }); }); diff --git a/src/__tests__/hooks/useIAP.test.ts b/src/__tests__/hooks/useIAP.test.ts index 45919e589..35759063c 100644 --- a/src/__tests__/hooks/useIAP.test.ts +++ b/src/__tests__/hooks/useIAP.test.ts @@ -400,6 +400,29 @@ describe('hooks/useIAP (renderer)', () => { expect(api.connected).toBe(true); }); + it('returns false and sets connected=false when initConnection returns false', async () => { + let api: any; + const Harness = () => { + api = useIAP(); + return null; + }; + + await act(async () => { + TestRenderer.create(React.createElement(Harness)); + }); + await act(async () => {}); + + jest.spyOn(IAP, 'initConnection').mockResolvedValueOnce(false as any); + + let result: boolean | undefined; + await act(async () => { + result = await api.reconnect(); + }); + + expect(result).toBe(false); + expect(api.connected).toBe(false); + }); + it('calls onError and returns false when reconnect fails', async () => { const reconnectError = new Error('Reconnect failed'); @@ -425,5 +448,58 @@ describe('hooks/useIAP (renderer)', () => { expect(result).toBe(false); expect(onError).toHaveBeenCalledWith(reconnectError); }); + + it('does not throw when reconnect fails without onError', async () => { + let api: any; + const Harness = () => { + api = useIAP(); + return null; + }; + + await act(async () => { + TestRenderer.create(React.createElement(Harness)); + }); + await act(async () => {}); + + jest + .spyOn(IAP, 'initConnection') + .mockRejectedValueOnce(new Error('fail')); + + let result: boolean | undefined; + await act(async () => { + result = await api.reconnect(); + }); + + expect(result).toBe(false); + }); + + it('reconnect re-registers listeners after successful reconnection', async () => { + let api: any; + const onPurchaseSuccess = jest.fn(); + const Harness = () => { + api = useIAP({onPurchaseSuccess}); + return null; + }; + + await act(async () => { + TestRenderer.create(React.createElement(Harness)); + }); + await act(async () => {}); + + // purchaseUpdatedListener should have been called during init + expect(IAP.purchaseUpdatedListener).toHaveBeenCalled(); + + // Clear and reconnect + (IAP.purchaseUpdatedListener as jest.Mock).mockClear(); + jest.spyOn(IAP, 'initConnection').mockResolvedValueOnce(true as any); + + await act(async () => { + await api.reconnect(); + }); + + // Listeners are already active from init, so reconnect skips re-registration + // (guarded by !subscriptionsRef.current.purchaseUpdate check) + expect(api.connected).toBe(true); + }); }); }); diff --git a/src/hooks/useIAP.ts b/src/hooks/useIAP.ts index fd57fce35..425c0340d 100644 --- a/src/hooks/useIAP.ts +++ b/src/hooks/useIAP.ts @@ -363,9 +363,8 @@ export function useIAP(options?: UseIapOptions): UseIap { [], ); - const initIapWithSubscriptions = useCallback(async (): Promise => { - // Initialize connection with config - // Prefer enableBillingProgramAndroid over deprecated alternativeBillingModeAndroid + // Shared helper: build Android billing config from options + const buildAndroidConfig = useCallback(() => { let config: | { enableBillingProgramAndroid?: BillingProgramAndroid; @@ -380,7 +379,6 @@ export function useIAP(options?: UseIapOptions): UseIap { optionsRef.current.enableBillingProgramAndroid, }; } else if (optionsRef.current?.alternativeBillingModeAndroid) { - // Deprecated: use alternativeBillingModeAndroid for backwards compatibility config = { alternativeBillingModeAndroid: optionsRef.current.alternativeBillingModeAndroid, @@ -388,29 +386,14 @@ export function useIAP(options?: UseIapOptions): UseIap { } } - try { - // Initialize connection FIRST to ensure Nitro is ready - // This fixes tvOS where Nitro may initialize later than iOS - const result = await initConnection(config); - - // Check if component unmounted during async initConnection - // to prevent listener leaks - if (!isMountedRef.current) { - return; - } - - setConnected(result); - - if (!result) { - RnIapConsole.warn('[useIAP] initConnection returned false'); - return; - } + return config; + }, []); - // Register listeners AFTER initConnection succeeds - // This ensures Nitro runtime is fully initialized + // Shared helper: register event listeners if not already active + const registerListeners = useCallback(() => { + if (!subscriptionsRef.current.purchaseUpdate) { subscriptionsRef.current.purchaseUpdate = purchaseUpdatedListener( async (purchase: Purchase) => { - // Always refresh subscription state after a purchase event try { await getActiveSubscriptionsInternal(); await getAvailablePurchasesInternal(); @@ -422,11 +405,11 @@ export function useIAP(options?: UseIapOptions): UseIap { } }, ); + } + if (!subscriptionsRef.current.purchaseError) { subscriptionsRef.current.purchaseError = purchaseErrorListener( (error) => { - // error is already normalized by purchaseErrorListener in src/index.ts - // Ignore init error until connected if ( error.code === ErrorCode.InitConnection && !connectedRef.current @@ -438,76 +421,76 @@ export function useIAP(options?: UseIapOptions): UseIap { } }, ); + } + + if (isStandardIOS() && !subscriptionsRef.current.promotedProductIOS) { + subscriptionsRef.current.promotedProductIOS = promotedProductListenerIOS( + (product: Product) => { + setPromotedProductIOS(product); + if (optionsRef.current?.onPromotedProductIOS) { + optionsRef.current.onPromotedProductIOS(product); + } + }, + ); + } - // iOS promoted products listener (only supported on standard iOS, not tvOS/macOS) - if (isStandardIOS()) { - subscriptionsRef.current.promotedProductIOS = - promotedProductListenerIOS((product: Product) => { - setPromotedProductIOS(product); + if ( + Platform.OS === 'android' && + optionsRef.current?.onUserChoiceBillingAndroid && + !subscriptionsRef.current.userChoiceBillingAndroid + ) { + subscriptionsRef.current.userChoiceBillingAndroid = + userChoiceBillingListenerAndroid((details) => { + if (optionsRef.current?.onUserChoiceBillingAndroid) { + optionsRef.current.onUserChoiceBillingAndroid(details); + } + }); + } + }, [getActiveSubscriptionsInternal, getAvailablePurchasesInternal]); + + // Shared helper: clean up all listeners + const cleanupListeners = useCallback(() => { + subscriptionsRef.current.purchaseUpdate?.remove(); + subscriptionsRef.current.purchaseError?.remove(); + subscriptionsRef.current.promotedProductIOS?.remove(); + subscriptionsRef.current.userChoiceBillingAndroid?.remove(); + subscriptionsRef.current.purchaseUpdate = undefined; + subscriptionsRef.current.purchaseError = undefined; + subscriptionsRef.current.promotedProductIOS = undefined; + subscriptionsRef.current.userChoiceBillingAndroid = undefined; + }, []); - if (optionsRef.current?.onPromotedProductIOS) { - optionsRef.current.onPromotedProductIOS(product); - } - }); + const initIapWithSubscriptions = useCallback(async (): Promise => { + const config = buildAndroidConfig(); + + try { + const result = await initConnection(config); + + if (!isMountedRef.current) { + return; } - // Add user choice billing listener for Android (if provided) - if ( - Platform.OS === 'android' && - optionsRef.current?.onUserChoiceBillingAndroid - ) { - subscriptionsRef.current.userChoiceBillingAndroid = - userChoiceBillingListenerAndroid((details) => { - if (optionsRef.current?.onUserChoiceBillingAndroid) { - optionsRef.current.onUserChoiceBillingAndroid(details); - } - }); + setConnected(result); + + if (!result) { + RnIapConsole.warn('[useIAP] initConnection returned false'); + return; } + + registerListeners(); } catch (error) { RnIapConsole.error('initConnection failed:', error); invokeOnError(error); - // Clean up listeners on error (if any were registered) - subscriptionsRef.current.purchaseUpdate?.remove(); - subscriptionsRef.current.purchaseError?.remove(); - subscriptionsRef.current.promotedProductIOS?.remove(); - subscriptionsRef.current.userChoiceBillingAndroid?.remove(); - subscriptionsRef.current.purchaseUpdate = undefined; - subscriptionsRef.current.purchaseError = undefined; - subscriptionsRef.current.promotedProductIOS = undefined; - subscriptionsRef.current.userChoiceBillingAndroid = undefined; + cleanupListeners(); } - }, [ - getActiveSubscriptionsInternal, - getAvailablePurchasesInternal, - invokeOnError, - ]); + }, [buildAndroidConfig, registerListeners, cleanupListeners, invokeOnError]); const reconnect = useCallback(async (): Promise => { - let config: - | { - enableBillingProgramAndroid?: BillingProgramAndroid; - alternativeBillingModeAndroid?: AlternativeBillingModeAndroid; - } - | undefined; - - if (Platform.OS === 'android') { - if (optionsRef.current?.enableBillingProgramAndroid) { - config = { - enableBillingProgramAndroid: - optionsRef.current.enableBillingProgramAndroid, - }; - } else if (optionsRef.current?.alternativeBillingModeAndroid) { - config = { - alternativeBillingModeAndroid: - optionsRef.current.alternativeBillingModeAndroid, - }; - } - } + const config = buildAndroidConfig(); try { const result = await initConnection(config); - // Check if component unmounted during async initConnection if (!isMountedRef.current) { return false; } @@ -515,64 +498,7 @@ export function useIAP(options?: UseIapOptions): UseIap { setConnected(result); if (result) { - // Re-register purchase listener if not already active - if (!subscriptionsRef.current.purchaseUpdate) { - subscriptionsRef.current.purchaseUpdate = purchaseUpdatedListener( - async (purchase: Purchase) => { - try { - await getActiveSubscriptionsInternal(); - await getAvailablePurchasesInternal(); - } catch (e) { - RnIapConsole.warn('[useIAP] post-purchase refresh failed:', e); - } - if (optionsRef.current?.onPurchaseSuccess) { - optionsRef.current.onPurchaseSuccess(purchase); - } - }, - ); - } - - // Re-register error listener if not already active - if (!subscriptionsRef.current.purchaseError) { - subscriptionsRef.current.purchaseError = purchaseErrorListener( - (error) => { - if ( - error.code === ErrorCode.InitConnection && - !connectedRef.current - ) { - return; - } - if (optionsRef.current?.onPurchaseError) { - optionsRef.current.onPurchaseError(error); - } - }, - ); - } - - // iOS promoted products listener - if (isStandardIOS() && !subscriptionsRef.current.promotedProductIOS) { - subscriptionsRef.current.promotedProductIOS = - promotedProductListenerIOS((product: Product) => { - setPromotedProductIOS(product); - if (optionsRef.current?.onPromotedProductIOS) { - optionsRef.current.onPromotedProductIOS(product); - } - }); - } - - // Re-register user choice billing listener for Android - if ( - Platform.OS === 'android' && - optionsRef.current?.onUserChoiceBillingAndroid && - !subscriptionsRef.current.userChoiceBillingAndroid - ) { - subscriptionsRef.current.userChoiceBillingAndroid = - userChoiceBillingListenerAndroid((details) => { - if (optionsRef.current?.onUserChoiceBillingAndroid) { - optionsRef.current.onUserChoiceBillingAndroid(details); - } - }); - } + registerListeners(); } return result; @@ -581,11 +507,7 @@ export function useIAP(options?: UseIapOptions): UseIap { invokeOnError(error); return false; } - }, [ - getActiveSubscriptionsInternal, - getAvailablePurchasesInternal, - invokeOnError, - ]); + }, [buildAndroidConfig, registerListeners, invokeOnError]); useEffect(() => { isMountedRef.current = true; From 939949274c63b4ce7800246e7028326f72197775 Mon Sep 17 00:00:00 2001 From: hyochan Date: Wed, 25 Mar 2026 18:05:25 +0900 Subject: [PATCH 4/4] fix(hooks): use cleanupListeners in effect teardown and defer setConnected - Use shared cleanupListeners() in useEffect cleanup to reset refs - Defer setConnected(true) until after registerListeners() succeeds - Add cleanupListeners in reconnect catch to prevent partial registrations - Guard setConnected with isMountedRef in catch blocks Co-Authored-By: Claude Opus 4.6 (1M context) --- src/hooks/useIAP.ts | 30 +++++++++++++++++------------- 1 file changed, 17 insertions(+), 13 deletions(-) diff --git a/src/hooks/useIAP.ts b/src/hooks/useIAP.ts index 425c0340d..17993f584 100644 --- a/src/hooks/useIAP.ts +++ b/src/hooks/useIAP.ts @@ -470,18 +470,21 @@ export function useIAP(options?: UseIapOptions): UseIap { return; } - setConnected(result); - if (!result) { + setConnected(false); RnIapConsole.warn('[useIAP] initConnection returned false'); return; } registerListeners(); + setConnected(true); } catch (error) { RnIapConsole.error('initConnection failed:', error); - invokeOnError(error); cleanupListeners(); + if (isMountedRef.current) { + setConnected(false); + } + invokeOnError(error); } }, [buildAndroidConfig, registerListeners, cleanupListeners, invokeOnError]); @@ -495,35 +498,36 @@ export function useIAP(options?: UseIapOptions): UseIap { return false; } - setConnected(result); - if (result) { registerListeners(); + setConnected(true); + return true; } - return result; + setConnected(false); + return false; } catch (error) { RnIapConsole.error('[useIAP] reconnect failed:', error); + cleanupListeners(); + if (isMountedRef.current) { + setConnected(false); + } invokeOnError(error); return false; } - }, [buildAndroidConfig, registerListeners, invokeOnError]); + }, [buildAndroidConfig, registerListeners, cleanupListeners, invokeOnError]); useEffect(() => { isMountedRef.current = true; initIapWithSubscriptions(); - const currentSubscriptions = subscriptionsRef.current; return () => { isMountedRef.current = false; - currentSubscriptions.purchaseUpdate?.remove(); - currentSubscriptions.purchaseError?.remove(); - currentSubscriptions.promotedProductIOS?.remove(); - currentSubscriptions.userChoiceBillingAndroid?.remove(); + cleanupListeners(); // Keep connection alive across screens to avoid race conditions setConnected(false); }; - }, [initIapWithSubscriptions]); + }, [initIapWithSubscriptions, cleanupListeners]); return { connected,