From a30598ee1a2da0257d9d3c18ad15ef5f5ba2e908 Mon Sep 17 00:00:00 2001 From: Mark Murray Date: Mon, 13 Jul 2026 20:37:56 +0100 Subject: [PATCH 1/5] [Swift] Add preload state observability --- .../Sources/Scenes/Cart/CartView.swift | 13 +- .../Scenes/Cart/CartViewController.swift | 1 + .../ShopifyCheckoutKit/CheckoutWebView.swift | 37 +- .../ShopifyCheckoutKit/PreloadState.swift | 43 + .../ShopifyCheckoutKit.swift | 13 +- .../PreloadObservabilityTests.swift | 126 +++ platforms/swift/api/ShopifyCheckoutKit.json | 770 +++++++++++++++++- 7 files changed, 991 insertions(+), 12 deletions(-) create mode 100644 platforms/swift/Sources/ShopifyCheckoutKit/PreloadState.swift create mode 100644 platforms/swift/Tests/ShopifyCheckoutKitTests/PreloadObservabilityTests.swift diff --git a/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemo/Sources/Scenes/Cart/CartView.swift b/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemo/Sources/Scenes/Cart/CartView.swift index 90c19f48d..67bcb0516 100644 --- a/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemo/Sources/Scenes/Cart/CartView.swift +++ b/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemo/Sources/Scenes/Cart/CartView.swift @@ -11,6 +11,7 @@ struct CartView: View { @State var isBusy: Bool = false @State var isCompleted: Bool = false @State var showCheckoutSheet: Bool = false + @State private var checkoutPreload: CheckoutPreload? @ObservedObject var cartManager: CartManager = .shared @@ -32,7 +33,7 @@ struct CartView: View { ZStack(alignment: .bottom) { ScrollView { VStack { - CartLines(lines: lines, isBusy: $isBusy) + CartLines(lines: lines, update: preloadCheckoutIfNeeded, isBusy: $isBusy) } .padding(.bottom, 130) } @@ -124,6 +125,7 @@ struct CartView: View { ToolbarItem(placement: .navigationBarTrailing) { Button(action: { cartManager.resetCart() + ShopifyCheckoutKit.invalidate() }) { Image(systemName: "trash") .foregroundColor(.red) @@ -153,7 +155,11 @@ struct CartView: View { private func preloadCheckoutIfNeeded() { guard checkoutPreloadingEnabled, let url = cartManager.cart?.checkoutURL else { return } - ShopifyCheckoutKit.preload(checkout: url) + ShopifyCheckoutKit.invalidate() + checkoutPreload = ShopifyCheckoutKit.preload(checkout: url) { state in + print("[Preload] state changed to \(state)") + ShopifyCheckoutKit.configuration.logger.log("Preload state changed to \(state)") + } } } @@ -173,6 +179,7 @@ struct EmptyState: View { struct CartLines: View { var lines: [CartLineNode] + var update: () -> Void @State var updating: String? { didSet { isBusy = updating != nil @@ -238,6 +245,7 @@ struct CartLines: View { let cart = try await CartManager.shared.performCartLinesUpdate(id: node.id, quantity: node.quantity - 1) CartManager.shared.cart = cart updating = nil + update() } }, label: { Image(systemName: "minus") @@ -272,6 +280,7 @@ struct CartLines: View { ) CartManager.shared.cart = cart updating = nil + update() } }, label: { diff --git a/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemo/Sources/Scenes/Cart/CartViewController.swift b/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemo/Sources/Scenes/Cart/CartViewController.swift index c499ee14a..ddcb08c8a 100644 --- a/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemo/Sources/Scenes/Cart/CartViewController.swift +++ b/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemo/Sources/Scenes/Cart/CartViewController.swift @@ -377,6 +377,7 @@ class CartViewController: UIViewController, UITableViewDelegate, UITableViewData @objc private func resetCart() { CartManager.shared.resetCart() + ShopifyCheckoutKit.invalidate() } private func node(at indexPath: IndexPath) -> CartLineNode { diff --git a/platforms/swift/Sources/ShopifyCheckoutKit/CheckoutWebView.swift b/platforms/swift/Sources/ShopifyCheckoutKit/CheckoutWebView.swift index 9649f0513..110e99f07 100644 --- a/platforms/swift/Sources/ShopifyCheckoutKit/CheckoutWebView.swift +++ b/platforms/swift/Sources/ShopifyCheckoutKit/CheckoutWebView.swift @@ -40,6 +40,13 @@ final class PreloadCache { private var keepAliveTimer: Timer? private var expiryTimer: Timer? + private(set) var state: PreloadState = .idle + private weak var observer: CheckoutPreload? + + func setObserver(_ observer: CheckoutPreload) { + self.observer = observer + } + func store(_ view: CheckoutWebView, for key: PreloadKey, createdAt: Date = Date()) -> Bool { if let entry, entry.key == key, !entry.isStale { return true @@ -56,9 +63,15 @@ final class PreloadCache { self.entry = entry startKeepAlive(for: view) startExpiryTimer(after: entry.remainingTTL) + transition(to: .loading) return true } + func transition(to newState: PreloadState) { + state = newState + observer?.receive(newState) + } + func view(for key: PreloadKey) -> CheckoutWebView? { guard let entry, entry.key == key, !entry.isStale else { invalidate() @@ -124,7 +137,7 @@ final class PreloadCache { _ = try await view?.evaluateJavaScript("void 0") } catch { OSLogger.shared.debug("Preload keep-alive failed; invalidating preload cache") - self?.invalidate() + self?.keepAliveDidFail() } } } @@ -136,13 +149,23 @@ final class PreloadCache { stopExpiryTimer() let timer = Timer.scheduledTimer(withTimeInterval: interval, repeats: false) { [weak self] _ in Task { @MainActor in - self?.invalidate() + self?.expire() } } timer.tolerance = min(interval / 2, 1) expiryTimer = timer } + func expire() { + transition(to: .expired) + invalidate() + } + + func keepAliveDidFail() { + transition(to: .failed(reason: .keepAliveLost)) + invalidate() + } + private func stopKeepAlive() { keepAliveTimer?.invalidate() keepAliveTimer = nil @@ -532,6 +555,9 @@ extension CheckoutWebView: WKNavigationDelegate { } if statusCode >= 400 { + if CheckoutWebView.preloadCache.contains(self) { + CheckoutWebView.preloadCache.transition(to: .failed(reason: .httpError(statusCode: statusCode))) + } CheckoutWebView.invalidate() OSLogger.shared.debug("Handling response for URL: \(LogSafeURL.string(response.url)), status code: \(statusCode)") @@ -569,6 +595,10 @@ extension CheckoutWebView: WKNavigationDelegate { } func webView(_: WKWebView, didFinish _: WKNavigation!) { + if CheckoutWebView.preloadCache.contains(self) { + CheckoutWebView.preloadCache.transition(to: .ready) + } + viewDelegate?.checkoutViewDidFinishNavigation() if let startTime = timer { @@ -593,6 +623,9 @@ extension CheckoutWebView: WKNavigationDelegate { return } + if CheckoutWebView.preloadCache.contains(self) { + CheckoutWebView.preloadCache.transition(to: .failed(reason: .navigationFailed)) + } CheckoutWebView.invalidate() viewDelegate?.checkoutViewDidFailWithError(error: .sdkError(underlying: error)) } diff --git a/platforms/swift/Sources/ShopifyCheckoutKit/PreloadState.swift b/platforms/swift/Sources/ShopifyCheckoutKit/PreloadState.swift new file mode 100644 index 000000000..20baa4366 --- /dev/null +++ b/platforms/swift/Sources/ShopifyCheckoutKit/PreloadState.swift @@ -0,0 +1,43 @@ +import Foundation + +/// Observable lifecycle state of a preloaded checkout. +public enum PreloadState: Equatable { + case idle + case loading + case ready + case expired + case failed(reason: FailureReason) + + public enum FailureReason: Equatable { + case httpError(statusCode: Int) + case navigationFailed + case keepAliveLost + } +} + +/// Returned by `preload(checkout:onStateChange:)` exposing the current preload +/// state. Because the preload cache is single-slot, every instance reflects the +/// same shared state. +/// +/// Retain the returned instance for as long as you want to observe state +/// changes; the cache holds it weakly. +@MainActor +public final class CheckoutPreload { + private let cache: PreloadCache + + init(cache: PreloadCache) { + self.cache = cache + cache.setObserver(self) + } + + /// Called on the main actor whenever the preload state changes. + public var onStateChange: ((PreloadState) -> Void)? + + public var state: PreloadState { + cache.state + } + + func receive(_ state: PreloadState) { + onStateChange?(state) + } +} diff --git a/platforms/swift/Sources/ShopifyCheckoutKit/ShopifyCheckoutKit.swift b/platforms/swift/Sources/ShopifyCheckoutKit/ShopifyCheckoutKit.swift index 0cd822a3b..04f2abc48 100644 --- a/platforms/swift/Sources/ShopifyCheckoutKit/ShopifyCheckoutKit.swift +++ b/platforms/swift/Sources/ShopifyCheckoutKit/ShopifyCheckoutKit.swift @@ -41,21 +41,28 @@ private func applyConfigurationChange(configuration: Configuration, previousConf } } -/// Preloads the checkout for faster presentation. +/// Preloads the checkout for faster presentation and returns a handle for +/// observing preload state. Retain the handle to keep observing. @MainActor -public func preload(checkout url: URL) { +@discardableResult +public func preload(checkout url: URL, onStateChange: ((PreloadState) -> Void)? = nil) -> CheckoutPreload { + let checkoutPreload = CheckoutPreload(cache: CheckoutWebView.preloadCache) + checkoutPreload.onStateChange = onStateChange + guard configuration.preloading.enabled else { - return + return checkoutPreload } let decorated = CheckoutURLDecorator.decorate(url) CheckoutWebView.preload(checkout: decorated) + return checkoutPreload } /// Invalidates any cached checkout created by preload calls. @MainActor public func invalidate() { CheckoutWebView.invalidate(disconnect: true) + CheckoutWebView.preloadCache.transition(to: .idle) } @MainActor diff --git a/platforms/swift/Tests/ShopifyCheckoutKitTests/PreloadObservabilityTests.swift b/platforms/swift/Tests/ShopifyCheckoutKitTests/PreloadObservabilityTests.swift new file mode 100644 index 000000000..e4085c5e8 --- /dev/null +++ b/platforms/swift/Tests/ShopifyCheckoutKitTests/PreloadObservabilityTests.swift @@ -0,0 +1,126 @@ +import Combine +import EmbeddedCheckoutProtocol +@testable import ShopifyCheckoutKit +import WebKit +import XCTest + +@MainActor +class PreloadObservabilityTests: XCTestCase { + private var url = URL(string: "http://shopify1.shopify.com/checkouts/cn/123")! + + override func setUp() async throws { + try await super.setUp() + ShopifyCheckoutKit.configuration.preloading.enabled = true + CheckoutWebView.invalidate() + } + + override func tearDown() async throws { + CheckoutWebView.invalidate() + ShopifyCheckoutKit.configuration.preloading.enabled = true + try await super.tearDown() + } + + func testPreloadReturnsHandleInLoadingState() { + let preload = ShopifyCheckoutKit.preload(checkout: url) + + guard case .loading = preload.state else { + return XCTFail("expected .loading, got \(preload.state)") + } + } + + func testManualInvalidateTransitionsToIdle() { + let preload = ShopifyCheckoutKit.preload(checkout: url) + + ShopifyCheckoutKit.invalidate() + + guard case .idle = preload.state else { + return XCTFail("expected .idle, got \(preload.state)") + } + } + + func testOnStateChangeReceivesTransitions() { + var states: [PreloadState] = [] + + let preload = ShopifyCheckoutKit.preload(checkout: url) { states.append($0) } + ShopifyCheckoutKit.invalidate() + + withExtendedLifetime(preload) { + XCTAssertEqual(states, [.loading, .idle]) + } + } + + func testNewObserverReplacesPrevious() { + var firstStates: [PreloadState] = [] + var secondStates: [PreloadState] = [] + + let first = ShopifyCheckoutKit.preload(checkout: url) { firstStates.append($0) } + let second = ShopifyCheckoutKit.preload(checkout: url) { secondStates.append($0) } + + ShopifyCheckoutKit.invalidate() + + withExtendedLifetime((first, second)) { + XCTAssertEqual(firstStates, [.loading]) + XCTAssertEqual(secondStates, [.idle]) + } + } + + func testReadyTransitionOnDidFinish() { + let preload = ShopifyCheckoutKit.preload(checkout: url) + let view = CheckoutWebView(entryPoint: nil) + _ = CheckoutWebView.preloadCache.store(view, for: PreloadKey(url: url, entryPoint: nil)) + + view.webView(view, didFinish: nil) + + withExtendedLifetime(preload) { + XCTAssertEqual(preload.state, .ready) + } + } + + func testExpiryTransitionsToExpired() { + let preload = ShopifyCheckoutKit.preload(checkout: url) + + CheckoutWebView.preloadCache.expire() + + withExtendedLifetime(preload) { + XCTAssertEqual(preload.state, .expired) + } + } + + func testKeepAliveFailureTransitionsToFailed() { + let preload = ShopifyCheckoutKit.preload(checkout: url) + + CheckoutWebView.preloadCache.keepAliveDidFail() + + withExtendedLifetime(preload) { + XCTAssertEqual(preload.state, .failed(reason: .keepAliveLost)) + } + } + + func testHTTPErrorTransitionsToFailed() throws { + let preload = ShopifyCheckoutKit.preload(checkout: url) + let view = CheckoutWebView(entryPoint: nil) + _ = CheckoutWebView.preloadCache.store(view, for: PreloadKey(url: url, entryPoint: nil)) + view.load(checkout: url) + let link = view.url ?? url + + let response = try XCTUnwrap(HTTPURLResponse(url: link, statusCode: 500, httpVersion: nil, headerFields: nil)) + _ = view.handleResponse(response) + + withExtendedLifetime(preload) { + XCTAssertEqual(preload.state, .failed(reason: .httpError(statusCode: 500))) + } + } + + func testNavigationFailureTransitionsToFailed() { + let preload = ShopifyCheckoutKit.preload(checkout: url) + let view = CheckoutWebView(entryPoint: nil) + _ = CheckoutWebView.preloadCache.store(view, for: PreloadKey(url: url, entryPoint: nil)) + + let error = NSError(domain: NSURLErrorDomain, code: NSURLErrorTimedOut) + view.webView(view, didFail: nil, withError: error) + + withExtendedLifetime(preload) { + XCTAssertEqual(preload.state, .failed(reason: .navigationFailed)) + } + } +} diff --git a/platforms/swift/api/ShopifyCheckoutKit.json b/platforms/swift/api/ShopifyCheckoutKit.json index 670028ebe..49759478f 100644 --- a/platforms/swift/api/ShopifyCheckoutKit.json +++ b/platforms/swift/api/ShopifyCheckoutKit.json @@ -6532,6 +6532,730 @@ } ] }, + { + "kind": "TypeDecl", + "name": "PreloadState", + "printedName": "PreloadState", + "children": [ + { + "kind": "Var", + "name": "idle", + "printedName": "idle", + "children": [ + { + "kind": "TypeFunc", + "name": "Function", + "printedName": "(ShopifyCheckoutKit.PreloadState.Type) -> ShopifyCheckoutKit.PreloadState", + "children": [ + { + "kind": "TypeNominal", + "name": "PreloadState", + "printedName": "ShopifyCheckoutKit.PreloadState", + "usr": "s:18ShopifyCheckoutKit12PreloadStateO" + }, + { + "kind": "TypeNominal", + "name": "Metatype", + "printedName": "ShopifyCheckoutKit.PreloadState.Type", + "children": [ + { + "kind": "TypeNominal", + "name": "PreloadState", + "printedName": "ShopifyCheckoutKit.PreloadState", + "usr": "s:18ShopifyCheckoutKit12PreloadStateO" + } + ] + } + ] + } + ], + "declKind": "EnumElement", + "usr": "s:18ShopifyCheckoutKit12PreloadStateO4idleyA2CmF", + "mangledName": "$s18ShopifyCheckoutKit12PreloadStateO4idleyA2CmF", + "moduleName": "ShopifyCheckoutKit" + }, + { + "kind": "Var", + "name": "loading", + "printedName": "loading", + "children": [ + { + "kind": "TypeFunc", + "name": "Function", + "printedName": "(ShopifyCheckoutKit.PreloadState.Type) -> ShopifyCheckoutKit.PreloadState", + "children": [ + { + "kind": "TypeNominal", + "name": "PreloadState", + "printedName": "ShopifyCheckoutKit.PreloadState", + "usr": "s:18ShopifyCheckoutKit12PreloadStateO" + }, + { + "kind": "TypeNominal", + "name": "Metatype", + "printedName": "ShopifyCheckoutKit.PreloadState.Type", + "children": [ + { + "kind": "TypeNominal", + "name": "PreloadState", + "printedName": "ShopifyCheckoutKit.PreloadState", + "usr": "s:18ShopifyCheckoutKit12PreloadStateO" + } + ] + } + ] + } + ], + "declKind": "EnumElement", + "usr": "s:18ShopifyCheckoutKit12PreloadStateO7loadingyA2CmF", + "mangledName": "$s18ShopifyCheckoutKit12PreloadStateO7loadingyA2CmF", + "moduleName": "ShopifyCheckoutKit" + }, + { + "kind": "Var", + "name": "ready", + "printedName": "ready", + "children": [ + { + "kind": "TypeFunc", + "name": "Function", + "printedName": "(ShopifyCheckoutKit.PreloadState.Type) -> ShopifyCheckoutKit.PreloadState", + "children": [ + { + "kind": "TypeNominal", + "name": "PreloadState", + "printedName": "ShopifyCheckoutKit.PreloadState", + "usr": "s:18ShopifyCheckoutKit12PreloadStateO" + }, + { + "kind": "TypeNominal", + "name": "Metatype", + "printedName": "ShopifyCheckoutKit.PreloadState.Type", + "children": [ + { + "kind": "TypeNominal", + "name": "PreloadState", + "printedName": "ShopifyCheckoutKit.PreloadState", + "usr": "s:18ShopifyCheckoutKit12PreloadStateO" + } + ] + } + ] + } + ], + "declKind": "EnumElement", + "usr": "s:18ShopifyCheckoutKit12PreloadStateO5readyyA2CmF", + "mangledName": "$s18ShopifyCheckoutKit12PreloadStateO5readyyA2CmF", + "moduleName": "ShopifyCheckoutKit" + }, + { + "kind": "Var", + "name": "expired", + "printedName": "expired", + "children": [ + { + "kind": "TypeFunc", + "name": "Function", + "printedName": "(ShopifyCheckoutKit.PreloadState.Type) -> ShopifyCheckoutKit.PreloadState", + "children": [ + { + "kind": "TypeNominal", + "name": "PreloadState", + "printedName": "ShopifyCheckoutKit.PreloadState", + "usr": "s:18ShopifyCheckoutKit12PreloadStateO" + }, + { + "kind": "TypeNominal", + "name": "Metatype", + "printedName": "ShopifyCheckoutKit.PreloadState.Type", + "children": [ + { + "kind": "TypeNominal", + "name": "PreloadState", + "printedName": "ShopifyCheckoutKit.PreloadState", + "usr": "s:18ShopifyCheckoutKit12PreloadStateO" + } + ] + } + ] + } + ], + "declKind": "EnumElement", + "usr": "s:18ShopifyCheckoutKit12PreloadStateO7expiredyA2CmF", + "mangledName": "$s18ShopifyCheckoutKit12PreloadStateO7expiredyA2CmF", + "moduleName": "ShopifyCheckoutKit" + }, + { + "kind": "Var", + "name": "failed", + "printedName": "failed", + "children": [ + { + "kind": "TypeFunc", + "name": "Function", + "printedName": "(ShopifyCheckoutKit.PreloadState.Type) -> (ShopifyCheckoutKit.PreloadState.FailureReason) -> ShopifyCheckoutKit.PreloadState", + "children": [ + { + "kind": "TypeFunc", + "name": "Function", + "printedName": "(ShopifyCheckoutKit.PreloadState.FailureReason) -> ShopifyCheckoutKit.PreloadState", + "children": [ + { + "kind": "TypeNominal", + "name": "PreloadState", + "printedName": "ShopifyCheckoutKit.PreloadState", + "usr": "s:18ShopifyCheckoutKit12PreloadStateO" + }, + { + "kind": "TypeNominal", + "name": "Tuple", + "printedName": "(reason: ShopifyCheckoutKit.PreloadState.FailureReason)", + "children": [ + { + "kind": "TypeNominal", + "name": "FailureReason", + "printedName": "ShopifyCheckoutKit.PreloadState.FailureReason", + "usr": "s:18ShopifyCheckoutKit12PreloadStateO13FailureReasonO" + } + ] + } + ] + }, + { + "kind": "TypeNominal", + "name": "Metatype", + "printedName": "ShopifyCheckoutKit.PreloadState.Type", + "children": [ + { + "kind": "TypeNominal", + "name": "PreloadState", + "printedName": "ShopifyCheckoutKit.PreloadState", + "usr": "s:18ShopifyCheckoutKit12PreloadStateO" + } + ] + } + ] + } + ], + "declKind": "EnumElement", + "usr": "s:18ShopifyCheckoutKit12PreloadStateO6failedyA2C13FailureReasonO_tcACmF", + "mangledName": "$s18ShopifyCheckoutKit12PreloadStateO6failedyA2C13FailureReasonO_tcACmF", + "moduleName": "ShopifyCheckoutKit" + }, + { + "kind": "TypeDecl", + "name": "FailureReason", + "printedName": "FailureReason", + "children": [ + { + "kind": "Var", + "name": "httpError", + "printedName": "httpError", + "children": [ + { + "kind": "TypeFunc", + "name": "Function", + "printedName": "(ShopifyCheckoutKit.PreloadState.FailureReason.Type) -> (Swift.Int) -> ShopifyCheckoutKit.PreloadState.FailureReason", + "children": [ + { + "kind": "TypeFunc", + "name": "Function", + "printedName": "(Swift.Int) -> ShopifyCheckoutKit.PreloadState.FailureReason", + "children": [ + { + "kind": "TypeNominal", + "name": "FailureReason", + "printedName": "ShopifyCheckoutKit.PreloadState.FailureReason", + "usr": "s:18ShopifyCheckoutKit12PreloadStateO13FailureReasonO" + }, + { + "kind": "TypeNominal", + "name": "Tuple", + "printedName": "(statusCode: Swift.Int)", + "children": [ + { + "kind": "TypeNominal", + "name": "Int", + "printedName": "Swift.Int", + "usr": "s:Si" + } + ] + } + ] + }, + { + "kind": "TypeNominal", + "name": "Metatype", + "printedName": "ShopifyCheckoutKit.PreloadState.FailureReason.Type", + "children": [ + { + "kind": "TypeNominal", + "name": "FailureReason", + "printedName": "ShopifyCheckoutKit.PreloadState.FailureReason", + "usr": "s:18ShopifyCheckoutKit12PreloadStateO13FailureReasonO" + } + ] + } + ] + } + ], + "declKind": "EnumElement", + "usr": "s:18ShopifyCheckoutKit12PreloadStateO13FailureReasonO9httpErroryAESi_tcAEmF", + "mangledName": "$s18ShopifyCheckoutKit12PreloadStateO13FailureReasonO9httpErroryAESi_tcAEmF", + "moduleName": "ShopifyCheckoutKit" + }, + { + "kind": "Var", + "name": "navigationFailed", + "printedName": "navigationFailed", + "children": [ + { + "kind": "TypeFunc", + "name": "Function", + "printedName": "(ShopifyCheckoutKit.PreloadState.FailureReason.Type) -> ShopifyCheckoutKit.PreloadState.FailureReason", + "children": [ + { + "kind": "TypeNominal", + "name": "FailureReason", + "printedName": "ShopifyCheckoutKit.PreloadState.FailureReason", + "usr": "s:18ShopifyCheckoutKit12PreloadStateO13FailureReasonO" + }, + { + "kind": "TypeNominal", + "name": "Metatype", + "printedName": "ShopifyCheckoutKit.PreloadState.FailureReason.Type", + "children": [ + { + "kind": "TypeNominal", + "name": "FailureReason", + "printedName": "ShopifyCheckoutKit.PreloadState.FailureReason", + "usr": "s:18ShopifyCheckoutKit12PreloadStateO13FailureReasonO" + } + ] + } + ] + } + ], + "declKind": "EnumElement", + "usr": "s:18ShopifyCheckoutKit12PreloadStateO13FailureReasonO16navigationFailedyA2EmF", + "mangledName": "$s18ShopifyCheckoutKit12PreloadStateO13FailureReasonO16navigationFailedyA2EmF", + "moduleName": "ShopifyCheckoutKit" + }, + { + "kind": "Var", + "name": "keepAliveLost", + "printedName": "keepAliveLost", + "children": [ + { + "kind": "TypeFunc", + "name": "Function", + "printedName": "(ShopifyCheckoutKit.PreloadState.FailureReason.Type) -> ShopifyCheckoutKit.PreloadState.FailureReason", + "children": [ + { + "kind": "TypeNominal", + "name": "FailureReason", + "printedName": "ShopifyCheckoutKit.PreloadState.FailureReason", + "usr": "s:18ShopifyCheckoutKit12PreloadStateO13FailureReasonO" + }, + { + "kind": "TypeNominal", + "name": "Metatype", + "printedName": "ShopifyCheckoutKit.PreloadState.FailureReason.Type", + "children": [ + { + "kind": "TypeNominal", + "name": "FailureReason", + "printedName": "ShopifyCheckoutKit.PreloadState.FailureReason", + "usr": "s:18ShopifyCheckoutKit12PreloadStateO13FailureReasonO" + } + ] + } + ] + } + ], + "declKind": "EnumElement", + "usr": "s:18ShopifyCheckoutKit12PreloadStateO13FailureReasonO13keepAliveLostyA2EmF", + "mangledName": "$s18ShopifyCheckoutKit12PreloadStateO13FailureReasonO13keepAliveLostyA2EmF", + "moduleName": "ShopifyCheckoutKit" + }, + { + "kind": "Function", + "name": "__derived_enum_equals", + "printedName": "__derived_enum_equals(_:_:)", + "children": [ + { + "kind": "TypeNominal", + "name": "Bool", + "printedName": "Swift.Bool", + "usr": "s:Sb" + }, + { + "kind": "TypeNominal", + "name": "FailureReason", + "printedName": "ShopifyCheckoutKit.PreloadState.FailureReason", + "usr": "s:18ShopifyCheckoutKit12PreloadStateO13FailureReasonO" + }, + { + "kind": "TypeNominal", + "name": "FailureReason", + "printedName": "ShopifyCheckoutKit.PreloadState.FailureReason", + "usr": "s:18ShopifyCheckoutKit12PreloadStateO13FailureReasonO" + } + ], + "declKind": "Func", + "usr": "s:18ShopifyCheckoutKit12PreloadStateO13FailureReasonO21__derived_enum_equalsySbAE_AEtFZ", + "mangledName": "$s18ShopifyCheckoutKit12PreloadStateO13FailureReasonO21__derived_enum_equalsySbAE_AEtFZ", + "moduleName": "ShopifyCheckoutKit", + "static": true, + "implicit": true, + "declAttributes": [ + "Implements" + ], + "funcSelfKind": "NonMutating" + } + ], + "declKind": "Enum", + "usr": "s:18ShopifyCheckoutKit12PreloadStateO13FailureReasonO", + "mangledName": "$s18ShopifyCheckoutKit12PreloadStateO13FailureReasonO", + "moduleName": "ShopifyCheckoutKit", + "isEnumExhaustive": true, + "conformances": [ + { + "kind": "Conformance", + "name": "Equatable", + "printedName": "Equatable", + "usr": "s:SQ", + "mangledName": "$sSQ" + }, + { + "kind": "Conformance", + "name": "Copyable", + "printedName": "Copyable", + "usr": "s:s8CopyableP", + "mangledName": "$ss8CopyableP" + }, + { + "kind": "Conformance", + "name": "Escapable", + "printedName": "Escapable", + "usr": "s:s9EscapableP", + "mangledName": "$ss9EscapableP" + } + ] + }, + { + "kind": "Function", + "name": "__derived_enum_equals", + "printedName": "__derived_enum_equals(_:_:)", + "children": [ + { + "kind": "TypeNominal", + "name": "Bool", + "printedName": "Swift.Bool", + "usr": "s:Sb" + }, + { + "kind": "TypeNominal", + "name": "PreloadState", + "printedName": "ShopifyCheckoutKit.PreloadState", + "usr": "s:18ShopifyCheckoutKit12PreloadStateO" + }, + { + "kind": "TypeNominal", + "name": "PreloadState", + "printedName": "ShopifyCheckoutKit.PreloadState", + "usr": "s:18ShopifyCheckoutKit12PreloadStateO" + } + ], + "declKind": "Func", + "usr": "s:18ShopifyCheckoutKit12PreloadStateO21__derived_enum_equalsySbAC_ACtFZ", + "mangledName": "$s18ShopifyCheckoutKit12PreloadStateO21__derived_enum_equalsySbAC_ACtFZ", + "moduleName": "ShopifyCheckoutKit", + "static": true, + "implicit": true, + "declAttributes": [ + "Implements" + ], + "funcSelfKind": "NonMutating" + } + ], + "declKind": "Enum", + "usr": "s:18ShopifyCheckoutKit12PreloadStateO", + "mangledName": "$s18ShopifyCheckoutKit12PreloadStateO", + "moduleName": "ShopifyCheckoutKit", + "isEnumExhaustive": true, + "conformances": [ + { + "kind": "Conformance", + "name": "Equatable", + "printedName": "Equatable", + "usr": "s:SQ", + "mangledName": "$sSQ" + }, + { + "kind": "Conformance", + "name": "Copyable", + "printedName": "Copyable", + "usr": "s:s8CopyableP", + "mangledName": "$ss8CopyableP" + }, + { + "kind": "Conformance", + "name": "Escapable", + "printedName": "Escapable", + "usr": "s:s9EscapableP", + "mangledName": "$ss9EscapableP" + } + ] + }, + { + "kind": "TypeDecl", + "name": "CheckoutPreload", + "printedName": "CheckoutPreload", + "children": [ + { + "kind": "Var", + "name": "onStateChange", + "printedName": "onStateChange", + "children": [ + { + "kind": "TypeNominal", + "name": "Optional", + "printedName": "((ShopifyCheckoutKit.PreloadState) -> Swift.Void)?", + "children": [ + { + "kind": "TypeFunc", + "name": "Function", + "printedName": "(ShopifyCheckoutKit.PreloadState) -> Swift.Void", + "children": [ + { + "kind": "TypeNameAlias", + "name": "Void", + "printedName": "Swift.Void", + "children": [ + { + "kind": "TypeNominal", + "name": "Void", + "printedName": "()" + } + ] + }, + { + "kind": "TypeNominal", + "name": "PreloadState", + "printedName": "ShopifyCheckoutKit.PreloadState", + "usr": "s:18ShopifyCheckoutKit12PreloadStateO" + } + ] + } + ], + "usr": "s:Sq" + } + ], + "declKind": "Var", + "usr": "s:18ShopifyCheckoutKit0B7PreloadC13onStateChangeyAA0dF0OcSgvp", + "mangledName": "$s18ShopifyCheckoutKit0B7PreloadC13onStateChangeyAA0dF0OcSgvp", + "moduleName": "ShopifyCheckoutKit", + "declAttributes": [ + "HasInitialValue", + "Final", + "HasStorage", + "Custom" + ], + "hasStorage": true, + "accessors": [ + { + "kind": "Accessor", + "name": "Get", + "printedName": "Get()", + "children": [ + { + "kind": "TypeNominal", + "name": "Optional", + "printedName": "((ShopifyCheckoutKit.PreloadState) -> Swift.Void)?", + "children": [ + { + "kind": "TypeFunc", + "name": "Function", + "printedName": "(ShopifyCheckoutKit.PreloadState) -> Swift.Void", + "children": [ + { + "kind": "TypeNameAlias", + "name": "Void", + "printedName": "Swift.Void", + "children": [ + { + "kind": "TypeNominal", + "name": "Void", + "printedName": "()" + } + ] + }, + { + "kind": "TypeNominal", + "name": "PreloadState", + "printedName": "ShopifyCheckoutKit.PreloadState", + "usr": "s:18ShopifyCheckoutKit12PreloadStateO" + } + ] + } + ], + "usr": "s:Sq" + } + ], + "declKind": "Accessor", + "usr": "s:18ShopifyCheckoutKit0B7PreloadC13onStateChangeyAA0dF0OcSgvg", + "mangledName": "$s18ShopifyCheckoutKit0B7PreloadC13onStateChangeyAA0dF0OcSgvg", + "moduleName": "ShopifyCheckoutKit", + "implicit": true, + "declAttributes": [ + "Final", + "Transparent" + ], + "accessorKind": "get" + }, + { + "kind": "Accessor", + "name": "Set", + "printedName": "Set()", + "children": [ + { + "kind": "TypeNominal", + "name": "Void", + "printedName": "()" + }, + { + "kind": "TypeNominal", + "name": "Optional", + "printedName": "((ShopifyCheckoutKit.PreloadState) -> Swift.Void)?", + "children": [ + { + "kind": "TypeFunc", + "name": "Function", + "printedName": "(ShopifyCheckoutKit.PreloadState) -> Swift.Void", + "children": [ + { + "kind": "TypeNameAlias", + "name": "Void", + "printedName": "Swift.Void", + "children": [ + { + "kind": "TypeNominal", + "name": "Void", + "printedName": "()" + } + ] + }, + { + "kind": "TypeNominal", + "name": "PreloadState", + "printedName": "ShopifyCheckoutKit.PreloadState", + "usr": "s:18ShopifyCheckoutKit12PreloadStateO" + } + ] + } + ], + "usr": "s:Sq" + } + ], + "declKind": "Accessor", + "usr": "s:18ShopifyCheckoutKit0B7PreloadC13onStateChangeyAA0dF0OcSgvs", + "mangledName": "$s18ShopifyCheckoutKit0B7PreloadC13onStateChangeyAA0dF0OcSgvs", + "moduleName": "ShopifyCheckoutKit", + "implicit": true, + "declAttributes": [ + "Final", + "Transparent" + ], + "accessorKind": "set" + } + ] + }, + { + "kind": "Var", + "name": "state", + "printedName": "state", + "children": [ + { + "kind": "TypeNominal", + "name": "PreloadState", + "printedName": "ShopifyCheckoutKit.PreloadState", + "usr": "s:18ShopifyCheckoutKit12PreloadStateO" + } + ], + "declKind": "Var", + "usr": "s:18ShopifyCheckoutKit0B7PreloadC5stateAA0D5StateOvp", + "mangledName": "$s18ShopifyCheckoutKit0B7PreloadC5stateAA0D5StateOvp", + "moduleName": "ShopifyCheckoutKit", + "declAttributes": [ + "Final", + "Custom" + ], + "accessors": [ + { + "kind": "Accessor", + "name": "Get", + "printedName": "Get()", + "children": [ + { + "kind": "TypeNominal", + "name": "PreloadState", + "printedName": "ShopifyCheckoutKit.PreloadState", + "usr": "s:18ShopifyCheckoutKit12PreloadStateO" + } + ], + "declKind": "Accessor", + "usr": "s:18ShopifyCheckoutKit0B7PreloadC5stateAA0D5StateOvg", + "mangledName": "$s18ShopifyCheckoutKit0B7PreloadC5stateAA0D5StateOvg", + "moduleName": "ShopifyCheckoutKit", + "declAttributes": [ + "Final" + ], + "accessorKind": "get" + } + ] + } + ], + "declKind": "Class", + "usr": "s:18ShopifyCheckoutKit0B7PreloadC", + "mangledName": "$s18ShopifyCheckoutKit0B7PreloadC", + "moduleName": "ShopifyCheckoutKit", + "declAttributes": [ + "Final", + "Custom" + ], + "hasMissingDesignatedInitializers": true, + "conformances": [ + { + "kind": "Conformance", + "name": "Sendable", + "printedName": "Sendable", + "usr": "s:s8SendableP", + "mangledName": "$ss8SendableP" + }, + { + "kind": "Conformance", + "name": "Copyable", + "printedName": "Copyable", + "usr": "s:s8CopyableP", + "mangledName": "$ss8CopyableP" + }, + { + "kind": "Conformance", + "name": "Escapable", + "printedName": "Escapable", + "usr": "s:s9EscapableP", + "mangledName": "$ss9EscapableP" + }, + { + "kind": "Conformance", + "name": "SendableMetatype", + "printedName": "SendableMetatype", + "usr": "s:s16SendableMetatypeP", + "mangledName": "$ss16SendableMetatypeP" + } + ] + }, { "kind": "Var", "name": "version", @@ -6663,25 +7387,61 @@ { "kind": "Function", "name": "preload", - "printedName": "preload(checkout:)", + "printedName": "preload(checkout:onStateChange:)", "children": [ { "kind": "TypeNominal", - "name": "Void", - "printedName": "()" + "name": "CheckoutPreload", + "printedName": "ShopifyCheckoutKit.CheckoutPreload", + "usr": "s:18ShopifyCheckoutKit0B7PreloadC" }, { "kind": "TypeNominal", "name": "URL", "printedName": "Foundation.URL", "usr": "s:10Foundation3URLV" + }, + { + "kind": "TypeNominal", + "name": "Optional", + "printedName": "((ShopifyCheckoutKit.PreloadState) -> Swift.Void)?", + "children": [ + { + "kind": "TypeFunc", + "name": "Function", + "printedName": "(ShopifyCheckoutKit.PreloadState) -> Swift.Void", + "children": [ + { + "kind": "TypeNameAlias", + "name": "Void", + "printedName": "Swift.Void", + "children": [ + { + "kind": "TypeNominal", + "name": "Void", + "printedName": "()" + } + ] + }, + { + "kind": "TypeNominal", + "name": "PreloadState", + "printedName": "ShopifyCheckoutKit.PreloadState", + "usr": "s:18ShopifyCheckoutKit12PreloadStateO" + } + ] + } + ], + "hasDefaultArg": true, + "usr": "s:Sq" } ], "declKind": "Func", - "usr": "s:18ShopifyCheckoutKit7preload8checkouty10Foundation3URLV_tF", - "mangledName": "$s18ShopifyCheckoutKit7preload8checkouty10Foundation3URLV_tF", + "usr": "s:18ShopifyCheckoutKit7preload8checkout13onStateChangeAA0B7PreloadC10Foundation3URLV_yAA0iG0OcSgtF", + "mangledName": "$s18ShopifyCheckoutKit7preload8checkout13onStateChangeAA0B7PreloadC10Foundation3URLV_yAA0iG0OcSgtF", "moduleName": "ShopifyCheckoutKit", "declAttributes": [ + "DiscardableResult", "Custom" ], "funcSelfKind": "NonMutating" From 7381e48ef28b31b3c983b2fc4b89cdf123b440c5 Mon Sep 17 00:00:00 2001 From: Mark Murray Date: Wed, 15 Jul 2026 12:36:29 +0100 Subject: [PATCH 2/5] [Swift] Address preload observability review feedback - Add terminate(with:disconnect:) to unify invalidate + state transition - Emit terminal state (.expired/.idle) on cache miss/stale in view(for:) - Ignore cancelled provisional navigations (redirects/policy cancels) - Clear cache before notifying so re-entrant preload survives expiry --- .../Sources/Scenes/Cart/CartView.swift | 3 +- .../ShopifyCheckoutKit/CheckoutWebView.swift | 42 +++- .../ShopifyCheckoutKit/PreloadState.swift | 23 +- .../ShopifyCheckoutKit.swift | 8 +- .../PreloadObservabilityTests.swift | 96 +++++++- platforms/swift/api/ShopifyCheckoutKit.json | 216 +++++++++++------- 6 files changed, 280 insertions(+), 108 deletions(-) diff --git a/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemo/Sources/Scenes/Cart/CartView.swift b/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemo/Sources/Scenes/Cart/CartView.swift index 67bcb0516..701968a1b 100644 --- a/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemo/Sources/Scenes/Cart/CartView.swift +++ b/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemo/Sources/Scenes/Cart/CartView.swift @@ -156,7 +156,8 @@ struct CartView: View { guard checkoutPreloadingEnabled, let url = cartManager.cart?.checkoutURL else { return } ShopifyCheckoutKit.invalidate() - checkoutPreload = ShopifyCheckoutKit.preload(checkout: url) { state in + checkoutPreload = ShopifyCheckoutKit.preload(checkout: url) + checkoutPreload?.onStateChange = { state in print("[Preload] state changed to \(state)") ShopifyCheckoutKit.configuration.logger.log("Preload state changed to \(state)") } diff --git a/platforms/swift/Sources/ShopifyCheckoutKit/CheckoutWebView.swift b/platforms/swift/Sources/ShopifyCheckoutKit/CheckoutWebView.swift index 110e99f07..fb9731588 100644 --- a/platforms/swift/Sources/ShopifyCheckoutKit/CheckoutWebView.swift +++ b/platforms/swift/Sources/ShopifyCheckoutKit/CheckoutWebView.swift @@ -72,15 +72,27 @@ final class PreloadCache { observer?.receive(newState) } + /// Clears the cache, then notifies observers of the terminal `state`. + /// Clearing before notifying ensures a preload started re-entrantly from the + /// callback is not wiped by this invalidation. + func terminate(with state: PreloadState, disconnect: Bool = true) { + invalidate(disconnect: disconnect) + transition(to: state) + } + func view(for key: PreloadKey) -> CheckoutWebView? { - guard let entry, entry.key == key, !entry.isStale else { + guard let cached = entry, cached.key == key, !cached.isStale else { + let missed = entry invalidate() + if let missed { + transition(to: missed.isStale ? .expired : .idle) + } return nil } stopKeepAlive() stopExpiryTimer() - return entry.view + return cached.view } func invalidate(disconnect: Bool = true) { @@ -157,13 +169,11 @@ final class PreloadCache { } func expire() { - transition(to: .expired) - invalidate() + terminate(with: .expired) } func keepAliveDidFail() { - transition(to: .failed(reason: .keepAliveLost)) - invalidate() + terminate(with: .failed(reason: .keepAliveLost)) } private func stopKeepAlive() { @@ -556,9 +566,10 @@ extension CheckoutWebView: WKNavigationDelegate { if statusCode >= 400 { if CheckoutWebView.preloadCache.contains(self) { - CheckoutWebView.preloadCache.transition(to: .failed(reason: .httpError(statusCode: statusCode))) + CheckoutWebView.preloadCache.terminate(with: .failed(reason: .httpError(statusCode: statusCode))) + } else { + CheckoutWebView.invalidate() } - CheckoutWebView.invalidate() OSLogger.shared.debug("Handling response for URL: \(LogSafeURL.string(response.url)), status code: \(statusCode)") switch statusCode { @@ -592,6 +603,16 @@ extension CheckoutWebView: WKNavigationDelegate { let url = LogSafeURL.string(webView.url) OSLogger.shared.debug("Failed provisional navigation with error: \(error.localizedDescription) url:\(url)") timer = nil + + let nsError = error as NSError + if nsError.domain == NSURLErrorDomain, nsError.code == NSURLErrorCancelled { + OSLogger.shared.debug("Ignoring cancelled provisional navigation. code:NSURLErrorCancelled") + return + } + + if CheckoutWebView.preloadCache.contains(self) { + CheckoutWebView.preloadCache.terminate(with: .failed(reason: .navigationFailed)) + } } func webView(_: WKWebView, didFinish _: WKNavigation!) { @@ -624,9 +645,10 @@ extension CheckoutWebView: WKNavigationDelegate { } if CheckoutWebView.preloadCache.contains(self) { - CheckoutWebView.preloadCache.transition(to: .failed(reason: .navigationFailed)) + CheckoutWebView.preloadCache.terminate(with: .failed(reason: .navigationFailed)) + } else { + CheckoutWebView.invalidate() } - CheckoutWebView.invalidate() viewDelegate?.checkoutViewDidFailWithError(error: .sdkError(underlying: error)) } diff --git a/platforms/swift/Sources/ShopifyCheckoutKit/PreloadState.swift b/platforms/swift/Sources/ShopifyCheckoutKit/PreloadState.swift index 20baa4366..3eb630dc9 100644 --- a/platforms/swift/Sources/ShopifyCheckoutKit/PreloadState.swift +++ b/platforms/swift/Sources/ShopifyCheckoutKit/PreloadState.swift @@ -1,3 +1,4 @@ +import Combine import Foundation /// Observable lifecycle state of a preloaded checkout. @@ -15,29 +16,29 @@ public enum PreloadState: Equatable { } } -/// Returned by `preload(checkout:onStateChange:)` exposing the current preload -/// state. Because the preload cache is single-slot, every instance reflects the -/// same shared state. +/// Returned by `preload(checkout:)` to expose the current preload state. /// /// Retain the returned instance for as long as you want to observe state /// changes; the cache holds it weakly. @MainActor -public final class CheckoutPreload { - private let cache: PreloadCache +public final class CheckoutPreload: ObservableObject { + /// The latest observed preload state. + @Published public private(set) var state: PreloadState init(cache: PreloadCache) { - self.cache = cache + state = cache.state cache.setObserver(self) } - /// Called on the main actor whenever the preload state changes. - public var onStateChange: ((PreloadState) -> Void)? - - public var state: PreloadState { - cache.state + /// Called immediately with the current state and whenever it changes. + public var onStateChange: ((PreloadState) -> Void)? { + didSet { + onStateChange?(state) + } } func receive(_ state: PreloadState) { + self.state = state onStateChange?(state) } } diff --git a/platforms/swift/Sources/ShopifyCheckoutKit/ShopifyCheckoutKit.swift b/platforms/swift/Sources/ShopifyCheckoutKit/ShopifyCheckoutKit.swift index 04f2abc48..1d0741781 100644 --- a/platforms/swift/Sources/ShopifyCheckoutKit/ShopifyCheckoutKit.swift +++ b/platforms/swift/Sources/ShopifyCheckoutKit/ShopifyCheckoutKit.swift @@ -36,7 +36,7 @@ private func applyConfigurationChange(configuration: Configuration, previousConf if configuration.preloading.enabled != previousConfiguration.preloading.enabled { Task { @MainActor in - CheckoutWebView.invalidate() + invalidate() } } } @@ -45,9 +45,8 @@ private func applyConfigurationChange(configuration: Configuration, previousConf /// observing preload state. Retain the handle to keep observing. @MainActor @discardableResult -public func preload(checkout url: URL, onStateChange: ((PreloadState) -> Void)? = nil) -> CheckoutPreload { +public func preload(checkout url: URL) -> CheckoutPreload { let checkoutPreload = CheckoutPreload(cache: CheckoutWebView.preloadCache) - checkoutPreload.onStateChange = onStateChange guard configuration.preloading.enabled else { return checkoutPreload @@ -61,8 +60,7 @@ public func preload(checkout url: URL, onStateChange: ((PreloadState) -> Void)? /// Invalidates any cached checkout created by preload calls. @MainActor public func invalidate() { - CheckoutWebView.invalidate(disconnect: true) - CheckoutWebView.preloadCache.transition(to: .idle) + CheckoutWebView.preloadCache.terminate(with: .idle, disconnect: true) } @MainActor diff --git a/platforms/swift/Tests/ShopifyCheckoutKitTests/PreloadObservabilityTests.swift b/platforms/swift/Tests/ShopifyCheckoutKitTests/PreloadObservabilityTests.swift index e4085c5e8..ecc997d04 100644 --- a/platforms/swift/Tests/ShopifyCheckoutKitTests/PreloadObservabilityTests.swift +++ b/platforms/swift/Tests/ShopifyCheckoutKitTests/PreloadObservabilityTests.swift @@ -41,7 +41,8 @@ class PreloadObservabilityTests: XCTestCase { func testOnStateChangeReceivesTransitions() { var states: [PreloadState] = [] - let preload = ShopifyCheckoutKit.preload(checkout: url) { states.append($0) } + let preload = ShopifyCheckoutKit.preload(checkout: url) + preload.onStateChange = { states.append($0) } ShopifyCheckoutKit.invalidate() withExtendedLifetime(preload) { @@ -53,14 +54,28 @@ class PreloadObservabilityTests: XCTestCase { var firstStates: [PreloadState] = [] var secondStates: [PreloadState] = [] - let first = ShopifyCheckoutKit.preload(checkout: url) { firstStates.append($0) } - let second = ShopifyCheckoutKit.preload(checkout: url) { secondStates.append($0) } + let first = ShopifyCheckoutKit.preload(checkout: url) + first.onStateChange = { firstStates.append($0) } + let second = ShopifyCheckoutKit.preload(checkout: url) + second.onStateChange = { secondStates.append($0) } ShopifyCheckoutKit.invalidate() withExtendedLifetime((first, second)) { XCTAssertEqual(firstStates, [.loading]) - XCTAssertEqual(secondStates, [.idle]) + XCTAssertEqual(secondStates, [.loading, .idle]) + } + } + + func testPublishedStateReceivesTransitions() { + let preload = ShopifyCheckoutKit.preload(checkout: url) + var states: [PreloadState] = [] + let cancellable = preload.$state.sink { states.append($0) } + + ShopifyCheckoutKit.invalidate() + + withExtendedLifetime((preload, cancellable)) { + XCTAssertEqual(states, [.loading, .idle]) } } @@ -123,4 +138,77 @@ class PreloadObservabilityTests: XCTestCase { XCTAssertEqual(preload.state, .failed(reason: .navigationFailed)) } } + + func testProvisionalNavigationFailureTransitionsToFailed() { + let preload = ShopifyCheckoutKit.preload(checkout: url) + let view = CheckoutWebView(entryPoint: nil) + _ = CheckoutWebView.preloadCache.store(view, for: PreloadKey(url: url, entryPoint: nil)) + + let error = NSError(domain: NSURLErrorDomain, code: NSURLErrorTimedOut) + view.webView(view, didFailProvisionalNavigation: nil, withError: error) + + withExtendedLifetime(preload) { + XCTAssertEqual(preload.state, .failed(reason: .navigationFailed)) + } + } + + func testProvisionalNavigationCancelledDoesNotTransition() { + let preload = ShopifyCheckoutKit.preload(checkout: url) + let view = CheckoutWebView(entryPoint: nil) + _ = CheckoutWebView.preloadCache.store(view, for: PreloadKey(url: url, entryPoint: nil)) + + let error = NSError(domain: NSURLErrorDomain, code: NSURLErrorCancelled) + view.webView(view, didFailProvisionalNavigation: nil, withError: error) + + withExtendedLifetime(preload) { + XCTAssertEqual(preload.state, .loading) + } + } + + func testViewLookupWithDifferentKeyTransitionsHandleToIdle() throws { + let preload = ShopifyCheckoutKit.preload(checkout: url) + _ = CheckoutWebView.preloadCache.store( + CheckoutWebView(entryPoint: nil), + for: PreloadKey(url: url, entryPoint: nil) + ) + + let otherURL = try XCTUnwrap(URL(string: "http://shopify1.shopify.com/checkouts/cn/other")) + _ = CheckoutWebView.preloadCache.view(for: PreloadKey(url: otherURL, entryPoint: nil)) + + withExtendedLifetime(preload) { + XCTAssertEqual(preload.state, .idle) + } + } + + func testExpireClearsCacheBeforeNotifyingSoReentrantPreloadSurvives() { + let preload = ShopifyCheckoutKit.preload(checkout: url) + preload.onStateChange = { state in + if case .expired = state { + _ = CheckoutWebView.preloadCache.store( + CheckoutWebView(entryPoint: nil), + for: PreloadKey(url: self.url, entryPoint: nil) + ) + } + } + + CheckoutWebView.preloadCache.expire() + + withExtendedLifetime(preload) { + XCTAssertTrue(CheckoutWebView.preloadCache.hasEntry()) + } + } + + func testDisablingPreloadViaConfigTransitionsToIdle() async { + let preload = ShopifyCheckoutKit.preload(checkout: url) + + ShopifyCheckoutKit.configuration.preloading.enabled = false + + for _ in 0 ..< 20 where preload.state != .idle { + await Task.yield() + } + + withExtendedLifetime(preload) { + XCTAssertEqual(preload.state, .idle) + } + } } diff --git a/platforms/swift/api/ShopifyCheckoutKit.json b/platforms/swift/api/ShopifyCheckoutKit.json index 49759478f..0d05149a6 100644 --- a/platforms/swift/api/ShopifyCheckoutKit.json +++ b/platforms/swift/api/ShopifyCheckoutKit.json @@ -4,6 +4,13 @@ "name": "ShopifyCheckoutKit", "printedName": "ShopifyCheckoutKit", "children": [ + { + "kind": "Import", + "name": "Combine", + "printedName": "Combine", + "declKind": "Import", + "moduleName": "ShopifyCheckoutKit" + }, { "kind": "Import", "name": "DeveloperToolsSupport", @@ -7013,6 +7020,101 @@ "name": "CheckoutPreload", "printedName": "CheckoutPreload", "children": [ + { + "kind": "Var", + "name": "state", + "printedName": "state", + "children": [ + { + "kind": "TypeNominal", + "name": "PreloadState", + "printedName": "ShopifyCheckoutKit.PreloadState", + "usr": "s:18ShopifyCheckoutKit12PreloadStateO" + } + ], + "declKind": "Var", + "usr": "s:18ShopifyCheckoutKit0B7PreloadC5stateAA0D5StateOvp", + "mangledName": "$s18ShopifyCheckoutKit0B7PreloadC5stateAA0D5StateOvp", + "moduleName": "ShopifyCheckoutKit", + "declAttributes": [ + "Final", + "ProjectedValueProperty", + "SetterAccess", + "Custom", + "Custom" + ], + "accessors": [ + { + "kind": "Accessor", + "name": "Get", + "printedName": "Get()", + "children": [ + { + "kind": "TypeNominal", + "name": "PreloadState", + "printedName": "ShopifyCheckoutKit.PreloadState", + "usr": "s:18ShopifyCheckoutKit12PreloadStateO" + } + ], + "declKind": "Accessor", + "usr": "s:18ShopifyCheckoutKit0B7PreloadC5stateAA0D5StateOvg", + "mangledName": "$s18ShopifyCheckoutKit0B7PreloadC5stateAA0D5StateOvg", + "moduleName": "ShopifyCheckoutKit", + "implicit": true, + "declAttributes": [ + "Final" + ], + "accessorKind": "get" + } + ] + }, + { + "kind": "Var", + "name": "$state", + "printedName": "$state", + "children": [ + { + "kind": "TypeNominal", + "name": "Publisher", + "printedName": "Combine.Published.Publisher", + "usr": "s:7Combine9PublishedV9PublisherV" + } + ], + "declKind": "Var", + "usr": "s:18ShopifyCheckoutKit0B7PreloadC6$state7Combine9PublishedV9PublisherVyAA0D5StateO_Gvp", + "mangledName": "$s18ShopifyCheckoutKit0B7PreloadC6$state7Combine9PublishedV9PublisherVyAA0D5StateO_Gvp", + "moduleName": "ShopifyCheckoutKit", + "implicit": true, + "declAttributes": [ + "Final", + "SetterAccess", + "Custom" + ], + "accessors": [ + { + "kind": "Accessor", + "name": "Get", + "printedName": "Get()", + "children": [ + { + "kind": "TypeNominal", + "name": "Publisher", + "printedName": "Combine.Published.Publisher", + "usr": "s:7Combine9PublishedV9PublisherV" + } + ], + "declKind": "Accessor", + "usr": "s:18ShopifyCheckoutKit0B7PreloadC6$state7Combine9PublishedV9PublisherVyAA0D5StateO_Gvg", + "mangledName": "$s18ShopifyCheckoutKit0B7PreloadC6$state7Combine9PublishedV9PublisherVyAA0D5StateO_Gvg", + "moduleName": "ShopifyCheckoutKit", + "implicit": true, + "declAttributes": [ + "Final" + ], + "accessorKind": "get" + } + ] + }, { "kind": "Var", "name": "onStateChange", @@ -7109,8 +7211,7 @@ "moduleName": "ShopifyCheckoutKit", "implicit": true, "declAttributes": [ - "Final", - "Transparent" + "Final" ], "accessorKind": "get" }, @@ -7164,56 +7265,29 @@ "moduleName": "ShopifyCheckoutKit", "implicit": true, "declAttributes": [ - "Final", - "Transparent" + "Final" ], "accessorKind": "set" } ] }, { - "kind": "Var", - "name": "state", - "printedName": "state", + "kind": "TypeAlias", + "name": "ObjectWillChangePublisher", + "printedName": "ObjectWillChangePublisher", "children": [ { "kind": "TypeNominal", - "name": "PreloadState", - "printedName": "ShopifyCheckoutKit.PreloadState", - "usr": "s:18ShopifyCheckoutKit12PreloadStateO" + "name": "ObservableObjectPublisher", + "printedName": "Combine.ObservableObjectPublisher", + "usr": "s:7Combine25ObservableObjectPublisherC" } ], - "declKind": "Var", - "usr": "s:18ShopifyCheckoutKit0B7PreloadC5stateAA0D5StateOvp", - "mangledName": "$s18ShopifyCheckoutKit0B7PreloadC5stateAA0D5StateOvp", + "declKind": "TypeAlias", + "usr": "s:18ShopifyCheckoutKit0B7PreloadC25ObjectWillChangePublishera", + "mangledName": "$s18ShopifyCheckoutKit0B7PreloadC25ObjectWillChangePublishera", "moduleName": "ShopifyCheckoutKit", - "declAttributes": [ - "Final", - "Custom" - ], - "accessors": [ - { - "kind": "Accessor", - "name": "Get", - "printedName": "Get()", - "children": [ - { - "kind": "TypeNominal", - "name": "PreloadState", - "printedName": "ShopifyCheckoutKit.PreloadState", - "usr": "s:18ShopifyCheckoutKit12PreloadStateO" - } - ], - "declKind": "Accessor", - "usr": "s:18ShopifyCheckoutKit0B7PreloadC5stateAA0D5StateOvg", - "mangledName": "$s18ShopifyCheckoutKit0B7PreloadC5stateAA0D5StateOvg", - "moduleName": "ShopifyCheckoutKit", - "declAttributes": [ - "Final" - ], - "accessorKind": "get" - } - ] + "implicit": true } ], "declKind": "Class", @@ -7226,6 +7300,28 @@ ], "hasMissingDesignatedInitializers": true, "conformances": [ + { + "kind": "Conformance", + "name": "ObservableObject", + "printedName": "ObservableObject", + "children": [ + { + "kind": "TypeWitness", + "name": "ObjectWillChangePublisher", + "printedName": "ObjectWillChangePublisher", + "children": [ + { + "kind": "TypeNominal", + "name": "ObservableObjectPublisher", + "printedName": "Combine.ObservableObjectPublisher", + "usr": "s:7Combine25ObservableObjectPublisherC" + } + ] + } + ], + "usr": "s:7Combine16ObservableObjectP", + "mangledName": "$s7Combine16ObservableObjectP" + }, { "kind": "Conformance", "name": "Sendable", @@ -7387,7 +7483,7 @@ { "kind": "Function", "name": "preload", - "printedName": "preload(checkout:onStateChange:)", + "printedName": "preload(checkout:)", "children": [ { "kind": "TypeNominal", @@ -7400,45 +7496,11 @@ "name": "URL", "printedName": "Foundation.URL", "usr": "s:10Foundation3URLV" - }, - { - "kind": "TypeNominal", - "name": "Optional", - "printedName": "((ShopifyCheckoutKit.PreloadState) -> Swift.Void)?", - "children": [ - { - "kind": "TypeFunc", - "name": "Function", - "printedName": "(ShopifyCheckoutKit.PreloadState) -> Swift.Void", - "children": [ - { - "kind": "TypeNameAlias", - "name": "Void", - "printedName": "Swift.Void", - "children": [ - { - "kind": "TypeNominal", - "name": "Void", - "printedName": "()" - } - ] - }, - { - "kind": "TypeNominal", - "name": "PreloadState", - "printedName": "ShopifyCheckoutKit.PreloadState", - "usr": "s:18ShopifyCheckoutKit12PreloadStateO" - } - ] - } - ], - "hasDefaultArg": true, - "usr": "s:Sq" } ], "declKind": "Func", - "usr": "s:18ShopifyCheckoutKit7preload8checkout13onStateChangeAA0B7PreloadC10Foundation3URLV_yAA0iG0OcSgtF", - "mangledName": "$s18ShopifyCheckoutKit7preload8checkout13onStateChangeAA0B7PreloadC10Foundation3URLV_yAA0iG0OcSgtF", + "usr": "s:18ShopifyCheckoutKit7preload8checkoutAA0B7PreloadC10Foundation3URLV_tF", + "mangledName": "$s18ShopifyCheckoutKit7preload8checkoutAA0B7PreloadC10Foundation3URLV_tF", "moduleName": "ShopifyCheckoutKit", "declAttributes": [ "DiscardableResult", From 81a65875ae9e8689f8531dbc812b30eef5e63679 Mon Sep 17 00:00:00 2001 From: Mark Murray Date: Thu, 16 Jul 2026 14:32:50 +0100 Subject: [PATCH 3/5] Harden preload cache lifecycle and observability --- .../ShopifyCheckoutKit/CheckoutWebView.swift | 76 ++++++--- .../ShopifyCheckoutKit.swift | 9 +- .../PreloadCacheTests.swift | 149 ++++++++++++++++++ .../PreloadObservabilityTests.swift | 54 ++++--- .../ShopifyCheckoutKitTests.swift | 39 ++++- 5 files changed, 276 insertions(+), 51 deletions(-) create mode 100644 platforms/swift/Tests/ShopifyCheckoutKitTests/PreloadCacheTests.swift diff --git a/platforms/swift/Sources/ShopifyCheckoutKit/CheckoutWebView.swift b/platforms/swift/Sources/ShopifyCheckoutKit/CheckoutWebView.swift index fb9731588..8ddfb95e4 100644 --- a/platforms/swift/Sources/ShopifyCheckoutKit/CheckoutWebView.swift +++ b/platforms/swift/Sources/ShopifyCheckoutKit/CheckoutWebView.swift @@ -41,6 +41,10 @@ final class PreloadCache { private var expiryTimer: Timer? private(set) var state: PreloadState = .idle + + /// The cache notifies a single observer. Each `preload(checkout:)` call + /// replaces it, so only the most recently returned `CheckoutPreload` handle + /// receives state updates; earlier handles stop observing. private weak var observer: CheckoutPreload? func setObserver(_ observer: CheckoutPreload) { @@ -68,14 +72,16 @@ final class PreloadCache { } func transition(to newState: PreloadState) { + guard state != newState else { return } + state = newState observer?.receive(newState) } - /// Clears the cache, then notifies observers of the terminal `state`. + /// Evicts the cached view, then notifies observers of the resulting `state`. /// Clearing before notifying ensures a preload started re-entrantly from the /// callback is not wiped by this invalidation. - func terminate(with state: PreloadState, disconnect: Bool = true) { + func evict(with state: PreloadState, disconnect: Bool = true) { invalidate(disconnect: disconnect) transition(to: state) } @@ -92,6 +98,7 @@ final class PreloadCache { stopKeepAlive() stopExpiryTimer() + cached.view.hasBeenPresented = true return cached.view } @@ -110,7 +117,7 @@ final class PreloadCache { func hasEntry() -> Bool { if entry?.isStale == true { - invalidate() + evict(with: .expired) return false } @@ -169,11 +176,11 @@ final class PreloadCache { } func expire() { - terminate(with: .expired) + evict(with: .expired) } func keepAliveDidFail() { - terminate(with: .failed(reason: .keepAliveLost)) + evict(with: .failed(reason: .keepAliveLost)) } private func stopKeepAlive() { @@ -276,8 +283,9 @@ class CheckoutWebView: WKWebView { .on(CheckoutProtocol.ready) { _ in ReadyResult(checkout: nil, credential: nil, ucp: .success(), upgrade: nil, continueURL: nil, messages: nil) } - .on(CheckoutProtocol.complete) { _ in - CheckoutWebView.invalidate(disconnect: false) + .on(CheckoutProtocol.complete) { [weak self] _ in + guard let self, CheckoutWebView.preloadCache.contains(self) else { return } + CheckoutWebView.preloadCache.evict(with: .idle, disconnect: false) } .on(CheckoutProtocol.windowOpen) { request in guard let target = request.parsedURL, self.canOpenExternalURL(target) else { @@ -287,9 +295,11 @@ class CheckoutWebView: WKWebView { return .success() } .on(CheckoutProtocol.error) { [weak self] payload in - guard payload.messages.contains(where: { $0.severity == .unrecoverable }) else { return } - CheckoutWebView.invalidate() - self?.viewDelegate?.checkoutViewDidFailWithError( + guard let self, payload.messages.contains(where: { $0.severity == .unrecoverable }) else { return } + if CheckoutWebView.preloadCache.contains(self) { + CheckoutWebView.preloadCache.evict(with: .idle) + } + self.viewDelegate?.checkoutViewDidFailWithError( error: .checkoutUnavailable( message: "Embedded checkout reported unrecoverable error.", code: .clientError(code: .unknown) @@ -366,6 +376,11 @@ class CheckoutWebView: WKWebView { var isPreloadRequest = false + /// Latches true once the cached view is handed off for presentation. A + /// presented view is a live session, so its navigation events must no + /// longer drive preload state, even after dismissal or reuse. + var hasBeenPresented = false + private var entryPoint: MetaData.EntryPoint? // MARK: Initializers @@ -453,6 +468,26 @@ class CheckoutWebView: WKWebView { load(request) } + + private var isPreloadBackgrounded: Bool { + CheckoutWebView.preloadCache.contains(self) && !hasBeenPresented + } + + private func handleCachedViewFailure(_ reason: PreloadState.FailureReason) { + guard CheckoutWebView.preloadCache.contains(self) else { return } + + if hasBeenPresented { + CheckoutWebView.preloadCache.evict(with: .idle) + } else { + CheckoutWebView.preloadCache.evict(with: .failed(reason: reason)) + } + } + + private func markPreloadReadyIfActive() { + guard isPreloadBackgrounded else { return } + + CheckoutWebView.preloadCache.transition(to: .ready) + } } /// Holds a WebKit script-message registration outside CheckoutWebView deinit. @@ -565,11 +600,8 @@ extension CheckoutWebView: WKNavigationDelegate { } if statusCode >= 400 { - if CheckoutWebView.preloadCache.contains(self) { - CheckoutWebView.preloadCache.terminate(with: .failed(reason: .httpError(statusCode: statusCode))) - } else { - CheckoutWebView.invalidate() - } + handleCachedViewFailure(.httpError(statusCode: statusCode)) + OSLogger.shared.debug("Handling response for URL: \(LogSafeURL.string(response.url)), status code: \(statusCode)") switch statusCode { @@ -610,15 +642,11 @@ extension CheckoutWebView: WKNavigationDelegate { return } - if CheckoutWebView.preloadCache.contains(self) { - CheckoutWebView.preloadCache.terminate(with: .failed(reason: .navigationFailed)) - } + handleCachedViewFailure(.navigationFailed) } func webView(_: WKWebView, didFinish _: WKNavigation!) { - if CheckoutWebView.preloadCache.contains(self) { - CheckoutWebView.preloadCache.transition(to: .ready) - } + markPreloadReadyIfActive() viewDelegate?.checkoutViewDidFinishNavigation() @@ -644,11 +672,7 @@ extension CheckoutWebView: WKNavigationDelegate { return } - if CheckoutWebView.preloadCache.contains(self) { - CheckoutWebView.preloadCache.terminate(with: .failed(reason: .navigationFailed)) - } else { - CheckoutWebView.invalidate() - } + handleCachedViewFailure(.navigationFailed) viewDelegate?.checkoutViewDidFailWithError(error: .sdkError(underlying: error)) } diff --git a/platforms/swift/Sources/ShopifyCheckoutKit/ShopifyCheckoutKit.swift b/platforms/swift/Sources/ShopifyCheckoutKit/ShopifyCheckoutKit.swift index 1d0741781..025bb3cc3 100644 --- a/platforms/swift/Sources/ShopifyCheckoutKit/ShopifyCheckoutKit.swift +++ b/platforms/swift/Sources/ShopifyCheckoutKit/ShopifyCheckoutKit.swift @@ -45,13 +45,12 @@ private func applyConfigurationChange(configuration: Configuration, previousConf /// observing preload state. Retain the handle to keep observing. @MainActor @discardableResult -public func preload(checkout url: URL) -> CheckoutPreload { - let checkoutPreload = CheckoutPreload(cache: CheckoutWebView.preloadCache) - +public func preload(checkout url: URL) -> CheckoutPreload? { guard configuration.preloading.enabled else { - return checkoutPreload + return nil } + let checkoutPreload = CheckoutPreload(cache: CheckoutWebView.preloadCache) let decorated = CheckoutURLDecorator.decorate(url) CheckoutWebView.preload(checkout: decorated) return checkoutPreload @@ -60,7 +59,7 @@ public func preload(checkout url: URL) -> CheckoutPreload { /// Invalidates any cached checkout created by preload calls. @MainActor public func invalidate() { - CheckoutWebView.preloadCache.terminate(with: .idle, disconnect: true) + CheckoutWebView.preloadCache.evict(with: .idle, disconnect: true) } @MainActor diff --git a/platforms/swift/Tests/ShopifyCheckoutKitTests/PreloadCacheTests.swift b/platforms/swift/Tests/ShopifyCheckoutKitTests/PreloadCacheTests.swift new file mode 100644 index 000000000..63a7d5ec5 --- /dev/null +++ b/platforms/swift/Tests/ShopifyCheckoutKitTests/PreloadCacheTests.swift @@ -0,0 +1,149 @@ +import EmbeddedCheckoutProtocol +@testable import ShopifyCheckoutKit +import WebKit +import XCTest + +/// Slot-ownership rules for the single-slot preload cache. +/// +/// The cache holds at most one entry. A view may fire navigation and protocol +/// events both while it is the cached preload and after it has been presented +/// (the entry is retained across presentation so a buyer can reopen the sheet). +/// Every cache mutation triggered by a view must therefore be gated on whether +/// that view is the current slot occupant — an event from any other view must +/// leave the slot untouched. +@MainActor +class PreloadCacheTests: XCTestCase { + private var url = URL(string: "http://shopify1.shopify.com/checkouts/cn/123")! + + override func setUp() async throws { + try await super.setUp() + ShopifyCheckoutKit.configuration.preloading.enabled = true + CheckoutWebView.invalidate() + } + + override func tearDown() async throws { + CheckoutWebView.invalidate() + ShopifyCheckoutKit.configuration.preloading.enabled = true + try await super.tearDown() + } + + // MARK: - A view's own terminal events clear the slot it occupies + + func test_HTTPErrorOnSlotOccupantClearsSlot() { + let entry = storeCacheEntry() + entry.load(checkout: url) + + _ = entry.handleResponse(httpResponse(url: entry.url ?? url, statusCode: 500)) + + XCTAssertFalse(CheckoutWebView.preloadCache.contains(entry)) + } + + func test_NavigationFailureOnSlotOccupantClearsSlot() { + let entry = storeCacheEntry() + + entry.webView(entry, didFail: nil, withError: timeoutError()) + + XCTAssertFalse(CheckoutWebView.preloadCache.contains(entry)) + } + + func test_CompleteOnSlotOccupantClearsSlot() async { + let entry = storeCacheEntry() + + _ = await entry.defaultsClient.process(ecCompleteBody()) + + XCTAssertFalse(CheckoutWebView.preloadCache.contains(entry)) + } + + func test_UnrecoverableErrorOnSlotOccupantClearsSlot() async { + let entry = storeCacheEntry() + + _ = await entry.defaultsClient.process(ecErrorBody(severity: "unrecoverable")) + + XCTAssertFalse(CheckoutWebView.preloadCache.contains(entry)) + } + + // MARK: - Events from a non-occupant view must not touch the slot + + func test_HTTPErrorOnForeignViewPreservesSlot() { + let entry = storeCacheEntry() + let foreign = CheckoutWebView(entryPoint: nil) + foreign.load(checkout: url) + + _ = foreign.handleResponse(httpResponse(url: foreign.url ?? url, statusCode: 500)) + + XCTAssertTrue(CheckoutWebView.preloadCache.contains(entry)) + } + + func test_NavigationFailureOnForeignViewPreservesSlot() { + let entry = storeCacheEntry() + let foreign = CheckoutWebView(entryPoint: nil) + + foreign.webView(foreign, didFail: nil, withError: timeoutError()) + + XCTAssertTrue(CheckoutWebView.preloadCache.contains(entry)) + } + + func test_CompleteOnForeignViewPreservesSlot() async { + let entry = storeCacheEntry() + let foreign = CheckoutWebView(entryPoint: nil) + + _ = await foreign.defaultsClient.process(ecCompleteBody()) + + XCTAssertTrue(CheckoutWebView.preloadCache.contains(entry)) + } + + func test_UnrecoverableErrorOnForeignViewPreservesSlot() async { + let entry = storeCacheEntry() + let foreign = CheckoutWebView(entryPoint: nil) + + _ = await foreign.defaultsClient.process(ecErrorBody(severity: "unrecoverable")) + + XCTAssertTrue(CheckoutWebView.preloadCache.contains(entry)) + } + + func test_ProvisionalFailureOnForeignViewPreservesSlot() { + let entry = storeCacheEntry() + let foreign = CheckoutWebView(entryPoint: nil) + + foreign.webView(foreign, didFailProvisionalNavigation: nil, withError: timeoutError()) + + XCTAssertTrue(CheckoutWebView.preloadCache.contains(entry)) + } + + func test_DidFinishOnForeignViewPreservesSlot() { + let entry = storeCacheEntry() + let foreign = CheckoutWebView(entryPoint: nil) + + foreign.webView(foreign, didFinish: nil) + + XCTAssertTrue(CheckoutWebView.preloadCache.contains(entry)) + } + + // MARK: - Helpers + + private func storeCacheEntry() -> CheckoutWebView { + let entry = CheckoutWebView(entryPoint: nil) + _ = CheckoutWebView.preloadCache.store(entry, for: PreloadKey(url: url, entryPoint: nil)) + return entry + } + + private func httpResponse(url link: URL, statusCode: Int) -> HTTPURLResponse { + HTTPURLResponse(url: link, statusCode: statusCode, httpVersion: nil, headerFields: nil)! + } + + private func timeoutError() -> NSError { + NSError(domain: NSURLErrorDomain, code: NSURLErrorTimedOut) + } + + private func ecCompleteBody() -> String { + """ + {"jsonrpc":"2.0","method":"ec.complete","params":{"checkout":{"currency":"USD","id":"c-1","line_items":[],"links":[],"status":"completed","totals":[],"ucp":{"payment_handlers":{},"version":"\(EmbeddedCheckoutProtocol.specVersion)"}}}} + """ + } + + private func ecErrorBody(severity: String) -> String { + """ + {"jsonrpc":"2.0","method":"ec.error","params":{"error":{"ucp":{"status":"error","version":"\(EmbeddedCheckoutProtocol.specVersion)"},"messages":[{"type":"error","code":"session_failed","content":"Session failed","severity":"\(severity)"}]}}} + """ + } +} diff --git a/platforms/swift/Tests/ShopifyCheckoutKitTests/PreloadObservabilityTests.swift b/platforms/swift/Tests/ShopifyCheckoutKitTests/PreloadObservabilityTests.swift index ecc997d04..b56e1e3e4 100644 --- a/platforms/swift/Tests/ShopifyCheckoutKitTests/PreloadObservabilityTests.swift +++ b/platforms/swift/Tests/ShopifyCheckoutKitTests/PreloadObservabilityTests.swift @@ -23,8 +23,8 @@ class PreloadObservabilityTests: XCTestCase { func testPreloadReturnsHandleInLoadingState() { let preload = ShopifyCheckoutKit.preload(checkout: url) - guard case .loading = preload.state else { - return XCTFail("expected .loading, got \(preload.state)") + guard case .loading = preload?.state else { + return XCTFail("expected .loading, got \(String(describing: preload?.state))") } } @@ -33,8 +33,8 @@ class PreloadObservabilityTests: XCTestCase { ShopifyCheckoutKit.invalidate() - guard case .idle = preload.state else { - return XCTFail("expected .idle, got \(preload.state)") + guard case .idle = preload?.state else { + return XCTFail("expected .idle, got \(String(describing: preload?.state))") } } @@ -42,7 +42,7 @@ class PreloadObservabilityTests: XCTestCase { var states: [PreloadState] = [] let preload = ShopifyCheckoutKit.preload(checkout: url) - preload.onStateChange = { states.append($0) } + preload?.onStateChange = { states.append($0) } ShopifyCheckoutKit.invalidate() withExtendedLifetime(preload) { @@ -55,9 +55,9 @@ class PreloadObservabilityTests: XCTestCase { var secondStates: [PreloadState] = [] let first = ShopifyCheckoutKit.preload(checkout: url) - first.onStateChange = { firstStates.append($0) } + first?.onStateChange = { firstStates.append($0) } let second = ShopifyCheckoutKit.preload(checkout: url) - second.onStateChange = { secondStates.append($0) } + second?.onStateChange = { secondStates.append($0) } ShopifyCheckoutKit.invalidate() @@ -70,7 +70,7 @@ class PreloadObservabilityTests: XCTestCase { func testPublishedStateReceivesTransitions() { let preload = ShopifyCheckoutKit.preload(checkout: url) var states: [PreloadState] = [] - let cancellable = preload.$state.sink { states.append($0) } + let cancellable = preload?.$state.sink { states.append($0) } ShopifyCheckoutKit.invalidate() @@ -87,7 +87,23 @@ class PreloadObservabilityTests: XCTestCase { view.webView(view, didFinish: nil) withExtendedLifetime(preload) { - XCTAssertEqual(preload.state, .ready) + XCTAssertEqual(preload?.state, .ready) + } + } + + func testRepeatDidFinishDoesNotReNotifyReadyState() { + let preload = ShopifyCheckoutKit.preload(checkout: url) + var states: [PreloadState] = [] + preload?.onStateChange = { states.append($0) } + + let view = CheckoutWebView(entryPoint: nil) + _ = CheckoutWebView.preloadCache.store(view, for: PreloadKey(url: url, entryPoint: nil)) + + view.webView(view, didFinish: nil) + view.webView(view, didFinish: nil) + + withExtendedLifetime(preload) { + XCTAssertEqual(states, [.loading, .ready]) } } @@ -97,7 +113,7 @@ class PreloadObservabilityTests: XCTestCase { CheckoutWebView.preloadCache.expire() withExtendedLifetime(preload) { - XCTAssertEqual(preload.state, .expired) + XCTAssertEqual(preload?.state, .expired) } } @@ -107,7 +123,7 @@ class PreloadObservabilityTests: XCTestCase { CheckoutWebView.preloadCache.keepAliveDidFail() withExtendedLifetime(preload) { - XCTAssertEqual(preload.state, .failed(reason: .keepAliveLost)) + XCTAssertEqual(preload?.state, .failed(reason: .keepAliveLost)) } } @@ -122,7 +138,7 @@ class PreloadObservabilityTests: XCTestCase { _ = view.handleResponse(response) withExtendedLifetime(preload) { - XCTAssertEqual(preload.state, .failed(reason: .httpError(statusCode: 500))) + XCTAssertEqual(preload?.state, .failed(reason: .httpError(statusCode: 500))) } } @@ -135,7 +151,7 @@ class PreloadObservabilityTests: XCTestCase { view.webView(view, didFail: nil, withError: error) withExtendedLifetime(preload) { - XCTAssertEqual(preload.state, .failed(reason: .navigationFailed)) + XCTAssertEqual(preload?.state, .failed(reason: .navigationFailed)) } } @@ -148,7 +164,7 @@ class PreloadObservabilityTests: XCTestCase { view.webView(view, didFailProvisionalNavigation: nil, withError: error) withExtendedLifetime(preload) { - XCTAssertEqual(preload.state, .failed(reason: .navigationFailed)) + XCTAssertEqual(preload?.state, .failed(reason: .navigationFailed)) } } @@ -161,7 +177,7 @@ class PreloadObservabilityTests: XCTestCase { view.webView(view, didFailProvisionalNavigation: nil, withError: error) withExtendedLifetime(preload) { - XCTAssertEqual(preload.state, .loading) + XCTAssertEqual(preload?.state, .loading) } } @@ -176,13 +192,13 @@ class PreloadObservabilityTests: XCTestCase { _ = CheckoutWebView.preloadCache.view(for: PreloadKey(url: otherURL, entryPoint: nil)) withExtendedLifetime(preload) { - XCTAssertEqual(preload.state, .idle) + XCTAssertEqual(preload?.state, .idle) } } func testExpireClearsCacheBeforeNotifyingSoReentrantPreloadSurvives() { let preload = ShopifyCheckoutKit.preload(checkout: url) - preload.onStateChange = { state in + preload?.onStateChange = { state in if case .expired = state { _ = CheckoutWebView.preloadCache.store( CheckoutWebView(entryPoint: nil), @@ -203,12 +219,12 @@ class PreloadObservabilityTests: XCTestCase { ShopifyCheckoutKit.configuration.preloading.enabled = false - for _ in 0 ..< 20 where preload.state != .idle { + for _ in 0 ..< 20 where preload?.state != .idle { await Task.yield() } withExtendedLifetime(preload) { - XCTAssertEqual(preload.state, .idle) + XCTAssertEqual(preload?.state, .idle) } } } diff --git a/platforms/swift/Tests/ShopifyCheckoutKitTests/ShopifyCheckoutKitTests.swift b/platforms/swift/Tests/ShopifyCheckoutKitTests/ShopifyCheckoutKitTests.swift index a452a94d0..5237e8748 100644 --- a/platforms/swift/Tests/ShopifyCheckoutKitTests/ShopifyCheckoutKitTests.swift +++ b/platforms/swift/Tests/ShopifyCheckoutKitTests/ShopifyCheckoutKitTests.swift @@ -3,6 +3,22 @@ import XCTest @MainActor class ShopifyCheckoutKitTests: XCTestCase { + let checkoutURL = URL(string: "https://shop.example/checkouts/cn/123")! + + private var originalConfiguration: Configuration! + + override func setUp() async throws { + try await super.setUp() + originalConfiguration = ShopifyCheckoutKit.configuration + CheckoutWebView.invalidate() + } + + override func tearDown() async throws { + CheckoutWebView.invalidate() + ShopifyCheckoutKit.configuration = originalConfiguration + try await super.tearDown() + } + func test_version_whenAccessed_shouldExist() { XCTAssertFalse(ShopifyCheckoutKit.version.isEmpty) } @@ -45,7 +61,6 @@ class ShopifyCheckoutKitTests: XCTestCase { let delegate = MockCheckoutDelegate() let client = MockBridgeClient() let presenter = UIViewController() - let checkoutURL = try XCTUnwrap(URL(string: "https://shop.example/checkouts/cn/123")) let viewController = ShopifyCheckoutKit.present( checkout: checkoutURL, @@ -91,4 +106,26 @@ class ShopifyCheckoutKitTests: XCTestCase { "Logger should have .none log level" ) } + + func test_preload_returnsNilWhenDisabled() { + ShopifyCheckoutKit.configuration.preloading.enabled = false + XCTAssertNil(ShopifyCheckoutKit.preload(checkout: checkoutURL)) + } + + func test_preload_returnsPreloadWhenEnabled() { + ShopifyCheckoutKit.configuration.preloading.enabled = true + let preload = ShopifyCheckoutKit.preload(checkout: checkoutURL) + var states: [PreloadState] = [] + + preload?.onStateChange = { state in + states.append(state) + } + + XCTAssertNotNil(preload) + XCTAssertEqual(preload?.state, .loading) + XCTAssertTrue( + states.contains(PreloadState.loading), + "States should include .loading after starting preload" + ) + } } From 365c328be42469ba903aa55be232ed19a0331553 Mon Sep 17 00:00:00 2001 From: Mark Murray Date: Thu, 16 Jul 2026 15:18:29 +0100 Subject: [PATCH 4/5] Swift API dump --- platforms/swift/api/ShopifyCheckoutKit.json | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/platforms/swift/api/ShopifyCheckoutKit.json b/platforms/swift/api/ShopifyCheckoutKit.json index 0d05149a6..c420d6af9 100644 --- a/platforms/swift/api/ShopifyCheckoutKit.json +++ b/platforms/swift/api/ShopifyCheckoutKit.json @@ -7487,9 +7487,17 @@ "children": [ { "kind": "TypeNominal", - "name": "CheckoutPreload", - "printedName": "ShopifyCheckoutKit.CheckoutPreload", - "usr": "s:18ShopifyCheckoutKit0B7PreloadC" + "name": "Optional", + "printedName": "ShopifyCheckoutKit.CheckoutPreload?", + "children": [ + { + "kind": "TypeNominal", + "name": "CheckoutPreload", + "printedName": "ShopifyCheckoutKit.CheckoutPreload", + "usr": "s:18ShopifyCheckoutKit0B7PreloadC" + } + ], + "usr": "s:Sq" }, { "kind": "TypeNominal", @@ -7499,8 +7507,8 @@ } ], "declKind": "Func", - "usr": "s:18ShopifyCheckoutKit7preload8checkoutAA0B7PreloadC10Foundation3URLV_tF", - "mangledName": "$s18ShopifyCheckoutKit7preload8checkoutAA0B7PreloadC10Foundation3URLV_tF", + "usr": "s:18ShopifyCheckoutKit7preload8checkoutAA0B7PreloadCSg10Foundation3URLV_tF", + "mangledName": "$s18ShopifyCheckoutKit7preload8checkoutAA0B7PreloadCSg10Foundation3URLV_tF", "moduleName": "ShopifyCheckoutKit", "declAttributes": [ "DiscardableResult", From d9587b4e4390c2eaca56cc99354d6cb9a468280b Mon Sep 17 00:00:00 2001 From: Mark Murray Date: Thu, 16 Jul 2026 20:16:39 +0100 Subject: [PATCH 5/5] Expire stale ready for hasEntry(key:) --- .../Sources/ShopifyCheckoutKit/CheckoutWebView.swift | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/platforms/swift/Sources/ShopifyCheckoutKit/CheckoutWebView.swift b/platforms/swift/Sources/ShopifyCheckoutKit/CheckoutWebView.swift index 8ddfb95e4..a57c8260c 100644 --- a/platforms/swift/Sources/ShopifyCheckoutKit/CheckoutWebView.swift +++ b/platforms/swift/Sources/ShopifyCheckoutKit/CheckoutWebView.swift @@ -117,7 +117,7 @@ final class PreloadCache { func hasEntry() -> Bool { if entry?.isStale == true { - evict(with: .expired) + expire() return false } @@ -125,7 +125,12 @@ final class PreloadCache { } func hasEntry(for key: PreloadKey) -> Bool { - guard let entry, entry.key == key, !entry.isStale else { + guard let entry, entry.key == key else { + return false + } + + if entry.isStale { + expire() return false }