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..701968a1b 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,12 @@ 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) + checkoutPreload?.onStateChange = { state in + print("[Preload] state changed to \(state)") + ShopifyCheckoutKit.configuration.logger.log("Preload state changed to \(state)") + } } } @@ -173,6 +180,7 @@ struct EmptyState: View { struct CartLines: View { var lines: [CartLineNode] + var update: () -> Void @State var updating: String? { didSet { isBusy = updating != nil @@ -238,6 +246,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 +281,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..a57c8260c 100644 --- a/platforms/swift/Sources/ShopifyCheckoutKit/CheckoutWebView.swift +++ b/platforms/swift/Sources/ShopifyCheckoutKit/CheckoutWebView.swift @@ -40,6 +40,17 @@ final class PreloadCache { private var keepAliveTimer: Timer? 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) { + 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,18 +67,39 @@ final class PreloadCache { self.entry = entry startKeepAlive(for: view) startExpiryTimer(after: entry.remainingTTL) + transition(to: .loading) return true } + func transition(to newState: PreloadState) { + guard state != newState else { return } + + state = newState + observer?.receive(newState) + } + + /// 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 evict(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 + cached.view.hasBeenPresented = true + return cached.view } func invalidate(disconnect: Bool = true) { @@ -85,7 +117,7 @@ final class PreloadCache { func hasEntry() -> Bool { if entry?.isStale == true { - invalidate() + expire() return false } @@ -93,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 } @@ -124,7 +161,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 +173,21 @@ 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() { + evict(with: .expired) + } + + func keepAliveDidFail() { + evict(with: .failed(reason: .keepAliveLost)) + } + private func stopKeepAlive() { keepAliveTimer?.invalidate() keepAliveTimer = nil @@ -243,8 +288,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 { @@ -254,9 +300,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) @@ -333,6 +381,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 @@ -420,6 +473,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. @@ -532,7 +605,8 @@ extension CheckoutWebView: WKNavigationDelegate { } if statusCode >= 400 { - CheckoutWebView.invalidate() + handleCachedViewFailure(.httpError(statusCode: statusCode)) + OSLogger.shared.debug("Handling response for URL: \(LogSafeURL.string(response.url)), status code: \(statusCode)") switch statusCode { @@ -566,9 +640,19 @@ 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 + } + + handleCachedViewFailure(.navigationFailed) } func webView(_: WKWebView, didFinish _: WKNavigation!) { + markPreloadReadyIfActive() + viewDelegate?.checkoutViewDidFinishNavigation() if let startTime = timer { @@ -593,7 +677,7 @@ extension CheckoutWebView: WKNavigationDelegate { return } - CheckoutWebView.invalidate() + handleCachedViewFailure(.navigationFailed) 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..3eb630dc9 --- /dev/null +++ b/platforms/swift/Sources/ShopifyCheckoutKit/PreloadState.swift @@ -0,0 +1,44 @@ +import Combine +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:)` 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: ObservableObject { + /// The latest observed preload state. + @Published public private(set) var state: PreloadState + + init(cache: PreloadCache) { + state = cache.state + cache.setObserver(self) + } + + /// 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 0cd822a3b..025bb3cc3 100644 --- a/platforms/swift/Sources/ShopifyCheckoutKit/ShopifyCheckoutKit.swift +++ b/platforms/swift/Sources/ShopifyCheckoutKit/ShopifyCheckoutKit.swift @@ -36,26 +36,30 @@ private func applyConfigurationChange(configuration: Configuration, previousConf if configuration.preloading.enabled != previousConfiguration.preloading.enabled { Task { @MainActor in - CheckoutWebView.invalidate() + invalidate() } } } -/// 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) -> CheckoutPreload? { guard configuration.preloading.enabled else { - return + return nil } + let checkoutPreload = CheckoutPreload(cache: CheckoutWebView.preloadCache) 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.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 new file mode 100644 index 000000000..b56e1e3e4 --- /dev/null +++ b/platforms/swift/Tests/ShopifyCheckoutKitTests/PreloadObservabilityTests.swift @@ -0,0 +1,230 @@ +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 \(String(describing: preload?.state))") + } + } + + func testManualInvalidateTransitionsToIdle() { + let preload = ShopifyCheckoutKit.preload(checkout: url) + + ShopifyCheckoutKit.invalidate() + + guard case .idle = preload?.state else { + return XCTFail("expected .idle, got \(String(describing: preload?.state))") + } + } + + func testOnStateChangeReceivesTransitions() { + var states: [PreloadState] = [] + + let preload = ShopifyCheckoutKit.preload(checkout: url) + preload?.onStateChange = { 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) + 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, [.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]) + } + } + + 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 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]) + } + } + + 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)) + } + } + + 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/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" + ) + } } diff --git a/platforms/swift/api/ShopifyCheckoutKit.json b/platforms/swift/api/ShopifyCheckoutKit.json index 670028ebe..c420d6af9 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", @@ -6533,155 +6540,978 @@ ] }, { - "kind": "Var", - "name": "version", - "printedName": "version", - "children": [ - { - "kind": "TypeNominal", - "name": "String", - "printedName": "Swift.String", - "usr": "s:SS" - } - ], - "declKind": "Var", - "usr": "s:18ShopifyCheckoutKit7versionSSvp", - "mangledName": "$s18ShopifyCheckoutKit7versionSSvp", - "moduleName": "ShopifyCheckoutKit", - "declAttributes": [ - "HasInitialValue", - "HasStorage" - ], - "isLet": true, - "hasStorage": true - }, - { - "kind": "Var", - "name": "configuration", - "printedName": "configuration", + "kind": "TypeDecl", + "name": "PreloadState", + "printedName": "PreloadState", "children": [ { - "kind": "TypeNominal", - "name": "Configuration", - "printedName": "ShopifyCheckoutKit.Configuration", - "usr": "s:18ShopifyCheckoutKit13ConfigurationV" - } - ], - "declKind": "Var", - "usr": "s:18ShopifyCheckoutKit13configurationAA13ConfigurationVvp", - "mangledName": "$s18ShopifyCheckoutKit13configurationAA13ConfigurationVvp", - "moduleName": "ShopifyCheckoutKit", - "accessors": [ - { - "kind": "Accessor", - "name": "Get", - "printedName": "Get()", + "kind": "Var", + "name": "idle", + "printedName": "idle", "children": [ { - "kind": "TypeNominal", - "name": "Configuration", - "printedName": "ShopifyCheckoutKit.Configuration", - "usr": "s:18ShopifyCheckoutKit13ConfigurationV" + "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": "Accessor", - "usr": "s:18ShopifyCheckoutKit13configurationAA13ConfigurationVvg", - "mangledName": "$s18ShopifyCheckoutKit13configurationAA13ConfigurationVvg", - "moduleName": "ShopifyCheckoutKit", - "accessorKind": "get" + "declKind": "EnumElement", + "usr": "s:18ShopifyCheckoutKit12PreloadStateO4idleyA2CmF", + "mangledName": "$s18ShopifyCheckoutKit12PreloadStateO4idleyA2CmF", + "moduleName": "ShopifyCheckoutKit" }, { - "kind": "Accessor", - "name": "Set", - "printedName": "Set()", + "kind": "Var", + "name": "loading", + "printedName": "loading", "children": [ { - "kind": "TypeNominal", - "name": "Void", - "printedName": "()" - }, - { - "kind": "TypeNominal", - "name": "Configuration", - "printedName": "ShopifyCheckoutKit.Configuration", - "usr": "s:18ShopifyCheckoutKit13ConfigurationV" + "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": "Accessor", - "usr": "s:18ShopifyCheckoutKit13configurationAA13ConfigurationVvs", - "mangledName": "$s18ShopifyCheckoutKit13configurationAA13ConfigurationVvs", - "moduleName": "ShopifyCheckoutKit", - "accessorKind": "set" - } - ] - }, - { - "kind": "Function", - "name": "configure", - "printedName": "configure(_:)", - "children": [ - { - "kind": "TypeNominal", - "name": "Void", - "printedName": "()" + "declKind": "EnumElement", + "usr": "s:18ShopifyCheckoutKit12PreloadStateO7loadingyA2CmF", + "mangledName": "$s18ShopifyCheckoutKit12PreloadStateO7loadingyA2CmF", + "moduleName": "ShopifyCheckoutKit" }, { - "kind": "TypeFunc", - "name": "Function", - "printedName": "(inout ShopifyCheckoutKit.Configuration) -> Swift.Void", + "kind": "Var", + "name": "ready", + "printedName": "ready", "children": [ { - "kind": "TypeNameAlias", - "name": "Void", - "printedName": "Swift.Void", + "kind": "TypeFunc", + "name": "Function", + "printedName": "(ShopifyCheckoutKit.PreloadState.Type) -> ShopifyCheckoutKit.PreloadState", "children": [ { "kind": "TypeNominal", - "name": "Void", - "printedName": "()" + "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": "TypeNominal", - "name": "Configuration", - "printedName": "ShopifyCheckoutKit.Configuration", - "usr": "s:18ShopifyCheckoutKit13ConfigurationV" + "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" + } + ] + } + ] } ], - "typeAttributes": [ - "noescape" - ] - } - ], - "declKind": "Func", - "usr": "s:18ShopifyCheckoutKit9configureyyyAA13ConfigurationVzXEF", - "mangledName": "$s18ShopifyCheckoutKit9configureyyyAA13ConfigurationVzXEF", - "moduleName": "ShopifyCheckoutKit", - "funcSelfKind": "NonMutating" - }, - { - "kind": "Function", - "name": "preload", - "printedName": "preload(checkout:)", - "children": [ + "declKind": "EnumElement", + "usr": "s:18ShopifyCheckoutKit12PreloadStateO7expiredyA2CmF", + "mangledName": "$s18ShopifyCheckoutKit12PreloadStateO7expiredyA2CmF", + "moduleName": "ShopifyCheckoutKit" + }, { - "kind": "TypeNominal", - "name": "Void", - "printedName": "()" + "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": "TypeNominal", - "name": "URL", - "printedName": "Foundation.URL", - "usr": "s:10Foundation3URLV" - } - ], - "declKind": "Func", - "usr": "s:18ShopifyCheckoutKit7preload8checkouty10Foundation3URLV_tF", - "mangledName": "$s18ShopifyCheckoutKit7preload8checkouty10Foundation3URLV_tF", - "moduleName": "ShopifyCheckoutKit", - "declAttributes": [ + "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": "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", + "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" + ], + "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" + ], + "accessorKind": "set" + } + ] + }, + { + "kind": "TypeAlias", + "name": "ObjectWillChangePublisher", + "printedName": "ObjectWillChangePublisher", + "children": [ + { + "kind": "TypeNominal", + "name": "ObservableObjectPublisher", + "printedName": "Combine.ObservableObjectPublisher", + "usr": "s:7Combine25ObservableObjectPublisherC" + } + ], + "declKind": "TypeAlias", + "usr": "s:18ShopifyCheckoutKit0B7PreloadC25ObjectWillChangePublishera", + "mangledName": "$s18ShopifyCheckoutKit0B7PreloadC25ObjectWillChangePublishera", + "moduleName": "ShopifyCheckoutKit", + "implicit": true + } + ], + "declKind": "Class", + "usr": "s:18ShopifyCheckoutKit0B7PreloadC", + "mangledName": "$s18ShopifyCheckoutKit0B7PreloadC", + "moduleName": "ShopifyCheckoutKit", + "declAttributes": [ + "Final", + "Custom" + ], + "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", + "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", + "printedName": "version", + "children": [ + { + "kind": "TypeNominal", + "name": "String", + "printedName": "Swift.String", + "usr": "s:SS" + } + ], + "declKind": "Var", + "usr": "s:18ShopifyCheckoutKit7versionSSvp", + "mangledName": "$s18ShopifyCheckoutKit7versionSSvp", + "moduleName": "ShopifyCheckoutKit", + "declAttributes": [ + "HasInitialValue", + "HasStorage" + ], + "isLet": true, + "hasStorage": true + }, + { + "kind": "Var", + "name": "configuration", + "printedName": "configuration", + "children": [ + { + "kind": "TypeNominal", + "name": "Configuration", + "printedName": "ShopifyCheckoutKit.Configuration", + "usr": "s:18ShopifyCheckoutKit13ConfigurationV" + } + ], + "declKind": "Var", + "usr": "s:18ShopifyCheckoutKit13configurationAA13ConfigurationVvp", + "mangledName": "$s18ShopifyCheckoutKit13configurationAA13ConfigurationVvp", + "moduleName": "ShopifyCheckoutKit", + "accessors": [ + { + "kind": "Accessor", + "name": "Get", + "printedName": "Get()", + "children": [ + { + "kind": "TypeNominal", + "name": "Configuration", + "printedName": "ShopifyCheckoutKit.Configuration", + "usr": "s:18ShopifyCheckoutKit13ConfigurationV" + } + ], + "declKind": "Accessor", + "usr": "s:18ShopifyCheckoutKit13configurationAA13ConfigurationVvg", + "mangledName": "$s18ShopifyCheckoutKit13configurationAA13ConfigurationVvg", + "moduleName": "ShopifyCheckoutKit", + "accessorKind": "get" + }, + { + "kind": "Accessor", + "name": "Set", + "printedName": "Set()", + "children": [ + { + "kind": "TypeNominal", + "name": "Void", + "printedName": "()" + }, + { + "kind": "TypeNominal", + "name": "Configuration", + "printedName": "ShopifyCheckoutKit.Configuration", + "usr": "s:18ShopifyCheckoutKit13ConfigurationV" + } + ], + "declKind": "Accessor", + "usr": "s:18ShopifyCheckoutKit13configurationAA13ConfigurationVvs", + "mangledName": "$s18ShopifyCheckoutKit13configurationAA13ConfigurationVvs", + "moduleName": "ShopifyCheckoutKit", + "accessorKind": "set" + } + ] + }, + { + "kind": "Function", + "name": "configure", + "printedName": "configure(_:)", + "children": [ + { + "kind": "TypeNominal", + "name": "Void", + "printedName": "()" + }, + { + "kind": "TypeFunc", + "name": "Function", + "printedName": "(inout ShopifyCheckoutKit.Configuration) -> Swift.Void", + "children": [ + { + "kind": "TypeNameAlias", + "name": "Void", + "printedName": "Swift.Void", + "children": [ + { + "kind": "TypeNominal", + "name": "Void", + "printedName": "()" + } + ] + }, + { + "kind": "TypeNominal", + "name": "Configuration", + "printedName": "ShopifyCheckoutKit.Configuration", + "usr": "s:18ShopifyCheckoutKit13ConfigurationV" + } + ], + "typeAttributes": [ + "noescape" + ] + } + ], + "declKind": "Func", + "usr": "s:18ShopifyCheckoutKit9configureyyyAA13ConfigurationVzXEF", + "mangledName": "$s18ShopifyCheckoutKit9configureyyyAA13ConfigurationVzXEF", + "moduleName": "ShopifyCheckoutKit", + "funcSelfKind": "NonMutating" + }, + { + "kind": "Function", + "name": "preload", + "printedName": "preload(checkout:)", + "children": [ + { + "kind": "TypeNominal", + "name": "Optional", + "printedName": "ShopifyCheckoutKit.CheckoutPreload?", + "children": [ + { + "kind": "TypeNominal", + "name": "CheckoutPreload", + "printedName": "ShopifyCheckoutKit.CheckoutPreload", + "usr": "s:18ShopifyCheckoutKit0B7PreloadC" + } + ], + "usr": "s:Sq" + }, + { + "kind": "TypeNominal", + "name": "URL", + "printedName": "Foundation.URL", + "usr": "s:10Foundation3URLV" + } + ], + "declKind": "Func", + "usr": "s:18ShopifyCheckoutKit7preload8checkoutAA0B7PreloadCSg10Foundation3URLV_tF", + "mangledName": "$s18ShopifyCheckoutKit7preload8checkoutAA0B7PreloadCSg10Foundation3URLV_tF", + "moduleName": "ShopifyCheckoutKit", + "declAttributes": [ + "DiscardableResult", "Custom" ], "funcSelfKind": "NonMutating"