From 0322e751e4e279667adf3000f4844290370ba9f4 Mon Sep 17 00:00:00 2001 From: Ian Rumac Date: Wed, 15 Apr 2026 15:53:30 +0200 Subject: [PATCH] Fix dismiss post purchase race --- .../PaywallViewController.swift | 9 ++ ...swift => PaywallViewControllerTests.swift} | 89 ++++++++++++++++++- 2 files changed, 95 insertions(+), 3 deletions(-) rename Tests/SuperwallKitTests/Paywall/View Controller/{PaywallViewControllerDrawerTests.swift => PaywallViewControllerTests.swift} (62%) diff --git a/Sources/SuperwallKit/Paywall/View Controller/PaywallViewController.swift b/Sources/SuperwallKit/Paywall/View Controller/PaywallViewController.swift index 32cff16239..393dab188a 100644 --- a/Sources/SuperwallKit/Paywall/View Controller/PaywallViewController.swift +++ b/Sources/SuperwallKit/Paywall/View Controller/PaywallViewController.swift @@ -1539,6 +1539,15 @@ extension PaywallViewController { closeReason: PaywallCloseReason, completion: (() -> Void)? = nil ) { + // Ignore redundant dismiss calls after a terminal purchase/restore. + switch paywallResult { + case .purchased, .restored: + completion?() + return + case .declined, .none: + break + } + dismissCompletionBlock = completion paywallResult = result paywall.closeReason = closeReason diff --git a/Tests/SuperwallKitTests/Paywall/View Controller/PaywallViewControllerDrawerTests.swift b/Tests/SuperwallKitTests/Paywall/View Controller/PaywallViewControllerTests.swift similarity index 62% rename from Tests/SuperwallKitTests/Paywall/View Controller/PaywallViewControllerDrawerTests.swift rename to Tests/SuperwallKitTests/Paywall/View Controller/PaywallViewControllerTests.swift index c6c844d3c1..05c9c425d1 100644 --- a/Tests/SuperwallKitTests/Paywall/View Controller/PaywallViewControllerDrawerTests.swift +++ b/Tests/SuperwallKitTests/Paywall/View Controller/PaywallViewControllerTests.swift @@ -1,12 +1,11 @@ // -// PaywallViewControllerDrawerTests.swift +// PaywallViewControllerTests.swift // SuperwallKitTests // -// Created by Claude on 08/01/2025. -// import Testing import Foundation +import StoreKit @testable import SuperwallKit struct PaywallViewControllerDrawerTests { @@ -128,3 +127,87 @@ struct PaywallViewControllerDrawerTests { #expect(PaywallPresentationStyleObjc.none.toSwift() == .none) } } + +@MainActor +struct PaywallViewControllerDismissIdempotencyTests { + final class RecordingDelegate: PaywallViewControllerDelegate { + nonisolated(unsafe) var results: [PaywallResult] = [] + func paywall( + _ paywall: PaywallViewController, + didFinishWith result: PaywallResult, + shouldDismiss: Bool + ) { + results.append(result) + } + } + + private func makeMock() -> PaywallViewControllerMock { + let dependencyContainer = DependencyContainer() + let messageHandler = PaywallMessageHandler( + receiptManager: dependencyContainer.receiptManager, + factory: dependencyContainer, + permissionHandler: FakePermissionHandler(), + customCallbackRegistry: dependencyContainer.customCallbackRegistry + ) + let webView = SWWebView( + isMac: false, + messageHandler: messageHandler, + isOnDeviceCacheEnabled: true, + factory: dependencyContainer + ) + return PaywallViewControllerMock( + paywall: .stub(), + deviceHelper: dependencyContainer.deviceHelper, + factory: dependencyContainer, + storage: dependencyContainer.storage, + network: dependencyContainer.network, + webView: webView, + webEntitlementRedeemer: dependencyContainer.webEntitlementRedeemer, + cache: nil, + paywallArchiveManager: nil + ) + } + + @Test("`.closed` dismiss after a successful purchase is ignored") + func closedEventAfterPurchaseIsIgnored() async throws { + let paywallVc = makeMock() + let recorder = RecordingDelegate() + paywallVc.delegate = PaywallViewControllerDelegateAdapter( + swiftDelegate: recorder, + objcDelegate: nil + ) + + let product = StoreProduct( + sk1Product: MockSkProduct(productIdentifier: "com.example.test") + ) + + paywallVc.dismiss(result: .purchased(product), closeReason: .systemLogic) + paywallVc.dismiss(result: .declined, closeReason: .manualClose) + + try await Task.sleep(nanoseconds: 100_000_000) + + #expect(recorder.results.count == 1) + if case .purchased = recorder.results.first { + } else { + Issue.record("Expected delegate to receive .purchased only, got \(recorder.results)") + } + } + + @Test("`.closed` dismiss after a restore is ignored") + func closedEventAfterRestoreIsIgnored() async throws { + let paywallVc = makeMock() + let recorder = RecordingDelegate() + paywallVc.delegate = PaywallViewControllerDelegateAdapter( + swiftDelegate: recorder, + objcDelegate: nil + ) + + paywallVc.dismiss(result: .restored, closeReason: .systemLogic) + paywallVc.dismiss(result: .declined, closeReason: .manualClose) + + try await Task.sleep(nanoseconds: 100_000_000) + + #expect(recorder.results.count == 1) + #expect(recorder.results.first == .restored) + } +}