Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
66 changes: 59 additions & 7 deletions Sources/SuperwallKit/Config/ConfigManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<Void, Never>?

private unowned let storeKitManager: StoreKitManager
unowned let storage: Storage
private unowned let network: Network
Expand Down Expand Up @@ -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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hoisting processConfig above this block means the device_attributes track now happens after the StoreKit read on the async path, since processConfig still awaits purchasesLoad.value before returning. That delays the event by the same 10–25 s the PR is removing from register, on the exact launches the PR description measures. Was that intended, or should the track stay ahead of the load and only configState move?

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(
Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand All @@ -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))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

loadPurchasedProducts populates more than the trial gate, and only that one consumer got an await. manager.purchases has no disk backing (SK1ReceiptManager.swift:13, SK2ReceiptManager.swift:66 are plain = []), so getActiveProductIds()DeviceTemplate.activeProducts (DeviceHelper.swift:1018) is empty for every audience filter evaluated between this send and the load finishing — for exactly the subscribed users this path targets. Separately, receiptDelegate?.syncSubscriptionStatus (ReceiptManager.swift:216) is what demotes a lapsed or refunded subscriber, and it now lands after register has already been answered from the disk-cached status.

Technical details
# Post-load state readable while empty/stale on the cached-config path

## Affected sites
- `Sources/SuperwallKit/Config/ConfigManager.swift:471``configState.send(.retrieved(config))` now precedes `await purchasesLoad.value`, unblocking every `register`/`track` awaiter.
- `Sources/SuperwallKit/StoreKit/Products/Receipt Manager/Receipt Manager/ReceiptManager.swift:272``getActiveProductIds()` reads `manager.purchases`, which is in-memory only and populated solely by `loadPurchases` inside the deferred task.
- `Sources/SuperwallKit/Network/Device Helper/DeviceHelper.swift:1018``activeProducts:` in `getTemplateDevice()`, which feeds `DependencyContainer.makeAudienceFilterAttributes` (`:471`, the `device` dict handed to CEL) and `makeJsonVariables` (`:359`, paywall template variables).
- `Sources/SuperwallKit/StoreKit/Products/Receipt Manager/Receipt Manager/ReceiptManager.swift:264``isSubscribed(to:)`, same backing, surfaced as the per-product `isSubscribed` template variable via `TemplateLogic.swift:29`.
- `Sources/SuperwallKit/StoreKit/Products/Receipt Manager/Receipt Manager/ReceiptManager.swift:216``syncSubscriptionStatus(purchases:)`, the correction that deactivates refunded/expired App Store subscriptions, now runs concurrently with presentation instead of before it.

## Required outcome
- Audience-filter evaluation during the window must not silently report `device.activeProducts` as empty for a user who owns products; either the value is restored/derived from disk, or the attribute is omitted rather than reported as an empty set, or the evaluation awaits the load.
- The staleness accepted for `device.subscriptionStatus` / `activeEntitlements` during the window should be a stated decision, since the neighbouring CHANGELOG entry promises that refunded and expired App Store subscriptions "still deactivate immediately".

## Open questions for the human
- How widely is `device.activeProducts` used in customer audience filters? If it is rare, documenting the window may be enough; if it is common, an empty set is a silent wrong-audience match for the whole cold-launch window.
- Is a lapsed subscriber getting gated features unlocked for the duration of the StoreKit read an accepted trade-off for the latency win?

didPublishConfig = true
await purchasesLoad.value
} else {
await factory.loadPurchasedProducts(config: config)
}
}

if !testModeManager.isTestMode {
Expand All @@ -442,6 +492,8 @@ class ConfigManager {
let reason = testModeManager.testModeReason {
await presentTestModeModal(reason: reason, config: config)
}

return didPublishConfig
}

/// Reassigns variants and preloads paywalls again.
Expand Down
4 changes: 4 additions & 0 deletions Sources/SuperwallKit/Dependencies/DependencyContainer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Comment on lines +255 to +259

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The claim that paywall callers all go through DependencyContainer.isFreeTrialAvailable does not hold: TransactionManager.isFreeTrialAvailable (TransactionManager.swift:734) calls receiptManager.isFreeTrialAvailable directly and is reached from prepareToPurchase (:655) the moment a user taps buy — newly possible during the load window that this PR opens. With activeSubscriptionGroupIds still empty, an in-group upgrade gets reported as .freeTrialStart instead of .subscriptionStart.

Technical details
# `ReceiptManager.isFreeTrialAvailable` has a caller that bypasses `initialPurchasesLoad`

## Affected sites
- `Sources/SuperwallKit/StoreKit/Products/Receipt Manager/Receipt Manager/ReceiptManager.swift:255-259` — the new comment asserts a universal guarantee.
- `Sources/SuperwallKit/StoreKit/Transactions/TransactionManager.swift:734``return await receiptManager.isFreeTrialAvailable(for: product)`, via a directly held `receiptManager`, not `factory`. `TransactionManager.Factory` is `OptionsFactory` only (`:21`), so it currently cannot reach the awaited wrapper.
- Downstream: `TransactionManager.swift:655``:725``PurchasingCoordinator.isFreeTrialAvailable``TransactionManager.swift:1092` `didStartOffer``:1103` `TransactionType.freeTrialStart` vs `.subscriptionStart`.
- Secondary, lower severity: `Sources/SuperwallKit/Paywall/Request/Operators/AddPaywallProducts.swift:311` — the `.eligible` branch calls `hasActiveIntroOffer`, which reads `Superwall.shared.customerInfo.subscriptions` (`:336-338`) with no `isPlaceholder` guard, unlike `hasEverHadEntitlement` at `:368`. That value is disk-restored so it is stale rather than empty, but it drives the user-facing `paywall.isFreeTrialAvailable` template value.

## Required outcome
- Either the purchase path observes the same `activeSubscriptionGroupIds` guarantee as the paywall path, or the comment is corrected to say which callers are unprotected and why that is acceptable.

## Suggested approach
- Widen `TransactionManager.Factory` to `OptionsFactory & ReceiptFactory` and route `:734` through `factory.isFreeTrialAvailable(for:)`. `DependencyContainer` already conforms, so every production and test call site is unchanged.

return !activeSubscriptionGroupIds.contains(subscriptionGroupId)
}

Expand Down
224 changes: 224 additions & 0 deletions Tests/SuperwallKitTests/Config/ConfigManagerTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
//
// swiftlint:disable all

import Foundation
@testable import SuperwallKit
import Testing

Expand Down Expand Up @@ -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<Entitlement> = [.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<Purchase> = []
var transactionReceipts: [TransactionReceipt] = []
var latestSubscriptionPeriodType: LatestSubscription.PeriodType?
var latestSubscriptionWillAutoRenew: Bool?
var latestSubscriptionState: LatestSubscription.State?

init(loadDelay: TimeInterval) {
self.loadDelay = loadDelay
}

func loadIntroOfferEligibility(forProducts _: Set<StoreProduct>) async {}

func loadPurchases(serverEntitlementsByProductId _: [String: Set<Entitlement>]) 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
}
}
Loading