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
50 changes: 30 additions & 20 deletions platforms/swift/Sources/ShopifyCheckoutKit/CheckoutWebView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -53,10 +53,6 @@ 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()

let entry = Entry(key: key, view: view, createdAt: createdAt)
Expand All @@ -82,8 +78,8 @@ final class PreloadCache {
/// 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)
func evict(with state: PreloadState) {
invalidate()
transition(to: state)
}

Expand Down Expand Up @@ -121,17 +117,15 @@ final class PreloadCache {
return true
}

func invalidate(disconnect: Bool = true) {
OSLogger.shared.debug("Invalidating preload cache, disconnect: \(disconnect)")
func invalidate() {
OSLogger.shared.debug("Invalidating preload cache")

let cachedView = entry?.view
stopKeepAlive()
stopExpiryTimer()
entry = nil

if disconnect {
cachedView?.detachBridge()
}
cachedView?.detachBridge()
}

func hasEntry() -> Bool {
Expand Down Expand Up @@ -344,8 +338,23 @@ 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)
}
.on(CheckoutProtocol.windowOpen) { [externalURLHandler] request in
guard let target = request.parsedURL else {
Expand Down Expand Up @@ -407,11 +416,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 @@ -423,8 +427,8 @@ class CheckoutWebView: WKWebView {
}
}

static func invalidate(disconnect: Bool = true) {
preloadCache.invalidate(disconnect: disconnect)
static func invalidate() {
preloadCache.invalidate()
}

// MARK: Properties
Expand All @@ -438,6 +442,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 Expand Up @@ -495,6 +503,8 @@ class CheckoutWebView: WKWebView {
}

public func detachBridge() {
// Once presented, the controller owns the view and its bridge until dismissal.
guard !isPresented else { return }
guard isBridgeAttached else { return }

OSLogger.shared.debug("Detaching bridge")
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 All @@ -59,7 +60,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)
}

@MainActor
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 @@ -51,6 +51,19 @@ class CheckoutWebViewTests: XCTestCase {
XCTAssertFalse(view.isBridgeAttached)
}

func testDetachBridgeWaitsUntilCheckoutIsNoLongerPresented() {
view.isPresented = true

view.detachBridge()

XCTAssertTrue(view.isBridgeAttached)

view.isPresented = false
view.detachBridge()

XCTAssertFalse(view.isBridgeAttached)
}

func testHTTPSLinkIsAllowed() throws {
let link = try XCTUnwrap(URL(string: "https://www.shopify.com/legal/privacy/app-users"))
let received = expectation(description: "policy decided")
Expand Down Expand Up @@ -309,14 +322,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
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,20 @@ class PreloadObservabilityTests: XCTestCase {
}
}

func testManualInvalidateDoesNotDetachPresentedCheckout() {
let view = CheckoutWebView(entryPoint: nil)
view.isPresented = true
_ = CheckoutWebView.preloadCache.store(
view,
for: PreloadKey(url: url, entryPoint: nil)
)

ShopifyCheckoutKit.invalidate()

XCTAssertFalse(CheckoutWebView.preloadCache.hasEntry())
XCTAssertTrue(view.isBridgeAttached)
}

func testOnStateChangeReceivesTransitions() {
var states: [PreloadState] = []

Expand Down
Loading