From b791e4ede760ee65f61f2c1f53ecf25af4bf271b Mon Sep 17 00:00:00 2001 From: hyochan Date: Fri, 8 May 2026 19:01:16 +0900 Subject: [PATCH 1/4] fix: handle promoted iap cold start Register the Apple promoted purchase observer before JS init, replay pending promoted products to Expo listeners, and add the Expo example/docs release note for issue #143. --- .../expo-iap/example/__tests__/index.test.tsx | 33 ++- .../example/__tests__/layout.test.tsx | 13 +- libraries/expo-iap/example/app.config.ts | 2 +- libraries/expo-iap/example/app/_layout.tsx | 7 + libraries/expo-iap/example/app/index.tsx | 207 +++++++------- .../expo-iap/example/app/promoted-iap.tsx | 261 ++++++++++++++++++ .../expo-iap/example/src/promotedIapEvents.ts | 168 +++++++++++ libraries/expo-iap/expo-module.config.json | 4 +- .../ios/ExpoIapAppDelegateSubscriber.swift | 17 ++ libraries/expo-iap/plugin/src/withIAP.ts | 57 +++- .../expo-iap/src/__tests__/index.test.ts | 32 +++ libraries/expo-iap/src/index.ts | 27 +- packages/apple/Sources/Helpers/IapState.swift | 3 +- .../Helpers/OpenIapConnectionLifecycle.swift | 32 +-- packages/apple/Sources/OpenIapModule.swift | 83 ++++-- packages/apple/Tests/OpenIapTests.swift | 10 + .../docs/src/pages/docs/updates/releases.tsx | 158 +++++++++++ 17 files changed, 933 insertions(+), 181 deletions(-) create mode 100644 libraries/expo-iap/example/app/promoted-iap.tsx create mode 100644 libraries/expo-iap/example/src/promotedIapEvents.ts create mode 100644 libraries/expo-iap/ios/ExpoIapAppDelegateSubscriber.swift diff --git a/libraries/expo-iap/example/__tests__/index.test.tsx b/libraries/expo-iap/example/__tests__/index.test.tsx index 9f310657..43ad1cdc 100644 --- a/libraries/expo-iap/example/__tests__/index.test.tsx +++ b/libraries/expo-iap/example/__tests__/index.test.tsx @@ -28,21 +28,30 @@ describe('Home Component', () => { }); }); - it('should render without crashing', () => { + it('should render without crashing', async () => { const {getByText} = render(); expect(getByText('expo-iap Examples')).toBeDefined(); + + await waitFor(() => { + expect(ExpoIap.getStorefront).toHaveBeenCalled(); + }); }); - it('should render the full example menu', () => { + it('should render the full example menu', async () => { const {getByText} = render(); - expect(getByText('πŸ“± All Products')).toBeDefined(); - expect(getByText('πŸ›’ In-App Purchase Flow')).toBeDefined(); - expect(getByText('πŸ”„ Subscription Flow')).toBeDefined(); - expect(getByText('πŸ“¦ Available Purchases')).toBeDefined(); - expect(getByText('🎁 Offer Code Redemption')).toBeDefined(); - expect(getByText('🌐 Alternative Billing')).toBeDefined(); - expect(getByText('πŸ“‘ Webhook Stream')).toBeDefined(); + expect(getByText('All Products')).toBeDefined(); + expect(getByText('In-App Purchase Flow')).toBeDefined(); + expect(getByText('Subscription Flow')).toBeDefined(); + expect(getByText('Available Purchases')).toBeDefined(); + expect(getByText('Offer Code Redemption')).toBeDefined(); + expect(getByText('Alternative Billing')).toBeDefined(); + expect(getByText('Webhook Stream')).toBeDefined(); + expect(getByText('Promoted IAP')).toBeDefined(); + + await waitFor(() => { + expect(ExpoIap.getStorefront).toHaveBeenCalled(); + }); }); it('should render on iOS platform', async () => { @@ -61,7 +70,7 @@ describe('Home Component', () => { }); }); - it('should render on Android platform', () => { + it('should render on Android platform', async () => { // Mock Platform.OS to be Android Object.defineProperty(Platform, 'OS', { get: jest.fn(() => 'android'), @@ -74,7 +83,9 @@ describe('Home Component', () => { expect(getByText('expo-iap Examples')).toBeDefined(); // getStorefront is called but resolves to empty string on unsupported platforms - expect(ExpoIap.getStorefront).toHaveBeenCalled(); + await waitFor(() => { + expect(ExpoIap.getStorefront).toHaveBeenCalled(); + }); consoleLog.mockRestore(); }); diff --git a/libraries/expo-iap/example/__tests__/layout.test.tsx b/libraries/expo-iap/example/__tests__/layout.test.tsx index feb564e0..ac0f8d2b 100644 --- a/libraries/expo-iap/example/__tests__/layout.test.tsx +++ b/libraries/expo-iap/example/__tests__/layout.test.tsx @@ -2,6 +2,10 @@ import React from 'react'; import {render} from '@testing-library/react-native'; import RootLayout from '../app/_layout'; +jest.mock('../src/promotedIapEvents', () => ({ + registerPromotedIapEvents: jest.fn(), +})); + jest.mock('@expo/react-native-action-sheet', () => ({ ActionSheetProvider: ({children}: {children?: React.ReactNode}) => children, })); @@ -17,7 +21,6 @@ jest.mock('expo-router', () => { Stack.Screen = function MockScreen({name}: {name: string; options?: object}) { return ReactMock.createElement('View', {testID: name}); }; - Stack.Screen.displayName = 'MockScreen'; return { Stack, }; @@ -25,13 +28,12 @@ jest.mock('expo-router', () => { describe('RootLayout', () => { it('should render without crashing', () => { - // Just call the function to ensure it executes without errors - const component = RootLayout(); - expect(component).toBeDefined(); + const {toJSON} = render(); + expect(toJSON()).toBeDefined(); }); it('should return a valid React element', () => { - const component = RootLayout(); + const component = ; expect(React.isValidElement(component)).toBe(true); }); @@ -47,6 +49,7 @@ describe('RootLayout', () => { 'offer-code', 'alternative-billing', 'webhook-stream', + 'promoted-iap', ].forEach((route) => { expect(getByTestId(route)).toBeDefined(); }); diff --git a/libraries/expo-iap/example/app.config.ts b/libraries/expo-iap/example/app.config.ts index 428f3ba9..5e731708 100644 --- a/libraries/expo-iap/example/app.config.ts +++ b/libraries/expo-iap/example/app.config.ts @@ -126,7 +126,7 @@ export default ({config}: ConfigContext): ExpoConfig => { userInterfaceStyle: 'automatic', ios: { ...config.ios, - supportsTablet: true, + supportsTablet: false, bundleIdentifier: 'dev.hyo.martie', }, android: { diff --git a/libraries/expo-iap/example/app/_layout.tsx b/libraries/expo-iap/example/app/_layout.tsx index f1082257..75d6c62d 100644 --- a/libraries/expo-iap/example/app/_layout.tsx +++ b/libraries/expo-iap/example/app/_layout.tsx @@ -1,7 +1,13 @@ +import {useEffect} from 'react'; import {Stack} from 'expo-router'; import {ActionSheetProvider} from '@expo/react-native-action-sheet'; +import {registerPromotedIapEvents} from '../src/promotedIapEvents'; export default function RootLayout() { + useEffect(() => { + registerPromotedIapEvents(); + }, []); + return ( @@ -31,6 +37,7 @@ export default function RootLayout() { name="webhook-stream" options={{title: 'Webhook Stream'}} /> + ); diff --git a/libraries/expo-iap/example/app/index.tsx b/libraries/expo-iap/example/app/index.tsx index 140dec99..fc062fb2 100644 --- a/libraries/expo-iap/example/app/index.tsx +++ b/libraries/expo-iap/example/app/index.tsx @@ -1,5 +1,11 @@ import React, {useEffect, useState} from 'react'; -import {View, Text, StyleSheet, TouchableOpacity, FlatList} from 'react-native'; +import { + ScrollView, + StyleSheet, + Text, + TouchableOpacity, + View, +} from 'react-native'; import {Link} from 'expo-router'; import {getStorefront} from 'expo-iap'; @@ -9,7 +15,7 @@ type MenuItem = { icon: string; title: string; subtitle: string; - buttonStyle: string; + accentColor: string; }; const MENU_ITEMS: MenuItem[] = [ @@ -19,7 +25,7 @@ const MENU_ITEMS: MenuItem[] = [ icon: 'πŸ“±', title: 'All Products', subtitle: 'View all items at once', - buttonStyle: 'allProductsButton', + accentColor: '#EF4444', }, { id: 'purchase-flow', @@ -27,7 +33,7 @@ const MENU_ITEMS: MenuItem[] = [ icon: 'πŸ›’', title: 'In-App Purchase Flow', subtitle: 'One-time products', - buttonStyle: 'primaryButton', + accentColor: '#2563EB', }, { id: 'subscription-flow', @@ -35,7 +41,7 @@ const MENU_ITEMS: MenuItem[] = [ icon: 'πŸ”„', title: 'Subscription Flow', subtitle: 'Recurring subscriptions', - buttonStyle: 'secondaryButton', + accentColor: '#16A34A', }, { id: 'available-purchases', @@ -43,7 +49,7 @@ const MENU_ITEMS: MenuItem[] = [ icon: 'πŸ“¦', title: 'Available Purchases', subtitle: 'View past purchases', - buttonStyle: 'quaternaryButton', + accentColor: '#7C3AED', }, { id: 'offer-code', @@ -51,7 +57,7 @@ const MENU_ITEMS: MenuItem[] = [ icon: '🎁', title: 'Offer Code Redemption', subtitle: 'Redeem promo codes', - buttonStyle: 'tertiaryButton', + accentColor: '#4B5563', }, { id: 'alternative-billing', @@ -59,7 +65,7 @@ const MENU_ITEMS: MenuItem[] = [ icon: '🌐', title: 'Alternative Billing', subtitle: 'External payment links', - buttonStyle: 'alternativeBillingButton', + accentColor: '#EA580C', }, { id: 'webhook-stream', @@ -67,7 +73,15 @@ const MENU_ITEMS: MenuItem[] = [ icon: 'πŸ“‘', title: 'Webhook Stream', subtitle: 'IAPKit SSE + test notification', - buttonStyle: 'webhookStreamButton', + accentColor: '#0284C7', + }, + { + id: 'promoted-iap', + href: '/promoted-iap', + icon: 'πŸ”—', + title: 'Promoted IAP', + subtitle: 'App Store promoted purchases', + accentColor: '#111827', }, ]; @@ -95,137 +109,132 @@ export default function Home() { expo-iap Examples - Best Practice Implementations{' '} - {storefront ? `(Store: ${storefront})` : ''} - - - - These examples demonstrate TypeScript-first approaches to in-app - purchases with: - {'\n'}β€’ Automatic type inference (no manual casting) - {'\n'}β€’ Platform-agnostic property access - {'\n'}β€’ Clean error handling with proper types - {'\n'}β€’ Focused implementations for each use case - {'\n'}β€’ CPK React Native compliant code style + Example flows for purchases, subscriptions, restore, offers, and + platform-specific APIs{storefront ? ` Β· Store ${storefront}` : ''} ); - const renderItem = ({item}: {item: MenuItem}) => { - const buttonStyleMap: Record = { - allProductsButton: styles.allProductsButton, - primaryButton: styles.primaryButton, - secondaryButton: styles.secondaryButton, - tertiaryButton: styles.tertiaryButton, - quaternaryButton: styles.quaternaryButton, - alternativeBillingButton: styles.alternativeBillingButton, - webhookStreamButton: styles.webhookStreamButton, - }; - + const renderItem = (item: MenuItem) => { return ( - - - {item.icon} {item.title} - - {item.subtitle} + + + {item.icon} + + + {item.title} + {item.subtitle} + + β€Ί ); }; return ( - item.id} - ListHeaderComponent={renderHeader} - ItemSeparatorComponent={() => } - /> + + + {renderHeader()} + + {MENU_ITEMS.map((item) => ( + {renderItem(item)} + ))} + + + ); } const styles = StyleSheet.create({ container: { flex: 1, - backgroundColor: '#ffffff', + backgroundColor: '#F8FAFC', }, - contentContainer: { - padding: 20, + content: { + alignItems: 'center', + paddingHorizontal: 20, + paddingTop: 24, paddingBottom: 40, }, + contentInner: { + maxWidth: 430, + width: '100%', + }, headerContainer: { - alignItems: 'center', - marginBottom: 24, + marginBottom: 20, }, title: { - fontSize: 28, - fontWeight: 'bold', - color: '#000000', + fontSize: 30, + fontWeight: '700', + color: '#0F172A', marginBottom: 8, }, subtitle: { - fontSize: 18, - color: '#333333', - marginBottom: 24, - }, - description: { fontSize: 16, - color: '#333333', - textAlign: 'center', - lineHeight: 24, - marginBottom: 16, - }, - separator: { - height: 12, - }, - button: { - paddingHorizontal: 24, - paddingVertical: 16, - borderRadius: 12, + lineHeight: 20, + color: '#475569', + }, + menuGrid: { + gap: 12, + }, + menuItem: { + alignItems: 'center', + backgroundColor: '#FFFFFF', + borderColor: '#E2E8F0', + borderRadius: 8, + borderWidth: 1, + flexDirection: 'row', + minHeight: 84, + paddingHorizontal: 16, + paddingVertical: 14, shadowColor: '#000', shadowOffset: { width: 0, height: 2, }, - shadowOpacity: 0.1, - shadowRadius: 4, + shadowOpacity: 0.04, + shadowRadius: 6, elevation: 3, - alignItems: 'center', - }, - allProductsButton: { - backgroundColor: '#FF6B6B', - }, - primaryButton: { - backgroundColor: '#007AFF', }, - secondaryButton: { - backgroundColor: '#28a745', - }, - tertiaryButton: { - backgroundColor: '#6c757d', - }, - quaternaryButton: { - backgroundColor: '#9c27b0', + iconContainer: { + alignItems: 'center', + borderRadius: 8, + height: 44, + justifyContent: 'center', + marginRight: 14, + width: 44, }, - alternativeBillingButton: { - backgroundColor: '#FF9800', + menuIcon: { + fontSize: 22, + lineHeight: 26, }, - webhookStreamButton: { - backgroundColor: '#0EA5E9', + menuLabel: { + flex: 1, + minWidth: 0, }, - buttonText: { - color: '#ffffff', + menuTitle: { + color: '#111827', fontSize: 16, - fontWeight: '600', + flexShrink: 1, + flexWrap: 'wrap', + fontWeight: '700', + lineHeight: 20, marginBottom: 4, }, - buttonSubtext: { - color: 'rgba(255, 255, 255, 0.85)', + menuSubtitle: { + color: '#64748B', fontSize: 14, + flexShrink: 1, + flexWrap: 'wrap', + lineHeight: 18, + }, + chevron: { + color: '#94A3B8', + fontSize: 24, + lineHeight: 26, + marginLeft: 8, }, }); diff --git a/libraries/expo-iap/example/app/promoted-iap.tsx b/libraries/expo-iap/example/app/promoted-iap.tsx new file mode 100644 index 00000000..b866b7c2 --- /dev/null +++ b/libraries/expo-iap/example/app/promoted-iap.tsx @@ -0,0 +1,261 @@ +import React, {useMemo, useState} from 'react'; +import * as Clipboard from 'expo-clipboard'; +import { + Linking, + Platform, + ScrollView, + StyleSheet, + Text, + TouchableOpacity, + View, +} from 'react-native'; +import { + buildPromotedIapUrl, + PROMOTED_IAP_BUNDLE_ID, + PROMOTED_IAP_DEFAULT_PRODUCT_ID, + PROMOTED_IAP_PRODUCT_IDS, + refreshPromotedIapProduct, + resetPromotedIapEvents, + usePromotedIapEvents, +} from '../src/promotedIapEvents'; + +export default function PromotedIap() { + const events = usePromotedIapEvents(); + const [selectedProductId, setSelectedProductId] = useState( + PROMOTED_IAP_DEFAULT_PRODUCT_ID, + ); + + const purchaseIntentUrl = useMemo( + () => buildPromotedIapUrl(selectedProductId), + [selectedProductId], + ); + + const copyUrl = async () => { + await Clipboard.setStringAsync(purchaseIntentUrl); + }; + + const openUrl = async () => { + await Linking.openURL(purchaseIntentUrl); + }; + + return ( + + + Promoted IAP + Bundle {PROMOTED_IAP_BUNDLE_ID} + Platform {Platform.OS} + + + + Product + + {PROMOTED_IAP_PRODUCT_IDS.map((productId) => { + const selected = selectedProductId === productId; + return ( + setSelectedProductId(productId)} + > + + {productId} + + + ); + })} + + + + + Purchase Intent URL + + {purchaseIntentUrl} + + + + Copy URL + + + Open URL + + + + + + + Events + + + Refresh + + + Clear + + + + + {events.length === 0 ? ( + No events yet + ) : ( + events.map((event) => ( + + {event.source} + {event.message} + {event.at} + + )) + )} + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: '#ffffff', + }, + content: { + padding: 20, + paddingBottom: 40, + }, + header: { + marginBottom: 24, + }, + title: { + color: '#111827', + fontSize: 28, + fontWeight: '700', + marginBottom: 8, + }, + subtitle: { + color: '#374151', + fontSize: 15, + marginBottom: 4, + }, + platform: { + color: '#6B7280', + fontSize: 14, + }, + section: { + borderColor: '#E5E7EB', + borderRadius: 8, + borderWidth: 1, + marginBottom: 16, + padding: 16, + }, + sectionTitle: { + color: '#111827', + fontSize: 17, + fontWeight: '700', + marginBottom: 12, + }, + productList: { + gap: 8, + }, + productButton: { + borderColor: '#D1D5DB', + borderRadius: 8, + borderWidth: 1, + paddingHorizontal: 12, + paddingVertical: 10, + }, + productButtonSelected: { + backgroundColor: '#111827', + borderColor: '#111827', + }, + productButtonText: { + color: '#111827', + fontSize: 14, + fontWeight: '500', + }, + productButtonTextSelected: { + color: '#ffffff', + }, + urlText: { + backgroundColor: '#F3F4F6', + borderRadius: 8, + color: '#111827', + fontSize: 13, + lineHeight: 20, + marginBottom: 12, + padding: 12, + }, + actionRow: { + flexDirection: 'row', + gap: 10, + }, + actionRowCompact: { + flexDirection: 'row', + gap: 8, + }, + actionButton: { + alignItems: 'center', + backgroundColor: '#2563EB', + borderRadius: 8, + flex: 1, + paddingVertical: 12, + }, + actionButtonText: { + color: '#ffffff', + fontSize: 15, + fontWeight: '700', + }, + secondaryButton: { + borderColor: '#D1D5DB', + borderRadius: 8, + borderWidth: 1, + paddingHorizontal: 12, + paddingVertical: 8, + }, + secondaryButtonText: { + color: '#111827', + fontSize: 13, + fontWeight: '600', + }, + logHeader: { + alignItems: 'center', + flexDirection: 'row', + justifyContent: 'space-between', + }, + emptyText: { + color: '#6B7280', + fontSize: 14, + }, + eventRow: { + borderColor: '#E5E7EB', + borderRadius: 8, + borderWidth: 1, + marginBottom: 8, + padding: 12, + }, + eventSource: { + color: '#2563EB', + fontSize: 13, + fontWeight: '700', + marginBottom: 4, + }, + eventMessage: { + color: '#111827', + fontSize: 14, + marginBottom: 4, + }, + eventTime: { + color: '#6B7280', + fontSize: 12, + }, +}); diff --git a/libraries/expo-iap/example/src/promotedIapEvents.ts b/libraries/expo-iap/example/src/promotedIapEvents.ts new file mode 100644 index 00000000..e6212cb1 --- /dev/null +++ b/libraries/expo-iap/example/src/promotedIapEvents.ts @@ -0,0 +1,168 @@ +import {useSyncExternalStore} from 'react'; +import { + getPromotedProductIOS, + promotedProductListenerIOS, + type Product, +} from 'expo-iap'; +import {Platform} from 'react-native'; +import { + DEFAULT_SUBSCRIPTION_PRODUCT_ID, + PRODUCT_IDS, + SUBSCRIPTION_PRODUCT_IDS, +} from './utils/constants'; + +export const PROMOTED_IAP_BUNDLE_ID = 'dev.hyo.martie'; + +export const PROMOTED_IAP_PRODUCT_IDS = [ + ...SUBSCRIPTION_PRODUCT_IDS, + ...PRODUCT_IDS, +] as readonly string[]; + +export const PROMOTED_IAP_DEFAULT_PRODUCT_ID = DEFAULT_SUBSCRIPTION_PRODUCT_ID; + +export type PromotedIapEventSource = + | 'setup' + | 'listener' + | 'getPromotedProductIOS' + | 'error'; + +export type PromotedIapEvent = { + id: number; + at: string; + source: PromotedIapEventSource; + message: string; + productId?: string; + product?: Product | null; +}; + +let didRegisterEvents = false; +let eventCounter = 0; +let events: PromotedIapEvent[] = []; +let subscription: {remove: () => void} | undefined; +const listeners = new Set<() => void>(); + +const isPromotedIapSupported = () => Platform.OS === 'ios'; + +const getProductId = (product: Product | null | undefined) => { + if (!product) { + return undefined; + } + + return product.id ?? (product as Product & {productId?: string}).productId; +}; + +const notify = () => { + listeners.forEach((listener) => listener()); +}; + +const publish = ( + event: Omit, +): PromotedIapEvent => { + const nextEvent = { + ...event, + id: ++eventCounter, + at: new Date().toISOString(), + }; + events = [nextEvent, ...events].slice(0, 25); + + const logPayload = + nextEvent.product === undefined + ? '' + : ` ${JSON.stringify(nextEvent.product)}`; + console.log( + `[PromotedIap] ${nextEvent.source}: ${nextEvent.message}${logPayload}`, + ); + + notify(); + return nextEvent; +}; + +const snapshot = () => events; + +const subscribe = (listener: () => void) => { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; +}; + +export const buildPromotedIapUrl = ( + productId: string = PROMOTED_IAP_DEFAULT_PRODUCT_ID, + bundleId: string = PROMOTED_IAP_BUNDLE_ID, +) => + `itms-services://?action=purchaseIntent&bundleId=${encodeURIComponent( + bundleId, + )}&productIdentifier=${encodeURIComponent(productId)}`; + +export const refreshPromotedIapProduct = async () => { + if (!isPromotedIapSupported()) { + publish({ + source: 'error', + message: 'promoted IAP is available on iOS only', + }); + return null; + } + + try { + const product = await getPromotedProductIOS(); + const productId = getProductId(product); + publish({ + source: 'getPromotedProductIOS', + product, + productId, + message: productId ?? 'no pending promoted product', + }); + return product; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + publish({ + source: 'error', + message: `getPromotedProductIOS failed: ${message}`, + }); + return null; + } +}; + +export const registerPromotedIapEvents = () => { + if (didRegisterEvents) { + return; + } + didRegisterEvents = true; + + publish({ + source: 'setup', + message: isPromotedIapSupported() + ? 'root listener registered' + : 'promoted IAP is available on iOS only', + }); + + if (!isPromotedIapSupported()) { + return; + } + + subscription = promotedProductListenerIOS((product) => { + const productId = getProductId(product); + publish({ + source: 'listener', + product, + productId, + message: productId ?? 'promoted product without product id', + }); + }); + + void refreshPromotedIapProduct(); +}; + +export const unregisterPromotedIapEvents = () => { + subscription?.remove(); + subscription = undefined; + didRegisterEvents = false; +}; + +export const resetPromotedIapEvents = () => { + events = []; + notify(); +}; + +export const usePromotedIapEvents = () => + useSyncExternalStore(subscribe, snapshot, snapshot); diff --git a/libraries/expo-iap/expo-module.config.json b/libraries/expo-iap/expo-module.config.json index 179dfd8a..6e3accdb 100644 --- a/libraries/expo-iap/expo-module.config.json +++ b/libraries/expo-iap/expo-module.config.json @@ -7,7 +7,9 @@ "modules": [ "ExpoIapModule" ], - "appDelegateSubscribers": [] + "appDelegateSubscribers": [ + "ExpoIapAppDelegateSubscriber" + ] }, "android": { "modules": [ diff --git a/libraries/expo-iap/ios/ExpoIapAppDelegateSubscriber.swift b/libraries/expo-iap/ios/ExpoIapAppDelegateSubscriber.swift new file mode 100644 index 00000000..16a4972d --- /dev/null +++ b/libraries/expo-iap/ios/ExpoIapAppDelegateSubscriber.swift @@ -0,0 +1,17 @@ +import ExpoModulesCore +import OpenIAP +#if canImport(UIKit) +import UIKit +#endif + +public class ExpoIapAppDelegateSubscriber: ExpoAppDelegateSubscriber { + public func application( + _ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil + ) -> Bool { + if #available(iOS 15.0, tvOS 16.0, *) { + _ = OpenIapModule.shared + } + return true + } +} diff --git a/libraries/expo-iap/plugin/src/withIAP.ts b/libraries/expo-iap/plugin/src/withIAP.ts index af7364f2..ee782b8a 100644 --- a/libraries/expo-iap/plugin/src/withIAP.ts +++ b/libraries/expo-iap/plugin/src/withIAP.ts @@ -368,21 +368,56 @@ const syncAutolinking = (state: AutolinkState) => { ) ? iosConfig.appDelegateSubscribers : []; - const onsideSubscriberName = 'OnsideAppDelegateSubscriber'; - const hasSubscriber = existingSubscribers.includes(onsideSubscriberName); - let nextSubscribers = existingSubscribers; - if (state.onside && !hasSubscriber) { - nextSubscribers = [...existingSubscribers, onsideSubscriberName]; - logOnce('πŸ”— expo-iap: Enabled OnsideAppDelegateSubscriber'); - } else if (!state.onside && hasSubscriber) { - nextSubscribers = existingSubscribers.filter( - (s: string) => s !== onsideSubscriberName, + const desiredSubscribers: { + name: string; + enable: boolean; + addLog: string; + removeLog: string; + }[] = [ + { + name: 'ExpoIapAppDelegateSubscriber', + enable: state.expoIap, + addLog: 'πŸ”— expo-iap: Enabled ExpoIapAppDelegateSubscriber', + removeLog: '🧹 expo-iap: Disabled ExpoIapAppDelegateSubscriber', + }, + { + name: 'OnsideAppDelegateSubscriber', + enable: state.onside, + addLog: 'πŸ”— expo-iap: Enabled OnsideAppDelegateSubscriber', + removeLog: '🧹 expo-iap: Disabled OnsideAppDelegateSubscriber', + }, + ]; + + const { + modules: nextSubscribers, + added: addedSubscribers, + removed: removedSubscribers, + } = computeAutolinkModules( + existingSubscribers, + desiredSubscribers.map(({name, enable}) => ({name, enable})), + ); + + for (const name of addedSubscribers) { + const entry = desiredSubscribers.find( + (candidate) => candidate.name === name, ); - logOnce('🧹 expo-iap: Disabled OnsideAppDelegateSubscriber'); + if (entry) { + logOnce(entry.addLog); + } + } + + for (const name of removedSubscribers) { + const entry = desiredSubscribers.find( + (candidate) => candidate.name === name, + ); + if (entry) { + logOnce(entry.removeLog); + } } const modulesChanged = added.length > 0 || removed.length > 0; - const subscribersChanged = nextSubscribers !== existingSubscribers; + const subscribersChanged = + addedSubscribers.length > 0 || removedSubscribers.length > 0; if (modulesChanged || subscribersChanged) { iosConfig.modules = nextModules; diff --git a/libraries/expo-iap/src/__tests__/index.test.ts b/libraries/expo-iap/src/__tests__/index.test.ts index 379d73e1..c0f5a7d5 100644 --- a/libraries/expo-iap/src/__tests__/index.test.ts +++ b/libraries/expo-iap/src/__tests__/index.test.ts @@ -102,6 +102,38 @@ describe('Public API (index.ts)', () => { ); }); + it('promotedProductListenerIOS replays pending promoted product on iOS', async () => { + (Platform as any).OS = 'ios'; + const product = {id: 'promoted-product', platform: 'ios'} as any; + (ExpoIapModule.getPromotedProductIOS as jest.Mock).mockResolvedValue( + product, + ); + const listener = jest.fn(); + + promotedProductListenerIOS(listener); + await Promise.resolve(); + + expect(ExpoIapModule.getPromotedProductIOS).toHaveBeenCalledTimes(1); + expect(listener).toHaveBeenCalledWith(product); + }); + + it('promotedProductListenerIOS dedupes replayed promoted product', async () => { + (Platform as any).OS = 'ios'; + const product = {id: 'promoted-product', platform: 'ios'} as any; + (ExpoIapModule.getPromotedProductIOS as jest.Mock).mockResolvedValue( + product, + ); + const addListener = (ExpoIapModule as any).addListener as jest.Mock; + const listener = jest.fn(); + + promotedProductListenerIOS(listener); + const nativeListener = addListener.mock.calls[0][1]; + nativeListener(product); + await Promise.resolve(); + + expect(listener).toHaveBeenCalledTimes(1); + }); + it('userChoiceBillingListenerAndroid warns on non‑Android, adds on Android', () => { (Platform as any).OS = 'ios'; const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); diff --git a/libraries/expo-iap/src/index.ts b/libraries/expo-iap/src/index.ts index 9d596fc2..ed1a1976 100644 --- a/libraries/expo-iap/src/index.ts +++ b/libraries/expo-iap/src/index.ts @@ -210,7 +210,32 @@ export const promotedProductListenerIOS = ( ); return {remove: () => {}}; } - return emitter.addListener(OpenIapEvent.PromotedProductIOS, listener); + + let deliveredProductId: string | undefined; + const deliver = (product: Product) => { + const productId = + product.id ?? (product as Product & {productId?: string}).productId; + if (productId && productId === deliveredProductId) { + return; + } + deliveredProductId = productId; + listener(product); + }; + + const subscription = emitter.addListener( + OpenIapEvent.PromotedProductIOS, + deliver, + ); + + void Promise.resolve(ExpoIapModule.getPromotedProductIOS()) + .then((product: Product | null) => { + if (product) { + deliver(product); + } + }) + .catch(() => {}); + + return subscription; }; /** diff --git a/packages/apple/Sources/Helpers/IapState.swift b/packages/apple/Sources/Helpers/IapState.swift index f88bcaab..5c1c7fc0 100644 --- a/packages/apple/Sources/Helpers/IapState.swift +++ b/packages/apple/Sources/Helpers/IapState.swift @@ -41,8 +41,9 @@ actor IapState { func addPurchaseErrorListener(_ pair: (UUID, PurchaseErrorListener)) { purchaseErrorListeners.append((id: pair.0, listener: pair.1)) } - func addPromotedProductListener(_ pair: (UUID, PromotedProductListener)) { + func addPromotedProductListener(_ pair: (UUID, PromotedProductListener)) -> String? { promotedProductListeners.append((id: pair.0, listener: pair.1)) + return promotedProductId } func addSubscriptionBillingIssueListener(_ pair: (UUID, SubscriptionBillingIssueListener)) { subscriptionBillingIssueListeners.append((id: pair.0, listener: pair.1)) diff --git a/packages/apple/Sources/Helpers/OpenIapConnectionLifecycle.swift b/packages/apple/Sources/Helpers/OpenIapConnectionLifecycle.swift index 553898c8..946795af 100644 --- a/packages/apple/Sources/Helpers/OpenIapConnectionLifecycle.swift +++ b/packages/apple/Sources/Helpers/OpenIapConnectionLifecycle.swift @@ -12,7 +12,6 @@ final class OpenIapConnectionLifecycle { let messageListenerTask: Task? let unfinishedTransactionTask: Task? let productManager: ProductManager? - let didRegisterPaymentQueueObserver: Bool } struct DeinitResources { @@ -34,10 +33,6 @@ final class OpenIapConnectionLifecycle { private var unfinishedTransactionTask: Task? private var productManager: ProductManager? - #if os(iOS) - private var didRegisterPaymentQueueObserver = false - #endif - // MARK: - Init / End Tasks func currentEndTask() -> Task? { @@ -141,23 +136,6 @@ final class OpenIapConnectionLifecycle { } } - #if os(iOS) - func markPaymentQueueObserverRegisteredIfNeeded(generation: UInt64) throws -> Bool { - try withLock { - guard connectionGeneration == generation else { - throw CancellationError() - } - - if didRegisterPaymentQueueObserver { - return false - } - - didRegisterPaymentQueueObserver = true - return true - } - } - #endif - // MARK: - Listener Tasks func startTransactionListenerTask( @@ -212,19 +190,11 @@ final class OpenIapConnectionLifecycle { func detachResourcesForCleanup() -> CleanupResources { withLock { - #if os(iOS) - let wasRegistered = didRegisterPaymentQueueObserver - didRegisterPaymentQueueObserver = false - #else - let wasRegistered = false - #endif - let resources = CleanupResources( updateListenerTask: updateListenerTask, messageListenerTask: messageListenerTask, unfinishedTransactionTask: unfinishedTransactionTask, - productManager: productManager, - didRegisterPaymentQueueObserver: wasRegistered + productManager: productManager ) updateListenerTask = nil diff --git a/packages/apple/Sources/OpenIapModule.swift b/packages/apple/Sources/OpenIapModule.swift index 8092dac9..f9537ece 100644 --- a/packages/apple/Sources/OpenIapModule.swift +++ b/packages/apple/Sources/OpenIapModule.swift @@ -26,6 +26,11 @@ public final class OpenIapModule: NSObject, OpenIapModuleProtocol { private static let initRetryDelayNanoseconds: UInt64 = 1_000_000 private static let subscriptionPreflightTimeoutNanoseconds: UInt64 = 750_000_000 + #if os(iOS) + private let promotedPurchaseObserverLock = NSLock() + private var didRegisterPromotedPurchaseObserver = false + #endif + private enum SubscriptionPreflightOutcome { case completed case timedOut @@ -33,9 +38,11 @@ public final class OpenIapModule: NSObject, OpenIapModuleProtocol { private override init() { super.init() + registerPromotedPurchaseObserverIfNeeded() } deinit { + unregisterPromotedPurchaseObserverIfNeeded() cancelConnectionTasksForDeinit() } @@ -1375,7 +1382,13 @@ public final class OpenIapModule: NSObject, OpenIapModuleProtocol { public func promotedProductListenerIOS(_ listener: @escaping PromotedProductListener) -> Subscription { let subscription = Subscription(eventType: .promotedProductIos) - Task { await state.addPromotedProductListener((subscription.id, listener)) } + Task { [state] in + let pendingSku = await state.addPromotedProductListener((subscription.id, listener)) + guard let pendingSku else { return } + await MainActor.run { + listener(pendingSku) + } + } return subscription } @@ -1407,16 +1420,7 @@ public final class OpenIapModule: NSObject, OpenIapModuleProtocol { _ = try connection.getOrCreateProductManager(generation: generation) - // iOS-only: Register SKPaymentQueue observer for promoted in-app purchases - // Reference: https://developer.apple.com/documentation/storekit/promoting-in-app-purchases - #if os(iOS) - if try connection.markPaymentQueueObserverRegisteredIfNeeded(generation: generation) { - try Task.checkCancellation() - await MainActor.run { - SKPaymentQueue.default().add(self) - } - } - #endif // os(iOS) + registerPromotedPurchaseObserverIfNeeded() try Task.checkCancellation() try connection.ensureCurrent(generation) @@ -1456,6 +1460,54 @@ public final class OpenIapModule: NSObject, OpenIapModuleProtocol { resources.unfinishedTransactionTask?.cancel() } + private func registerPromotedPurchaseObserverIfNeeded() { + #if os(iOS) + promotedPurchaseObserverLock.lock() + let shouldRegister = !didRegisterPromotedPurchaseObserver + if shouldRegister { + didRegisterPromotedPurchaseObserver = true + } + promotedPurchaseObserverLock.unlock() + + guard shouldRegister else { return } + + let addObserver = { [weak self] in + guard let self else { return } + SKPaymentQueue.default().add(self) + } + + if Thread.isMainThread { + addObserver() + } else { + DispatchQueue.main.async(execute: addObserver) + } + #endif // os(iOS) + } + + private func unregisterPromotedPurchaseObserverIfNeeded() { + #if os(iOS) + promotedPurchaseObserverLock.lock() + let shouldUnregister = didRegisterPromotedPurchaseObserver + if shouldUnregister { + didRegisterPromotedPurchaseObserver = false + } + promotedPurchaseObserverLock.unlock() + + guard shouldUnregister else { return } + + let removeObserver = { [weak self] in + guard let self else { return } + SKPaymentQueue.default().remove(self) + } + + if Thread.isMainThread { + removeObserver() + } else { + DispatchQueue.main.async(execute: removeObserver) + } + #endif // os(iOS) + } + private func ensureConnection() async throws { if let endTask = connection.currentEndTask() { await endTask.value @@ -1484,15 +1536,6 @@ public final class OpenIapModule: NSObject, OpenIapModuleProtocol { resources.messageListenerTask?.cancel() resources.unfinishedTransactionTask?.cancel() await state.reset() - // iOS-only: Remove SKPaymentQueue observer for promoted in-app purchases - // Reference: https://developer.apple.com/documentation/storekit/promoting-in-app-purchases - #if os(iOS) - if resources.didRegisterPaymentQueueObserver { - await MainActor.run { - SKPaymentQueue.default().remove(self) - } - } - #endif // os(iOS) if let manager = resources.productManager { await manager.removeAll() } } diff --git a/packages/apple/Tests/OpenIapTests.swift b/packages/apple/Tests/OpenIapTests.swift index 9548fad7..d647ad90 100644 --- a/packages/apple/Tests/OpenIapTests.swift +++ b/packages/apple/Tests/OpenIapTests.swift @@ -60,6 +60,16 @@ final class OpenIapTests: XCTestCase { XCTAssertEqual(error.message, "User cancelled the purchase flow") } + @available(iOS 15.0, macOS 14.0, tvOS 16.0, watchOS 8.0, *) + func testPromotedProductListenerReceivesPendingIdentifier() async { + let state = IapState() + await state.setPromotedProductId("dev.hyo.promoted") + + let pendingSku = await state.addPromotedProductListener((UUID(), { _ in })) + + XCTAssertEqual(pendingSku, "dev.hyo.promoted") + } + func testPurchaseIOSWithRenewalInfo() { let renewalInfo = RenewalInfoIOS( autoRenewPreference: "dev.hyo.premium_year", diff --git a/packages/docs/src/pages/docs/updates/releases.tsx b/packages/docs/src/pages/docs/updates/releases.tsx index ac75908e..1ff3ac13 100644 --- a/packages/docs/src/pages/docs/updates/releases.tsx +++ b/packages/docs/src/pages/docs/updates/releases.tsx @@ -26,6 +26,164 @@ function Releases() { useScrollToHash(); const allNotes: Note[] = [ + // May 8, 2026 β€” openiap-apple 2.1.8 promoted IAP cold-start fix + { + id: 'apple-2-1-8-promoted-iap-cold-start', + date: new Date('2026-05-08'), + element: ( +
+ + May 8, 2026 β€” openiap-apple 2.1.8 promoted IAP cold-start fix + + +

