From a9f5fd4c7ef788222bbcf0f9c6ca0f18e72019a6 Mon Sep 17 00:00:00 2001 From: Kieran Osgood Date: Wed, 19 Aug 2026 15:59:49 +0100 Subject: [PATCH] fix: discard preload cache on swift configuration changes --- platforms/android/README.md | 5 + .../shopify/checkoutkit/ShopifyCheckoutKit.kt | 2 + platforms/react-native/README.md | 10 +- platforms/swift/README.md | 8 +- .../CheckoutViewController.swift | 20 ++-- .../CheckoutWebViewController.swift | 99 +++++++++++-------- .../ShopifyCheckoutKit/ProgressBarView.swift | 4 +- .../ShopifyCheckoutKit.swift | 26 ++--- .../CheckoutViewControllerTests.swift | 8 ++ .../ConfigurationTests.swift | 7 +- .../SwiftUITests.swift | 61 ++++++++++-- 11 files changed, 170 insertions(+), 80 deletions(-) diff --git a/platforms/android/README.md b/platforms/android/README.md index b37b6fcf1..9ece62664 100644 --- a/platforms/android/README.md +++ b/platforms/android/README.md @@ -258,6 +258,11 @@ Clear unused preloaded checkout work with `invalidate`: ShopifyCheckoutKit.invalidate() ``` +Updating configuration through `ShopifyCheckoutKit.configure {}` triggers +`ShopifyCheckoutKit.invalidate()`. Discarding cached preloads avoids presenting a +stale checkout. If a preload may already have run when configuration changes, +call `preload` again. + Preloading is enabled by default. Disable it when appropriate, for example for data-saver modes or app-specific runtime conditions: ```kotlin diff --git a/platforms/android/lib/src/main/java/com/shopify/checkoutkit/ShopifyCheckoutKit.kt b/platforms/android/lib/src/main/java/com/shopify/checkoutkit/ShopifyCheckoutKit.kt index 03bbc0b1c..66362e33b 100644 --- a/platforms/android/lib/src/main/java/com/shopify/checkoutkit/ShopifyCheckoutKit.kt +++ b/platforms/android/lib/src/main/java/com/shopify/checkoutkit/ShopifyCheckoutKit.kt @@ -35,6 +35,8 @@ public object ShopifyCheckoutKit { /** * Allows configuring ShopifyCheckoutKit. * + * Calling this function invalidates any cached preload. + * * Kotlin example: * {@code ShopifyCheckoutKit.configure { it.appearance = CheckoutAppearance.App(ColorScheme.Dark()) }} * diff --git a/platforms/react-native/README.md b/platforms/react-native/README.md index ded999e28..b426c6abd 100644 --- a/platforms/react-native/README.md +++ b/platforms/react-native/README.md @@ -767,7 +767,15 @@ Instead, a better approach is to call `preload()` when you have a strong enough ### Cache invalidation -Should you wish to manually clear the preload cache, call `invalidate()` on your `ShopifyCheckout` instance or the value returned by `useShopifyCheckout()`. +To manually clear the preload cache, call `invalidate()` on your `ShopifyCheckout` +instance or the value returned by `useShopifyCheckout()`. + +Updating configuration through `shopifyCheckout.setConfig(...)` or the +`configuration` prop on `ShopifyCheckoutProvider` triggers +`shopifyCheckout.invalidate()`. Discarding cached preloads avoids presenting a +stale checkout. If a preload may already have run when configuration changes, +call `preload` again. Keep a provider's configuration object stable across renders to +avoid unintentionally invalidating preloads. ## Checkout lifecycle diff --git a/platforms/swift/README.md b/platforms/swift/README.md index 6149004a7..35c2f3cf8 100644 --- a/platforms/swift/README.md +++ b/platforms/swift/README.md @@ -226,6 +226,12 @@ Clear unused preloaded checkout work with `invalidate`: ShopifyCheckoutKit.invalidate() ``` +Updating configuration through `ShopifyCheckoutKit.configure {}` or a direct +mutation such as `ShopifyCheckoutKit.configuration.title = "Checkout"` triggers +`ShopifyCheckoutKit.invalidate()`. Discarding cached preloads avoids presenting a +stale checkout. If a preload may already have run when configuration changes, +call `preload` again. + Preloading is enabled by default. Disable it when appropriate, for example for data-saver modes or app-specific runtime conditions: ```swift @@ -250,7 +256,7 @@ ShopifyCheckoutKit.configure { } ``` -SwiftUI modifiers such as `.appearance(...)`, `.tintColor(...)`, and `.title(...)` override these defaults only for that `ShopifyCheckout` value. They do not mutate `ShopifyCheckoutKit.configuration` or invalidate a cached preload. +`ShopifyCheckout` uses the global configuration as its defaults. When present, modifiers such as `.appearance(...)`, `.tintColor(...)`, and `.title(...)` take precedence over the corresponding `ShopifyCheckoutKit.configuration` values for that checkout. | Option | Default | Purpose | | --- | --- | --- | diff --git a/platforms/swift/Sources/ShopifyCheckoutKit/CheckoutViewController.swift b/platforms/swift/Sources/ShopifyCheckoutKit/CheckoutViewController.swift index 1553f1ad2..3e5b6e42b 100644 --- a/platforms/swift/Sources/ShopifyCheckoutKit/CheckoutViewController.swift +++ b/platforms/swift/Sources/ShopifyCheckoutKit/CheckoutViewController.swift @@ -9,7 +9,6 @@ public class CheckoutViewController: UINavigationController { public init(checkout url: URL, delegate: (any CheckoutDelegate)? = nil, client: (any CheckoutCommunicationProtocol)? = nil) { let rootViewController = CheckoutWebViewController( checkoutURL: url, - configuration: ShopifyCheckoutKit.configuration, delegate: delegate, client: client, entryPoint: nil @@ -22,7 +21,6 @@ public class CheckoutViewController: UINavigationController { package init(checkout url: URL, delegate: (any CheckoutDelegate)? = nil, client: (any CheckoutCommunicationProtocol)? = nil, entryPoint: MetaData.EntryPoint? = nil) { let rootViewController = CheckoutWebViewController( checkoutURL: url, - configuration: ShopifyCheckoutKit.configuration, delegate: delegate, client: client, entryPoint: entryPoint @@ -65,14 +63,19 @@ public struct ShopifyCheckout: UIViewControllerRepresentable, CheckoutConfigurab public typealias UIViewControllerType = CheckoutViewController var checkoutURL: URL - var configuration: Configuration + var configurationModifiers: [(inout Configuration) -> Void] = [] var client: (any CheckoutCommunicationProtocol)? var onDismissAction: (() -> Void)? var onFailAction: ((CheckoutError) -> Void)? public init(checkout url: URL) { checkoutURL = url - configuration = ShopifyCheckoutKit.configuration + } + + var configuration: Configuration { + var configuration = ShopifyCheckoutKit.configuration + configurationModifiers.forEach { $0(&configuration) } + return configuration } var decoratedCheckoutURL: URL { @@ -80,6 +83,8 @@ public struct ShopifyCheckout: UIViewControllerRepresentable, CheckoutConfigurab } public func makeUIViewController(context _: Self.Context) -> CheckoutViewController { + let configuration = configuration + let decoratedCheckoutURL = CheckoutURLDecorator.decorate(checkoutURL, configuration: configuration) let viewController = CheckoutViewController( checkout: decoratedCheckoutURL, configuration: configuration, @@ -93,7 +98,7 @@ public struct ShopifyCheckout: UIViewControllerRepresentable, CheckoutConfigurab configureWebViewController(uiViewController) } - private func configureWebViewController(_ navigationController: CheckoutViewController) { + func configureWebViewController(_ navigationController: CheckoutViewController) { guard let webViewController = navigationController .viewControllers @@ -103,6 +108,7 @@ public struct ShopifyCheckout: UIViewControllerRepresentable, CheckoutConfigurab return } + webViewController.apply(configuration: configuration) webViewController.client = client webViewController.checkoutView?.client = client webViewController.onDismiss = onDismissAction @@ -161,12 +167,12 @@ extension CheckoutConfigurable { modifyingConfiguration { $0.closeButtonTintColor = color } } - private func modifyingConfiguration(_ update: (inout Configuration) -> Void) -> Self { + private func modifyingConfiguration(_ update: @escaping (inout Configuration) -> Void) -> Self { guard var copy = self as? ShopifyCheckout else { return self } - update(©.configuration) + copy.configurationModifiers.append(update) return copy as? Self ?? self } } diff --git a/platforms/swift/Sources/ShopifyCheckoutKit/CheckoutWebViewController.swift b/platforms/swift/Sources/ShopifyCheckoutKit/CheckoutWebViewController.swift index 6b831ba70..c79e48b15 100644 --- a/platforms/swift/Sources/ShopifyCheckoutKit/CheckoutWebViewController.swift +++ b/platforms/swift/Sources/ShopifyCheckoutKit/CheckoutWebViewController.swift @@ -15,7 +15,7 @@ class CheckoutWebViewController: UIViewController, UIAdaptivePresentationControl var checkoutView: CheckoutWebView? lazy var progressBar: ProgressBarView = { - let progressBar = ProgressBarView(frame: .zero, tintColor: configuration.tintColor) + let progressBar = ProgressBarView(frame: .zero, tintColor: resolvedConfiguration.tintColor) progressBar.translatesAutoresizingMaskIntoConstraints = false return progressBar }() @@ -23,55 +23,26 @@ class CheckoutWebViewController: UIViewController, UIAdaptivePresentationControl var initialNavigation: Bool = true private let checkoutURL: URL - private let configuration: Configuration - - private lazy var closeBarButtonItem: UIBarButtonItem = { - if let closeButtonTintColor = configuration.closeButtonTintColor { - var item: UIBarButtonItem - - if #available(iOS 26.0, *) { - item = UIBarButtonItem( - image: UIImage(systemName: "xmark"), - style: .plain, - target: self, - action: #selector(close) - ) - } else { - item = UIBarButtonItem( - image: UIImage(systemName: "xmark.circle.fill"), - style: .plain, - target: self, - action: #selector(close) - ) - } + private var configurationOverride: Configuration? - item.tintColor = closeButtonTintColor - item.accessibilityIdentifier = Self.closeButtonAccessibilityIdentifier - return item - } - - let item = UIBarButtonItem( - barButtonSystemItem: .close, - target: self, - action: #selector(close) - ) - item.accessibilityIdentifier = Self.closeButtonAccessibilityIdentifier - return item - }() + private var resolvedConfiguration: Configuration { + configurationOverride ?? ShopifyCheckoutKit.configuration + } var progressObserver: NSKeyValueObservation? // MARK: Initializers - public init(checkoutURL url: URL, configuration: Configuration = ShopifyCheckoutKit.configuration, delegate: (any CheckoutDelegate)? = nil, client: (any CheckoutCommunicationProtocol)? = nil, entryPoint: MetaData.EntryPoint? = nil) { + public init(checkoutURL url: URL, configuration: Configuration? = nil, delegate: (any CheckoutDelegate)? = nil, client: (any CheckoutCommunicationProtocol)? = nil, entryPoint: MetaData.EntryPoint? = nil) { checkoutURL = url - self.configuration = configuration + configurationOverride = configuration self.delegate = delegate self.client = client + let resolvedConfiguration = configuration ?? ShopifyCheckoutKit.configuration let checkoutView = CheckoutWebView.for(checkout: url, entryPoint: entryPoint) - checkoutView.backgroundColor = configuration.backgroundColor - checkoutView.underPageBackgroundColor = configuration.backgroundColor + checkoutView.backgroundColor = resolvedConfiguration.backgroundColor + checkoutView.underPageBackgroundColor = resolvedConfiguration.backgroundColor checkoutView.translatesAutoresizingMaskIntoConstraints = false checkoutView.scrollView.contentInsetAdjustmentBehavior = .automatic checkoutView.client = client @@ -79,13 +50,55 @@ class CheckoutWebViewController: UIViewController, UIAdaptivePresentationControl super.init(nibName: nil, bundle: nil) + checkoutView.viewDelegate = self + applyPresentationConfiguration(resolvedConfiguration) + } + + func apply(configuration: Configuration) { + configurationOverride = configuration + applyPresentationConfiguration(configuration) + } + + private func applyPresentationConfiguration(_ configuration: Configuration) { title = configuration.title + view.backgroundColor = configuration.backgroundColor + checkoutView?.backgroundColor = configuration.backgroundColor + checkoutView?.underPageBackgroundColor = configuration.backgroundColor + progressBar.progressBar.tintColor = configuration.tintColor + navigationItem.rightBarButtonItem = closeBarButtonItem(tintColor: configuration.closeButtonTintColor) + } - navigationItem.rightBarButtonItem = closeBarButtonItem + private func closeBarButtonItem(tintColor: UIColor?) -> UIBarButtonItem { + guard let tintColor else { + let item = UIBarButtonItem( + barButtonSystemItem: .close, + target: self, + action: #selector(close) + ) + item.accessibilityIdentifier = Self.closeButtonAccessibilityIdentifier + return item + } - checkoutView.viewDelegate = self + let item: UIBarButtonItem + if #available(iOS 26.0, *) { + item = UIBarButtonItem( + image: UIImage(systemName: "xmark"), + style: .plain, + target: self, + action: #selector(close) + ) + } else { + item = UIBarButtonItem( + image: UIImage(systemName: "xmark.circle.fill"), + style: .plain, + target: self, + action: #selector(close) + ) + } - view.backgroundColor = configuration.backgroundColor + item.tintColor = tintColor + item.accessibilityIdentifier = Self.closeButtonAccessibilityIdentifier + return item } @available(*, unavailable) @@ -98,7 +111,7 @@ class CheckoutWebViewController: UIViewController, UIAdaptivePresentationControl override public func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) - view.backgroundColor = configuration.backgroundColor + applyPresentationConfiguration(resolvedConfiguration) } override public func viewDidLoad() { diff --git a/platforms/swift/Sources/ShopifyCheckoutKit/ProgressBarView.swift b/platforms/swift/Sources/ShopifyCheckoutKit/ProgressBarView.swift index 256afe2d8..282710ba8 100644 --- a/platforms/swift/Sources/ShopifyCheckoutKit/ProgressBarView.swift +++ b/platforms/swift/Sources/ShopifyCheckoutKit/ProgressBarView.swift @@ -10,10 +10,8 @@ class ProgressBarView: UIView { }() private var progressAnimation: UIViewPropertyAnimator? - private let configuredTintColor: UIColor init(frame: CGRect, tintColor: UIColor) { - configuredTintColor = tintColor super.init(frame: frame) addSubview(progressBar) @@ -23,7 +21,7 @@ class ProgressBarView: UIView { progressBar.heightAnchor.constraint(equalToConstant: 1) ]) - progressBar.tintColor = configuredTintColor + progressBar.tintColor = tintColor } override func didMoveToSuperview() { diff --git a/platforms/swift/Sources/ShopifyCheckoutKit/ShopifyCheckoutKit.swift b/platforms/swift/Sources/ShopifyCheckoutKit/ShopifyCheckoutKit.swift index a3eae5bfe..aab1dad9f 100644 --- a/platforms/swift/Sources/ShopifyCheckoutKit/ShopifyCheckoutKit.swift +++ b/platforms/swift/Sources/ShopifyCheckoutKit/ShopifyCheckoutKit.swift @@ -9,35 +9,29 @@ public let version = "4.0.0-alpha.5" private let lockedCheckoutKitConfiguration = LockedValue(Configuration()) /// The configuration options for the `ShopifyCheckoutKit` library. +/// +/// Assigning configuration invalidates any cached preload. public var configuration: Configuration { get { lockedCheckoutKitConfiguration.get() } set { - let previousConfiguration = lockedCheckoutKitConfiguration.get() lockedCheckoutKitConfiguration.set(newValue) - applyConfigurationChange( - configuration: newValue, - previousConfiguration: previousConfiguration - ) + applyConfigurationChange(newValue) } } -/// A convienence function for configuring the `ShopifyCheckoutKit` library. +/// A convenience function for configuring the `ShopifyCheckoutKit` library. +/// +/// Calling this function invalidates any cached preload. public func configure(_ block: (inout Configuration) -> Void) { - let previousConfiguration = lockedCheckoutKitConfiguration.get() lockedCheckoutKitConfiguration.update(block) - applyConfigurationChange( - configuration: lockedCheckoutKitConfiguration.get(), - previousConfiguration: previousConfiguration - ) + applyConfigurationChange(lockedCheckoutKitConfiguration.get()) } -private func applyConfigurationChange(configuration: Configuration, previousConfiguration: Configuration) { +private func applyConfigurationChange(_ configuration: Configuration) { OSLogger.shared.logLevel = configuration.logLevel - if configuration.preloading.enabled != previousConfiguration.preloading.enabled { - Task { @MainActor in - invalidate() - } + Task { @MainActor in + invalidate() } } diff --git a/platforms/swift/Tests/ShopifyCheckoutKitTests/CheckoutViewControllerTests.swift b/platforms/swift/Tests/ShopifyCheckoutKitTests/CheckoutViewControllerTests.swift index 4aa9f61f4..f039fcce7 100644 --- a/platforms/swift/Tests/ShopifyCheckoutKitTests/CheckoutViewControllerTests.swift +++ b/platforms/swift/Tests/ShopifyCheckoutKitTests/CheckoutViewControllerTests.swift @@ -56,6 +56,14 @@ class CheckoutViewDelegateTests: XCTestCase { XCTAssertEqual(viewController.title, "Custom title") } + func testViewWillAppearAppliesLatestGlobalConfigurationToImperativeCheckout() { + ShopifyCheckoutKit.configuration.title = "Updated global title" + + viewController.viewWillAppear(false) + + XCTAssertEqual(viewController.title, "Updated global title") + } + func testInstanceConfigurationIsAppliedToCheckoutChrome() throws { var configuration = Configuration() configuration.backgroundColor = .red diff --git a/platforms/swift/Tests/ShopifyCheckoutKitTests/ConfigurationTests.swift b/platforms/swift/Tests/ShopifyCheckoutKitTests/ConfigurationTests.swift index 19debd3cc..235d331ea 100644 --- a/platforms/swift/Tests/ShopifyCheckoutKitTests/ConfigurationTests.swift +++ b/platforms/swift/Tests/ShopifyCheckoutKitTests/ConfigurationTests.swift @@ -69,9 +69,10 @@ class ConfigurationTests: XCTestCase { XCTAssertFalse(CheckoutWebView.preloadCache.hasEntry()) } - func testChangingConfigurationWithoutChangingPreloadingDoesNotInvalidatePreload() async throws { + func testChangingConfigurationWithoutChangingPreloadingInvalidatesPreload() async throws { let checkoutURL = try XCTUnwrap(URL(string: "https://shopify1.shopify.com/checkouts/cn/123")) + await Task.yield() ShopifyCheckoutKit.preload(checkout: checkoutURL) XCTAssertTrue(CheckoutWebView.preloadCache.hasEntry()) @@ -79,11 +80,11 @@ class ConfigurationTests: XCTestCase { $0.title = "Thank you!" } - for _ in 0 ..< 10 { + for _ in 0 ..< 10 where CheckoutWebView.preloadCache.hasEntry() { await Task.yield() } - XCTAssertTrue(CheckoutWebView.preloadCache.hasEntry()) + XCTAssertFalse(CheckoutWebView.preloadCache.hasEntry()) } func testAppearanceCanBeSetDirectly() { diff --git a/platforms/swift/Tests/ShopifyCheckoutKitTests/SwiftUITests.swift b/platforms/swift/Tests/ShopifyCheckoutKitTests/SwiftUITests.swift index 92ef3372b..febdb1c06 100644 --- a/platforms/swift/Tests/ShopifyCheckoutKitTests/SwiftUITests.swift +++ b/platforms/swift/Tests/ShopifyCheckoutKitTests/SwiftUITests.swift @@ -83,7 +83,7 @@ class CheckoutConfigurableTests: XCTestCase { try await super.tearDown() } - func testBackgroundColorIsCapturedWithoutChangingGlobalConfiguration() { + func testBackgroundColorOverridesWithoutChangingGlobalConfiguration() { let globalColor = ShopifyCheckoutKit.configuration.backgroundColor let color = UIColor.red @@ -94,7 +94,7 @@ class CheckoutConfigurableTests: XCTestCase { XCTAssertEqual(ShopifyCheckoutKit.configuration.backgroundColor, globalColor) } - func testAppearanceIsCapturedWithoutChangingGlobalConfiguration() { + func testAppearanceOverridesWithoutChangingGlobalConfiguration() { let globalAppearance = ShopifyCheckoutKit.configuration.appearance let appearance = ShopifyCheckoutKit.Configuration.Appearance.app(.light) @@ -105,7 +105,7 @@ class CheckoutConfigurableTests: XCTestCase { XCTAssertEqual(ShopifyCheckoutKit.configuration.appearance, globalAppearance) } - func testAppearanceDecoratesCheckoutURLFromCapturedConfiguration() throws { + func testAppearanceDecoratesCheckoutURLFromResolvedConfiguration() throws { let sheet = shopifyCheckout.appearance(.app(.dark)) let items = try XCTUnwrap(URLComponents(url: sheet.decoratedCheckoutURL, resolvingAgainstBaseURL: false)?.queryItems) @@ -113,7 +113,7 @@ class CheckoutConfigurableTests: XCTestCase { XCTAssertEqual(items.first(where: { $0.name == "ck_branding" })?.value, "app") } - func testTintColorIsCapturedWithoutChangingGlobalConfiguration() { + func testTintColorOverridesWithoutChangingGlobalConfiguration() { let globalColor = ShopifyCheckoutKit.configuration.tintColor let color = UIColor.blue @@ -124,7 +124,7 @@ class CheckoutConfigurableTests: XCTestCase { XCTAssertEqual(ShopifyCheckoutKit.configuration.tintColor, globalColor) } - func testTitleIsCapturedWithoutChangingGlobalConfiguration() { + func testTitleOverridesWithoutChangingGlobalConfiguration() { let globalTitle = ShopifyCheckoutKit.configuration.title let title = "Test Title" @@ -135,7 +135,7 @@ class CheckoutConfigurableTests: XCTestCase { XCTAssertEqual(ShopifyCheckoutKit.configuration.title, globalTitle) } - func testCloseButtonTintColorIsCapturedWithoutChangingGlobalConfiguration() { + func testCloseButtonTintColorOverridesWithoutChangingGlobalConfiguration() { let globalColor = ShopifyCheckoutKit.configuration.closeButtonTintColor let color = UIColor.green @@ -170,4 +170,53 @@ class CheckoutConfigurableTests: XCTestCase { XCTAssertTrue(CheckoutWebView.preloadCache.hasEntry()) } + + func testUnmodifiedValuesResolveFromLatestGlobalConfiguration() { + let sheet = shopifyCheckout.appearance(.app(.dark)) + + ShopifyCheckoutKit.configuration.title = "Updated global title" + + XCTAssertEqual(sheet.configuration.title, "Updated global title") + XCTAssertEqual(sheet.configuration.appearance, .app(.dark)) + } + + func testModifierTakesPrecedenceOverLatestGlobalConfiguration() { + let sheet = shopifyCheckout.title("Instance title") + + ShopifyCheckoutKit.configuration.title = "Updated global title" + + XCTAssertEqual(sheet.configuration.title, "Instance title") + } + + func testUpdatedGlobalTitleReconfiguresPresentedCheckout() throws { + let viewController = CheckoutViewController(checkout: shopifyCheckout.decoratedCheckoutURL) + shopifyCheckout.configureWebViewController(viewController) + let webViewController = try XCTUnwrap( + viewController.viewControllers.compactMap { $0 as? CheckoutWebViewController }.first + ) + let checkoutView = try XCTUnwrap(webViewController.checkoutView) + + ShopifyCheckoutKit.configure { $0.title = "Updated global title" } + shopifyCheckout.configureWebViewController(viewController) + + XCTAssertEqual(webViewController.title, "Updated global title") + XCTAssertIdentical(webViewController.checkoutView, checkoutView) + } + + func testUpdatedTitleModifierReconfiguresPresentedCheckout() throws { + let initial = shopifyCheckout.title("Initial title") + let viewController = CheckoutViewController(checkout: initial.decoratedCheckoutURL) + initial.configureWebViewController(viewController) + let webViewController = try XCTUnwrap( + viewController.viewControllers.compactMap { $0 as? CheckoutWebViewController }.first + ) + let checkoutView = try XCTUnwrap(webViewController.checkoutView) + XCTAssertEqual(webViewController.title, "Initial title") + + let updated = shopifyCheckout.title("Updated title") + updated.configureWebViewController(viewController) + + XCTAssertEqual(webViewController.title, "Updated title") + XCTAssertIdentical(webViewController.checkoutView, checkoutView) + } }