From 6d954b734a4b6929c7609597f6119f669a88d458 Mon Sep 17 00:00:00 2001 From: Jake Mor Date: Tue, 8 Sep 2026 09:41:05 -0400 Subject: [PATCH] Publish config before the StoreKit read for cached subscribers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A subscriber with a cached config takes the async config path, so the config itself is available at once. But configState was only published after processConfig finished, and processConfig awaits loadPurchasedProducts, which reads StoreKit. On a weak network that read took 18 to 23 seconds in production. Every register call waits on configState, so gated features froze for that long at cold launch, and a feature closure that never ran before the app was killed was lost. fetchConfiguration now publishes configState right after the config is applied and before the purchases load starts, on the cached-config path only. The sync path keeps its order: with no known subscriber, purchases are still read before a paywall can be shown. The purchases load is kept in ConfigManager.initialPurchasesLoad, and DependencyContainer.isFreeTrialAvailable awaits it. That preserves the upgrade/crossgrade trial gate, which depends on the active subscription groups the load computes. Three tests: the reproduction (config must be available while a 2s StoreKit read is still running), the sync path staying unchanged, and trial eligibility waiting for the load. 🌸 Shipped with Kanna — https://kanna.sh Co-Authored-By: Kanna Kanna-Agent: claude/fable --- CHANGELOG.md | 1 + .../SuperwallKit/Config/ConfigManager.swift | 66 +++++- .../Dependencies/DependencyContainer.swift | 4 + .../Receipt Manager/ReceiptManager.swift | 8 +- .../Config/ConfigManagerTests.swift | 224 ++++++++++++++++++ 5 files changed, 293 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2e1880528a..f9ca3de63c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ The changelog for `SuperwallKit`. Also see the [releases](https://github.com/sup - Fixes duplicate device attribute and subscription status change events being tracked when the subscription status is repeatedly set to the same logical state. As part of this, `subscriptionStatusDidChange` now fires only when the logical status changes — the status case, the set of entitlements, or an entitlement's `isActive` flag. Updates to transaction metadata such as expiry dates or renewal state no longer trigger it; use `customerInfoDidChange` for those. - Fixes subscribers with an unexpired subscription being reported as `inactive` on cold launch when the App Store has no purchases to report. Refunded and expired App Store subscriptions still deactivate immediately. +- Fixes `register` calls stalling for 10 seconds or more at cold launch for subscribers on a weak network. The SDK now marks its configuration ready before it reads purchases from StoreKit when the config is cached and the user is a known subscriber, so gated features run at once. - Fixes a data race during SDK configuration that Thread Sanitizer flagged on every launch. - Fixes issue where paying web users could end up having a temporary inactive subscription status if the server temporarily returns no entitlement data for them. - Fixes audiences matching users they shouldn't when you use a Purchase Controller. diff --git a/Sources/SuperwallKit/Config/ConfigManager.swift b/Sources/SuperwallKit/Config/ConfigManager.swift index 49c4485861..a677adad18 100644 --- a/Sources/SuperwallKit/Config/ConfigManager.swift +++ b/Sources/SuperwallKit/Config/ConfigManager.swift @@ -35,6 +35,11 @@ class ConfigManager { var configRetryCount = 0 + /// The purchases load that `fetchConfiguration` starts before it publishes + /// `configState` on the cached-config path. Trial eligibility awaits it so a + /// paywall opened during the load still sees the active subscription groups. + private(set) var initialPurchasesLoad: Task? + private unowned let storeKitManager: StoreKitManager unowned let storage: Storage private unowned let network: Network @@ -179,15 +184,36 @@ class ConfigManager { ) } - // Step 5: Track device attributes + // Step 5: Process config and set state. + // + // On the cached-config path the user is a known subscriber and the + // config is already on disk, so nothing below needs the network. But + // `processConfig` reads StoreKit, which can take 10 to 25 seconds on a + // weak connection, and every `register` call waits on `configState`. + // Publishing before that read keeps gated features from stalling at + // cold launch. The sync path keeps its order: with no known subscriber, + // purchases are read before a paywall can be shown. + var didPublishConfig = false + if shouldFetchAsync { + didPublishConfig = await processConfig( + config, + isFirstTime: true, + publishBeforeLoadingPurchases: true + ) + } + + // Step 6: Track device attributes let deviceAttributes = await factory.makeSessionDeviceAttributes() await Superwall.shared.track( InternalSuperwallEvent.DeviceAttributes(deviceAttributes: deviceAttributes) ) - // Step 6: Process config and set state - await processConfig(config, isFirstTime: true) - configState.send(.retrieved(config)) + if !shouldFetchAsync { + await processConfig(config, isFirstTime: true) + } + if !didPublishConfig { + configState.send(.retrieved(config)) + } // Step 7: Schedule background tasks scheduleBackgroundTasks( @@ -387,10 +413,20 @@ class ConfigManager { ) } + /// Applies `config` and loads purchases from StoreKit. + /// + /// - Parameter publishBeforeLoadingPurchases: When `true`, sends + /// `configState` as soon as the config is applied and before the StoreKit + /// read starts. Only safe when the subscription status on disk is already + /// known, which is why `fetchConfiguration` passes it on the cached-config + /// path alone. Ignored in test mode, where products come from the API. + /// - Returns: Whether `configState` was published here. + @discardableResult private func processConfig( _ config: Config, - isFirstTime: Bool - ) async { + isFirstTime: Bool, + publishBeforeLoadingPurchases: Bool = false + ) async -> Bool { storage.save( config.featureFlags.disableVerbosePlacements, forType: DisableVerbosePlacements.self) storage.save(config, forType: LatestConfig.self) @@ -404,6 +440,7 @@ class ConfigManager { let testModeJustActivated = !wasTestMode && testModeManager.isTestMode let testModeJustDeactivated = wasTestMode && !testModeManager.isTestMode + var didPublishConfig = false if testModeManager.isTestMode { // In test mode, fetch products from API instead of StoreKit await fetchTestModeProducts(testModeManager: testModeManager) @@ -423,7 +460,20 @@ class ConfigManager { entitlements: [] ).merging(with: .blank(), granting: entitlementsInfo.granted) } - await factory.loadPurchasedProducts(config: config) + if publishBeforeLoadingPurchases { + // The task handle is stored before the publish so anything that + // presents on this config can await the load through + // `initialPurchasesLoad`. + let purchasesLoad = Task { [factory] in + await factory.loadPurchasedProducts(config: config) + } + initialPurchasesLoad = purchasesLoad + configState.send(.retrieved(config)) + didPublishConfig = true + await purchasesLoad.value + } else { + await factory.loadPurchasedProducts(config: config) + } } if !testModeManager.isTestMode { @@ -442,6 +492,8 @@ class ConfigManager { let reason = testModeManager.testModeReason { await presentTestModeModal(reason: reason, config: config) } + + return didPublishConfig } /// Reassigns variants and preloads paywalls again. diff --git a/Sources/SuperwallKit/Dependencies/DependencyContainer.swift b/Sources/SuperwallKit/Dependencies/DependencyContainer.swift index 25e57cfdf3..6edd44302d 100644 --- a/Sources/SuperwallKit/Dependencies/DependencyContainer.swift +++ b/Sources/SuperwallKit/Dependencies/DependencyContainer.swift @@ -614,6 +614,10 @@ extension DependencyContainer: ReceiptFactory { return false } } + // Config can be published before the first purchases load finishes (see + // `ConfigManager.fetchConfiguration`). The active subscription groups + // that gate upgrades come from that load, so wait for it. + await configManager.initialPurchasesLoad?.value return await receiptManager.isFreeTrialAvailable(for: product) } diff --git a/Sources/SuperwallKit/StoreKit/Products/Receipt Manager/Receipt Manager/ReceiptManager.swift b/Sources/SuperwallKit/StoreKit/Products/Receipt Manager/Receipt Manager/ReceiptManager.swift index 86419a9942..92fddf7da2 100644 --- a/Sources/SuperwallKit/StoreKit/Products/Receipt Manager/Receipt Manager/ReceiptManager.swift +++ b/Sources/SuperwallKit/StoreKit/Products/Receipt Manager/Receipt Manager/ReceiptManager.swift @@ -252,9 +252,11 @@ actor ReceiptManager { return true } - // `activeSubscriptionGroupIds` is populated in `loadPurchasedProducts`, which always - // completes before a paywall opens (config is only marked retrieved after it runs, - // and presentation waits for config), so this reflects current subscription state. + // `activeSubscriptionGroupIds` is populated in `loadPurchasedProducts`. On the + // sync config path that load completes before config is published. On the + // cached-config path config is published first, so paywall callers go through + // `DependencyContainer.isFreeTrialAvailable`, which awaits the load before + // reaching here. return !activeSubscriptionGroupIds.contains(subscriptionGroupId) } diff --git a/Tests/SuperwallKitTests/Config/ConfigManagerTests.swift b/Tests/SuperwallKitTests/Config/ConfigManagerTests.swift index 4888d048c4..c4ed9f70f9 100644 --- a/Tests/SuperwallKitTests/Config/ConfigManagerTests.swift +++ b/Tests/SuperwallKitTests/Config/ConfigManagerTests.swift @@ -6,6 +6,7 @@ // // swiftlint:disable all +import Foundation @testable import SuperwallKit import Testing @@ -598,3 +599,226 @@ struct ConfigManagerTests { try? await Task.sleep(nanoseconds: UInt64(0.2 * 1_000_000_000)) } } + +// MARK: - Cold-launch StoreKit stall + +// Regression tests for a cold-launch stall. A subscriber with a cached config +// took the async config path, so the config itself was available at once, but +// `configState` was not published until `loadPurchasedProducts` finished. +// That call reads StoreKit, which can take 10 to 25 seconds on a weak network. +// Every `register` call waits on `configState`, so gated features froze for +// that long, and features that ran inside the closure were lost if the app +// was killed first. +// +@Suite(.serialized) +struct ConfigManagerStoreKitStallTests { + /// Held for the lifetime of each test: the managers keep `unowned` + /// references to the container. + let dependencyContainer = DependencyContainer() + + private struct Harness { + let storage: StorageMock + let configManager: ConfigManager + let receipt: SlowReceiptManagerType + /// Kept alive: other container members hold `unowned` references to the + /// original receipt manager and to the products manager. + let originalReceiptManager: ReceiptManager + let productsManager: ProductsManager + /// Kept alive: `ConfigManager` holds these `unowned`. + let network: NetworkMock + let deviceHelper: DeviceHelperMock + } + + /// Builds a config manager whose receipt loading takes `loadDelay` seconds + /// on the first call only, so the background refresh that follows does not + /// keep the test alive. + private func makeHarness( + isSubscribed: Bool, + loadDelay: TimeInterval + ) -> Harness { + let storage = StorageMock() + let network = NetworkMock( + options: SuperwallOptions(), + factory: dependencyContainer + ) + let deviceHelper = DeviceHelperMock( + api: dependencyContainer.api, + storage: storage, + network: network, + entitlementsInfo: dependencyContainer.entitlementsInfo, + receiptManager: dependencyContainer.receiptManager, + factory: dependencyContainer + ) + + let receipt = SlowReceiptManagerType(loadDelay: loadDelay) + let productsFetcher = ProductsFetcherSK1Mock( + productCompletionResult: .success([]), + entitlementsInfo: dependencyContainer.entitlementsInfo + ) + let productsManager = ProductsManager( + entitlementsInfo: dependencyContainer.entitlementsInfo, + storeKitVersion: .storeKit1, + productsFetcher: productsFetcher + ) + let originalReceiptManager: ReceiptManager = dependencyContainer.receiptManager + dependencyContainer.receiptManager = ReceiptManager( + storeKitVersion: .storeKit2, + shouldBypassAppTransactionCheck: true, + productsManager: productsManager, + receiptManager: receipt, + receiptDelegate: nil, + factory: dependencyContainer, + storage: storage + ) + + let cachedConfig: Config = .stub() + .setting(\.buildId, to: "cached_123") + .setting(\.featureFlags, to: .stub()) + storage.save(cachedConfig, forType: LatestConfig.self) + + if isSubscribed { + let activeEntitlements: Set = [.stub()] + storage.save(SubscriptionStatus.active(activeEntitlements), forType: SubscriptionStatusKey.self) + } else { + storage.save(SubscriptionStatus.inactive, forType: SubscriptionStatusKey.self) + } + + let enrichment = Enrichment( + user: JSON(["test_user_key": "test_user_value"]), + device: JSON(["test_device_key": "test_device_value"]) + ) + storage.save(enrichment, forType: LatestEnrichment.self) + + let newConfig: Config = .stub() + .setting(\.buildId, to: "fresh_456") + network.configReturnValue = .success(newConfig) + + let configManager = ConfigManager( + options: SuperwallOptions(), + storeKitManager: dependencyContainer.storeKitManager, + storage: storage, + network: network, + paywallManager: dependencyContainer.paywallManager, + deviceHelper: deviceHelper, + entitlementsInfo: dependencyContainer.entitlementsInfo, + webEntitlementRedeemer: dependencyContainer.webEntitlementRedeemer, + factory: dependencyContainer + ) + dependencyContainer.configManager = configManager + + return Harness( + storage: storage, + configManager: configManager, + receipt: receipt, + originalReceiptManager: originalReceiptManager, + productsManager: productsManager, + network: network, + deviceHelper: deviceHelper + ) + } + + /// Polls until `configState` holds a config or `timeout` passes. Returns the + /// seconds it waited. + private func waitForConfig( + _ configManager: ConfigManager, + timeout: TimeInterval + ) async -> TimeInterval { + let start = Date() + while configManager.config == nil, Date().timeIntervalSince(start) < timeout { + try? await Task.sleep(nanoseconds: 10_000_000) + } + return Date().timeIntervalSince(start) + } + + @Test("Subscriber with cached config: config is published before StoreKit finishes") + func subscriberWithCachedConfigDoesNotWaitForStoreKit() async { + let harness = makeHarness(isSubscribed: true, loadDelay: 2) + + let fetch = Task { await harness.configManager.fetchConfiguration() } + let waited = await waitForConfig(harness.configManager, timeout: 1.5) + + #expect(harness.configManager.config?.buildId == "cached_123") + #expect(waited < 1, "config took \(waited)s but StoreKit was still loading") + #expect(harness.receipt.didStartLoad, "purchases load must still be kicked off") + #expect(!harness.receipt.didFinishLoad, "config was published only after StoreKit finished") + + await fetch.value + #expect(harness.receipt.didFinishLoad, "fetchConfiguration still waits for the purchases load") + + // Let the background refresh finish before the container goes away. + try? await Task.sleep(nanoseconds: 300_000_000) + } + + @Test("Unknown subscriber: purchases still load before config is published") + func syncPathStillLoadsPurchasesBeforePublishing() async { + let harness = makeHarness(isSubscribed: false, loadDelay: 1) + + let fetch = Task { await harness.configManager.fetchConfiguration() } + let waited = await waitForConfig(harness.configManager, timeout: 0.5) + + #expect(harness.configManager.config == nil, "sync path published config after \(waited)s, before purchases loaded") + + await fetch.value + #expect(harness.receipt.didFinishLoad) + #expect(harness.configManager.config != nil) + + try? await Task.sleep(nanoseconds: 300_000_000) + } + + @Test("Trial eligibility waits for the purchases load that config no longer waits for") + func trialEligibilityWaitsForInitialPurchasesLoad() async { + let harness = makeHarness(isSubscribed: true, loadDelay: 1) + + let fetch = Task { await harness.configManager.fetchConfiguration() } + _ = await waitForConfig(harness.configManager, timeout: 1.5) + #expect(!harness.receipt.didFinishLoad, "test needs config to be published mid-load") + + let product = StoreProduct( + sk1Product: MockSkProduct( + productIdentifier: "com.app.gold", + subscriptionGroupIdentifier: "group_A" + ) + ) + _ = await dependencyContainer.isFreeTrialAvailable(for: product) + #expect(harness.receipt.didFinishLoad, "eligibility was answered before active subscription groups were known") + + await fetch.value + try? await Task.sleep(nanoseconds: 300_000_000) + } +} + +/// A `ReceiptManagerType` whose first `loadPurchases` sleeps, standing in for a +/// StoreKit read on a weak network. +private final class SlowReceiptManagerType: ReceiptManagerType, @unchecked Sendable { + private let loadDelay: TimeInterval + private(set) var didStartLoad = false + private(set) var didFinishLoad = false + var purchases: Set = [] + var transactionReceipts: [TransactionReceipt] = [] + var latestSubscriptionPeriodType: LatestSubscription.PeriodType? + var latestSubscriptionWillAutoRenew: Bool? + var latestSubscriptionState: LatestSubscription.State? + + init(loadDelay: TimeInterval) { + self.loadDelay = loadDelay + } + + func loadIntroOfferEligibility(forProducts _: Set) async {} + + func loadPurchases(serverEntitlementsByProductId _: [String: Set]) async -> PurchaseSnapshot { + let isFirstLoad = !didStartLoad + didStartLoad = true + if isFirstLoad { + try? await Task.sleep(nanoseconds: UInt64(loadDelay * 1_000_000_000)) + } + didFinishLoad = true + return PurchaseSnapshot( + purchases: [], + customerInfo: CustomerInfo(subscriptions: [], nonSubscriptions: [], entitlements: []) + ) + } + + func isEligibleForIntroOffer(_ storeProduct: StoreProduct) async -> Bool { + return true + } +}