From 7c809a5bf9aabb25d11e1726d20804776c6c7c92 Mon Sep 17 00:00:00 2001 From: Mark Murray Date: Thu, 8 Oct 2026 12:01:51 +0100 Subject: [PATCH] Ignore duplicate Swift checkout presentations --- .../ShopifyCheckoutKit.swift | 26 +++++- .../ShopifyCheckoutKitTests.swift | 80 +++++++++++++++++++ 2 files changed, 102 insertions(+), 4 deletions(-) diff --git a/platforms/swift/Sources/ShopifyCheckoutKit/ShopifyCheckoutKit.swift b/platforms/swift/Sources/ShopifyCheckoutKit/ShopifyCheckoutKit.swift index 3df1996f2..85baf1cff 100644 --- a/platforms/swift/Sources/ShopifyCheckoutKit/ShopifyCheckoutKit.swift +++ b/platforms/swift/Sources/ShopifyCheckoutKit/ShopifyCheckoutKit.swift @@ -67,18 +67,36 @@ public func invalidate() { CheckoutWebView.preloadCache.evict(with: .idle) } +/// Presents checkout, or returns the existing checkout in the presenter’s modal hierarchy. +/// Duplicate calls preserve the original checkout URL and delegate, including during dismissal. @MainActor @discardableResult public func present(checkout url: URL, from: UIViewController, delegate: (any CheckoutDelegate)? = nil) -> CheckoutViewController { - let decorated = CheckoutURLDecorator.decorate(url) - let viewController = CheckoutViewController(checkout: decorated, delegate: delegate) - from.present(viewController, animated: true) - return viewController + presentCheckout(checkout: url, from: from, delegate: delegate) } @MainActor @discardableResult package func present(checkout url: URL, from: UIViewController, entryPoint: MetaData.EntryPoint, delegate: (any CheckoutDelegate)? = nil, client: (any CheckoutCommunicationProtocol)? = nil) -> CheckoutViewController { + presentCheckout(checkout: url, from: from, delegate: delegate, client: client, entryPoint: entryPoint) +} + +@MainActor +private func presentCheckout(checkout url: URL, from: UIViewController, delegate: (any CheckoutDelegate)?, client: (any CheckoutCommunicationProtocol)? = nil, entryPoint: MetaData.EntryPoint? = nil) -> CheckoutViewController { + // Start at the root so calls from a checkout child or a modal above it share the same guard. + var root = from + while let ancestor = root.parent ?? root.presentingViewController { + root = ancestor + } + var presented: UIViewController? = root + while let current = presented { + if let checkout = current as? CheckoutViewController { + OSLogger.shared.warn("Checkout is already presented; ignoring duplicate presentation.") + return checkout + } + presented = current.presentedViewController + } + let decorated = CheckoutURLDecorator.decorate(url) let viewController = CheckoutViewController(checkout: decorated, delegate: delegate, client: client, entryPoint: entryPoint) from.present(viewController, animated: true) diff --git a/platforms/swift/Tests/ShopifyCheckoutKitTests/ShopifyCheckoutKitTests.swift b/platforms/swift/Tests/ShopifyCheckoutKitTests/ShopifyCheckoutKitTests.swift index 958f0bdb2..e9691e9db 100644 --- a/platforms/swift/Tests/ShopifyCheckoutKitTests/ShopifyCheckoutKitTests.swift +++ b/platforms/swift/Tests/ShopifyCheckoutKitTests/ShopifyCheckoutKitTests.swift @@ -90,6 +90,70 @@ class ShopifyCheckoutKitTests: XCTestCase { XCTAssertNotNil(webViewController.checkoutView?.client) } + func test_present_duplicatePreservesCheckoutAndDelegate() throws { + let presenter = RecordingCheckoutPresenter() + let originalDelegate = MockCheckoutDelegate() + let checkout = ShopifyCheckoutKit.present(checkout: checkoutURL, from: presenter, delegate: originalDelegate) + + let duplicate = try ShopifyCheckoutKit.present( + checkout: XCTUnwrap(URL(string: "https://shop.example/checkouts/cn/other")), + from: presenter, + delegate: MockCheckoutDelegate() + ) + + XCTAssertTrue(duplicate === checkout) + XCTAssertEqual(presenter.presentationCount, 1) + let content = try XCTUnwrap(checkout.viewControllers.first as? CheckoutWebViewController) + XCTAssertTrue(content.delegate === originalDelegate) + } + + func test_present_fromCheckoutOrItsChildReturnsExistingCheckout() throws { + let presenter = RecordingCheckoutPresenter() + let checkout = ShopifyCheckoutKit.present(checkout: checkoutURL, from: presenter) + let child = try XCTUnwrap(checkout.viewControllers.first) + + XCTAssertTrue(ShopifyCheckoutKit.present(checkout: checkoutURL, from: checkout) === checkout) + XCTAssertTrue(ShopifyCheckoutKit.present(checkout: checkoutURL, from: child) === checkout) + XCTAssertEqual(presenter.presentationCount, 1) + } + + func test_present_fromContainerChildFindsPresentedCheckout() { + let presenter = RecordingCheckoutPresenter() + let child = UIViewController() + presenter.addChild(child) + let checkout = ShopifyCheckoutKit.present(checkout: checkoutURL, from: presenter) + + XCTAssertTrue(ShopifyCheckoutKit.present(checkout: checkoutURL, from: child) === checkout) + XCTAssertEqual(presenter.presentationCount, 1) + } + + func test_present_acceleratedEntryPointUsesSameGuard() { + let presenter = RecordingCheckoutPresenter() + let checkout = ShopifyCheckoutKit.present(checkout: checkoutURL, from: presenter, entryPoint: .acceleratedCheckouts) + + XCTAssertTrue(ShopifyCheckoutKit.present(checkout: checkoutURL, from: presenter) === checkout) + XCTAssertTrue(ShopifyCheckoutKit.present(checkout: checkoutURL, from: presenter, entryPoint: .acceleratedCheckouts) === checkout) + XCTAssertEqual(presenter.presentationCount, 1) + } + + func test_present_afterDismissalCreatesNewCheckoutEvenWhenOldControllerIsRetained() { + let presenter = RecordingCheckoutPresenter() + let checkout = ShopifyCheckoutKit.present(checkout: checkoutURL, from: presenter) + presenter.modal = nil + + let next = ShopifyCheckoutKit.present(checkout: checkoutURL, from: presenter) + + XCTAssertFalse(next === checkout) + XCTAssertEqual(presenter.presentationCount, 2) + } + + func test_present_independentPresentersCanPresentTheirOwnCheckout() { + let first = ShopifyCheckoutKit.present(checkout: checkoutURL, from: RecordingCheckoutPresenter()) + let second = ShopifyCheckoutKit.present(checkout: checkoutURL, from: RecordingCheckoutPresenter()) + + XCTAssertFalse(first === second) + } + func test_logger_withDifferentLogLevels_shouldHaveCorrectLogLevel() { ShopifyCheckoutKit.configuration.logLevel = .debug XCTAssertEqual( @@ -142,3 +206,19 @@ class ShopifyCheckoutKitTests: XCTestCase { ) } } + +@MainActor +private final class RecordingCheckoutPresenter: UIViewController { + var modal: UIViewController? + private(set) var presentationCount = 0 + + override var presentedViewController: UIViewController? { + modal + } + + override func present(_ viewControllerToPresent: UIViewController, animated _: Bool, completion: (() -> Void)? = nil) { + presentationCount += 1 + modal = viewControllerToPresent + completion?() + } +}