Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ struct CartView: View {
@State var isBusy: Bool = false
@State var isCompleted: Bool = false
@State var showCheckoutSheet: Bool = false
@State private var checkoutPreload: CheckoutPreload?

@ObservedObject var cartManager: CartManager = .shared

Expand All @@ -32,7 +33,7 @@ struct CartView: View {
ZStack(alignment: .bottom) {
ScrollView {
VStack {
CartLines(lines: lines, isBusy: $isBusy)
CartLines(lines: lines, update: preloadCheckoutIfNeeded, isBusy: $isBusy)
}
.padding(.bottom, 130)
}
Expand Down Expand Up @@ -124,6 +125,7 @@ struct CartView: View {
ToolbarItem(placement: .navigationBarTrailing) {
Button(action: {
cartManager.resetCart()
ShopifyCheckoutKit.invalidate()
}) {
Image(systemName: "trash")
.foregroundColor(.red)
Expand Down Expand Up @@ -153,7 +155,12 @@ struct CartView: View {
private func preloadCheckoutIfNeeded() {
guard checkoutPreloadingEnabled, let url = cartManager.cart?.checkoutURL else { return }

ShopifyCheckoutKit.preload(checkout: url)
ShopifyCheckoutKit.invalidate()
checkoutPreload = ShopifyCheckoutKit.preload(checkout: url)
checkoutPreload?.onStateChange = { state in
print("[Preload] state changed to \(state)")
ShopifyCheckoutKit.configuration.logger.log("Preload state changed to \(state)")
}
}
}

Expand All @@ -173,6 +180,7 @@ struct EmptyState: View {

struct CartLines: View {
var lines: [CartLineNode]
var update: () -> Void
@State var updating: String? {
didSet {
isBusy = updating != nil
Expand Down Expand Up @@ -238,6 +246,7 @@ struct CartLines: View {
let cart = try await CartManager.shared.performCartLinesUpdate(id: node.id, quantity: node.quantity - 1)
CartManager.shared.cart = cart
updating = nil
update()
}
}, label: {
Image(systemName: "minus")
Expand Down Expand Up @@ -272,6 +281,7 @@ struct CartLines: View {
)
CartManager.shared.cart = cart
updating = nil
update()
}
},
label: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -377,6 +377,7 @@ class CartViewController: UIViewController, UITableViewDelegate, UITableViewData

@objc private func resetCart() {
CartManager.shared.resetCart()
ShopifyCheckoutKit.invalidate()
}

private func node(at indexPath: IndexPath) -> CartLineNode {
Expand Down
110 changes: 97 additions & 13 deletions platforms/swift/Sources/ShopifyCheckoutKit/CheckoutWebView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,17 @@ final class PreloadCache {
private var keepAliveTimer: Timer?
private var expiryTimer: Timer?

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
/// receives state updates; earlier handles stop observing.
private weak var observer: CheckoutPreload?

func setObserver(_ observer: CheckoutPreload) {
self.observer = observer
}

func store(_ view: CheckoutWebView, for key: PreloadKey, createdAt: Date = Date()) -> Bool {
if let entry, entry.key == key, !entry.isStale {
return true
Expand All @@ -56,18 +67,39 @@ final class PreloadCache {
self.entry = entry
startKeepAlive(for: view)
startExpiryTimer(after: entry.remainingTTL)
transition(to: .loading)
return true
}

func transition(to newState: PreloadState) {
guard state != newState else { return }

state = newState
observer?.receive(newState)
Comment thread
markmur marked this conversation as resolved.
}

/// 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)
transition(to: state)
}

func view(for key: PreloadKey) -> CheckoutWebView? {
guard let entry, entry.key == key, !entry.isStale else {
guard let cached = entry, cached.key == key, !cached.isStale else {
let missed = entry
invalidate()
Comment thread
markmur marked this conversation as resolved.
if let missed {
transition(to: missed.isStale ? .expired : .idle)
}
return nil
}

stopKeepAlive()
stopExpiryTimer()
return entry.view
cached.view.hasBeenPresented = true
return cached.view
}

func invalidate(disconnect: Bool = true) {
Comment thread
markmur marked this conversation as resolved.
Expand All @@ -85,15 +117,20 @@ final class PreloadCache {

func hasEntry() -> Bool {
if entry?.isStale == true {
invalidate()
expire()
return false
}

Comment thread
markmur marked this conversation as resolved.
return entry != nil
}

func hasEntry(for key: PreloadKey) -> Bool {
guard let entry, entry.key == key, !entry.isStale else {
guard let entry, entry.key == key else {
return false
}

if entry.isStale {
expire()
return false
}

Expand Down Expand Up @@ -124,7 +161,7 @@ final class PreloadCache {
_ = try await view?.evaluateJavaScript("void 0")
} catch {
OSLogger.shared.debug("Preload keep-alive failed; invalidating preload cache")
self?.invalidate()
self?.keepAliveDidFail()
}
}
}
Expand All @@ -136,13 +173,21 @@ final class PreloadCache {
stopExpiryTimer()
let timer = Timer.scheduledTimer(withTimeInterval: interval, repeats: false) { [weak self] _ in
Task { @MainActor in
self?.invalidate()
self?.expire()
}
}
timer.tolerance = min(interval / 2, 1)
expiryTimer = timer
}

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

func keepAliveDidFail() {
evict(with: .failed(reason: .keepAliveLost))
}

private func stopKeepAlive() {
keepAliveTimer?.invalidate()
keepAliveTimer = nil
Expand Down Expand Up @@ -243,8 +288,9 @@ class CheckoutWebView: WKWebView {
.on(CheckoutProtocol.ready) { _ in
ReadyResult(checkout: nil, credential: nil, ucp: .success(), upgrade: nil, continueURL: nil, messages: nil)
}
.on(CheckoutProtocol.complete) { _ in
CheckoutWebView.invalidate(disconnect: false)
.on(CheckoutProtocol.complete) { [weak self] _ in
guard let self, CheckoutWebView.preloadCache.contains(self) else { return }
CheckoutWebView.preloadCache.evict(with: .idle, disconnect: false)
}
.on(CheckoutProtocol.windowOpen) { request in
guard let target = request.parsedURL, self.canOpenExternalURL(target) else {
Expand All @@ -254,9 +300,11 @@ class CheckoutWebView: WKWebView {
return .success()
}
.on(CheckoutProtocol.error) { [weak self] payload in
guard payload.messages.contains(where: { $0.severity == .unrecoverable }) else { return }
CheckoutWebView.invalidate()
self?.viewDelegate?.checkoutViewDidFailWithError(
guard let self, payload.messages.contains(where: { $0.severity == .unrecoverable }) else { return }
if CheckoutWebView.preloadCache.contains(self) {
CheckoutWebView.preloadCache.evict(with: .idle)
}
self.viewDelegate?.checkoutViewDidFailWithError(
error: .checkoutUnavailable(
message: "Embedded checkout reported unrecoverable error.",
code: .clientError(code: .unknown)
Expand Down Expand Up @@ -333,6 +381,11 @@ class CheckoutWebView: WKWebView {

var isPreloadRequest = false

/// Latches true once the cached view is handed off for presentation. A
/// presented view is a live session, so its navigation events must no
/// longer drive preload state, even after dismissal or reuse.
var hasBeenPresented = false

private var entryPoint: MetaData.EntryPoint?

// MARK: Initializers
Expand Down Expand Up @@ -420,6 +473,26 @@ class CheckoutWebView: WKWebView {

load(request)
}

private var isPreloadBackgrounded: Bool {
CheckoutWebView.preloadCache.contains(self) && !hasBeenPresented
}

private func handleCachedViewFailure(_ reason: PreloadState.FailureReason) {
guard CheckoutWebView.preloadCache.contains(self) else { return }

if hasBeenPresented {
CheckoutWebView.preloadCache.evict(with: .idle)
} else {
CheckoutWebView.preloadCache.evict(with: .failed(reason: reason))
}
}

private func markPreloadReadyIfActive() {
guard isPreloadBackgrounded else { return }

CheckoutWebView.preloadCache.transition(to: .ready)
}
}

/// Holds a WebKit script-message registration outside CheckoutWebView deinit.
Expand Down Expand Up @@ -532,7 +605,8 @@ extension CheckoutWebView: WKNavigationDelegate {
}

if statusCode >= 400 {
CheckoutWebView.invalidate()
handleCachedViewFailure(.httpError(statusCode: statusCode))

OSLogger.shared.debug("Handling response for URL: \(LogSafeURL.string(response.url)), status code: \(statusCode)")

switch statusCode {
Expand Down Expand Up @@ -566,9 +640,19 @@ extension CheckoutWebView: WKNavigationDelegate {
let url = LogSafeURL.string(webView.url)
OSLogger.shared.debug("Failed provisional navigation with error: \(error.localizedDescription) url:\(url)")
timer = nil

let nsError = error as NSError
if nsError.domain == NSURLErrorDomain, nsError.code == NSURLErrorCancelled {
OSLogger.shared.debug("Ignoring cancelled provisional navigation. code:NSURLErrorCancelled")
return
}

handleCachedViewFailure(.navigationFailed)
}

func webView(_: WKWebView, didFinish _: WKNavigation!) {
markPreloadReadyIfActive()

viewDelegate?.checkoutViewDidFinishNavigation()

if let startTime = timer {
Expand All @@ -593,7 +677,7 @@ extension CheckoutWebView: WKNavigationDelegate {
return
}

CheckoutWebView.invalidate()
handleCachedViewFailure(.navigationFailed)
viewDelegate?.checkoutViewDidFailWithError(error: .sdkError(underlying: error))
}

Expand Down
44 changes: 44 additions & 0 deletions platforms/swift/Sources/ShopifyCheckoutKit/PreloadState.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import Combine
import Foundation

/// Observable lifecycle state of a preloaded checkout.
public enum PreloadState: Equatable {
case idle
case loading
case ready
case expired
case failed(reason: FailureReason)

public enum FailureReason: Equatable {
case httpError(statusCode: Int)
case navigationFailed
case keepAliveLost
}
}

/// Returned by `preload(checkout:)` to expose the current preload state.
///
/// Retain the returned instance for as long as you want to observe state
/// changes; the cache holds it weakly.
@MainActor
public final class CheckoutPreload: ObservableObject {
/// The latest observed preload state.
@Published public private(set) var state: PreloadState

init(cache: PreloadCache) {
state = cache.state
cache.setObserver(self)
}

/// Called immediately with the current state and whenever it changes.
public var onStateChange: ((PreloadState) -> Void)? {
didSet {
onStateChange?(state)
}
}

func receive(_ state: PreloadState) {
self.state = state
onStateChange?(state)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -36,26 +36,30 @@ private func applyConfigurationChange(configuration: Configuration, previousConf

if configuration.preloading.enabled != previousConfiguration.preloading.enabled {
Task { @MainActor in
CheckoutWebView.invalidate()
invalidate()
}
}
}

/// Preloads the checkout for faster presentation.
/// Preloads the checkout for faster presentation and returns a handle for
/// observing preload state. Retain the handle to keep observing.
@MainActor
public func preload(checkout url: URL) {
@discardableResult
public func preload(checkout url: URL) -> CheckoutPreload? {
guard configuration.preloading.enabled else {
return
return nil
}
Comment thread
markmur marked this conversation as resolved.

let checkoutPreload = CheckoutPreload(cache: CheckoutWebView.preloadCache)
let decorated = CheckoutURLDecorator.decorate(url)
CheckoutWebView.preload(checkout: decorated)
return checkoutPreload
}

/// Invalidates any cached checkout created by preload calls.
@MainActor
public func invalidate() {
CheckoutWebView.invalidate(disconnect: true)
CheckoutWebView.preloadCache.evict(with: .idle, disconnect: true)
}

@MainActor
Expand Down
Loading
Loading