Skip to content
Draft
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
5 changes: 5 additions & 0 deletions platforms/android/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,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()) }}
*
Expand Down
10 changes: 9 additions & 1 deletion platforms/react-native/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -775,7 +775,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

Expand Down
8 changes: 8 additions & 0 deletions platforms/swift/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,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
Expand All @@ -253,6 +259,8 @@ ShopifyCheckoutKit.configure {
}
```

`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 |
| --- | --- | --- |
| `appearance` | `.storefront` | Match the storefront's web checkout branding with a light color scheme, or use the Checkout Kit style with `.app(.automatic)`, `.app(.light)`, or `.app(.dark)`. |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,24 @@ import UIKit

@MainActor
public class CheckoutViewController: UINavigationController {
public init(checkout url: URL, delegate: (any CheckoutDelegate)? = nil, client: (any CheckoutCommunicationProtocol)? = nil) {
let rootViewController = CheckoutWebViewController(checkoutURL: url, delegate: delegate, client: client, entryPoint: nil)
super.init(rootViewController: rootViewController)
configureNavigationBar()
presentationController?.delegate = rootViewController
}

package init(checkout url: URL, delegate: (any CheckoutDelegate)? = nil, client: (any CheckoutCommunicationProtocol)? = nil, entryPoint: MetaData.EntryPoint? = nil) {
let rootViewController = CheckoutWebViewController(checkoutURL: url, delegate: delegate, client: client, entryPoint: entryPoint)
public convenience init(checkout url: URL, delegate: (any CheckoutDelegate)? = nil, client: (any CheckoutCommunicationProtocol)? = nil) {
self.init(
checkout: url,
configuration: ShopifyCheckoutKit.configuration,
delegate: delegate,
client: client
)
}

/// Shared initializer that lets SwiftUI inject instance-scoped configuration while package callers use the global default.
package init(checkout url: URL, configuration: Configuration = ShopifyCheckoutKit.configuration, delegate: (any CheckoutDelegate)? = nil, client: (any CheckoutCommunicationProtocol)? = nil, entryPoint: MetaData.EntryPoint? = nil) {
let rootViewController = CheckoutWebViewController(
checkoutURL: url,
configuration: configuration,
delegate: delegate,
client: client,
entryPoint: entryPoint
)
super.init(rootViewController: rootViewController)
configureNavigationBar()
presentationController?.delegate = rootViewController
Expand All @@ -40,20 +49,30 @@ public struct ShopifyCheckout: UIViewControllerRepresentable, CheckoutConfigurab
public typealias UIViewControllerType = CheckoutViewController

var checkoutURL: URL

/// The configuration captured when this value was created, plus any modifier
/// overrides. Isolated from ``ShopifyCheckoutKit/configuration``.
public var configuration: Configuration

var client: (any CheckoutCommunicationProtocol)?
var onDismissAction: (() -> Void)?
var onFailAction: ((CheckoutError) -> Void)?

public init(checkout url: URL) {
checkoutURL = url
configuration = ShopifyCheckoutKit.configuration
}

var decoratedCheckoutURL: URL {
CheckoutURLDecorator.decorate(checkoutURL)
CheckoutURLDecorator.decorate(checkoutURL, configuration: configuration)
}

public func makeUIViewController(context _: Self.Context) -> CheckoutViewController {
let viewController = CheckoutViewController(checkout: decoratedCheckoutURL, client: client)
let viewController = CheckoutViewController(
checkout: decoratedCheckoutURL,
configuration: configuration,
client: client
)
configureWebViewController(viewController)
return viewController
}
Expand Down Expand Up @@ -102,6 +121,11 @@ public struct ShopifyCheckout: UIViewControllerRepresentable, CheckoutConfigurab

@MainActor
public protocol CheckoutConfigurable {
/// The configuration the modifiers read and write. Modifiers copy `self`,
/// update the copy's configuration, and return the copy, so conforming
/// types should have value semantics.
var configuration: ShopifyCheckoutKit.Configuration { get set }

func backgroundColor(_ color: UIColor) -> Self
func appearance(_ appearance: ShopifyCheckoutKit.Configuration.Appearance) -> Self
func tintColor(_ color: UIColor) -> Self
Expand All @@ -111,27 +135,28 @@ public protocol CheckoutConfigurable {

extension CheckoutConfigurable {
@discardableResult public func backgroundColor(_ color: UIColor) -> Self {
ShopifyCheckoutKit.configuration.backgroundColor = color
return self
modifyingConfiguration { $0.backgroundColor = color }
}

@discardableResult public func appearance(_ appearance: ShopifyCheckoutKit.Configuration.Appearance) -> Self {
ShopifyCheckoutKit.configuration.appearance = appearance
return self
modifyingConfiguration { $0.appearance = appearance }
}

@discardableResult public func tintColor(_ color: UIColor) -> Self {
ShopifyCheckoutKit.configuration.tintColor = color
return self
modifyingConfiguration { $0.tintColor = color }
}

@discardableResult public func title(_ title: String) -> Self {
ShopifyCheckoutKit.configuration.title = title
return self
modifyingConfiguration { $0.title = title }
}

@discardableResult public func closeButtonTintColor(_ color: UIColor?) -> Self {
ShopifyCheckoutKit.configuration.closeButtonTintColor = color
return self
modifyingConfiguration { $0.closeButtonTintColor = color }
}

private func modifyingConfiguration(_ update: (inout ShopifyCheckoutKit.Configuration) -> Void) -> Self {
var copy = self
update(&copy.configuration)
return copy
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,17 +15,18 @@ class CheckoutWebViewController: UIViewController, UIAdaptivePresentationControl
var checkoutView: CheckoutWebView?

lazy var progressBar: ProgressBarView = {
let progressBar = ProgressBarView(frame: .zero)
let progressBar = ProgressBarView(frame: .zero, tintColor: configuration.tintColor)
progressBar.translatesAutoresizingMaskIntoConstraints = false
return progressBar
}()

var initialNavigation: Bool = true

private let checkoutURL: URL
private let configuration: Configuration

private lazy var closeBarButtonItem: UIBarButtonItem = {
if let closeButtonTintColor = ShopifyCheckoutKit.configuration.closeButtonTintColor {
if let closeButtonTintColor = configuration.closeButtonTintColor {
var item: UIBarButtonItem

if #available(iOS 26.0, *) {
Expand Down Expand Up @@ -62,27 +63,30 @@ class CheckoutWebViewController: UIViewController, UIAdaptivePresentationControl

// MARK: Initializers

public init(checkoutURL url: URL, delegate: (any CheckoutDelegate)? = nil, client: (any CheckoutCommunicationProtocol)? = nil, entryPoint: MetaData.EntryPoint? = nil) {
public init(checkoutURL url: URL, configuration: Configuration = ShopifyCheckoutKit.configuration, delegate: (any CheckoutDelegate)? = nil, client: (any CheckoutCommunicationProtocol)? = nil, entryPoint: MetaData.EntryPoint? = nil) {
checkoutURL = url
self.configuration = configuration
self.delegate = delegate
self.client = client

let checkoutView = CheckoutWebView.for(checkout: url, entryPoint: entryPoint)
checkoutView.isPresented = true
checkoutView.backgroundColor = configuration.backgroundColor
checkoutView.underPageBackgroundColor = configuration.backgroundColor
checkoutView.translatesAutoresizingMaskIntoConstraints = false
checkoutView.scrollView.contentInsetAdjustmentBehavior = .automatic
checkoutView.client = client
self.checkoutView = checkoutView

super.init(nibName: nil, bundle: nil)

title = ShopifyCheckoutKit.configuration.title
title = configuration.title

navigationItem.rightBarButtonItem = closeBarButtonItem

checkoutView.viewDelegate = self

view.backgroundColor = ShopifyCheckoutKit.configuration.backgroundColor
view.backgroundColor = configuration.backgroundColor
}

@available(*, unavailable)
Expand All @@ -95,7 +99,7 @@ class CheckoutWebViewController: UIViewController, UIAdaptivePresentationControl
override public func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)

view.backgroundColor = ShopifyCheckoutKit.configuration.backgroundColor
view.backgroundColor = configuration.backgroundColor
}

override public func viewDidLoad() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,10 @@ class ProgressBarView: UIView {
}()

private var progressAnimation: UIViewPropertyAnimator?
private let configuredTintColor: UIColor

override init(frame: CGRect) {
init(frame: CGRect, tintColor: UIColor) {
configuredTintColor = tintColor
super.init(frame: frame)

addSubview(progressBar)
Expand All @@ -21,7 +23,7 @@ class ProgressBarView: UIView {
progressBar.heightAnchor.constraint(equalToConstant: 1)
])

progressBar.tintColor = ShopifyCheckoutKit.configuration.tintColor
progressBar.tintColor = configuredTintColor
}

override func didMoveToSuperview() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ public let version = "4.0.0-alpha.6"
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 {
Expand All @@ -21,7 +23,9 @@ public var configuration: Configuration {
}
}

/// 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)
Expand All @@ -38,10 +42,8 @@ private func applyConfigurationChange(configuration: Configuration, previousConf
CheckoutTelemetry.disable()
}

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

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import Foundation
import ShopifyCheckoutKit
import XCTest

@MainActor
final class CheckoutViewControllerPackageInitializerTests: XCTestCase {
func testInjectedConfigurationIsAppliedToCheckout() throws {
var configuration = ShopifyCheckoutKit.configuration
configuration.title = "Instance checkout"

let viewController = try CheckoutViewController(
checkout: XCTUnwrap(URL(string: "https://checkout-sdk.myshopify.com")),
configuration: configuration
)

let checkoutViewController = try XCTUnwrap(viewController.viewControllers.first)
XCTAssertEqual(checkoutViewController.title, "Instance checkout")
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,29 @@ class CheckoutViewDelegateTests: XCTestCase {
XCTAssertEqual(viewController.title, "Custom title")
}

func testInstanceConfigurationIsAppliedToCheckoutChrome() throws {
var configuration = Configuration()
configuration.backgroundColor = .red
configuration.tintColor = .blue
configuration.title = "Instance checkout"
configuration.closeButtonTintColor = .green

let controller = MockCheckoutWebViewController(
checkoutURL: checkoutURL,
configuration: configuration
)
controller.loadViewIfNeeded()

XCTAssertEqual(controller.title, "Instance checkout")
assertColor(controller.view.backgroundColor, equals: .red)
assertColor(controller.checkoutView?.backgroundColor, equals: .red)
assertColor(controller.checkoutView?.underPageBackgroundColor, equals: .red)
assertColor(controller.progressBar.progressBar.tintColor, equals: .blue)
let closeButton = try XCTUnwrap(controller.navigationItem.rightBarButtonItem)
assertColor(closeButton.tintColor, equals: .green)
XCTAssertNotNil(closeButton.image)
}

func testCheckoutViewDidFailWithErrorDismissesViewController() {
viewController.checkoutViewDidFailWithError(error: CheckoutError(code: .httpError, message: "error", httpStatusCode: 500))

Expand Down Expand Up @@ -125,6 +148,13 @@ class CheckoutViewDelegateTests: XCTestCase {
let closeButton = controller.navigationItem.rightBarButtonItem
XCTAssertNotNil(closeButton?.image)
}

private func assertColor(_ actual: UIColor?, equals expected: UIColor, file: StaticString = #filePath, line: UInt = #line) {
let actualComponents = actual?.cgColor.components
let expectedComponents = expected.cgColor.components

XCTAssertEqual(actualComponents, expectedComponents, file: file, line: line)
}
}

@MainActor
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -69,21 +69,22 @@ 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())

ShopifyCheckoutKit.configure {
$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() {
Expand Down
Loading
Loading