Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 7 additions & 8 deletions platforms/swift/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}

Expand All @@ -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.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -204,7 +204,7 @@ final class PreloadCache {
}

func expire() {
evict(with: .expired)
evict(with: .idle(reason: .expired))
}

func keepAliveDidFail() {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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))
}
Expand Down
17 changes: 13 additions & 4 deletions platforms/swift/Sources/ShopifyCheckoutKit/PreloadState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,23 +7,32 @@ 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

/// 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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand All @@ -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() {
Expand All @@ -57,7 +66,7 @@ class PreloadObservabilityTests: XCTestCase {
ShopifyCheckoutKit.invalidate()

withExtendedLifetime(preload) {
XCTAssertEqual(states, [.loading, .idle])
XCTAssertEqual(states, [.loading, .idle(reason: .invalidated)])
}
}

Expand All @@ -74,7 +83,7 @@ class PreloadObservabilityTests: XCTestCase {

withExtendedLifetime((first, second)) {
XCTAssertEqual(firstStates, [.loading])
XCTAssertEqual(secondStates, [.loading, .idle])
XCTAssertEqual(secondStates, [.loading, .idle(reason: .invalidated)])
}
}

Expand All @@ -86,7 +95,7 @@ class PreloadObservabilityTests: XCTestCase {
ShopifyCheckoutKit.invalidate()

withExtendedLifetime((preload, cancellable)) {
XCTAssertEqual(states, [.loading, .idle])
XCTAssertEqual(states, [.loading, .idle(reason: .invalidated)])
}
}

Expand Down Expand Up @@ -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))
}
}

Expand Down Expand Up @@ -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)
Expand All @@ -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))
}
}
}
Loading
Loading