Skip to content
Merged
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 @@ -7,6 +7,7 @@ The changelog for `SuperwallKit`. Also see the [releases](https://github.com/sup
### Fixes

- 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 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.

## 4.16.3
Expand Down
5 changes: 5 additions & 0 deletions Sources/SuperwallKit/Superwall.swift
Original file line number Diff line number Diff line change
Expand Up @@ -473,6 +473,11 @@ public final class Superwall: NSObject, ObservableObject {

addListeners()

// Recover any Stripe checkout that was pending when the app was killed.
// Kicked off here rather than inside an initializer so the redeemer's
// background poll can only ever see a fully-built dependency container.
dependencyContainer.webEntitlementRedeemer.pollPendingStripeCheckoutOnColdLaunch()

// This task runs on a background thread, even if called from a main thread.
// This is because the function isn't marked to run on the main thread,
// therefore, we don't need to make this detached.
Expand Down
13 changes: 9 additions & 4 deletions Sources/SuperwallKit/Web/WebEntitlementRedeemer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -112,12 +112,17 @@ actor WebEntitlementRedeemer {
name: UIApplication.willEnterForegroundNotification,
object: nil
)
}

// Also check once on SDK initialization so pending Stripe checkouts can be
// recovered on cold launch. Guard on factory readiness to avoid accessing
// dependencies (e.g. deviceHelper) before the container is fully set up.
/// Checks once on SDK initialization so pending Stripe checkouts can be
/// recovered on cold launch.
///
/// Called by `Superwall` after the dependency container is fully built,
/// rather than from this actor's own `init`: the poll reads container state
/// from a background task, so it must not start while the container is
/// still being set up.
nonisolated func pollPendingStripeCheckoutOnColdLaunch() {
Task {
guard factory.makeIsContainerReady() else { return }
await pollPendingStripeCheckoutOnForegroundIfNeeded()
}
}
Expand Down
18 changes: 16 additions & 2 deletions SuperwallKit.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,7 @@
4A4E788046CD308F465B37BF /* ProductsFetcherSK2.swift in Sources */ = {isa = PBXBuildFile; fileRef = 57AD390BC73341A49301B4AA /* ProductsFetcherSK2.swift */; };
4AA4E2CE223DC7CF1678E83C /* TrackTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 65E23B703C00044332FDEBE8 /* TrackTests.swift */; };
4AB907436D4A84932F09D8B3 /* String+MD5.swift in Sources */ = {isa = PBXBuildFile; fileRef = D449672964023589DA5535E3 /* String+MD5.swift */; };
4ABF9FB54105917343865145 /* DependencyContainerInitTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 880CE7E95B65A756C372CE05 /* DependencyContainerInitTests.swift */; };
4B0E203D477E48611797047C /* PaywallViewControllerCacheTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 672776875A4286319C2F2D61 /* PaywallViewControllerCacheTests.swift */; };
4B4BCB32699C3A1AF7E2BFE6 /* SK2StoreProductCyclesTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B00929DACD8621FC32F83927 /* SK2StoreProductCyclesTests.swift */; };
4B54BA9E52A97C486D808A05 /* IntroOfferToken.swift in Sources */ = {isa = PBXBuildFile; fileRef = DEF0596D5BDDE0911046E60D /* IntroOfferToken.swift */; };
Expand Down Expand Up @@ -897,6 +898,7 @@
8719AC2E83128EE469E58C36 /* RedeemRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RedeemRequest.swift; sourceTree = "<group>"; };
87AD727C5A8639E704F7BE98 /* PaywallView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaywallView.swift; sourceTree = "<group>"; };
87B1E659458AE78C3908562B /* SK2ReceiptManagerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SK2ReceiptManagerTests.swift; sourceTree = "<group>"; };
880CE7E95B65A756C372CE05 /* DependencyContainerInitTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DependencyContainerInitTests.swift; sourceTree = "<group>"; };
884DF3D8A1CA382BFEE9F8F4 /* ArchiveManifestUsage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArchiveManifestUsage.swift; sourceTree = "<group>"; };
887834329A06971D86D5282F /* NotificationProtocols.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationProtocols.swift; sourceTree = "<group>"; };
88EBF6FC3090E004EE1377B4 /* PaywallViewControllerDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaywallViewControllerDelegate.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -2716,6 +2718,7 @@
D554340BB6652F5FA1F21FF8 /* Config */,
38C02C19ED9C9958A7A61FB1 /* Debug */,
3B16D25FCB6991D55E0F63B3 /* DeepLink */,
E9FF04AB866B9CCA7DFBE592 /* Dependencies */,
373AFF230833A951B6E5DF36 /* Identity */,
8DD7B7C5E111EAB0878886B6 /* Logger */,
4D7656D6A565958F58A644AF /* Misc */,
Expand Down Expand Up @@ -2822,8 +2825,8 @@
children = (
0E3AC3B23DAAA8C1D125BDD3 /* CoreDataManager.swift */,
50458143450675EF205CE2C3 /* CoreDataStack.swift */,
EC51351CA716C5C3B71E2FA1 /* SuperwallKit_Model.xcdatamodeld */,
2DAF3427CF469F5373C2BFD7 /* Managed Models */,
EC51351CA716C5C3B71E2FA1 /* SuperwallKit_Model.xcdatamodeld */,
);
path = "Core Data";
sourceTree = "<group>";
Expand Down Expand Up @@ -2996,6 +2999,14 @@
path = Options;
sourceTree = "<group>";
};
E9FF04AB866B9CCA7DFBE592 /* Dependencies */ = {
isa = PBXGroup;
children = (
880CE7E95B65A756C372CE05 /* DependencyContainerInitTests.swift */,
);
path = Dependencies;
sourceTree = "<group>";
};
ED389E60F716C417202738F0 /* Misc */ = {
isa = PBXGroup;
children = (
Expand Down Expand Up @@ -3173,9 +3184,10 @@
attributes = {
BuildIndependentTargetsInParallel = YES;
LastUpgradeCheck = 1430;
TargetAttributes = {
};
};
buildConfigurationList = B7BB212B66F694F1FDA2FA4F /* Build configuration list for PBXProject "SuperwallKit" */;
compatibilityVersion = "Xcode 14.0";
developmentRegion = en;
hasScannedForEncodings = 0;
knownRegions = (
Expand Down Expand Up @@ -3228,6 +3240,7 @@
89F17188BC665EFC6FE5CEFA /* XCRemoteSwiftPackageReference "superscript-ios-next" */,
);
preferredProjectObjectVersion = 77;
productRefGroup = 778C04FFAA9840C37CA3C1CA /* Products */;
projectDirPath = "";
projectRoot = "";
targets = (
Expand Down Expand Up @@ -3289,6 +3302,7 @@
654803E77F7CDBF6282D0110 /* Date+IsWithinAnHourBeforeTests.swift in Sources */,
D91750797BB4947F6975B2B9 /* Date+IsoStringTests.swift in Sources */,
01BE837B492223B76A95CB5D /* DeepLinkRouterTests.swift in Sources */,
4ABF9FB54105917343865145 /* DependencyContainerInitTests.swift in Sources */,
0CA13E721ADB243882536D4A /* DeviceHelperMock.swift in Sources */,
9DBDDD10A1EFC7CD3575D9E5 /* DeviceHelperTests.swift in Sources */,
2743143ED664F942D5D758B1 /* DevicePreloadScriptTests.swift in Sources */,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
//
// DependencyContainerInitTests.swift
// SuperwallKit
//
// Created by Yusuf Tör on 25/08/2026.
//

import Testing
@testable import SuperwallKit
import Foundation

struct DependencyContainerInitTests {
/// https://github.com/superwall/Superwall-iOS/issues/504
///
/// `DependencyContainer.init` used to pass `self` to `WebEntitlementRedeemer`,
/// whose init spawned a task reading `configManager` on a background thread
/// while init was still assigning stored properties. This loop only signals
/// under Thread Sanitizer (`-enableThreadSanitizer YES`), where it reproduced
/// the race on the first iteration; without TSan it's a smoke test.
@Test("Constructing the container doesn't race against its own init")
func containerInitHasNoDataRace() {
Comment thread
yusuftor marked this conversation as resolved.
for _ in 0..<10 {
_ = DependencyContainer(apiKey: "pk_test_504")
}
}
}
83 changes: 82 additions & 1 deletion Tests/SuperwallKitTests/Web/WebEntitlementRedeemerTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ struct WebEntitlementRedeemerTests {

init() {
// Clear any pending stripe checkout state left on disk by a previous test
// to prevent the WebEntitlementRedeemer init Task from triggering unexpected saves.
// so foreground/cold-launch polls triggered later can't see stale state.
dependencyContainer.storage.delete(PendingStripeCheckoutPollStorage.self)
}

Expand Down Expand Up @@ -2942,4 +2942,85 @@ struct WebEntitlementRedeemerTests {
// Failed status clears pending state, but we can verify the poll happened
#expect(mockNetwork.pollRedemptionResultCallCount == 1)
}

@Test("Init doesn't start the cold-launch Stripe poll")
func testInit_doesNotStartColdLaunchPoll() async {
guard #available(iOS 14.0, *) else {
return
}

let superwall = Superwall(dependencyContainer: dependencyContainer)
let mockStorage = StorageMock(internalRedeemResponse: nil)
let mockNetwork = NetworkMock(
options: dependencyContainer.makeSuperwallOptions(),
factory: dependencyContainer
)

// Seed a pending checkout so a poll would fire if anything kicked one off.
mockStorage.save(
PendingStripeCheckoutPollState(
checkoutContextId: "ctx_cold",
productId: "prod_cold"
),
forType: PendingStripeCheckoutPollStorage.self
)
mockNetwork.pollRedemptionResultResponses = [.failure(NetworkError.unknown)]

_ = WebEntitlementRedeemer(
network: mockNetwork,
storage: mockStorage,
entitlementsInfo: dependencyContainer.entitlementsInfo,
delegate: dependencyContainer.delegateAdapter,
purchaseController: MockPurchaseController(),
receiptManager: dependencyContainer.receiptManager,
factory: dependencyContainer,
superwall: superwall
)

// Give a stray init-spawned task time to run before asserting it didn't.
try? await Task.sleep(nanoseconds: 300_000_000)
#expect(mockNetwork.pollRedemptionResultCallCount == 0)
}

@Test("Cold-launch kick-off polls a pending Stripe checkout")
func testPollPendingStripeCheckoutOnColdLaunch_pollsPendingCheckout() async {
guard #available(iOS 14.0, *) else {
return
}

let superwall = Superwall(dependencyContainer: dependencyContainer)
let mockStorage = StorageMock(internalRedeemResponse: nil)
let mockNetwork = NetworkMock(
options: dependencyContainer.makeSuperwallOptions(),
factory: dependencyContainer
)

mockStorage.save(
PendingStripeCheckoutPollState(
checkoutContextId: "ctx_cold",
productId: "prod_cold"
),
forType: PendingStripeCheckoutPollStorage.self
)
mockNetwork.pollRedemptionResultResponses = [.failure(NetworkError.unknown)]

let redeemer = WebEntitlementRedeemer(
network: mockNetwork,
storage: mockStorage,
entitlementsInfo: dependencyContainer.entitlementsInfo,
delegate: dependencyContainer.delegateAdapter,
purchaseController: MockPurchaseController(),
receiptManager: dependencyContainer.receiptManager,
factory: dependencyContainer,
superwall: superwall
)

redeemer.pollPendingStripeCheckoutOnColdLaunch()

// The kick-off spawns a task; wait for the poll to land.
for _ in 0..<100 where mockNetwork.pollRedemptionResultCallCount == 0 {
try? await Task.sleep(nanoseconds: 50_000_000)
}
#expect(mockNetwork.pollRedemptionResultCallCount == 1)
}
}
Loading