From f4920797c20236b969bd5ee5c48b233815ae198f Mon Sep 17 00:00:00 2001 From: Mark Murray Date: Mon, 17 Aug 2026 12:46:42 +0100 Subject: [PATCH 1/4] Refresh repeated Swift preloads --- platforms/swift/README.md | 2 ++ .../ShopifyCheckoutKit/CheckoutWebView.swift | 15 ++++++++------- .../CheckoutWebViewController.swift | 3 +++ .../ShopifyCheckoutKit/ShopifyCheckoutKit.swift | 3 ++- .../CheckoutWebViewTests.swift | 17 ++++++++++++++++- 5 files changed, 31 insertions(+), 9 deletions(-) diff --git a/platforms/swift/README.md b/platforms/swift/README.md index 081a6ab24..2c3e3c418 100644 --- a/platforms/swift/README.md +++ b/platforms/swift/README.md @@ -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 diff --git a/platforms/swift/Sources/ShopifyCheckoutKit/CheckoutWebView.swift b/platforms/swift/Sources/ShopifyCheckoutKit/CheckoutWebView.swift index d537654f7..33149a43e 100644 --- a/platforms/swift/Sources/ShopifyCheckoutKit/CheckoutWebView.swift +++ b/platforms/swift/Sources/ShopifyCheckoutKit/CheckoutWebView.swift @@ -53,8 +53,10 @@ final class PreloadCache { } func store(_ view: CheckoutWebView, for key: PreloadKey, createdAt: Date = Date()) -> Bool { - if let entry, entry.key == key, !entry.isStale { - return true + // A preload is only a background optimization. Never replace the cached + // view while it is backing a live checkout session. + guard entry?.view.isPresented != true else { + return false } invalidate() @@ -407,11 +409,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 @@ -438,6 +435,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 diff --git a/platforms/swift/Sources/ShopifyCheckoutKit/CheckoutWebViewController.swift b/platforms/swift/Sources/ShopifyCheckoutKit/CheckoutWebViewController.swift index 1fd0c6a0b..26b889a12 100644 --- a/platforms/swift/Sources/ShopifyCheckoutKit/CheckoutWebViewController.swift +++ b/platforms/swift/Sources/ShopifyCheckoutKit/CheckoutWebViewController.swift @@ -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 @@ -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 diff --git a/platforms/swift/Sources/ShopifyCheckoutKit/ShopifyCheckoutKit.swift b/platforms/swift/Sources/ShopifyCheckoutKit/ShopifyCheckoutKit.swift index a3eae5bfe..f5d18505e 100644 --- a/platforms/swift/Sources/ShopifyCheckoutKit/ShopifyCheckoutKit.swift +++ b/platforms/swift/Sources/ShopifyCheckoutKit/ShopifyCheckoutKit.swift @@ -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? { diff --git a/platforms/swift/Tests/ShopifyCheckoutKitTests/CheckoutWebViewTests.swift b/platforms/swift/Tests/ShopifyCheckoutKitTests/CheckoutWebViewTests.swift index b56d44f98..d7cac367f 100644 --- a/platforms/swift/Tests/ShopifyCheckoutKitTests/CheckoutWebViewTests.swift +++ b/platforms/swift/Tests/ShopifyCheckoutKitTests/CheckoutWebViewTests.swift @@ -309,7 +309,7 @@ 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)) @@ -317,6 +317,21 @@ class CheckoutWebViewTests: XCTestCase { CheckoutWebView.preload(checkout: checkoutURL) XCTAssertTrue(CheckoutWebView.preloadCache.hasEntry()) + XCTAssertFalse(CheckoutWebView.preloadCache.contains(webView)) + XCTAssertFalse(webView.isBridgeAttached) + XCTAssertNil(webView.lastLoadedURLRequest) + } + + func testRepeatedPreloadDoesNotReplacePresentedCheckout() { + 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) + + XCTAssertTrue(CheckoutWebView.preloadCache.contains(webView)) + XCTAssertTrue(webView.isBridgeAttached) XCTAssertNil(webView.lastLoadedURLRequest) } From c96e3604966206e7f06e0b1db8590186d230e6b5 Mon Sep 17 00:00:00 2001 From: Mark Murray Date: Tue, 18 Aug 2026 14:26:17 +0100 Subject: [PATCH 2/4] Refresh preload without disrupting active checkout --- .../ShopifyCheckoutKit/CheckoutWebView.swift | 35 ++++++++++++++----- .../CheckoutWebViewControllerTests.swift | 25 +++++++++++++ .../CheckoutWebViewTests.swift | 5 +-- .../PreloadCacheTests.swift | 17 +++++++++ 4 files changed, 71 insertions(+), 11 deletions(-) diff --git a/platforms/swift/Sources/ShopifyCheckoutKit/CheckoutWebView.swift b/platforms/swift/Sources/ShopifyCheckoutKit/CheckoutWebView.swift index 33149a43e..90280632d 100644 --- a/platforms/swift/Sources/ShopifyCheckoutKit/CheckoutWebView.swift +++ b/platforms/swift/Sources/ShopifyCheckoutKit/CheckoutWebView.swift @@ -53,13 +53,12 @@ final class PreloadCache { } func store(_ view: CheckoutWebView, for key: PreloadKey, createdAt: Date = Date()) -> Bool { - // A preload is only a background optimization. Never replace the cached - // view while it is backing a live checkout session. - guard entry?.view.isPresented != true else { - return false - } - - 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 { @@ -346,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 { diff --git a/platforms/swift/Tests/ShopifyCheckoutKitTests/CheckoutWebViewControllerTests.swift b/platforms/swift/Tests/ShopifyCheckoutKitTests/CheckoutWebViewControllerTests.swift index 4ed30cb75..a87829499 100644 --- a/platforms/swift/Tests/ShopifyCheckoutKitTests/CheckoutWebViewControllerTests.swift +++ b/platforms/swift/Tests/ShopifyCheckoutKitTests/CheckoutWebViewControllerTests.swift @@ -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() diff --git a/platforms/swift/Tests/ShopifyCheckoutKitTests/CheckoutWebViewTests.swift b/platforms/swift/Tests/ShopifyCheckoutKitTests/CheckoutWebViewTests.swift index d7cac367f..a632c7506 100644 --- a/platforms/swift/Tests/ShopifyCheckoutKitTests/CheckoutWebViewTests.swift +++ b/platforms/swift/Tests/ShopifyCheckoutKitTests/CheckoutWebViewTests.swift @@ -322,7 +322,7 @@ class CheckoutWebViewTests: XCTestCase { XCTAssertNil(webView.lastLoadedURLRequest) } - func testRepeatedPreloadDoesNotReplacePresentedCheckout() { + func testRepeatedPreloadReplacesCacheWithoutDisconnectingPresentedCheckout() { let webView = LoadedRequestObservableWebView() let checkoutURL = EmbeddedCheckoutProtocol.url(for: url) _ = CheckoutWebView.preloadCache.store(webView, for: PreloadKey(url: checkoutURL, entryPoint: nil)) @@ -330,7 +330,8 @@ class CheckoutWebViewTests: XCTestCase { CheckoutWebView.preload(checkout: checkoutURL) - XCTAssertTrue(CheckoutWebView.preloadCache.contains(webView)) + XCTAssertFalse(CheckoutWebView.preloadCache.contains(webView)) + XCTAssertTrue(CheckoutWebView.preloadCache.hasEntry()) XCTAssertTrue(webView.isBridgeAttached) XCTAssertNil(webView.lastLoadedURLRequest) } diff --git a/platforms/swift/Tests/ShopifyCheckoutKitTests/PreloadCacheTests.swift b/platforms/swift/Tests/ShopifyCheckoutKitTests/PreloadCacheTests.swift index f14e9ea5a..8646528b9 100644 --- a/platforms/swift/Tests/ShopifyCheckoutKitTests/PreloadCacheTests.swift +++ b/platforms/swift/Tests/ShopifyCheckoutKitTests/PreloadCacheTests.swift @@ -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) From 1c1de4686eb2018709433841ac920f75addea851 Mon Sep 17 00:00:00 2001 From: Mark Murray Date: Thu, 20 Aug 2026 10:35:08 +0100 Subject: [PATCH 3/4] Preserve presented checkout during invalidation Assisted-By: devx/a6e75808-d9b5-4fe0-928a-e0ede217c5eb --- .../ShopifyCheckoutKit/CheckoutWebView.swift | 27 +++++++------------ .../ShopifyCheckoutKit.swift | 2 +- .../PreloadObservabilityTests.swift | 14 ++++++++++ 3 files changed, 25 insertions(+), 18 deletions(-) diff --git a/platforms/swift/Sources/ShopifyCheckoutKit/CheckoutWebView.swift b/platforms/swift/Sources/ShopifyCheckoutKit/CheckoutWebView.swift index 90280632d..f6bda04c9 100644 --- a/platforms/swift/Sources/ShopifyCheckoutKit/CheckoutWebView.swift +++ b/platforms/swift/Sources/ShopifyCheckoutKit/CheckoutWebView.swift @@ -53,12 +53,7 @@ final class PreloadCache { } func store(_ view: CheckoutWebView, for key: PreloadKey, createdAt: Date = Date()) -> Bool { - // 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) + invalidate() let entry = Entry(key: key, view: view, createdAt: createdAt) guard !entry.isStale else { @@ -83,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) } @@ -122,15 +117,16 @@ 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 { + // Once presented, the controller owns the view and its bridge until dismissal. + if cachedView?.isPresented != true { cachedView?.detachBridge() } } @@ -361,10 +357,7 @@ class CheckoutWebView: WKWebView { // 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 - ) + CheckoutWebView.preloadCache.evict(with: .idle) } .on(CheckoutProtocol.windowOpen) { [externalURLHandler] request in guard let target = request.parsedURL else { @@ -437,8 +430,8 @@ class CheckoutWebView: WKWebView { } } - static func invalidate(disconnect: Bool = true) { - preloadCache.invalidate(disconnect: disconnect) + static func invalidate() { + preloadCache.invalidate() } // MARK: Properties diff --git a/platforms/swift/Sources/ShopifyCheckoutKit/ShopifyCheckoutKit.swift b/platforms/swift/Sources/ShopifyCheckoutKit/ShopifyCheckoutKit.swift index f5d18505e..9f16aa806 100644 --- a/platforms/swift/Sources/ShopifyCheckoutKit/ShopifyCheckoutKit.swift +++ b/platforms/swift/Sources/ShopifyCheckoutKit/ShopifyCheckoutKit.swift @@ -60,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 diff --git a/platforms/swift/Tests/ShopifyCheckoutKitTests/PreloadObservabilityTests.swift b/platforms/swift/Tests/ShopifyCheckoutKitTests/PreloadObservabilityTests.swift index 6f800bb67..6f46ad6a7 100644 --- a/platforms/swift/Tests/ShopifyCheckoutKitTests/PreloadObservabilityTests.swift +++ b/platforms/swift/Tests/ShopifyCheckoutKitTests/PreloadObservabilityTests.swift @@ -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] = [] From bebec75b834a97c9a649b8aff6e509dd5420c77f Mon Sep 17 00:00:00 2001 From: Mark Murray Date: Thu, 20 Aug 2026 10:48:46 +0100 Subject: [PATCH 4/4] Guard bridge detachment while presented Assisted-By: devx/a6e75808-d9b5-4fe0-928a-e0ede217c5eb --- .../ShopifyCheckoutKit/CheckoutWebView.swift | 7 +++---- .../CheckoutWebViewTests.swift | 13 +++++++++++++ 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/platforms/swift/Sources/ShopifyCheckoutKit/CheckoutWebView.swift b/platforms/swift/Sources/ShopifyCheckoutKit/CheckoutWebView.swift index f6bda04c9..4f312a99e 100644 --- a/platforms/swift/Sources/ShopifyCheckoutKit/CheckoutWebView.swift +++ b/platforms/swift/Sources/ShopifyCheckoutKit/CheckoutWebView.swift @@ -125,10 +125,7 @@ final class PreloadCache { stopExpiryTimer() entry = nil - // Once presented, the controller owns the view and its bridge until dismissal. - if cachedView?.isPresented != true { - cachedView?.detachBridge() - } + cachedView?.detachBridge() } func hasEntry() -> Bool { @@ -506,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") diff --git a/platforms/swift/Tests/ShopifyCheckoutKitTests/CheckoutWebViewTests.swift b/platforms/swift/Tests/ShopifyCheckoutKitTests/CheckoutWebViewTests.swift index a632c7506..60db70a7f 100644 --- a/platforms/swift/Tests/ShopifyCheckoutKitTests/CheckoutWebViewTests.swift +++ b/platforms/swift/Tests/ShopifyCheckoutKitTests/CheckoutWebViewTests.swift @@ -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")