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
2 changes: 2 additions & 0 deletions platforms/swift/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,8 @@ Call `preload` when your app has a strong signal that the buyer is likely to che
ShopifyCheckoutKit.preload(checkout: checkoutURL)
```

Each call refreshes the cached checkout, even when `checkoutURL` is unchanged. Call `preload` again after cart changes so the preloaded checkout reflects the latest cart state. A preload request made while checkout is presented leaves the active checkout session untouched.

`preload` returns an optional `CheckoutPreload` handle. You can ignore it when preloading is only a performance hint, or retain it to observe the preload lifecycle:

```swift
Expand Down
42 changes: 30 additions & 12 deletions platforms/swift/Sources/ShopifyCheckoutKit/CheckoutWebView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -53,11 +53,12 @@ final class PreloadCache {
}

func store(_ view: CheckoutWebView, for key: PreloadKey, createdAt: Date = Date()) -> Bool {
if let entry, entry.key == key, !entry.isStale {
return true
}

invalidate()
// Refresh the cache without reloading the checkout the buyer is currently using.
// Once presented, the controller owns the old view until dismissal, so it is safe to
// remove that view from the cache as long as its bridge remains attached. The replacement
// can then preload the latest cart state in the background for the buyer's next checkout.
let shouldPreservePresentedView = entry?.view.isPresented == true
invalidate(disconnect: !shouldPreservePresentedView)

let entry = Entry(key: key, view: view, createdAt: createdAt)
guard !entry.isStale else {
Expand Down Expand Up @@ -344,8 +345,26 @@ class CheckoutWebView: WKWebView {
ReadyResult(checkout: nil, credential: nil, ucp: .success(), upgrade: nil, continueURL: nil, messages: nil)
}
.on(CheckoutProtocol.complete) { [weak self] _ in
guard let self, CheckoutWebView.preloadCache.contains(self) else { return }
CheckoutWebView.preloadCache.evict(with: .idle, disconnect: false)
guard let self else { return }

let cacheContainsCompletedView = CheckoutWebView.preloadCache.contains(self)
let cacheContainsItsReplacement = if let loadedCheckoutURL, isPresented {
CheckoutWebView.preloadCache.hasEntry(for: PreloadKey(
url: loadedCheckoutURL,
entryPoint: entryPoint
))
} else {
false
}

// A preload requested during presentation moves the visible view out of the cache.
// If the buyer then completes that checkout, discard its background replacement too;
// otherwise the completed cart could be shown when checkout is opened again.
guard cacheContainsCompletedView || cacheContainsItsReplacement else { return }
CheckoutWebView.preloadCache.evict(
with: .idle,
disconnect: !cacheContainsCompletedView
)
}
.on(CheckoutProtocol.windowOpen) { [externalURLHandler] request in
guard let target = request.parsedURL else {
Expand Down Expand Up @@ -407,11 +426,6 @@ class CheckoutWebView: WKWebView {
}

let key = PreloadKey(url: url, entryPoint: entryPoint)
guard !preloadCache.hasEntry(for: key) else {
OSLogger.shared.debug("Preload cache already has matching entry")
return
}

let view = CheckoutWebView(entryPoint: entryPoint)
// Keep the preloaded webview out of any window. WebKit derives
// `document.visibilityState` from window membership, so an unparented webview reports
Expand All @@ -438,6 +452,10 @@ class CheckoutWebView: WKWebView {
/// longer drive preload state, even after dismissal or reuse.
var hasBeenPresented = false

/// Tracks whether this view is currently backing a presented checkout.
/// Background preload requests must not replace or reload a live session.
var isPresented = false

/// Ensures one terminal failure is handled per checkout session, regardless
/// of whether it originated from `ec.error` or WebKit process termination.
private var hasHandledTerminalFailure = false
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ class CheckoutWebViewController: UIViewController, UIAdaptivePresentationControl
self.client = client

let checkoutView = CheckoutWebView.for(checkout: url, entryPoint: entryPoint)
checkoutView.isPresented = true
checkoutView.translatesAutoresizingMaskIntoConstraints = false
checkoutView.scrollView.contentInsetAdjustmentBehavior = .automatic
checkoutView.client = client
Expand Down Expand Up @@ -178,6 +179,8 @@ class CheckoutWebViewController: UIViewController, UIAdaptivePresentationControl
progressObserver?.invalidate()
progressObserver = nil

checkoutView?.isPresented = false

if let checkoutView, CheckoutWebView.preloadCache.retainAfterPresentation(checkoutView) {
checkoutView.viewDelegate = nil
checkoutView.client = nil
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,8 @@ private func applyConfigurationChange(configuration: Configuration, previousConf
}

/// Preloads the checkout for faster presentation and returns a handle for
/// observing preload state. Retain the handle to keep observing.
/// observing preload state. Each call refreshes the cached checkout, even when
/// the URL is unchanged. Retain the handle to keep observing.
@MainActor
@discardableResult
public func preload(checkout url: URL) -> CheckoutPreload? {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,31 @@ class CheckoutWebViewControllerTests: XCTestCase {
XCTAssertTrue(CheckoutWebView.preloadCache.hasEntry())
}

func test_viewDidDisappear_preservesReplacementPreloadWhenPresentedCheckoutIsDismissed() throws {
ShopifyCheckoutKit.invalidate()
defer { ShopifyCheckoutKit.invalidate() }
ShopifyCheckoutKit.configuration.preloading.enabled = true
ShopifyCheckoutKit.preload(checkout: url)
let checkoutURL = CheckoutURLDecorator.decorate(url)
let viewController = TestableCheckoutWebViewController(checkoutURL: checkoutURL, entryPoint: nil)
viewController.loadViewIfNeeded()
let presentedView = try XCTUnwrap(viewController.checkoutView)

ShopifyCheckoutKit.preload(checkout: url)
let replacementView = CheckoutWebView.for(checkout: checkoutURL)

XCTAssertFalse(replacementView === presentedView)
XCTAssertTrue(CheckoutWebView.preloadCache.contains(replacementView))
XCTAssertTrue(presentedView.isBridgeAttached)

viewController.testIsBeingDismissed = true
viewController.viewDidDisappear(false)

XCTAssertFalse(presentedView.isBridgeAttached)
XCTAssertTrue(replacementView.isBridgeAttached)
XCTAssertTrue(CheckoutWebView.preloadCache.contains(replacementView))
}

func test_checkoutViewDidFailWithError_doesNotCleanUpBeforeViewDisappears() throws {
let viewController = TestableCheckoutWebViewController(checkoutURL: url, entryPoint: nil)
viewController.loadViewIfNeeded()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -309,14 +309,30 @@ class CheckoutWebViewTests: XCTestCase {
XCTAssertNotNil(cached.url)
}

func testRepeatedPreloadForMatchingCheckoutDoesNotReloadCachedWebView() {
func testRepeatedPreloadForMatchingCheckoutReplacesCachedWebView() {
let webView = LoadedRequestObservableWebView()
let checkoutURL = EmbeddedCheckoutProtocol.url(for: url)
_ = CheckoutWebView.preloadCache.store(webView, for: PreloadKey(url: checkoutURL, entryPoint: nil))

CheckoutWebView.preload(checkout: checkoutURL)

XCTAssertTrue(CheckoutWebView.preloadCache.hasEntry())
XCTAssertFalse(CheckoutWebView.preloadCache.contains(webView))
XCTAssertFalse(webView.isBridgeAttached)
XCTAssertNil(webView.lastLoadedURLRequest)
}

func testRepeatedPreloadReplacesCacheWithoutDisconnectingPresentedCheckout() {
let webView = LoadedRequestObservableWebView()
let checkoutURL = EmbeddedCheckoutProtocol.url(for: url)
_ = CheckoutWebView.preloadCache.store(webView, for: PreloadKey(url: checkoutURL, entryPoint: nil))
webView.isPresented = true

CheckoutWebView.preload(checkout: checkoutURL)

XCTAssertFalse(CheckoutWebView.preloadCache.contains(webView))
XCTAssertTrue(CheckoutWebView.preloadCache.hasEntry())
XCTAssertTrue(webView.isBridgeAttached)
XCTAssertNil(webView.lastLoadedURLRequest)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,23 @@ class PreloadCacheTests: XCTestCase {
XCTAssertFalse(CheckoutWebView.preloadCache.contains(entry))
}

func test_CompleteOnPresentedViewClearsMatchingReplacementPreload() async {
let presented = storeCacheEntry()
presented.load(checkout: url)
presented.isPresented = true
let replacement = CheckoutWebView(entryPoint: nil)
_ = CheckoutWebView.preloadCache.store(
replacement,
for: PreloadKey(url: url, entryPoint: nil)
)

_ = await presented.defaultsClient.process(ecCompleteBody())

XCTAssertFalse(CheckoutWebView.preloadCache.hasEntry())
XCTAssertFalse(replacement.isBridgeAttached)
XCTAssertTrue(presented.isBridgeAttached)
}

func test_TerminalErrorOnSlotOccupantClearsSlot() async {
let entry = storeCacheEntry()
let preload = CheckoutPreload(cache: CheckoutWebView.preloadCache)
Expand Down
Loading