swiftswiftuiasync-awaitstorekitstorekit2

StoreKit Product.products(for:) works inconsistently: Task.detached resolves the issue—why?


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:

  1. Why does the original load() implementation fail to fetch products, while wrapping the logic in Task.detached resolves the issue?
  2. Does this solution have any potential drawbacks or unintended side effects?
  3. Is there a more idiomatic or robust way to handle this situation with Swift concurrency?

Solution

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