diff --git a/platforms/swift/README.md b/platforms/swift/README.md index 53e7a0d2c..1d5a57c53 100644 --- a/platforms/swift/README.md +++ b/platforms/swift/README.md @@ -175,14 +175,14 @@ ShopifyCheckoutKit.preload(checkout: checkoutURL) let preload = ShopifyCheckoutKit.preload(checkout: checkoutURL) preload?.onStateChange = { state in switch state { + case .idle(let reason): + recordPreloadIdle(reason) case .loading: showPreloadProgress() case .ready: enableCheckoutAffordance() - case .failed(let reason): - recordPreloadFailure(reason) - case .expired, .idle: - break + case .failed(let reason, let message): + recordPreloadFailure(reason, message) } } @@ -194,15 +194,14 @@ if preload == nil { `onStateChange` receives the current state immediately, followed by state changes. The preload cache has one weak observer, so a later `preload` call replaces the observer associated with an earlier handle; retain the latest handle for as long as you need to observe state. When `present` reuses a preload, its handle also stops receiving updates and retains its last observed state, which may be `.loading`. -A successful background preload normally transitions from `.loading` to `.ready`. `.idle` means the preload was intentionally abandoned or became inapplicable, such as after explicit invalidation, disabling preloading, activity destruction, or a checkout URL mismatch. `.failed` means the SDK could not maintain usable preloaded web content; present still creates checkout normally. +A successful background preload normally transitions from `.loading` to `.ready`. `.idle()` is the initial state. An idle state with an `.invalidated` or `.expired` reason means a cached preload became unavailable through that expected lifecycle transition. `.failed` means the SDK could not maintain usable preloaded web content; present still creates checkout normally. | State | Meaning | | --- | --- | | `.loading` | The background checkout WebView is loading. | | `.ready` | The preload finished and can be used for the matching checkout URL. | -| `.idle` | The preload was invalidated or otherwise cleared. | -| `.expired` | The cached preload exceeded its lifetime before it could be used. | -| `.failed(reason:)` | An HTTP, navigation, or web-content failure occurred while preloading. | +| `.idle(reason:)` | No checkout is cached. The optional reason distinguishes invalidation and expiry from the initial state. | +| `.failed(reason:message:)` | An HTTP, navigation, protocol, or web-content failure occurred while preloading. | `preload` returns `nil` when preloading is disabled. diff --git a/platforms/swift/Sources/ShopifyCheckoutKit/CheckoutWebView.swift b/platforms/swift/Sources/ShopifyCheckoutKit/CheckoutWebView.swift index de597e96f..965521589 100644 --- a/platforms/swift/Sources/ShopifyCheckoutKit/CheckoutWebView.swift +++ b/platforms/swift/Sources/ShopifyCheckoutKit/CheckoutWebView.swift @@ -41,7 +41,7 @@ final class PreloadCache { private var keepAliveTimer: Timer? private var expiryTimer: Timer? - private(set) var state: PreloadState = .idle + 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 @@ -92,7 +92,7 @@ final class PreloadCache { let missed = entry invalidate() if let missed { - transition(to: missed.isStale ? .expired : .idle) + transition(to: .idle(reason: missed.isStale ? .expired : .invalidated)) } return nil } @@ -204,7 +204,7 @@ final class PreloadCache { } func expire() { - evict(with: .expired) + evict(with: .idle(reason: .expired)) } func keepAliveDidFail() { @@ -345,7 +345,7 @@ class CheckoutWebView: WKWebView { } .on(CheckoutProtocol.complete) { [weak self] _ in guard let self, CheckoutWebView.preloadCache.contains(self) else { return } - CheckoutWebView.preloadCache.evict(with: .idle, disconnect: false) + CheckoutWebView.preloadCache.evict(with: .idle(reason: .invalidated), disconnect: false) } .on(CheckoutProtocol.windowOpen) { [externalURLHandler] request in guard let target = request.parsedURL else { @@ -558,7 +558,7 @@ class CheckoutWebView: WKWebView { guard CheckoutWebView.preloadCache.contains(self) else { return } if hasBeenPresented { - CheckoutWebView.preloadCache.evict(with: .idle) + CheckoutWebView.preloadCache.evict(with: .idle(reason: .invalidated)) } else { CheckoutWebView.preloadCache.evict(with: .failed(reason: reason, message: message)) } diff --git a/platforms/swift/Sources/ShopifyCheckoutKit/PreloadState.swift b/platforms/swift/Sources/ShopifyCheckoutKit/PreloadState.swift index 48f1a438e..c06af226f 100644 --- a/platforms/swift/Sources/ShopifyCheckoutKit/PreloadState.swift +++ b/platforms/swift/Sources/ShopifyCheckoutKit/PreloadState.swift @@ -7,7 +7,10 @@ import Foundation /// ``CheckoutDelegate/checkoutDidFail(error:)``; a later presentation can load checkout normally. public enum PreloadState: Equatable { /// No checkout is currently cached for preload. - case idle + /// + /// A reason is present when a previously cached checkout became unavailable through an + /// expected lifecycle transition. The initial state has no reason. + case idle(reason: IdleReason? = nil) /// The cached checkout is loading in the background. case loading @@ -15,15 +18,21 @@ public enum PreloadState: Equatable { /// The cached checkout is ready for a matching presentation. case ready - /// The cached checkout passed its time-to-live and was evicted. - case expired - /// The cached checkout could not be retained for the associated reason. /// /// The message contains best-effort diagnostic context. It is not a stable, machine-readable /// value; use ``FailureReason`` to determine how to handle the failure. case failed(reason: FailureReason, message: String) + /// Reason no checkout is currently cached for preload. + public enum IdleReason: Equatable { + /// The preload was explicitly invalidated or became inapplicable. + case invalidated + + /// The cached preload passed its time-to-live. + case expired + } + /// Reason a preload cache entry was not available. public enum FailureReason: Equatable { /// The preload received an HTTP response that prevented it from loading. diff --git a/platforms/swift/Sources/ShopifyCheckoutKit/ShopifyCheckoutKit.swift b/platforms/swift/Sources/ShopifyCheckoutKit/ShopifyCheckoutKit.swift index a3eae5bfe..0b7c0e88a 100644 --- a/platforms/swift/Sources/ShopifyCheckoutKit/ShopifyCheckoutKit.swift +++ b/platforms/swift/Sources/ShopifyCheckoutKit/ShopifyCheckoutKit.swift @@ -59,7 +59,7 @@ public func preload(checkout url: URL) -> CheckoutPreload? { /// Invalidates any cached checkout created by preload calls. @MainActor public func invalidate() { - CheckoutWebView.preloadCache.evict(with: .idle, disconnect: true) + CheckoutWebView.preloadCache.evict(with: .idle(reason: .invalidated), disconnect: true) } @MainActor diff --git a/platforms/swift/Tests/ShopifyCheckoutKitTests/PreloadCacheTests.swift b/platforms/swift/Tests/ShopifyCheckoutKitTests/PreloadCacheTests.swift index 473a64eff..464ac7c35 100644 --- a/platforms/swift/Tests/ShopifyCheckoutKitTests/PreloadCacheTests.swift +++ b/platforms/swift/Tests/ShopifyCheckoutKitTests/PreloadCacheTests.swift @@ -79,7 +79,7 @@ class PreloadCacheTests: XCTestCase { entry.webViewWebContentProcessDidTerminate(entry) XCTAssertFalse(CheckoutWebView.preloadCache.contains(entry)) - XCTAssertEqual(CheckoutWebView.preloadCache.state, .idle) + XCTAssertEqual(CheckoutWebView.preloadCache.state, .idle(reason: .invalidated)) XCTAssertEqual(try XCTUnwrap(delegate.errorReceived).code, .webContentProcessTerminated) } diff --git a/platforms/swift/Tests/ShopifyCheckoutKitTests/PreloadObservabilityTests.swift b/platforms/swift/Tests/ShopifyCheckoutKitTests/PreloadObservabilityTests.swift index 6f800bb67..6ef0431d0 100644 --- a/platforms/swift/Tests/ShopifyCheckoutKitTests/PreloadObservabilityTests.swift +++ b/platforms/swift/Tests/ShopifyCheckoutKitTests/PreloadObservabilityTests.swift @@ -20,6 +20,14 @@ class PreloadObservabilityTests: XCTestCase { try await super.tearDown() } + func testNewCacheStartsIdleWithoutReason() { + guard case let .idle(reason) = PreloadCache().state else { + return XCTFail("expected .idle") + } + + XCTAssertNil(reason) + } + func testPreloadReturnsHandleInLoadingState() { let preload = ShopifyCheckoutKit.preload(checkout: url) @@ -44,9 +52,10 @@ class PreloadObservabilityTests: XCTestCase { ShopifyCheckoutKit.invalidate() - guard case .idle = preload?.state else { + guard case let .idle(reason) = preload?.state else { return XCTFail("expected .idle, got \(String(describing: preload?.state))") } + XCTAssertEqual(reason, .invalidated) } func testOnStateChangeReceivesTransitions() { @@ -57,7 +66,7 @@ class PreloadObservabilityTests: XCTestCase { ShopifyCheckoutKit.invalidate() withExtendedLifetime(preload) { - XCTAssertEqual(states, [.loading, .idle]) + XCTAssertEqual(states, [.loading, .idle(reason: .invalidated)]) } } @@ -74,7 +83,7 @@ class PreloadObservabilityTests: XCTestCase { withExtendedLifetime((first, second)) { XCTAssertEqual(firstStates, [.loading]) - XCTAssertEqual(secondStates, [.loading, .idle]) + XCTAssertEqual(secondStates, [.loading, .idle(reason: .invalidated)]) } } @@ -86,7 +95,7 @@ class PreloadObservabilityTests: XCTestCase { ShopifyCheckoutKit.invalidate() withExtendedLifetime((preload, cancellable)) { - XCTAssertEqual(states, [.loading, .idle]) + XCTAssertEqual(states, [.loading, .idle(reason: .invalidated)]) } } @@ -118,13 +127,13 @@ class PreloadObservabilityTests: XCTestCase { } } - func testExpiryTransitionsToExpired() { + func testExpiryTransitionsToIdleWithExpiredReason() { let preload = ShopifyCheckoutKit.preload(checkout: url) CheckoutWebView.preloadCache.expire() withExtendedLifetime(preload) { - XCTAssertEqual(preload?.state, .expired) + XCTAssertEqual(preload?.state, .idle(reason: .expired)) } } @@ -218,14 +227,14 @@ class PreloadObservabilityTests: XCTestCase { _ = CheckoutWebView.preloadCache.view(for: PreloadKey(url: otherURL, entryPoint: nil)) withExtendedLifetime(preload) { - XCTAssertEqual(preload?.state, .idle) + XCTAssertEqual(preload?.state, .idle(reason: .invalidated)) } } func testExpireClearsCacheBeforeNotifyingSoReentrantPreloadSurvives() { let preload = ShopifyCheckoutKit.preload(checkout: url) preload?.onStateChange = { state in - if case .expired = state { + if case .idle(reason: .expired) = state { _ = CheckoutWebView.preloadCache.store( CheckoutWebView(entryPoint: nil), for: PreloadKey(url: self.url, entryPoint: nil) @@ -245,12 +254,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(reason: .invalidated) { await Task.yield() } withExtendedLifetime(preload) { - XCTAssertEqual(preload?.state, .idle) + XCTAssertEqual(preload?.state, .idle(reason: .invalidated)) } } } diff --git a/platforms/swift/api/ShopifyCheckoutKit.json b/platforms/swift/api/ShopifyCheckoutKit.json index a3c1fd074..aee747766 100644 --- a/platforms/swift/api/ShopifyCheckoutKit.json +++ b/platforms/swift/api/ShopifyCheckoutKit.json @@ -6920,50 +6920,41 @@ { "kind": "TypeFunc", "name": "Function", - "printedName": "(ShopifyCheckoutKit.PreloadState.Type) -> ShopifyCheckoutKit.PreloadState", + "printedName": "(ShopifyCheckoutKit.PreloadState.Type) -> (ShopifyCheckoutKit.PreloadState.IdleReason?) -> ShopifyCheckoutKit.PreloadState", "children": [ { - "kind": "TypeNominal", - "name": "PreloadState", - "printedName": "ShopifyCheckoutKit.PreloadState", - "usr": "s:18ShopifyCheckoutKit12PreloadStateO" - }, - { - "kind": "TypeNominal", - "name": "Metatype", - "printedName": "ShopifyCheckoutKit.PreloadState.Type", + "kind": "TypeFunc", + "name": "Function", + "printedName": "(ShopifyCheckoutKit.PreloadState.IdleReason?) -> ShopifyCheckoutKit.PreloadState", "children": [ { "kind": "TypeNominal", "name": "PreloadState", "printedName": "ShopifyCheckoutKit.PreloadState", "usr": "s:18ShopifyCheckoutKit12PreloadStateO" + }, + { + "kind": "TypeNominal", + "name": "Tuple", + "printedName": "(reason: ShopifyCheckoutKit.PreloadState.IdleReason?)", + "children": [ + { + "kind": "TypeNominal", + "name": "Optional", + "printedName": "ShopifyCheckoutKit.PreloadState.IdleReason?", + "children": [ + { + "kind": "TypeNominal", + "name": "IdleReason", + "printedName": "ShopifyCheckoutKit.PreloadState.IdleReason", + "usr": "s:18ShopifyCheckoutKit12PreloadStateO10IdleReasonO" + } + ], + "usr": "s:Sq" + } + ] } ] - } - ] - } - ], - "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", @@ -6982,14 +6973,14 @@ } ], "declKind": "EnumElement", - "usr": "s:18ShopifyCheckoutKit12PreloadStateO7loadingyA2CmF", - "mangledName": "$s18ShopifyCheckoutKit12PreloadStateO7loadingyA2CmF", + "usr": "s:18ShopifyCheckoutKit12PreloadStateO4idleyA2C10IdleReasonOSg_tcACmF", + "mangledName": "$s18ShopifyCheckoutKit12PreloadStateO4idleyA2C10IdleReasonOSg_tcACmF", "moduleName": "ShopifyCheckoutKit" }, { "kind": "Var", - "name": "ready", - "printedName": "ready", + "name": "loading", + "printedName": "loading", "children": [ { "kind": "TypeFunc", @@ -7019,14 +7010,14 @@ } ], "declKind": "EnumElement", - "usr": "s:18ShopifyCheckoutKit12PreloadStateO5readyyA2CmF", - "mangledName": "$s18ShopifyCheckoutKit12PreloadStateO5readyyA2CmF", + "usr": "s:18ShopifyCheckoutKit12PreloadStateO7loadingyA2CmF", + "mangledName": "$s18ShopifyCheckoutKit12PreloadStateO7loadingyA2CmF", "moduleName": "ShopifyCheckoutKit" }, { "kind": "Var", - "name": "expired", - "printedName": "expired", + "name": "ready", + "printedName": "ready", "children": [ { "kind": "TypeFunc", @@ -7056,8 +7047,8 @@ } ], "declKind": "EnumElement", - "usr": "s:18ShopifyCheckoutKit12PreloadStateO7expiredyA2CmF", - "mangledName": "$s18ShopifyCheckoutKit12PreloadStateO7expiredyA2CmF", + "usr": "s:18ShopifyCheckoutKit12PreloadStateO5readyyA2CmF", + "mangledName": "$s18ShopifyCheckoutKit12PreloadStateO5readyyA2CmF", "moduleName": "ShopifyCheckoutKit" }, { @@ -7123,6 +7114,221 @@ "mangledName": "$s18ShopifyCheckoutKit12PreloadStateO6failedyA2C13FailureReasonO_SStcACmF", "moduleName": "ShopifyCheckoutKit" }, + { + "kind": "TypeDecl", + "name": "IdleReason", + "printedName": "IdleReason", + "children": [ + { + "kind": "Var", + "name": "invalidated", + "printedName": "invalidated", + "children": [ + { + "kind": "TypeFunc", + "name": "Function", + "printedName": "(ShopifyCheckoutKit.PreloadState.IdleReason.Type) -> ShopifyCheckoutKit.PreloadState.IdleReason", + "children": [ + { + "kind": "TypeNominal", + "name": "IdleReason", + "printedName": "ShopifyCheckoutKit.PreloadState.IdleReason", + "usr": "s:18ShopifyCheckoutKit12PreloadStateO10IdleReasonO" + }, + { + "kind": "TypeNominal", + "name": "Metatype", + "printedName": "ShopifyCheckoutKit.PreloadState.IdleReason.Type", + "children": [ + { + "kind": "TypeNominal", + "name": "IdleReason", + "printedName": "ShopifyCheckoutKit.PreloadState.IdleReason", + "usr": "s:18ShopifyCheckoutKit12PreloadStateO10IdleReasonO" + } + ] + } + ] + } + ], + "declKind": "EnumElement", + "usr": "s:18ShopifyCheckoutKit12PreloadStateO10IdleReasonO11invalidatedyA2EmF", + "mangledName": "$s18ShopifyCheckoutKit12PreloadStateO10IdleReasonO11invalidatedyA2EmF", + "moduleName": "ShopifyCheckoutKit" + }, + { + "kind": "Var", + "name": "expired", + "printedName": "expired", + "children": [ + { + "kind": "TypeFunc", + "name": "Function", + "printedName": "(ShopifyCheckoutKit.PreloadState.IdleReason.Type) -> ShopifyCheckoutKit.PreloadState.IdleReason", + "children": [ + { + "kind": "TypeNominal", + "name": "IdleReason", + "printedName": "ShopifyCheckoutKit.PreloadState.IdleReason", + "usr": "s:18ShopifyCheckoutKit12PreloadStateO10IdleReasonO" + }, + { + "kind": "TypeNominal", + "name": "Metatype", + "printedName": "ShopifyCheckoutKit.PreloadState.IdleReason.Type", + "children": [ + { + "kind": "TypeNominal", + "name": "IdleReason", + "printedName": "ShopifyCheckoutKit.PreloadState.IdleReason", + "usr": "s:18ShopifyCheckoutKit12PreloadStateO10IdleReasonO" + } + ] + } + ] + } + ], + "declKind": "EnumElement", + "usr": "s:18ShopifyCheckoutKit12PreloadStateO10IdleReasonO7expiredyA2EmF", + "mangledName": "$s18ShopifyCheckoutKit12PreloadStateO10IdleReasonO7expiredyA2EmF", + "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": "IdleReason", + "printedName": "ShopifyCheckoutKit.PreloadState.IdleReason", + "usr": "s:18ShopifyCheckoutKit12PreloadStateO10IdleReasonO" + }, + { + "kind": "TypeNominal", + "name": "IdleReason", + "printedName": "ShopifyCheckoutKit.PreloadState.IdleReason", + "usr": "s:18ShopifyCheckoutKit12PreloadStateO10IdleReasonO" + } + ], + "declKind": "Func", + "usr": "s:18ShopifyCheckoutKit12PreloadStateO10IdleReasonO21__derived_enum_equalsySbAE_AEtFZ", + "mangledName": "$s18ShopifyCheckoutKit12PreloadStateO10IdleReasonO21__derived_enum_equalsySbAE_AEtFZ", + "moduleName": "ShopifyCheckoutKit", + "static": true, + "implicit": true, + "declAttributes": [ + "Implements" + ], + "funcSelfKind": "NonMutating" + }, + { + "kind": "Function", + "name": "hash", + "printedName": "hash(into:)", + "children": [ + { + "kind": "TypeNominal", + "name": "Void", + "printedName": "()" + }, + { + "kind": "TypeNominal", + "name": "Hasher", + "printedName": "Swift.Hasher", + "paramValueOwnership": "InOut", + "usr": "s:s6HasherV" + } + ], + "declKind": "Func", + "usr": "s:18ShopifyCheckoutKit12PreloadStateO10IdleReasonO4hash4intoys6HasherVz_tF", + "mangledName": "$s18ShopifyCheckoutKit12PreloadStateO10IdleReasonO4hash4intoys6HasherVz_tF", + "moduleName": "ShopifyCheckoutKit", + "implicit": true, + "funcSelfKind": "NonMutating" + }, + { + "kind": "Var", + "name": "hashValue", + "printedName": "hashValue", + "children": [ + { + "kind": "TypeNominal", + "name": "Int", + "printedName": "Swift.Int", + "usr": "s:Si" + } + ], + "declKind": "Var", + "usr": "s:18ShopifyCheckoutKit12PreloadStateO10IdleReasonO9hashValueSivp", + "mangledName": "$s18ShopifyCheckoutKit12PreloadStateO10IdleReasonO9hashValueSivp", + "moduleName": "ShopifyCheckoutKit", + "implicit": true, + "accessors": [ + { + "kind": "Accessor", + "name": "Get", + "printedName": "Get()", + "children": [ + { + "kind": "TypeNominal", + "name": "Int", + "printedName": "Swift.Int", + "usr": "s:Si" + } + ], + "declKind": "Accessor", + "usr": "s:18ShopifyCheckoutKit12PreloadStateO10IdleReasonO9hashValueSivg", + "mangledName": "$s18ShopifyCheckoutKit12PreloadStateO10IdleReasonO9hashValueSivg", + "moduleName": "ShopifyCheckoutKit", + "implicit": true, + "accessorKind": "get" + } + ] + } + ], + "declKind": "Enum", + "usr": "s:18ShopifyCheckoutKit12PreloadStateO10IdleReasonO", + "mangledName": "$s18ShopifyCheckoutKit12PreloadStateO10IdleReasonO", + "moduleName": "ShopifyCheckoutKit", + "isEnumExhaustive": true, + "conformances": [ + { + "kind": "Conformance", + "name": "Hashable", + "printedName": "Hashable", + "usr": "s:SH", + "mangledName": "$sSH" + }, + { + "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": "FailureReason",