I’m implementing in-app purchases using StoreKit in my SwiftUI app, but I’ve encountered an issue where Product.products(for:) inconsistently returns an empty array. No errors are thrown, and everything seems configured correctly in App Store Connect.
Problem Context:
Below is a simplified version of my code:
struct ContentView: View {
@State private var status: Status = .idle
var body: some View {
switch status {
case .idle:
ProgressView()
.task {
await load()
}
case .loading:
ProgressView()
case .success(let products):
Text("\(products.count)")
case .failure(let error):
Text(error.localizedDescription)
}
}
private func load() async {
do {
self.status = .loading
let products = try await Product.products(for: ["com.example.app.productId"])
self.status = .success(products: products)
} catch {
self.status = .failure(error: error)
}
}
private enum Status {
case idle
case loading
case success(products: [Product])
case failure(error: Error)
}
}
In this implementation, load() doesn’t throw errors but always returns an empty product array. I suspected a threading issue since the function modifies the @State property status within an asynchronous task.
Solution That Works:
After experimenting, I found that wrapping the entire operation in Task.detached resolves the issue:
private func load() async {
await Task.detached {
do {
await MainActor.run {
self.status = .loading
}
let products = try await Product.products(for: ["com.example.app.productId"])
await MainActor.run {
self.status = .success(products: products)
}
} catch {
await MainActor.run {
self.status = .failure(error: error)
}
}
}.value
}
When using the updated version, Product.products(for:) works perfectly and fetches the correct products.
Questions:
The task
modifier is attached to the ProgressView
, so the task is cancelled when the ProgressView
disappears.
When does ProgressView
disappear? Right after you call Product.products(for:)
. That method is run on another thread, so the main actor is now free to update the UI. Since you have set status = .loading
, the ProgressView
disappears and the task is cancelled.
Product.products(for:)
sees that the task has now been cancelled, so it doesn't try to retrieve the products and just returns an empty array.
Your load
method doesn't check for task cancellation, so it continues to run after Product.products(for:)
, and sets status
to .success
.
Remember that task cancellation is a collaborative effort - the code in the task doesn't just suddenly stops executing when the task is cancelled. The code in the task has to check for task cancellation on their own and respond appropriately.
By using Task.detached
, you create a top level task that will not be cancelled just because a view disappears. To cancel it, you need to call directly cancel
on it. Therefore, by creating a top level task in the way you did, you cannot cancel
it even when you want it to be cancelled.
So instead of creating a top level task, I'd recommend moving the task
modifier to some view that actually exists throughout the duration of the task. The easiest way to do this is to wrap a ZStack
around the whole switch
and move the task
modifier to the ZStack
.