diff --git a/libraries/expo-iap/ios/ExpoIapHelper.swift b/libraries/expo-iap/ios/ExpoIapHelper.swift index 8eb2d3667..2b107fb8a 100644 --- a/libraries/expo-iap/ios/ExpoIapHelper.swift +++ b/libraries/expo-iap/ios/ExpoIapHelper.swift @@ -40,6 +40,12 @@ enum ExpoIapHelper { private static var purchaseUpdatedSub: OpenIAP.Subscription? private static var purchaseUpdatedHandler: ((Purchase) -> Void)? private static var purchaseUpdatedOptions = PurchaseUpdatedListenerOptions() + private static var listenerGeneration: UInt64 = 0 + private static var activeListenerGeneration: UInt64? + private static var pendingConnectionCleanup: ( + generation: UInt64, + task: Task + )? static func sanitizeDictionary(_ dictionary: [String: Any?]) -> [String: Any] { var result: [String: Any] = [:] @@ -170,17 +176,18 @@ enum ExpoIapHelper { } static func setupListeners( - module: ExpoIapModule, purchaseUpdated: @escaping (Purchase) -> Void, purchaseError: @escaping (PurchaseError) -> Void, promotedProduct: @escaping (String) async -> Void, subscriptionBillingIssue: @escaping (Purchase) -> Void - ) { + ) -> UInt64 { listenerLock.lock() defer { listenerLock.unlock() } - // Clean up any existing listeners first cleanupListenersLocked() + listenerGeneration &+= 1 + let generation = listenerGeneration + activeListenerGeneration = generation purchaseUpdatedHandler = purchaseUpdated attachPurchaseUpdatedListenerLocked() @@ -208,6 +215,7 @@ enum ExpoIapHelper { promotedProductSub, billingIssueSub, ] + return generation } static func setPurchaseUpdatedListenerOptions(_ options: PurchaseUpdatedListenerOptions?) { @@ -233,11 +241,37 @@ enum ExpoIapHelper { }, options: purchaseUpdatedOptions) } - static func cleanupListeners() { + private static func beginStoreCleanup( + ifOwnedBy listenerGeneration: UInt64 + ) -> Task? { listenerLock.lock() defer { listenerLock.unlock() } + guard activeListenerGeneration == listenerGeneration else { return nil } cleanupListenersLocked() + activeListenerGeneration = nil + let previousTask = pendingConnectionCleanup?.task + let task = Task { + await previousTask?.value + _ = try? await OpenIapModule.shared.endConnection() + } + pendingConnectionCleanup = (listenerGeneration, task) + return task + } + + private static func pendingConnectionCleanupTask() -> Task? { + listenerLock.lock() + defer { listenerLock.unlock() } + + return pendingConnectionCleanup?.task + } + + private static func finishStoreCleanup(listenerGeneration: UInt64) { + listenerLock.lock() + defer { listenerLock.unlock() } + + guard pendingConnectionCleanup?.generation == listenerGeneration else { return } + pendingConnectionCleanup = nil } private static func cleanupListenersLocked() { @@ -253,9 +287,8 @@ enum ExpoIapHelper { purchaseUpdatedOptions = PurchaseUpdatedListenerOptions() } - static func setupStore(module: ExpoIapModule) { + static func setupStore(module: ExpoIapModule) -> UInt64 { setupListeners( - module: module, purchaseUpdated: { [weak module] purchase in guard let module else { return } let payload = sanitizeDictionary(OpenIapSerialization.purchase(purchase)) @@ -291,8 +324,13 @@ enum ExpoIapHelper { ) } - static func cleanupStore() async { - cleanupListeners() - _ = try? await OpenIapModule.shared.endConnection() + static func cleanupStore(listenerGeneration: UInt64) async { + guard let task = beginStoreCleanup(ifOwnedBy: listenerGeneration) else { return } + await task.value + finishStoreCleanup(listenerGeneration: listenerGeneration) + } + + static func waitForStoreCleanup() async { + await pendingConnectionCleanupTask()?.value } } diff --git a/libraries/expo-iap/ios/ExpoIapModule.swift b/libraries/expo-iap/ios/ExpoIapModule.swift index 98e1e1bb7..a8657ea44 100644 --- a/libraries/expo-iap/ios/ExpoIapModule.swift +++ b/libraries/expo-iap/ios/ExpoIapModule.swift @@ -10,6 +10,7 @@ import UIKit @MainActor public final class ExpoIapModule: Module { private var isInitialized = false + private var listenerGeneration: UInt64? nonisolated public func definition() -> ModuleDefinition { Name("ExpoIap") @@ -25,21 +26,26 @@ public final class ExpoIapModule: Module { IapEvent.subscriptionBillingIssue.rawValue ) - OnCreate { - Task { @MainActor in - ExpoIapHelper.setupStore(module: self) + OnCreate { [weak self] in + Task { @MainActor [weak self] in + guard let self else { return } + self.listenerGeneration = ExpoIapHelper.setupStore(module: self) } } - OnDestroy { - Task { @MainActor in - await ExpoIapHelper.cleanupStore() + OnDestroy { [weak self] in + guard let self else { return } + Task { @MainActor [self] in + guard let listenerGeneration = self.listenerGeneration else { return } + self.listenerGeneration = nil + await ExpoIapHelper.cleanupStore(listenerGeneration: listenerGeneration) } } AsyncFunction("initConnection") { (config: [String: Any]?) async throws -> Bool in // Note: iOS doesn't support alternative billing config parameter // Config is ignored on iOS platform + await ExpoIapHelper.waitForStoreCleanup() let isConnected = try await OpenIapModule.shared.initConnection() await MainActor.run { self.isInitialized = isConnected } return isConnected diff --git a/libraries/expo-iap/ios/onside/OnsideIapModule.swift b/libraries/expo-iap/ios/onside/OnsideIapModule.swift index d6942fb3e..370edbb30 100644 --- a/libraries/expo-iap/ios/onside/OnsideIapModule.swift +++ b/libraries/expo-iap/ios/onside/OnsideIapModule.swift @@ -73,14 +73,15 @@ public final class ExpoIapOnsideModule: Module { OnsideEvent.subscriptionBillingIssue.rawValue ) - OnCreate { - Task { @MainActor in - self.configureObserverCallbacks() + OnCreate { [weak self] in + Task { @MainActor [weak self] in + self?.configureObserverCallbacks() } } - OnDestroy { - Task { @MainActor in + OnDestroy { [weak self] in + guard let self else { return } + Task { @MainActor [self] in self.cleanup() } } diff --git a/libraries/expo-iap/src/__tests__/ios-module-lifecycle.test.js b/libraries/expo-iap/src/__tests__/ios-module-lifecycle.test.js new file mode 100644 index 000000000..fc1d66523 --- /dev/null +++ b/libraries/expo-iap/src/__tests__/ios-module-lifecycle.test.js @@ -0,0 +1,42 @@ +const {readFileSync} = require('fs'); +const {resolve} = require('path'); + +const rootDir = resolve(__dirname, '../..'); + +function readExpoFile(path) { + return readFileSync(resolve(rootDir, path), 'utf8'); +} + +describe('iOS module lifecycle', () => { + it('scopes StoreKit teardown to the active module generation', () => { + const module = readExpoFile('ios/ExpoIapModule.swift'); + const helper = readExpoFile('ios/ExpoIapHelper.swift'); + + expect(module).toMatch(/OnCreate \{ \[weak self\] in/); + expect(module).toMatch(/Task \{ @MainActor \[weak self\] in/); + expect(module).toMatch(/OnDestroy \{ \[weak self\] in/); + expect(module).toMatch(/Task \{ @MainActor \[self\] in/); + expect(module).toContain( + 'ExpoIapHelper.cleanupStore(listenerGeneration: listenerGeneration)', + ); + expect(module).toContain('await ExpoIapHelper.waitForStoreCleanup()'); + expect(helper).toContain('private static var activeListenerGeneration'); + expect(helper).toContain( + 'guard activeListenerGeneration == listenerGeneration else { return nil }', + ); + expect(helper).toContain('private static var pendingConnectionCleanup'); + expect(helper).toMatch( + /let previousTask = pendingConnectionCleanup\?\.task\s+let task = Task \{\s+await previousTask\?\.value\s+_ = try\? await OpenIapModule\.shared\.endConnection\(\)/, + ); + expect(helper).toContain('await pendingConnectionCleanupTask()?.value'); + }); + + it('does not retain Onside modules through lifecycle definitions', () => { + const module = readExpoFile('ios/onside/OnsideIapModule.swift'); + + expect(module).toMatch(/OnCreate \{ \[weak self\] in/); + expect(module).toMatch(/Task \{ @MainActor \[weak self\] in/); + expect(module).toMatch(/OnDestroy \{ \[weak self\] in/); + expect(module).toMatch(/Task \{ @MainActor \[self\] in/); + }); +});