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..4f312a99e 100644 --- a/platforms/swift/Sources/ShopifyCheckoutKit/CheckoutWebView.swift +++ b/platforms/swift/Sources/ShopifyCheckoutKit/CheckoutWebView.swift @@ -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) @@ -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) } @@ -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 { @@ -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 { @@ -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 @@ -423,8 +427,8 @@ class CheckoutWebView: WKWebView { } } - static func invalidate(disconnect: Bool = true) { - preloadCache.invalidate(disconnect: disconnect) + static func invalidate() { + preloadCache.invalidate() } // MARK: Properties @@ -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 @@ -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") 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..9f16aa806 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? { @@ -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 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 b56d44f98..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") @@ -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) } 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) 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] = []