Skip to content

Fix dismiss post purchase race#461

Open
ianrumac wants to merge 1 commit intodevelopfrom
ir/fix/double-paths
Open

Fix dismiss post purchase race#461
ianrumac wants to merge 1 commit intodevelopfrom
ir/fix/double-paths

Conversation

@ianrumac
Copy link
Copy Markdown
Contributor

@ianrumac ianrumac commented Apr 15, 2026

Changes in this pull request

Checklist

  • All unit tests pass.
  • All UI tests pass.
  • Demo project builds and runs on iOS.
  • Demo project builds and runs on Mac Catalyst.
  • Demo project builds and runs on visionOS.
  • I added/updated tests or detailed why my change isn't tested.
  • I added an entry to the CHANGELOG.md for any breaking changes, enhancements, or bug fixes.
  • I have run swiftlint in the main directory and fixed any issues.
  • I have updated the SDK documentation as well as the online docs.
  • I have reviewed the contributing guide

Greptile Summary

Adds an idempotency guard to PaywallViewController.dismiss(result:closeReason:completion:) that short-circuits any subsequent dismiss call when paywallResult is already .purchased or .restored, preventing a system-close event from overwriting a terminal purchase/restore state. Two new @MainActor tests cover both the purchase and restore scenarios; the test file is also renamed from PaywallViewControllerDrawerTests to PaywallViewControllerTests.

Confidence Score: 5/5

Safe to merge — the guard is logically correct and all remaining findings are P2 style suggestions.

The fix synchronously guards on the already-set paywallResult property before any async work begins. Since PaywallViewController is @MainActor, reads and writes of paywallResult are serialized, making the guard race-free by construction. All open findings are P2.

Tests/SuperwallKitTests/Paywall/View Controller/PaywallViewControllerTests.swift — minor test quality concerns (sleep-based assertions, nonisolated(unsafe)).

Important Files Changed

Filename Overview
Sources/SuperwallKit/Paywall/View Controller/PaywallViewController.swift Adds an early-return idempotency guard at the top of dismiss(result:closeReason:completion:) that ignores any dismiss call when paywallResult is already .purchased or .restored, fixing the post-purchase race condition.
Tests/SuperwallKitTests/Paywall/View Controller/PaywallViewControllerTests.swift Renames the existing drawer test file and adds two new @MainActor tests verifying idempotency; minor concerns around time-based async assertions and nonisolated(unsafe) usage.

Sequence Diagram

sequenceDiagram
    participant TM as TransactionManager
    participant PVC as PaywallViewController
    participant Sys as System (viewDidDisappear)
    participant Del as Delegate

    TM->>PVC: dismiss(result: .purchased, closeReason: .systemLogic)
    Note over PVC: paywallResult = nil → guard passes<br/>paywallResult set to .purchased
    PVC->>Del: didFinish(result: .purchased, shouldDismiss: true)
    Note over PVC: UIKit dismiss animation starts

    Sys->>PVC: dismiss(result: .declined, closeReason: .manualClose)
    Note over PVC: paywallResult == .purchased → guard triggers<br/>completion?() called, return early
    PVC-->>Sys: (completion called, no delegate call)
Loading
Prompt To Fix All With AI
This is a comment left during a code review.
Path: Tests/SuperwallKitTests/Paywall/View Controller/PaywallViewControllerTests.swift
Line: 177-180

Comment:
**Time-based async assertion is fragile**

The 100 ms sleep is used to wait for the async `dismissView()` delegate call to complete. In slow CI environments or under heavy load this window can be missed, causing the assertion to see 0 results and fail spuriously. Consider structuring the test so the delegate call is awaited directly via a `withCheckedContinuation` or an `AsyncStream`-based expectation instead of a fixed sleep.

The same concern applies to the restore test at line 207.

How can I resolve this? If you propose a fix, please make it concise.

---

This is a comment left during a code review.
Path: Tests/SuperwallKitTests/Paywall/View Controller/PaywallViewControllerTests.swift
Line: 134-136

Comment:
**`nonisolated(unsafe)` bypasses Swift concurrency checks**

`nonisolated(unsafe) var results` disables actor-isolation enforcement on `RecordingDelegate`. If the delegate is ever called off the main actor the mutation would be a data race. Since the enclosing test suite is `@MainActor` and `PaywallViewController` also runs on the main actor the access is safe in practice, but marking `RecordingDelegate` itself `@MainActor` would be cleaner and restore compiler-enforced isolation.

How can I resolve this? If you propose a fix, please make it concise.

Reviews (1): Last reviewed commit: "Fix dismiss post purchase race" | Re-trigger Greptile

Greptile also left 2 inline comments on this PR.

Comment on lines +177 to +180
objcDelegate: nil
)

let product = StoreProduct(
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Time-based async assertion is fragile

The 100 ms sleep is used to wait for the async dismissView() delegate call to complete. In slow CI environments or under heavy load this window can be missed, causing the assertion to see 0 results and fail spuriously. Consider structuring the test so the delegate call is awaited directly via a withCheckedContinuation or an AsyncStream-based expectation instead of a fixed sleep.

The same concern applies to the restore test at line 207.

Prompt To Fix With AI
This is a comment left during a code review.
Path: Tests/SuperwallKitTests/Paywall/View Controller/PaywallViewControllerTests.swift
Line: 177-180

Comment:
**Time-based async assertion is fragile**

The 100 ms sleep is used to wait for the async `dismissView()` delegate call to complete. In slow CI environments or under heavy load this window can be missed, causing the assertion to see 0 results and fail spuriously. Consider structuring the test so the delegate call is awaited directly via a `withCheckedContinuation` or an `AsyncStream`-based expectation instead of a fixed sleep.

The same concern applies to the restore test at line 207.

How can I resolve this? If you propose a fix, please make it concise.

Comment on lines +134 to +136
nonisolated(unsafe) var results: [PaywallResult] = []
func paywall(
_ paywall: PaywallViewController,
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 nonisolated(unsafe) bypasses Swift concurrency checks

nonisolated(unsafe) var results disables actor-isolation enforcement on RecordingDelegate. If the delegate is ever called off the main actor the mutation would be a data race. Since the enclosing test suite is @MainActor and PaywallViewController also runs on the main actor the access is safe in practice, but marking RecordingDelegate itself @MainActor would be cleaner and restore compiler-enforced isolation.

Prompt To Fix With AI
This is a comment left during a code review.
Path: Tests/SuperwallKitTests/Paywall/View Controller/PaywallViewControllerTests.swift
Line: 134-136

Comment:
**`nonisolated(unsafe)` bypasses Swift concurrency checks**

`nonisolated(unsafe) var results` disables actor-isolation enforcement on `RecordingDelegate`. If the delegate is ever called off the main actor the mutation would be a data race. Since the enclosing test suite is `@MainActor` and `PaywallViewController` also runs on the main actor the access is safe in practice, but marking `RecordingDelegate` itself `@MainActor` would be cleaner and restore compiler-enforced isolation.

How can I resolve this? If you propose a fix, please make it concise.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant