diff --git a/libraries/expo-iap/example/__tests__/index.test.tsx b/libraries/expo-iap/example/__tests__/index.test.tsx index 9f310657..9caf7214 100644 --- a/libraries/expo-iap/example/__tests__/index.test.tsx +++ b/libraries/expo-iap/example/__tests__/index.test.tsx @@ -28,21 +28,29 @@ 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(); + + await waitFor(() => { + expect(ExpoIap.getStorefront).toHaveBeenCalled(); + }); }); it('should render on iOS platform', async () => { @@ -61,7 +69,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 +82,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..f7a855db 100644 --- a/libraries/expo-iap/example/__tests__/layout.test.tsx +++ b/libraries/expo-iap/example/__tests__/layout.test.tsx @@ -17,7 +17,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 +24,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); }); 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/index.tsx b/libraries/expo-iap/example/app/index.tsx index 140dec99..bec75aeb 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 {useEffect, useState} from 'react'; +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,7 @@ const MENU_ITEMS: MenuItem[] = [ icon: 'πŸ“‘', title: 'Webhook Stream', subtitle: 'IAPKit SSE + test notification', - buttonStyle: 'webhookStreamButton', + accentColor: '#0284C7', }, ]; @@ -95,137 +101,130 @@ 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(renderItem)} + + + ); } 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/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..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', () => { @@ -102,6 +103,56 @@ 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('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 9d596fc2..bf37bf84 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; @@ -210,7 +213,56 @@ 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 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, + (payload) => { + if (typeof payload === 'string') { + void replayPendingProduct(); + return; + } + + deliver(payload as Product); + }, + ); + + void replayPendingProduct(); + + return subscription; }; /** diff --git a/packages/apple/Sources/Helpers/IapState.swift b/packages/apple/Sources/Helpers/IapState.swift index f88bcaab..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)) { @@ -41,8 +54,11 @@ 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)) + 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/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..db22f686 100644 --- a/packages/apple/Sources/OpenIapModule.swift +++ b/packages/apple/Sources/OpenIapModule.swift @@ -26,6 +26,12 @@ 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 + private var isPromotedPurchaseObserverTransitionInFlight = false + #endif + private enum SubscriptionPreflightOutcome { case completed case timedOut @@ -33,9 +39,11 @@ public final class OpenIapModule: NSObject, OpenIapModuleProtocol { private override init() { super.init() + registerPromotedPurchaseObserverIfNeeded() } deinit { + unregisterPromotedPurchaseObserverIfNeeded() cancelConnectionTasksForDeinit() } @@ -1375,7 +1383,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 +1421,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 +1461,64 @@ public final class OpenIapModule: NSObject, OpenIapModuleProtocol { resources.unfinishedTransactionTask?.cancel() } + private func registerPromotedPurchaseObserverIfNeeded() { + #if os(iOS) + promotedPurchaseObserverLock.lock() + let shouldRegister = !didRegisterPromotedPurchaseObserver && + !isPromotedPurchaseObserverTransitionInFlight + if shouldRegister { + isPromotedPurchaseObserverTransitionInFlight = true + } + promotedPurchaseObserverLock.unlock() + + guard shouldRegister else { return } + + let addObserver = { + SKPaymentQueue.default().add(self) + } + + if Thread.isMainThread { + addObserver() + } else { + 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 && + !isPromotedPurchaseObserverTransitionInFlight + if shouldUnregister { + isPromotedPurchaseObserverTransitionInFlight = true + } + promotedPurchaseObserverLock.unlock() + + guard shouldUnregister else { return } + + let removeObserver = { + SKPaymentQueue.default().remove(self) + } + + if Thread.isMainThread { + removeObserver() + } else { + DispatchQueue.main.sync(execute: removeObserver) + } + + promotedPurchaseObserverLock.lock() + didRegisterPromotedPurchaseObserver = false + isPromotedPurchaseObserverTransitionInFlight = false + promotedPurchaseObserverLock.unlock() + #endif // os(iOS) + } + private func ensureConnection() async throws { if let endTask = connection.currentEndTask() { await endTask.value @@ -1484,15 +1547,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() } } @@ -1737,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) } } @@ -2002,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 9548fad7..f4a6c768 100644 --- a/packages/apple/Tests/OpenIapTests.swift +++ b/packages/apple/Tests/OpenIapTests.swift @@ -60,6 +60,31 @@ 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 testPromotedProductListenerConsumesPendingIdentifier() async { + let state = IapState() + 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() { 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..8cc89d2b 100644 --- a/packages/docs/src/pages/docs/updates/releases.tsx +++ b/packages/docs/src/pages/docs/updates/releases.tsx @@ -26,6 +26,118 @@ function Releases() { useScrollToHash(); const allNotes: Note[] = [ + // 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 β€” planned openiap-apple 2.1.8 promoted IAP cold-start + fix + + +

+ Plans openiap-apple 2.1.8 and framework-library + patch releases for an iOS launch race where App Store promoted + purchase intents can arrive before JavaScript calls{' '} + initConnection(). The + 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{' '} + + promotedProductListenerIOS + {' '} + and{' '} + + getPromotedProductIOS() + + . Track the fix in{' '} + + issue #143 + {' '} + and{' '} + + PR #144 + + . +

+ +
    +
  • + 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. +
  • +
+ + {/* Planned Package Releases */} +
+
Planned Package Releases
+
    +
  • openiap-apple 2.1.8
  • +
  • react-native-iap 15.2.3
  • +
  • expo-iap 4.2.7
  • +
  • flutter_inapp_purchase 9.2.7
  • +
  • godot-iap 2.2.7
  • +
  • kmp-iap 2.2.7
  • +
  • maui-iap 1.0.3
  • +
+
+
+ ), + }, + // May 8, 2026 β€” openiap-apple + framework SDK iOS connection teardown patches { id: 'apple-2-1-7-framework-ios-connection-teardown-patches',