diff --git a/platforms/react-native/test/rct-integration-app/RCTIntegrationAppTests/ShopifyCheckoutKitTests.swift b/platforms/react-native/test/rct-integration-app/RCTIntegrationAppTests/ShopifyCheckoutKitTests.swift index d1923db90..9fbb32a40 100644 --- a/platforms/react-native/test/rct-integration-app/RCTIntegrationAppTests/ShopifyCheckoutKitTests.swift +++ b/platforms/react-native/test/rct-integration-app/RCTIntegrationAppTests/ShopifyCheckoutKitTests.swift @@ -497,6 +497,7 @@ class ShopifyCheckoutKitTests: XCTestCase { wait(for: [presentAttemptCompleted], timeout: 1) } + @MainActor func testCheckoutDidDismissDismissesCheckoutSheetFromRCTWrapper() { let dismissCompleted = expectation(description: "checkout sheet dismissed") let checkoutSheet = DismissTrackingViewController() diff --git a/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemo/Sources/App/CartResettingCheckoutDelegate.swift b/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemo/Sources/App/CartResettingCheckoutDelegate.swift index ac9d9915f..b1100ea56 100644 --- a/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemo/Sources/App/CartResettingCheckoutDelegate.swift +++ b/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemo/Sources/App/CartResettingCheckoutDelegate.swift @@ -8,13 +8,11 @@ final class CartResettingCheckoutDelegate: CheckoutDelegate { completed = true } - nonisolated func checkoutDidDismiss() { - MainActor.assumeIsolated { - guard completed else { return } - completed = false - CartManager.shared.resetCart() - } + func checkoutDidDismiss() { + guard completed else { return } + completed = false + CartManager.shared.resetCart() } - nonisolated func checkoutDidFail(error _: CheckoutError) {} + func checkoutDidFail(error _: CheckoutError) {} } diff --git a/platforms/swift/Sources/ShopifyAcceleratedCheckouts/Wallets/ApplePay/ApplePayAuthorizationDelegate/ApplePayAuthorizationDelegate.swift b/platforms/swift/Sources/ShopifyAcceleratedCheckouts/Wallets/ApplePay/ApplePayAuthorizationDelegate/ApplePayAuthorizationDelegate.swift index 2917d090e..b891ebb77 100644 --- a/platforms/swift/Sources/ShopifyAcceleratedCheckouts/Wallets/ApplePay/ApplePayAuthorizationDelegate/ApplePayAuthorizationDelegate.swift +++ b/platforms/swift/Sources/ShopifyAcceleratedCheckouts/Wallets/ApplePay/ApplePayAuthorizationDelegate/ApplePayAuthorizationDelegate.swift @@ -188,17 +188,27 @@ class ApplePayAuthorizationDelegate: NSObject, ObservableObject { try? await controller.present(url: url) } + /// Completion is a routing state and does not necessarily mean the buyer dismissed checkout. private func onCompleted(previousState: ApplePayState) async throws { switch previousState { case .paymentAuthorizationFailed, .unexpectedError, .interrupt: + // These states leave Apple Pay and recover in Checkout Kit, so the experience remains open. try await transition(to: .presentingCheckoutKit(url: createCheckoutKitURL(for: previousState))) case let .cartSubmittedForCompletion(redirectURL): + // Continue to the thank-you page or checkout fallback, so the experience remains open. try await transition(to: .presentingCheckoutKit(url: redirectURL)) + case .appleSheetPresented: + // PassKit finishing before the state advances uniquely identifies native Apple Pay dismissal. + controller.onCheckoutDismiss?() + try await transition(to: .reset) + default: + // Setup, error, and Checkout Kit lifecycle completion do not imply dismissal. + // Checkout Kit forwards actual dismissal through ApplePayViewController.checkoutDidDismiss(). try await transition(to: .reset) } } diff --git a/platforms/swift/Sources/ShopifyAcceleratedCheckouts/Wallets/ApplePay/ApplePayViewController.swift b/platforms/swift/Sources/ShopifyAcceleratedCheckouts/Wallets/ApplePay/ApplePayViewController.swift index 1fe8b9c68..4ceaa2dc4 100644 --- a/platforms/swift/Sources/ShopifyAcceleratedCheckouts/Wallets/ApplePay/ApplePayViewController.swift +++ b/platforms/swift/Sources/ShopifyAcceleratedCheckouts/Wallets/ApplePay/ApplePayViewController.swift @@ -7,6 +7,7 @@ import SwiftUI protocol PayController: AnyObject { var cart: StorefrontAPI.Types.Cart? { get set } var storefront: StorefrontAPIProtocol { get set } + var onCheckoutDismiss: (() -> Void)? { get } /// Opens ShopifyCheckoutKit func present(url: URL) async throws @@ -19,6 +20,10 @@ class ApplePayViewController: WalletController, PayController { var cart: StorefrontAPI.Types.Cart? + override var checkoutDelegate: (any CheckoutDelegate)? { + self + } + var client: (any CheckoutCommunicationProtocol)? // MARK: - Callback Properties @@ -158,3 +163,18 @@ class ApplePayViewController: WalletController, PayController { try await present(url: url, client: client) } } + +@available(iOS 16.0, *) +extension ApplePayViewController: CheckoutDelegate { + func checkoutDidDismiss() { + onCheckoutDismiss?() + Task { @MainActor [weak self] in + guard let self else { return } + try? await authorizationDelegate.transition(to: .completed) + } + } + + func checkoutDidFail(error: CheckoutError) { + onCheckoutFail?(error) + } +} diff --git a/platforms/swift/Sources/ShopifyAcceleratedCheckouts/Wallets/ShopPay/ShopPayViewController.swift b/platforms/swift/Sources/ShopifyAcceleratedCheckouts/Wallets/ShopPay/ShopPayViewController.swift index 597f70b44..be7c570a9 100644 --- a/platforms/swift/Sources/ShopifyAcceleratedCheckouts/Wallets/ShopPay/ShopPayViewController.swift +++ b/platforms/swift/Sources/ShopifyAcceleratedCheckouts/Wallets/ShopPay/ShopPayViewController.swift @@ -7,6 +7,10 @@ class ShopPayViewController: WalletController { var eventHandlers: EventHandlers var client: (any CheckoutCommunicationProtocol)? + override var checkoutDelegate: (any CheckoutDelegate)? { + self + } + init( identifier: CheckoutIdentifier, configuration: ShopifyAcceleratedCheckouts.Configuration, @@ -42,3 +46,14 @@ class ShopPayViewController: WalletController { } } } + +@available(iOS 16.0, *) +extension ShopPayViewController: CheckoutDelegate { + func checkoutDidDismiss() { + eventHandlers.checkoutDidDismiss?() + } + + func checkoutDidFail(error: CheckoutError) { + eventHandlers.checkoutDidFail?(error) + } +} diff --git a/platforms/swift/Sources/ShopifyAcceleratedCheckouts/Wallets/WalletController.swift b/platforms/swift/Sources/ShopifyAcceleratedCheckouts/Wallets/WalletController.swift index 128d7180d..51f51ffd4 100644 --- a/platforms/swift/Sources/ShopifyAcceleratedCheckouts/Wallets/WalletController.swift +++ b/platforms/swift/Sources/ShopifyAcceleratedCheckouts/Wallets/WalletController.swift @@ -9,6 +9,10 @@ class WalletController: ObservableObject { @Published var checkoutViewController: CheckoutViewController? @Published var configuration: ShopifyAcceleratedCheckouts.Configuration + var checkoutDelegate: (any CheckoutDelegate)? { + nil + } + init(identifier: CheckoutIdentifier, storefront: StorefrontAPIProtocol, configuration: ShopifyAcceleratedCheckouts.Configuration) { self.identifier = identifier self.storefront = storefront @@ -43,6 +47,7 @@ class WalletController: ObservableObject { checkout: url, from: topViewController, entryPoint: .acceleratedCheckouts, + delegate: checkoutDelegate, client: client ) } diff --git a/platforms/swift/Sources/ShopifyCheckoutKit/CheckoutDelegate.swift b/platforms/swift/Sources/ShopifyCheckoutKit/CheckoutDelegate.swift index aa12d29a4..22b014224 100644 --- a/platforms/swift/Sources/ShopifyCheckoutKit/CheckoutDelegate.swift +++ b/platforms/swift/Sources/ShopifyCheckoutKit/CheckoutDelegate.swift @@ -1,6 +1,7 @@ import Foundation /// A delegate protocol for managing checkout lifecycle events. +@MainActor public protocol CheckoutDelegate: AnyObject { /// Tells the delegate that the buyer dismissed checkout. func checkoutDidDismiss() diff --git a/platforms/swift/Tests/ShopifyAcceleratedCheckoutsTests/Wallets/ApplePay/ApplePayAuthorizationDelegate/ApplePayAuthorizationDelegateControllerTests.swift b/platforms/swift/Tests/ShopifyAcceleratedCheckoutsTests/Wallets/ApplePay/ApplePayAuthorizationDelegate/ApplePayAuthorizationDelegateControllerTests.swift index c7ed15aa2..cdc8b1dd1 100644 --- a/platforms/swift/Tests/ShopifyAcceleratedCheckoutsTests/Wallets/ApplePay/ApplePayAuthorizationDelegate/ApplePayAuthorizationDelegateControllerTests.swift +++ b/platforms/swift/Tests/ShopifyAcceleratedCheckoutsTests/Wallets/ApplePay/ApplePayAuthorizationDelegate/ApplePayAuthorizationDelegateControllerTests.swift @@ -554,6 +554,7 @@ final class ApplePayAuthorizationDelegateControllerTests: XCTestCase { private class MockPayController: PayController { var cart: StorefrontAPI.Types.Cart? var storefront: StorefrontAPIProtocol + var onCheckoutDismiss: (() -> Void)? init() { let cfg = ShopifyAcceleratedCheckouts.Configuration.testConfiguration diff --git a/platforms/swift/Tests/ShopifyAcceleratedCheckoutsTests/Wallets/ApplePay/ApplePayAuthorizationDelegate/ApplePayAuthorizationDelegateTests.swift b/platforms/swift/Tests/ShopifyAcceleratedCheckoutsTests/Wallets/ApplePay/ApplePayAuthorizationDelegate/ApplePayAuthorizationDelegateTests.swift index e9d5c312e..84be88da3 100644 --- a/platforms/swift/Tests/ShopifyAcceleratedCheckoutsTests/Wallets/ApplePay/ApplePayAuthorizationDelegate/ApplePayAuthorizationDelegateTests.swift +++ b/platforms/swift/Tests/ShopifyAcceleratedCheckoutsTests/Wallets/ApplePay/ApplePayAuthorizationDelegate/ApplePayAuthorizationDelegateTests.swift @@ -444,8 +444,8 @@ final class ApplePayAuthorizationDelegateTests: XCTestCase { } /// User cancels the sheet without authorizing payment - func test_onCompleted_withDefaultCase_shouldTransitionToReset() async throws { - // Start with appleSheetPresented (a state that falls into default case) + func test_onCompleted_withAppleSheetPresented_shouldTransitionToReset() async throws { + // Start with appleSheetPresented try await delegate.transition(to: .startPaymentRequest) XCTAssertEqual(delegate.state, .appleSheetPresented) @@ -459,6 +459,16 @@ final class ApplePayAuthorizationDelegateTests: XCTestCase { ) } + func test_onCompleted_withAppleSheetPresented_shouldNotifyDismissal() async throws { + var dismissalCount = 0 + mockController.onCheckoutDismiss = { dismissalCount += 1 } + + try await delegate.transition(to: .startPaymentRequest) + try await delegate.transition(to: .completed) + + XCTAssertEqual(dismissalCount, 1) + } + // MARK: onPresentingCheckoutKit() func test_onPresentingCheckoutKit_withValidURL_shouldCallPresentSuccessfully() async throws { @@ -670,6 +680,7 @@ final class ApplePayAuthorizationDelegateTests: XCTestCase { var presentCallCount = 0 var presentCalledWith: URL? + var onCheckoutDismiss: (() -> Void)? init() { let config = ShopifyAcceleratedCheckouts.Configuration.testConfiguration @@ -688,6 +699,7 @@ final class ApplePayAuthorizationDelegateTests: XCTestCase { private class FailingMockPayController: PayController { var cart: StorefrontAPI.Types.Cart? var storefront: StorefrontAPIProtocol + var onCheckoutDismiss: (() -> Void)? var presentCallCount = 0 @@ -708,6 +720,7 @@ final class ApplePayAuthorizationDelegateTests: XCTestCase { private class SpyPayController: PayController { var cart: StorefrontAPI.Types.Cart? var storefront: StorefrontAPIProtocol + var onCheckoutDismiss: (() -> Void)? var presentCallCount = 0 var presentCalledWith: URL? diff --git a/platforms/swift/Tests/ShopifyAcceleratedCheckoutsTests/Wallets/ApplePay/ApplePayViewControllerTests.swift b/platforms/swift/Tests/ShopifyAcceleratedCheckoutsTests/Wallets/ApplePay/ApplePayViewControllerTests.swift index 0dacbc28c..7f17bb9ea 100644 --- a/platforms/swift/Tests/ShopifyAcceleratedCheckoutsTests/Wallets/ApplePay/ApplePayViewControllerTests.swift +++ b/platforms/swift/Tests/ShopifyAcceleratedCheckoutsTests/Wallets/ApplePay/ApplePayViewControllerTests.swift @@ -1,5 +1,5 @@ @testable import ShopifyAcceleratedCheckouts -import ShopifyCheckoutKit +@testable import ShopifyCheckoutKit import UIKit import XCTest @@ -141,6 +141,38 @@ class ApplePayViewControllerTests: XCTestCase { await fulfillment(of: [dismissCallbackExpectation], timeout: 1.0) } + func test_checkoutDidDismiss_whenPresentedCheckoutDismisses_invokesOnDismissCallback() async throws { + let dismissCallbackExpectation = expectation(description: "Dismiss callback should be invoked") + viewController.onCheckoutDismiss = { dismissCallbackExpectation.fulfill() } + viewController.mockTopViewController = UIViewController() + + let checkoutURL = try XCTUnwrap(URL(string: "https://test-shop.myshopify.com/checkout")) + try await viewController.present(url: checkoutURL, client: nil) + + let checkoutViewController = try XCTUnwrap(viewController.checkoutViewController) + let webViewController = try XCTUnwrap( + checkoutViewController.viewControllers.first as? CheckoutWebViewController + ) + webViewController.close() + + await fulfillment(of: [dismissCallbackExpectation], timeout: 1.0) + } + + func test_checkoutDidDismiss_fromDetachedTask_invokesCallbackOnMainActor() async throws { + let dismissCallbackExpectation = expectation(description: "Dismiss callback should run on the main actor") + viewController.onCheckoutDismiss = { + MainActor.preconditionIsolated() + dismissCallbackExpectation.fulfill() + } + let controller = try XCTUnwrap(viewController) + + await Task.detached { + await controller.checkoutDidDismiss() + }.value + + await fulfillment(of: [dismissCallbackExpectation], timeout: 1.0) + } + // MARK: - WalletController Inheritance func test_configuration_whenInitialized_usesCorrectStorefront() { diff --git a/platforms/swift/Tests/ShopifyAcceleratedCheckoutsTests/Wallets/ShopPay/ShopPayCallbackTests.swift b/platforms/swift/Tests/ShopifyAcceleratedCheckoutsTests/Wallets/ShopPay/ShopPayCallbackTests.swift index d6f3769cd..eb06d0d60 100644 --- a/platforms/swift/Tests/ShopifyAcceleratedCheckoutsTests/Wallets/ShopPay/ShopPayCallbackTests.swift +++ b/platforms/swift/Tests/ShopifyAcceleratedCheckoutsTests/Wallets/ShopPay/ShopPayCallbackTests.swift @@ -7,12 +7,20 @@ import XCTest final class ShopPayCallbackTests: XCTestCase { // MARK: - Properties - var viewController: ShopPayViewController! + var viewController: MockShopPayViewController! var mockConfiguration: ShopifyAcceleratedCheckouts.Configuration! var mockIdentifier: CheckoutIdentifier! var errorExpectation: XCTestExpectation! var dismissExpectation: XCTestExpectation! + final class MockShopPayViewController: ShopPayViewController { + let mockTopViewController = UIViewController() + + override func getTopViewController() -> UIViewController? { + mockTopViewController + } + } + // MARK: - Setup override func setUp() async throws { @@ -25,7 +33,7 @@ final class ShopPayCallbackTests: XCTestCase { mockIdentifier = .cart(cartID: "gid://Shopify/Cart/test-cart-id") - viewController = ShopPayViewController( + viewController = MockShopPayViewController( identifier: mockIdentifier, configuration: mockConfiguration ) @@ -96,6 +104,47 @@ final class ShopPayCallbackTests: XCTestCase { XCTAssertTrue(true, "Should not crash when callback is nil") } + func testDismissCallbackInvokedWhenPresentedCheckoutDismisses() async throws { + let dismissExpectation = expectation(description: "Dismiss callback should be invoked") + viewController.eventHandlers = EventHandlers( + checkoutDidDismiss: { dismissExpectation.fulfill() } + ) + + let checkoutURL = try XCTUnwrap(URL(string: "https://test-shop.myshopify.com/checkout")) + try await viewController.present(url: checkoutURL, client: nil) + + let checkoutViewController = try XCTUnwrap(viewController.checkoutViewController) + let webViewController = try XCTUnwrap( + checkoutViewController.viewControllers.first as? CheckoutWebViewController + ) + webViewController.close() + + await fulfillment(of: [dismissExpectation], timeout: 1.0) + } + + func testCheckoutDelegateCallbacksFromDetachedTaskRunOnMainActor() async throws { + let dismissExpectation = expectation(description: "Dismiss callback should run on the main actor") + let failExpectation = expectation(description: "Fail callback should run on the main actor") + viewController.eventHandlers = EventHandlers( + checkoutDidFail: { _ in + MainActor.preconditionIsolated() + failExpectation.fulfill() + }, + checkoutDidDismiss: { + MainActor.preconditionIsolated() + dismissExpectation.fulfill() + } + ) + let controller = try XCTUnwrap(viewController) + + await Task.detached { + await controller.checkoutDidDismiss() + await controller.checkoutDidFail(error: CheckoutError(code: .sdkError, message: "Test error")) + }.value + + await fulfillment(of: [dismissExpectation, failExpectation], timeout: 1.0) + } + // MARK: - Delegate Tests @MainActor diff --git a/platforms/swift/api/ShopifyCheckoutKit.json b/platforms/swift/api/ShopifyCheckoutKit.json index 7c3b2c2db..b0a804561 100644 --- a/platforms/swift/api/ShopifyCheckoutKit.json +++ b/platforms/swift/api/ShopifyCheckoutKit.json @@ -236,6 +236,9 @@ "moduleName": "ShopifyCheckoutKit", "genericSig": "", "protocolReq": true, + "declAttributes": [ + "Custom" + ], "reqNewWitnessTableEntry": true, "funcSelfKind": "NonMutating" }, @@ -262,6 +265,9 @@ "moduleName": "ShopifyCheckoutKit", "genericSig": "", "protocolReq": true, + "declAttributes": [ + "Custom" + ], "reqNewWitnessTableEntry": true, "funcSelfKind": "NonMutating" } @@ -271,6 +277,9 @@ "mangledName": "$s18ShopifyCheckoutKit0B8DelegateP", "moduleName": "ShopifyCheckoutKit", "genericSig": "", + "declAttributes": [ + "Custom" + ], "conformances": [ { "kind": "Conformance",