+ Publishes openiap-apple 2.1.8 and framework-library + patch releases for an iOS launch race where App Store promoted + purchase intents could arrive before JavaScript called{' '} + initConnection(). The + Apple runtime now registers its StoreKit payment-queue observer at + native module launch, keeps promoted-purchase observation + independent from connection teardown, and replays pending products + to{' '} + + promotedProductListenerIOS + {' '} + and{' '} + + getPromotedProductIOS() + + . See{' '} + + issue #143 + + . +

+ +
    +
  • + Cold-start delivery β€” promoted App Store purchase + intents are captured before JS initialization, including when the + app is force-quit and relaunched by the purchase intent URL. +
  • +
  • + Late-listener replay β€” JS listeners receive the + pending promoted product even when registration happens after the + native StoreKit callback. +
  • +
  • + Expo autolinking support β€” expo-iap registers an + AppDelegate subscriber so generated Expo projects instantiate the + Apple runtime early enough for promoted IAP callbacks. +
  • +
  • + No API changes β€” apps should continue using{' '} + promotedProductListenerIOS with{' '} + requestPurchase(); only the deprecated{' '} + requestPurchaseOnPromotedProductIOS helper remains + deprecated. +
  • +
+ + {/* Package Releases */} + +
+ ), + }, + // May 8, 2026 β€” openiap-apple + framework SDK iOS connection teardown patches { id: 'apple-2-1-7-framework-ios-connection-teardown-patches', From 52047ca1cd3655788a35e9680326f2099ddf0307 Mon Sep 17 00:00:00 2001 From: hyochan Date: Fri, 8 May 2026 19:05:00 +0900 Subject: [PATCH 2/4] fix(expo): satisfy example parity audit --- .../expo-iap/example/__tests__/index.test.tsx | 1 - .../example/__tests__/layout.test.tsx | 5 - libraries/expo-iap/example/app/_layout.tsx | 7 - libraries/expo-iap/example/app/index.tsx | 8 - .../expo-iap/example/app/promoted-iap.tsx | 261 ------------------ .../expo-iap/example/src/promotedIapEvents.ts | 168 ----------- 6 files changed, 450 deletions(-) delete mode 100644 libraries/expo-iap/example/app/promoted-iap.tsx delete mode 100644 libraries/expo-iap/example/src/promotedIapEvents.ts diff --git a/libraries/expo-iap/example/__tests__/index.test.tsx b/libraries/expo-iap/example/__tests__/index.test.tsx index 43ad1cdc..9caf7214 100644 --- a/libraries/expo-iap/example/__tests__/index.test.tsx +++ b/libraries/expo-iap/example/__tests__/index.test.tsx @@ -47,7 +47,6 @@ describe('Home Component', () => { expect(getByText('Offer Code Redemption')).toBeDefined(); expect(getByText('Alternative Billing')).toBeDefined(); expect(getByText('Webhook Stream')).toBeDefined(); - expect(getByText('Promoted IAP')).toBeDefined(); await waitFor(() => { expect(ExpoIap.getStorefront).toHaveBeenCalled(); diff --git a/libraries/expo-iap/example/__tests__/layout.test.tsx b/libraries/expo-iap/example/__tests__/layout.test.tsx index ac0f8d2b..f7a855db 100644 --- a/libraries/expo-iap/example/__tests__/layout.test.tsx +++ b/libraries/expo-iap/example/__tests__/layout.test.tsx @@ -2,10 +2,6 @@ import React from 'react'; import {render} from '@testing-library/react-native'; import RootLayout from '../app/_layout'; -jest.mock('../src/promotedIapEvents', () => ({ - registerPromotedIapEvents: jest.fn(), -})); - jest.mock('@expo/react-native-action-sheet', () => ({ ActionSheetProvider: ({children}: {children?: React.ReactNode}) => children, })); @@ -49,7 +45,6 @@ describe('RootLayout', () => { 'offer-code', 'alternative-billing', 'webhook-stream', - 'promoted-iap', ].forEach((route) => { expect(getByTestId(route)).toBeDefined(); }); diff --git a/libraries/expo-iap/example/app/_layout.tsx b/libraries/expo-iap/example/app/_layout.tsx index 75d6c62d..f1082257 100644 --- a/libraries/expo-iap/example/app/_layout.tsx +++ b/libraries/expo-iap/example/app/_layout.tsx @@ -1,13 +1,7 @@ -import {useEffect} from 'react'; import {Stack} from 'expo-router'; import {ActionSheetProvider} from '@expo/react-native-action-sheet'; -import {registerPromotedIapEvents} from '../src/promotedIapEvents'; export default function RootLayout() { - useEffect(() => { - registerPromotedIapEvents(); - }, []); - return ( @@ -37,7 +31,6 @@ export default function RootLayout() { name="webhook-stream" options={{title: 'Webhook Stream'}} /> - ); diff --git a/libraries/expo-iap/example/app/index.tsx b/libraries/expo-iap/example/app/index.tsx index fc062fb2..6435b9a7 100644 --- a/libraries/expo-iap/example/app/index.tsx +++ b/libraries/expo-iap/example/app/index.tsx @@ -75,14 +75,6 @@ const MENU_ITEMS: MenuItem[] = [ subtitle: 'IAPKit SSE + test notification', accentColor: '#0284C7', }, - { - id: 'promoted-iap', - href: '/promoted-iap', - icon: 'πŸ”—', - title: 'Promoted IAP', - subtitle: 'App Store promoted purchases', - accentColor: '#111827', - }, ]; /** diff --git a/libraries/expo-iap/example/app/promoted-iap.tsx b/libraries/expo-iap/example/app/promoted-iap.tsx deleted file mode 100644 index b866b7c2..00000000 --- a/libraries/expo-iap/example/app/promoted-iap.tsx +++ /dev/null @@ -1,261 +0,0 @@ -import React, {useMemo, useState} from 'react'; -import * as Clipboard from 'expo-clipboard'; -import { - Linking, - Platform, - ScrollView, - StyleSheet, - Text, - TouchableOpacity, - View, -} from 'react-native'; -import { - buildPromotedIapUrl, - PROMOTED_IAP_BUNDLE_ID, - PROMOTED_IAP_DEFAULT_PRODUCT_ID, - PROMOTED_IAP_PRODUCT_IDS, - refreshPromotedIapProduct, - resetPromotedIapEvents, - usePromotedIapEvents, -} from '../src/promotedIapEvents'; - -export default function PromotedIap() { - const events = usePromotedIapEvents(); - const [selectedProductId, setSelectedProductId] = useState( - PROMOTED_IAP_DEFAULT_PRODUCT_ID, - ); - - const purchaseIntentUrl = useMemo( - () => buildPromotedIapUrl(selectedProductId), - [selectedProductId], - ); - - const copyUrl = async () => { - await Clipboard.setStringAsync(purchaseIntentUrl); - }; - - const openUrl = async () => { - await Linking.openURL(purchaseIntentUrl); - }; - - return ( - - - Promoted IAP - Bundle {PROMOTED_IAP_BUNDLE_ID} - Platform {Platform.OS} - - - - Product - - {PROMOTED_IAP_PRODUCT_IDS.map((productId) => { - const selected = selectedProductId === productId; - return ( - setSelectedProductId(productId)} - > - - {productId} - - - ); - })} - - - - - Purchase Intent URL - - {purchaseIntentUrl} - - - - Copy URL - - - Open URL - - - - - - - Events - - - Refresh - - - Clear - - - - - {events.length === 0 ? ( - No events yet - ) : ( - events.map((event) => ( - - {event.source} - {event.message} - {event.at} - - )) - )} - - - ); -} - -const styles = StyleSheet.create({ - container: { - flex: 1, - backgroundColor: '#ffffff', - }, - content: { - padding: 20, - paddingBottom: 40, - }, - header: { - marginBottom: 24, - }, - title: { - color: '#111827', - fontSize: 28, - fontWeight: '700', - marginBottom: 8, - }, - subtitle: { - color: '#374151', - fontSize: 15, - marginBottom: 4, - }, - platform: { - color: '#6B7280', - fontSize: 14, - }, - section: { - borderColor: '#E5E7EB', - borderRadius: 8, - borderWidth: 1, - marginBottom: 16, - padding: 16, - }, - sectionTitle: { - color: '#111827', - fontSize: 17, - fontWeight: '700', - marginBottom: 12, - }, - productList: { - gap: 8, - }, - productButton: { - borderColor: '#D1D5DB', - borderRadius: 8, - borderWidth: 1, - paddingHorizontal: 12, - paddingVertical: 10, - }, - productButtonSelected: { - backgroundColor: '#111827', - borderColor: '#111827', - }, - productButtonText: { - color: '#111827', - fontSize: 14, - fontWeight: '500', - }, - productButtonTextSelected: { - color: '#ffffff', - }, - urlText: { - backgroundColor: '#F3F4F6', - borderRadius: 8, - color: '#111827', - fontSize: 13, - lineHeight: 20, - marginBottom: 12, - padding: 12, - }, - actionRow: { - flexDirection: 'row', - gap: 10, - }, - actionRowCompact: { - flexDirection: 'row', - gap: 8, - }, - actionButton: { - alignItems: 'center', - backgroundColor: '#2563EB', - borderRadius: 8, - flex: 1, - paddingVertical: 12, - }, - actionButtonText: { - color: '#ffffff', - fontSize: 15, - fontWeight: '700', - }, - secondaryButton: { - borderColor: '#D1D5DB', - borderRadius: 8, - borderWidth: 1, - paddingHorizontal: 12, - paddingVertical: 8, - }, - secondaryButtonText: { - color: '#111827', - fontSize: 13, - fontWeight: '600', - }, - logHeader: { - alignItems: 'center', - flexDirection: 'row', - justifyContent: 'space-between', - }, - emptyText: { - color: '#6B7280', - fontSize: 14, - }, - eventRow: { - borderColor: '#E5E7EB', - borderRadius: 8, - borderWidth: 1, - marginBottom: 8, - padding: 12, - }, - eventSource: { - color: '#2563EB', - fontSize: 13, - fontWeight: '700', - marginBottom: 4, - }, - eventMessage: { - color: '#111827', - fontSize: 14, - marginBottom: 4, - }, - eventTime: { - color: '#6B7280', - fontSize: 12, - }, -}); diff --git a/libraries/expo-iap/example/src/promotedIapEvents.ts b/libraries/expo-iap/example/src/promotedIapEvents.ts deleted file mode 100644 index e6212cb1..00000000 --- a/libraries/expo-iap/example/src/promotedIapEvents.ts +++ /dev/null @@ -1,168 +0,0 @@ -import {useSyncExternalStore} from 'react'; -import { - getPromotedProductIOS, - promotedProductListenerIOS, - type Product, -} from 'expo-iap'; -import {Platform} from 'react-native'; -import { - DEFAULT_SUBSCRIPTION_PRODUCT_ID, - PRODUCT_IDS, - SUBSCRIPTION_PRODUCT_IDS, -} from './utils/constants'; - -export const PROMOTED_IAP_BUNDLE_ID = 'dev.hyo.martie'; - -export const PROMOTED_IAP_PRODUCT_IDS = [ - ...SUBSCRIPTION_PRODUCT_IDS, - ...PRODUCT_IDS, -] as readonly string[]; - -export const PROMOTED_IAP_DEFAULT_PRODUCT_ID = DEFAULT_SUBSCRIPTION_PRODUCT_ID; - -export type PromotedIapEventSource = - | 'setup' - | 'listener' - | 'getPromotedProductIOS' - | 'error'; - -export type PromotedIapEvent = { - id: number; - at: string; - source: PromotedIapEventSource; - message: string; - productId?: string; - product?: Product | null; -}; - -let didRegisterEvents = false; -let eventCounter = 0; -let events: PromotedIapEvent[] = []; -let subscription: {remove: () => void} | undefined; -const listeners = new Set<() => void>(); - -const isPromotedIapSupported = () => Platform.OS === 'ios'; - -const getProductId = (product: Product | null | undefined) => { - if (!product) { - return undefined; - } - - return product.id ?? (product as Product & {productId?: string}).productId; -}; - -const notify = () => { - listeners.forEach((listener) => listener()); -}; - -const publish = ( - event: Omit, -): PromotedIapEvent => { - const nextEvent = { - ...event, - id: ++eventCounter, - at: new Date().toISOString(), - }; - events = [nextEvent, ...events].slice(0, 25); - - const logPayload = - nextEvent.product === undefined - ? '' - : ` ${JSON.stringify(nextEvent.product)}`; - console.log( - `[PromotedIap] ${nextEvent.source}: ${nextEvent.message}${logPayload}`, - ); - - notify(); - return nextEvent; -}; - -const snapshot = () => events; - -const subscribe = (listener: () => void) => { - listeners.add(listener); - return () => { - listeners.delete(listener); - }; -}; - -export const buildPromotedIapUrl = ( - productId: string = PROMOTED_IAP_DEFAULT_PRODUCT_ID, - bundleId: string = PROMOTED_IAP_BUNDLE_ID, -) => - `itms-services://?action=purchaseIntent&bundleId=${encodeURIComponent( - bundleId, - )}&productIdentifier=${encodeURIComponent(productId)}`; - -export const refreshPromotedIapProduct = async () => { - if (!isPromotedIapSupported()) { - publish({ - source: 'error', - message: 'promoted IAP is available on iOS only', - }); - return null; - } - - try { - const product = await getPromotedProductIOS(); - const productId = getProductId(product); - publish({ - source: 'getPromotedProductIOS', - product, - productId, - message: productId ?? 'no pending promoted product', - }); - return product; - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - publish({ - source: 'error', - message: `getPromotedProductIOS failed: ${message}`, - }); - return null; - } -}; - -export const registerPromotedIapEvents = () => { - if (didRegisterEvents) { - return; - } - didRegisterEvents = true; - - publish({ - source: 'setup', - message: isPromotedIapSupported() - ? 'root listener registered' - : 'promoted IAP is available on iOS only', - }); - - if (!isPromotedIapSupported()) { - return; - } - - subscription = promotedProductListenerIOS((product) => { - const productId = getProductId(product); - publish({ - source: 'listener', - product, - productId, - message: productId ?? 'promoted product without product id', - }); - }); - - void refreshPromotedIapProduct(); -}; - -export const unregisterPromotedIapEvents = () => { - subscription?.remove(); - subscription = undefined; - didRegisterEvents = false; -}; - -export const resetPromotedIapEvents = () => { - events = []; - notify(); -}; - -export const usePromotedIapEvents = () => - useSyncExternalStore(subscribe, snapshot, snapshot); From f6e4d07fde37aff0fc15c4ff8bdec66707785cfb Mon Sep 17 00:00:00 2001 From: hyochan Date: Fri, 8 May 2026 19:10:32 +0900 Subject: [PATCH 3/4] fix(expo): handle promoted sku event payloads --- libraries/expo-iap/example/app/index.tsx | 8 ++--- .../expo-iap/src/__tests__/index.test.ts | 21 ++++++++++++- libraries/expo-iap/src/index.ts | 31 +++++++++++++------ 3 files changed, 45 insertions(+), 15 deletions(-) diff --git a/libraries/expo-iap/example/app/index.tsx b/libraries/expo-iap/example/app/index.tsx index 6435b9a7..bec75aeb 100644 --- a/libraries/expo-iap/example/app/index.tsx +++ b/libraries/expo-iap/example/app/index.tsx @@ -1,4 +1,4 @@ -import React, {useEffect, useState} from 'react'; +import {useEffect, useState} from 'react'; import { ScrollView, StyleSheet, @@ -109,7 +109,7 @@ export default function Home() { const renderItem = (item: MenuItem) => { return ( - + {renderHeader()} - {MENU_ITEMS.map((item) => ( - {renderItem(item)} - ))} + {MENU_ITEMS.map(renderItem)} diff --git a/libraries/expo-iap/src/__tests__/index.test.ts b/libraries/expo-iap/src/__tests__/index.test.ts index c0f5a7d5..3b822f45 100644 --- a/libraries/expo-iap/src/__tests__/index.test.ts +++ b/libraries/expo-iap/src/__tests__/index.test.ts @@ -53,6 +53,7 @@ afterAll(() => { describe('Public API (index.ts)', () => { beforeEach(() => { jest.clearAllMocks(); + (ExpoIapModule.getPromotedProductIOS as jest.Mock).mockResolvedValue(null); }); describe('listeners', () => { @@ -128,12 +129,30 @@ describe('Public API (index.ts)', () => { promotedProductListenerIOS(listener); const nativeListener = addListener.mock.calls[0][1]; - nativeListener(product); + nativeListener('promoted-product'); await Promise.resolve(); expect(listener).toHaveBeenCalledTimes(1); }); + it('promotedProductListenerIOS resolves native SKU payloads', async () => { + (Platform as any).OS = 'ios'; + const product = {id: 'promoted-product', platform: 'ios'} as any; + (ExpoIapModule.getPromotedProductIOS as jest.Mock) + .mockResolvedValueOnce(null) + .mockResolvedValueOnce(product); + const addListener = (ExpoIapModule as any).addListener as jest.Mock; + const listener = jest.fn(); + + promotedProductListenerIOS(listener); + const nativeListener = addListener.mock.calls[0][1]; + nativeListener('promoted-product'); + await Promise.resolve(); + + expect(listener).toHaveBeenCalledWith(product); + expect(listener).not.toHaveBeenCalledWith('promoted-product'); + }); + it('userChoiceBillingListenerAndroid warns on non‑Android, adds on Android', () => { (Platform as any).OS = 'ios'; const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); diff --git a/libraries/expo-iap/src/index.ts b/libraries/expo-iap/src/index.ts index ed1a1976..f5a161ed 100644 --- a/libraries/expo-iap/src/index.ts +++ b/libraries/expo-iap/src/index.ts @@ -70,7 +70,10 @@ export enum OpenIapEvent { type ExpoIapEventPayloads = { [OpenIapEvent.PurchaseUpdated]: Purchase; [OpenIapEvent.PurchaseError]: PurchaseError; - [OpenIapEvent.PromotedProductIOS]: Product; + [OpenIapEvent.PromotedProductIOS]: + | Product + | string + | {id?: string; productId?: string}; [OpenIapEvent.UserChoiceBillingAndroid]: UserChoiceBillingDetails; [OpenIapEvent.DeveloperProvidedBillingAndroid]: DeveloperProvidedBillingDetailsAndroid; [OpenIapEvent.SubscriptionBillingIssue]: Purchase; @@ -222,18 +225,28 @@ export const promotedProductListenerIOS = ( listener(product); }; + const replayPendingProduct = () => + ExpoIapModule.getPromotedProductIOS() + .then((product: Product | null) => { + if (product) { + deliver(product); + } + }) + .catch(() => {}); + const subscription = emitter.addListener( OpenIapEvent.PromotedProductIOS, - deliver, + (payload) => { + if (typeof payload === 'string') { + void replayPendingProduct(); + return; + } + + deliver(payload as Product); + }, ); - void Promise.resolve(ExpoIapModule.getPromotedProductIOS()) - .then((product: Product | null) => { - if (product) { - deliver(product); - } - }) - .catch(() => {}); + void replayPendingProduct(); return subscription; }; From 5512a48635e7a9ee20265535c9a070e563cc1494 Mon Sep 17 00:00:00 2001 From: hyochan Date: Fri, 8 May 2026 19:22:23 +0900 Subject: [PATCH 4/4] fix: address promoted iap review --- libraries/expo-iap/src/index.ts | 18 +++- packages/apple/Sources/Helpers/IapState.swift | 19 +++- packages/apple/Sources/OpenIapModule.swift | 34 ++++--- packages/apple/Tests/OpenIapTests.swift | 19 +++- .../docs/src/pages/docs/updates/releases.tsx | 96 +++++-------------- 5 files changed, 97 insertions(+), 89 deletions(-) diff --git a/libraries/expo-iap/src/index.ts b/libraries/expo-iap/src/index.ts index f5a161ed..bf37bf84 100644 --- a/libraries/expo-iap/src/index.ts +++ b/libraries/expo-iap/src/index.ts @@ -225,14 +225,28 @@ export const promotedProductListenerIOS = ( listener(product); }; - const replayPendingProduct = () => - ExpoIapModule.getPromotedProductIOS() + const replayPendingProduct = () => { + let pendingProduct: Promise | undefined; + try { + pendingProduct = ExpoIapModule.getPromotedProductIOS() as + | Promise + | undefined; + } catch { + return Promise.resolve(); + } + + if (!pendingProduct || typeof pendingProduct.then !== 'function') { + return Promise.resolve(); + } + + return pendingProduct .then((product: Product | null) => { if (product) { deliver(product); } }) .catch(() => {}); + }; const subscription = emitter.addListener( OpenIapEvent.PromotedProductIOS, diff --git a/packages/apple/Sources/Helpers/IapState.swift b/packages/apple/Sources/Helpers/IapState.swift index 5c1c7fc0..310cac01 100644 --- a/packages/apple/Sources/Helpers/IapState.swift +++ b/packages/apple/Sources/Helpers/IapState.swift @@ -9,6 +9,7 @@ actor IapState { private(set) var isInitialized: Bool = false private var pendingTransactions: [String: Transaction] = [:] private var promotedProductId: String? + private var pendingPromotedProductReplayId: String? // Event listeners private var purchaseUpdatedListeners: [(id: UUID, listener: PurchaseUpdatedListener)] = [] @@ -22,6 +23,7 @@ actor IapState { pendingTransactions.removeAll() isInitialized = false promotedProductId = nil + pendingPromotedProductReplayId = nil } // MARK: - Pending Transactions @@ -31,8 +33,19 @@ actor IapState { func pendingSnapshot() -> [Transaction] { Array(pendingTransactions.values) } // MARK: - Promoted Products - func setPromotedProductId(_ id: String?) { promotedProductId = id } + func setPromotedProductId(_ id: String?) { + promotedProductId = id + if id == nil { + pendingPromotedProductReplayId = nil + } + } func promotedProductIdentifier() -> String? { promotedProductId } + func recordPromotedProductAndSnapshotListeners(_ id: String) -> [PromotedProductListener] { + promotedProductId = id + let listeners = promotedProductListeners.map { $0.listener } + pendingPromotedProductReplayId = listeners.isEmpty ? id : nil + return listeners + } // MARK: - Listeners func addPurchaseUpdatedListener(_ pair: (UUID, PurchaseUpdatedListener)) { @@ -43,7 +56,9 @@ actor IapState { } func addPromotedProductListener(_ pair: (UUID, PromotedProductListener)) -> String? { promotedProductListeners.append((id: pair.0, listener: pair.1)) - return promotedProductId + let pendingProductId = pendingPromotedProductReplayId + pendingPromotedProductReplayId = nil + return pendingProductId } func addSubscriptionBillingIssueListener(_ pair: (UUID, SubscriptionBillingIssueListener)) { subscriptionBillingIssueListeners.append((id: pair.0, listener: pair.1)) diff --git a/packages/apple/Sources/OpenIapModule.swift b/packages/apple/Sources/OpenIapModule.swift index f9537ece..db22f686 100644 --- a/packages/apple/Sources/OpenIapModule.swift +++ b/packages/apple/Sources/OpenIapModule.swift @@ -29,6 +29,7 @@ public final class OpenIapModule: NSObject, OpenIapModuleProtocol { #if os(iOS) private let promotedPurchaseObserverLock = NSLock() private var didRegisterPromotedPurchaseObserver = false + private var isPromotedPurchaseObserverTransitionInFlight = false #endif private enum SubscriptionPreflightOutcome { @@ -1463,48 +1464,58 @@ public final class OpenIapModule: NSObject, OpenIapModuleProtocol { private func registerPromotedPurchaseObserverIfNeeded() { #if os(iOS) promotedPurchaseObserverLock.lock() - let shouldRegister = !didRegisterPromotedPurchaseObserver + let shouldRegister = !didRegisterPromotedPurchaseObserver && + !isPromotedPurchaseObserverTransitionInFlight if shouldRegister { - didRegisterPromotedPurchaseObserver = true + isPromotedPurchaseObserverTransitionInFlight = true } promotedPurchaseObserverLock.unlock() guard shouldRegister else { return } - let addObserver = { [weak self] in - guard let self else { return } + let addObserver = { SKPaymentQueue.default().add(self) } if Thread.isMainThread { addObserver() } else { - DispatchQueue.main.async(execute: addObserver) + DispatchQueue.main.sync(execute: addObserver) } + + promotedPurchaseObserverLock.lock() + didRegisterPromotedPurchaseObserver = true + isPromotedPurchaseObserverTransitionInFlight = false + promotedPurchaseObserverLock.unlock() #endif // os(iOS) } private func unregisterPromotedPurchaseObserverIfNeeded() { #if os(iOS) promotedPurchaseObserverLock.lock() - let shouldUnregister = didRegisterPromotedPurchaseObserver + let shouldUnregister = didRegisterPromotedPurchaseObserver && + !isPromotedPurchaseObserverTransitionInFlight if shouldUnregister { - didRegisterPromotedPurchaseObserver = false + isPromotedPurchaseObserverTransitionInFlight = true } promotedPurchaseObserverLock.unlock() guard shouldUnregister else { return } - let removeObserver = { [weak self] in - guard let self else { return } + let removeObserver = { SKPaymentQueue.default().remove(self) } if Thread.isMainThread { removeObserver() } else { - DispatchQueue.main.async(execute: removeObserver) + DispatchQueue.main.sync(execute: removeObserver) } + + promotedPurchaseObserverLock.lock() + didRegisterPromotedPurchaseObserver = false + isPromotedPurchaseObserverTransitionInFlight = false + promotedPurchaseObserverLock.unlock() #endif // os(iOS) } @@ -1780,7 +1791,7 @@ public final class OpenIapModule: NSObject, OpenIapModuleProtocol { private func emitPromotedProduct(_ sku: String) { Task { [state] in - let listeners = await state.snapshotPromoted() + let listeners = await state.recordPromotedProductAndSnapshotListeners(sku) await MainActor.run { listeners.forEach { $0(sku) } } @@ -2045,7 +2056,6 @@ extension OpenIapModule: SKPaymentTransactionObserver { public func paymentQueue(_ queue: SKPaymentQueue, shouldAddStorePayment payment: SKPayment, for product: SKProduct) -> Bool { Task { [weak self] in guard let self else { return } - await self.state.setPromotedProductId(product.productIdentifier) self.emitPromotedProduct(product.productIdentifier) } return false diff --git a/packages/apple/Tests/OpenIapTests.swift b/packages/apple/Tests/OpenIapTests.swift index d647ad90..f4a6c768 100644 --- a/packages/apple/Tests/OpenIapTests.swift +++ b/packages/apple/Tests/OpenIapTests.swift @@ -61,13 +61,28 @@ final class OpenIapTests: XCTestCase { } @available(iOS 15.0, macOS 14.0, tvOS 16.0, watchOS 8.0, *) - func testPromotedProductListenerReceivesPendingIdentifier() async { + func testPromotedProductListenerConsumesPendingIdentifier() async { let state = IapState() - await state.setPromotedProductId("dev.hyo.promoted") + let initialListeners = await state.recordPromotedProductAndSnapshotListeners("dev.hyo.promoted") let pendingSku = await state.addPromotedProductListener((UUID(), { _ in })) + let consumedSku = await state.addPromotedProductListener((UUID(), { _ in })) + XCTAssertTrue(initialListeners.isEmpty) XCTAssertEqual(pendingSku, "dev.hyo.promoted") + XCTAssertNil(consumedSku) + } + + @available(iOS 15.0, macOS 14.0, tvOS 16.0, watchOS 8.0, *) + func testPromotedProductSnapshotPreventsLateListenerDuplicate() async { + let state = IapState() + _ = await state.addPromotedProductListener((UUID(), { _ in })) + + let listeners = await state.recordPromotedProductAndSnapshotListeners("dev.hyo.promoted") + let pendingSku = await state.addPromotedProductListener((UUID(), { _ in })) + + XCTAssertEqual(listeners.count, 1) + XCTAssertNil(pendingSku) } func testPurchaseIOSWithRenewalInfo() { diff --git a/packages/docs/src/pages/docs/updates/releases.tsx b/packages/docs/src/pages/docs/updates/releases.tsx index 1ff3ac13..8cc89d2b 100644 --- a/packages/docs/src/pages/docs/updates/releases.tsx +++ b/packages/docs/src/pages/docs/updates/releases.tsx @@ -26,14 +26,15 @@ function Releases() { useScrollToHash(); const allNotes: Note[] = [ - // May 8, 2026 β€” openiap-apple 2.1.8 promoted IAP cold-start fix + // May 8, 2026 β€” planned openiap-apple 2.1.8 promoted IAP cold-start fix { id: 'apple-2-1-8-promoted-iap-cold-start', date: new Date('2026-05-08'), element: (
- May 8, 2026 β€” openiap-apple 2.1.8 promoted IAP cold-start fix + May 8, 2026 β€” planned openiap-apple 2.1.8 promoted IAP cold-start + fix

- Publishes openiap-apple 2.1.8 and framework-library + Plans openiap-apple 2.1.8 and framework-library patch releases for an iOS launch race where App Store promoted - purchase intents could arrive before JavaScript called{' '} + purchase intents can arrive before JavaScript calls{' '} initConnection(). The - Apple runtime now registers its StoreKit payment-queue observer at + Apple runtime will register its StoreKit payment-queue observer at native module launch, keeps promoted-purchase observation independent from connection teardown, and replays pending products to{' '} @@ -57,7 +58,7 @@ function Releases() { getPromotedProductIOS() - . See{' '} + . Track the fix in{' '} issue #143 + {' '} + and{' '} + + PR #144 .

@@ -100,14 +110,14 @@ function Releases() { - {/* Package Releases */} + {/* Planned Package Releases */}
-
Package Releases
+
Planned Package Releases