diff --git a/platforms/react-native/README.md b/platforms/react-native/README.md index 4c91fba96..df5a7e19d 100644 --- a/platforms/react-native/README.md +++ b/platforms/react-native/README.md @@ -621,6 +621,98 @@ const shopifyCheckout = new ShopifyCheckout(); shopifyCheckout.preload(checkoutUrl); ``` +### Observe preload state + +Pass `onStateChange` when the application needs preload diagnostics or wants to +reflect its progress. The callback receives the current native state immediately +and every subsequent transition for that preload. + +```tsx +const preloadSubscription = shopifyCheckout.preload(checkoutUrl, { + onStateChange(state) { + if (state.type === 'ready') { + reportPreloadReady(); + } + + if (state.type === 'failed') { + reportPreloadFailure(state.reason, state.statusCode); + } + }, +}); + +// Stops state callbacks without invalidating the cached checkout. +preloadSubscription.remove(); +``` + +`preloadSubscription.state` contains the latest observed state. Calling +`preload(...)` again replaces the previous preload observer, so repeated calls +do not accumulate native or JavaScript event subscriptions. A previous +subscription retains its last state but receives no further callbacks. + +| State | Meaning | +| --------- | ------------------------------------------------------------------------------------------------------ | +| `idle` | No checkout is currently being preloaded. | +| `loading` | Checkout is loading in the background. | +| `ready` | The matching checkout is ready for presentation. | +| `expired` | The cached checkout reached its lifetime and was discarded. | +| `failed` | Preload could not retain usable checkout content. Inspect `reason` and the optional HTTP `statusCode`. | + +Preload state is not presentation lifecycle state. Do not disable checkout while +waiting for `ready`, and do not automatically retry from `failed` or `expired`. +Calling `present(checkoutUrl)` still loads checkout normally when a preload is +unavailable or incomplete. + +### Respond to cart activity + +Applications should preload when buyer intent is strong and after successful +cart mutations, using the cart returned by the Storefront API mutation. A +typical integration calls the same helper when the buyer enters the cart, +changes an item quantity, or removes an item: + +```tsx +let preloadSubscription: CheckoutPreloadSubscription | undefined; + +function preloadCart(cart: Cart) { + if (!cart.checkoutUrl || cart.totalQuantity === 0) { + shopifyCheckout.invalidate(); + return; + } + + preloadSubscription = shopifyCheckout.preload(cart.checkoutUrl, { + onStateChange(state) { + reportPreloadState(state); + }, + }); +} + +function onCartScreenEntered(cart: Cart) { + preloadCart(cart); +} + +async function changeQuantity(lineId: string, quantity: number) { + const updatedCart = await updateCartLine(lineId, quantity); + preloadCart(updatedCart); +} + +async function removeItem(lineId: string) { + const updatedCart = await removeCartLine(lineId); + preloadCart(updatedCart); +} + +function onCartScreenDisposed() { + preloadSubscription?.remove(); +} +``` + +Each explicit `preload(...)` call refreshes the cached checkout, even when the +`checkoutUrl` is unchanged. No separate `invalidate()` call is needed after a +successful cart mutation. + +Removing the subscription only stops observation. It intentionally leaves the +preloaded checkout available so navigation from the cart to checkout can reuse +it. Use `invalidate()` when the cart becomes empty or the cached checkout is no +longer applicable. + ### Important considerations 1. Initiating preload results in background network requests and additional diff --git a/platforms/react-native/__mocks__/react-native.ts b/platforms/react-native/__mocks__/react-native.ts index f640146f0..36612ac1d 100644 --- a/platforms/react-native/__mocks__/react-native.ts +++ b/platforms/react-native/__mocks__/react-native.ts @@ -82,6 +82,12 @@ const ShopifyCheckoutKit = { onDispatch: jest.fn((callback: (envelopeJson: string) => void) => shopifyCheckoutKitEventEmitter.addListener('onDispatch', callback), ), + onPreloadStateChange: jest.fn((callback: (eventJson: string) => void) => + shopifyCheckoutKitEventEmitter.addListener( + 'onPreloadStateChange', + callback, + ), + ), preload: jest.fn(), present: jest.fn(), dismiss: jest.fn(), diff --git a/platforms/react-native/modules/@shopify/checkout-kit-react-native/android/src/main/java/com/shopify/reactnative/checkoutkit/ShopifyCheckoutKitModule.java b/platforms/react-native/modules/@shopify/checkout-kit-react-native/android/src/main/java/com/shopify/reactnative/checkoutkit/ShopifyCheckoutKitModule.java index 40a3354b5..aaa23f92b 100644 --- a/platforms/react-native/modules/@shopify/checkout-kit-react-native/android/src/main/java/com/shopify/reactnative/checkoutkit/ShopifyCheckoutKitModule.java +++ b/platforms/react-native/modules/@shopify/checkout-kit-react-native/android/src/main/java/com/shopify/reactnative/checkoutkit/ShopifyCheckoutKitModule.java @@ -18,6 +18,9 @@ import java.util.Map; import java.util.Objects; +import org.json.JSONException; +import org.json.JSONObject; + public class ShopifyCheckoutKitModule extends NativeShopifyCheckoutKitSpec { /** The JavaScript name for {@link CheckoutAppearance.Storefront}, which has no native id. */ @@ -29,6 +32,8 @@ public class ShopifyCheckoutKitModule extends NativeShopifyCheckoutKitSpec { private CustomCheckoutListener checkoutListener; + private CheckoutPreload checkoutPreload; + public ShopifyCheckoutKitModule(ReactApplicationContext reactContext) { super(reactContext); @@ -38,6 +43,13 @@ public ShopifyCheckoutKitModule(ReactApplicationContext reactContext) { }); } + @Override + public void invalidate() { + releaseCheckoutListener(); + releaseCheckoutPreload(); + super.invalidate(); + } + @Override protected Map getTypedExportedConstants() { final Map constants = new HashMap<>(); @@ -98,18 +110,74 @@ public void dismiss() { } @ReactMethod - public void preload(String checkoutURL) { + public void preload(String checkoutURL, String requestId) { + releaseCheckoutPreload(); + Activity currentActivity = getCurrentActivity(); if (currentActivity instanceof ComponentActivity) { - ShopifyCheckoutKit.preload(checkoutURL, (ComponentActivity) currentActivity); + checkoutPreload = ShopifyCheckoutKit.preload( + checkoutURL, + (ComponentActivity) currentActivity, + state -> emitPreloadStateChange(requestId, state)); + + if (checkoutPreload == null) { + emitPreloadStateChange(requestId, PreloadState.Idle.INSTANCE); + } + } else { + emitPreloadStateChange(requestId, PreloadState.Idle.INSTANCE); } } @ReactMethod public void invalidateCache() { + releaseCheckoutPreload(); ShopifyCheckoutKit.invalidate(); } + private void emitPreloadStateChange(String requestId, PreloadState state) { + JSONObject event = new JSONObject(); + + try { + event.put("requestId", requestId); + + if (state instanceof PreloadState.Idle) { + event.put("type", "idle"); + } else if (state instanceof PreloadState.Loading) { + event.put("type", "loading"); + } else if (state instanceof PreloadState.Ready) { + event.put("type", "ready"); + } else if (state instanceof PreloadState.Expired) { + event.put("type", "expired"); + } else if (state instanceof PreloadState.Failed) { + PreloadState.FailureReason reason = ((PreloadState.Failed) state).getReason(); + event.put("type", "failed"); + + if (reason instanceof PreloadState.FailureReason.HttpError) { + event.put("reason", "httpError"); + event.put("statusCode", ((PreloadState.FailureReason.HttpError) reason).getStatusCode()); + } else if (reason instanceof PreloadState.FailureReason.NavigationFailed) { + event.put("reason", "navigationFailed"); + } else if (reason instanceof PreloadState.FailureReason.WebContentProcessTerminated) { + event.put("reason", "webContentProcessTerminated"); + } else if (reason instanceof PreloadState.FailureReason.ProtocolError) { + event.put("reason", "protocolError"); + } else { + event.put("reason", "unknown"); + } + } else { + return; + } + } catch (JSONException exception) { + throw new IllegalStateException("Failed to serialize preload state", exception); + } + + emitPreloadStateEvent(event.toString()); + } + + protected void emitPreloadStateEvent(String event) { + emitOnPreloadStateChange(event); + } + private void releaseCheckoutListener() { if (checkoutListener != null) { checkoutListener.release(); @@ -117,6 +185,13 @@ private void releaseCheckoutListener() { } } + private void releaseCheckoutPreload() { + if (checkoutPreload != null) { + checkoutPreload.setListener(null); + checkoutPreload = null; + } + } + @ReactMethod(isBlockingSynchronousMethod = true) public WritableMap getConfig() { WritableMap resultConfig = Arguments.createMap(); diff --git a/platforms/react-native/modules/@shopify/checkout-kit-react-native/api/checkout-kit-react-native.api.md b/platforms/react-native/modules/@shopify/checkout-kit-react-native/api/checkout-kit-react-native.api.md index 03b527b8b..d3b5dba6c 100644 --- a/platforms/react-native/modules/@shopify/checkout-kit-react-native/api/checkout-kit-react-native.api.md +++ b/platforms/react-native/modules/@shopify/checkout-kit-react-native/api/checkout-kit-react-native.api.md @@ -172,6 +172,12 @@ export type CheckoutNativeError = { statusCode?: number; }; +// @public +export interface CheckoutPreloadSubscription { + remove(): void; + readonly state: PreloadState; +} + // @public (undocumented) export const CheckoutProtocol: { readonly complete: "ec.complete"; @@ -264,6 +270,32 @@ export enum LogLevel { warn = "warn" } +// @public +export type PreloadFailureReason = +| 'httpError' +| 'navigationFailed' +| 'keepAliveLost' +| 'webContentProcessTerminated' +| 'protocolError' +| 'unknown'; + +// @public +export interface PreloadOptions { + onStateChange?: (state: PreloadState) => void; +} + +// @public +export type PreloadState = +| {type: 'idle'} +| {type: 'loading'} +| {type: 'ready'} +| {type: 'expired'} +| { + type: 'failed'; + reason: PreloadFailureReason; + statusCode?: number; +}; + // @public export interface PresentCallbacks { onClose?: () => void; @@ -304,7 +336,7 @@ export class ShopifyCheckout implements ShopifyCheckoutKit { getConfig(): Configuration; invalidate(): void; isAcceleratedCheckoutAvailable(): boolean; - preload(checkoutUrl: string): void; + preload(checkoutUrl: string, options?: PreloadOptions): CheckoutPreloadSubscription; present(checkoutUrl: string, callbacks?: PresentCallbacks, protocol?: ProtocolHandlers): void; setConfig(configuration: Configuration): void; teardown(): void; diff --git a/platforms/react-native/modules/@shopify/checkout-kit-react-native/ios/ShopifyCheckoutKit.mm b/platforms/react-native/modules/@shopify/checkout-kit-react-native/ios/ShopifyCheckoutKit.mm index c99cf281e..758abb561 100644 --- a/platforms/react-native/modules/@shopify/checkout-kit-react-native/ios/ShopifyCheckoutKit.mm +++ b/platforms/react-native/modules/@shopify/checkout-kit-react-native/ios/ShopifyCheckoutKit.mm @@ -20,7 +20,8 @@ @interface RCT_EXTERN_MODULE (RCTShopifyCheckoutKit, NativeShopifyCheckoutKitSpe RCT_EXTERN_METHOD(present:(NSString *)checkoutURL subscribedMethods:(NSArray *)subscribedMethods) -RCT_EXTERN_METHOD(preload:(NSString *)checkoutURL) +RCT_EXTERN_METHOD(preload:(NSString *)checkoutURL + requestId:(NSString *)requestId) RCT_EXTERN_METHOD(invalidateCache) @@ -58,6 +59,19 @@ - (void)emitOnDispatchFromSwift:(NSString *)value eventEmitterCallbackWrapper->_eventEmitterCallback("onDispatch", value); } +- (void)emitOnPreloadStateChangeFromSwift:(NSString *)value +{ + EventEmitterCallbackWrapper *eventEmitterCallbackWrapper = + (EventEmitterCallbackWrapper *)objc_getAssociatedObject( + self, RCTShopifyCheckoutKitEventEmitterCallbackKey); + + if (eventEmitterCallbackWrapper == nil) { + return; + } + + eventEmitterCallbackWrapper->_eventEmitterCallback("onPreloadStateChange", value); +} + @end // TurboModule registration. `RCTModuleProviders` (generated by codegen from diff --git a/platforms/react-native/modules/@shopify/checkout-kit-react-native/ios/ShopifyCheckoutKit.swift b/platforms/react-native/modules/@shopify/checkout-kit-react-native/ios/ShopifyCheckoutKit.swift index 1a35c076d..fa40b7a6f 100644 --- a/platforms/react-native/modules/@shopify/checkout-kit-react-native/ios/ShopifyCheckoutKit.swift +++ b/platforms/react-native/modules/@shopify/checkout-kit-react-native/ios/ShopifyCheckoutKit.swift @@ -24,6 +24,7 @@ class RCTShopifyCheckoutKit: NSObject { private static let storefrontColorScheme = "storefront" internal var checkoutSheet: UIViewController? + private var checkoutPreload: CheckoutPreload? private var acceleratedCheckoutsConfiguration: Any? private var acceleratedCheckoutsApplePayConfiguration: Any? @@ -92,6 +93,7 @@ class RCTShopifyCheckoutKit: NSObject { @objc func invalidateCache() { DispatchQueue.main.async { ShopifyCheckoutKit.invalidate() + self.checkoutPreload = nil } } @@ -123,11 +125,25 @@ class RCTShopifyCheckoutKit: NSObject { } } - @objc func preload(_ checkoutURL: String) { + @objc func preload(_ checkoutURL: String, requestId: String) { DispatchQueue.main.async { - guard let url = URL(string: checkoutURL) else { return } + self.checkoutPreload?.onStateChange = nil + self.checkoutPreload = nil - ShopifyCheckoutKit.preload(checkout: url) + guard let url = URL(string: checkoutURL) else { + self.emitPreloadStateChange(requestId: requestId, state: .idle) + return + } + + guard let checkoutPreload = ShopifyCheckoutKit.preload(checkout: url) else { + self.emitPreloadStateChange(requestId: requestId, state: .idle) + return + } + + self.checkoutPreload = checkoutPreload + checkoutPreload.onStateChange = { [weak self] state in + self?.emitPreloadStateChange(requestId: requestId, state: state) + } } } @@ -343,6 +359,47 @@ extension RCTShopifyCheckoutKit { perform(NSSelectorFromString("emitOnDispatchFromSwift:"), with: json) } + private func emitPreloadStateChange(requestId: String, state: PreloadState) { + var event: [String: Any] = ["requestId": requestId] + + switch state { + case .idle: + event["type"] = "idle" + case .loading: + event["type"] = "loading" + case .ready: + event["type"] = "ready" + case .expired: + event["type"] = "expired" + case let .failed(reason): + event["type"] = "failed" + event.merge(serializePreloadFailure(reason)) { _, new in new } + } + + do { + let data = try JSONSerialization.data(withJSONObject: event, options: []) + guard let json = String(data: data, encoding: .utf8) else { return } + perform(NSSelectorFromString("emitOnPreloadStateChangeFromSwift:"), with: json) + } catch { + NSLog("[ShopifyCheckoutKit] Failed to serialize preload state: \(error)") + } + } + + private func serializePreloadFailure(_ reason: PreloadState.FailureReason) -> [String: Any] { + switch reason { + case let .httpError(statusCode): + return ["reason": "httpError", "statusCode": statusCode] + case .navigationFailed: + return ["reason": "navigationFailed"] + case .keepAliveLost: + return ["reason": "keepAliveLost"] + case .webContentProcessTerminated: + return ["reason": "webContentProcessTerminated"] + case .protocolError: + return ["reason": "protocolError"] + } + } + /// Builds a `{ "type": ..., "payload": ... }` envelope and forwards /// it to the JS dispatch event stream. private func emitDispatchEnvelope(type: DispatchEventType, payload: [String: Any]?) { diff --git a/platforms/react-native/modules/@shopify/checkout-kit-react-native/package.snapshot.json b/platforms/react-native/modules/@shopify/checkout-kit-react-native/package.snapshot.json index 101620ee7..32f3e2743 100644 --- a/platforms/react-native/modules/@shopify/checkout-kit-react-native/package.snapshot.json +++ b/platforms/react-native/modules/@shopify/checkout-kit-react-native/package.snapshot.json @@ -34,6 +34,8 @@ "lib/commonjs/index.d.js.map", "lib/commonjs/index.js", "lib/commonjs/index.js.map", + "lib/commonjs/preload.js", + "lib/commonjs/preload.js.map", "lib/commonjs/present-dispatcher.js", "lib/commonjs/present-dispatcher.js.map", "lib/commonjs/protocol.js", @@ -58,6 +60,8 @@ "lib/module/index.d.js.map", "lib/module/index.js", "lib/module/index.js.map", + "lib/module/preload.js", + "lib/module/preload.js.map", "lib/module/present-dispatcher.js", "lib/module/present-dispatcher.js.map", "lib/module/protocol.js", @@ -80,6 +84,8 @@ "lib/typescript/src/errors.d.ts.map", "lib/typescript/src/index.d.ts", "lib/typescript/src/index.d.ts.map", + "lib/typescript/src/preload.d.ts", + "lib/typescript/src/preload.d.ts.map", "lib/typescript/src/present-dispatcher.d.ts", "lib/typescript/src/present-dispatcher.d.ts.map", "lib/typescript/src/protocol.d.ts", @@ -125,6 +131,7 @@ "src/errors.ts", "src/index.d.ts", "src/index.ts", + "src/preload.ts", "src/present-dispatcher.ts", "src/protocol.ts", "src/specs/NativeShopifyCheckoutKit.ts", diff --git a/platforms/react-native/modules/@shopify/checkout-kit-react-native/src/context.tsx b/platforms/react-native/modules/@shopify/checkout-kit-react-native/src/context.tsx index ba370ced5..1868e795b 100644 --- a/platforms/react-native/modules/@shopify/checkout-kit-react-native/src/context.tsx +++ b/platforms/react-native/modules/@shopify/checkout-kit-react-native/src/context.tsx @@ -1,7 +1,13 @@ import React, {useCallback, useMemo, useRef, useEffect, useState} from 'react'; import type {PropsWithChildren} from 'react'; import {ShopifyCheckout} from './index'; -import type {Configuration, Features, PresentCallbacks} from './index.d'; +import type { + CheckoutPreloadSubscription, + Configuration, + Features, + PreloadOptions, + PresentCallbacks, +} from './index.d'; import type {ProtocolHandlers} from './protocol'; type Maybe = T | undefined; @@ -15,7 +21,10 @@ interface Context { callbacks?: PresentCallbacks, protocol?: ProtocolHandlers, ) => void; - preload: (checkoutUrl: string) => void; + preload: ( + checkoutUrl: string, + options?: PreloadOptions, + ) => CheckoutPreloadSubscription | undefined; invalidate: () => void; dismiss: () => void; version: Maybe; @@ -42,17 +51,16 @@ export function ShopifyCheckoutProvider({ if (!instance.current) { instance.current = new ShopifyCheckout(configuration, features); } + const checkout = instance.current; useEffect(() => { - if (!instance.current || !configuration) { + if (!configuration) { return; } - instance.current.setConfig(configuration); - setAcceleratedCheckoutsAvailable( - instance.current.acceleratedCheckoutsReady, - ); - }, [configuration]); + checkout.setConfig(configuration); + setAcceleratedCheckoutsAvailable(checkout.acceleratedCheckoutsReady); + }, [checkout, configuration]); const present = useCallback( ( @@ -61,33 +69,41 @@ export function ShopifyCheckoutProvider({ protocol?: ProtocolHandlers, ) => { if (checkoutUrl) { - instance.current?.present(checkoutUrl, callbacks, protocol); + checkout.present(checkoutUrl, callbacks, protocol); } }, - [], + [checkout], ); - const preload = useCallback((checkoutUrl: string) => { - if (checkoutUrl) { - instance.current?.preload(checkoutUrl); - } - }, []); + const preload = useCallback( + (checkoutUrl: string, options?: PreloadOptions) => { + if (checkoutUrl) { + return checkout.preload(checkoutUrl, options); + } + + return undefined; + }, + [checkout], + ); const invalidate = useCallback(() => { - instance.current?.invalidate(); - }, []); + checkout.invalidate(); + }, [checkout]); const dismiss = useCallback(() => { - instance.current?.dismiss(); - }, []); + checkout.dismiss(); + }, [checkout]); - const setConfig = useCallback((config: Configuration) => { - instance.current?.setConfig(config); - }, []); + const setConfig = useCallback( + (config: Configuration) => { + checkout.setConfig(config); + }, + [checkout], + ); const getConfig = useCallback(() => { - return instance.current?.getConfig(); - }, []); + return checkout.getConfig(); + }, [checkout]); const context = useMemo((): Context => { return { @@ -98,10 +114,11 @@ export function ShopifyCheckoutProvider({ getConfig, present, preload, - version: instance.current?.version, + version: checkout.version, }; }, [ acceleratedCheckoutsAvailable, + checkout, dismiss, getConfig, invalidate, diff --git a/platforms/react-native/modules/@shopify/checkout-kit-react-native/src/index.d.ts b/platforms/react-native/modules/@shopify/checkout-kit-react-native/src/index.d.ts index e274b9993..7d43eaa14 100644 --- a/platforms/react-native/modules/@shopify/checkout-kit-react-native/src/index.d.ts +++ b/platforms/react-native/modules/@shopify/checkout-kit-react-native/src/index.d.ts @@ -198,6 +198,48 @@ export interface PresentCallbacks { onGeolocationRequest?: (event: GeolocationRequestEvent) => void; } +/** A stable, machine-readable reason why a preload failed. */ +export type PreloadFailureReason = + | 'httpError' + | 'navigationFailed' + | 'keepAliveLost' + | 'webContentProcessTerminated' + | 'protocolError' + | 'unknown'; + +/** The observable lifecycle state of a preloaded checkout. */ +export type PreloadState = + | {type: 'idle'} + | {type: 'loading'} + | {type: 'ready'} + | {type: 'expired'} + | { + type: 'failed'; + reason: PreloadFailureReason; + statusCode?: number; + }; + +/** Optional callbacks for a single `preload(...)` invocation. */ +export interface PreloadOptions { + /** + * Fires immediately with the initial native state and whenever it changes. + * Preload failures are performance signals and do not prevent a later + * `present(...)` call from loading checkout normally. + */ + onStateChange?: (state: PreloadState) => void; +} + +/** Observation returned by `preload(...)`. */ +export interface CheckoutPreloadSubscription { + /** The latest state delivered by the native SDK. */ + readonly state: PreloadState; + /** + * Stops delivering state changes to this observer. This does not cancel or + * invalidate the cached checkout. + */ + remove(): void; +} + /** * Customer information for personalized accelerated checkout. * @@ -285,8 +327,12 @@ export interface ShopifyCheckoutKit { * Preload the checkout for faster presentation. * * @param checkoutURL The URL of the checkout to preload. + * @param options Optional callbacks for observing preload state. */ - preload(checkoutURL: string): void; + preload( + checkoutURL: string, + options?: PreloadOptions, + ): CheckoutPreloadSubscription; /** * Clear any checkout cached by `preload`. */ diff --git a/platforms/react-native/modules/@shopify/checkout-kit-react-native/src/index.ts b/platforms/react-native/modules/@shopify/checkout-kit-react-native/src/index.ts index 20ba43eab..7907a42a0 100644 --- a/platforms/react-native/modules/@shopify/checkout-kit-react-native/src/index.ts +++ b/platforms/react-native/modules/@shopify/checkout-kit-react-native/src/index.ts @@ -22,6 +22,10 @@ import type { GeolocationRequestEvent, IosColors, PresentCallbacks, + PreloadFailureReason, + PreloadOptions, + PreloadState, + CheckoutPreloadSubscription, ShopifyCheckoutKit, } from './index.d'; import {AcceleratedCheckoutWallet} from './enums'; @@ -43,6 +47,7 @@ import type { ErrorResponse, ProtocolHandlers, } from './protocol'; +import {preload as preloadCheckout} from './preload'; const defaultFeatures: Features = { handleGeolocationRequests: true, @@ -53,6 +58,8 @@ class ShopifyCheckout implements ShopifyCheckoutKit { private dispatchSubscription?: {remove: () => void}; + private preloadSubscription?: CheckoutPreloadSubscription; + private _acceleratedCheckoutsReady = false; // TurboModule constants are immutable for the lifetime of the process — @@ -109,8 +116,13 @@ class ShopifyCheckout implements ShopifyCheckoutKit { * Preloads checkout for a given URL to improve presentation performance. * @param checkoutUrl The URL of the checkout to preload */ - public preload(checkoutUrl: string): void { - RNShopifyCheckoutKit.preload(checkoutUrl); + public preload( + checkoutUrl: string, + options?: PreloadOptions, + ): CheckoutPreloadSubscription { + const subscription = preloadCheckout(checkoutUrl, options); + this.preloadSubscription = subscription; + return subscription; } /** @@ -178,11 +190,12 @@ class ShopifyCheckout implements ShopifyCheckoutKit { /** * Cleans up resources and event listeners used by the checkout sheet. - * Currently a no-op — retained as part of the public API for forward - * compatibility with future protocol-client subscriptions. + * Stops callbacks retained by this instance without invalidating preload. */ public teardown() { this.releaseDispatchSubscription(); + this.preloadSubscription?.remove(); + this.preloadSubscription = undefined; } /** @@ -413,6 +426,10 @@ export type { GeolocationRequestEvent, IosColors, PresentCallbacks, + PreloadFailureReason, + PreloadOptions, + PreloadState, + CheckoutPreloadSubscription, ProtocolHandlers, RenderStateChangeEvent, }; diff --git a/platforms/react-native/modules/@shopify/checkout-kit-react-native/src/preload.ts b/platforms/react-native/modules/@shopify/checkout-kit-react-native/src/preload.ts new file mode 100644 index 000000000..3529b727e --- /dev/null +++ b/platforms/react-native/modules/@shopify/checkout-kit-react-native/src/preload.ts @@ -0,0 +1,181 @@ +import RNShopifyCheckoutKit from './specs/NativeShopifyCheckoutKit'; +import type { + CheckoutPreloadSubscription, + PreloadFailureReason, + PreloadOptions, + PreloadState, +} from './index.d'; + +type NativePreloadStateEvent = { + requestId: string; + type: PreloadState['type']; + reason?: PreloadFailureReason; + statusCode?: number; +}; + +const failureReasons = new Set([ + 'httpError', + 'navigationFailed', + 'keepAliveLost', + 'webContentProcessTerminated', + 'protocolError', + 'unknown', +]); + +let activeSubscription: PreloadSubscription | undefined; +let nativeSubscription: {remove: () => void} | undefined; +let requestSequence = 0; + +function isTerminal(state: PreloadState): boolean { + return ( + state.type === 'idle' || + state.type === 'expired' || + state.type === 'failed' + ); +} + +class PreloadSubscription implements CheckoutPreloadSubscription { + private currentState: PreloadState = {type: 'idle'}; + private onStateChange?: (state: PreloadState) => void; + private observing = true; + + constructor( + readonly requestId: string, + options?: PreloadOptions, + ) { + this.onStateChange = options?.onStateChange; + } + + get state(): PreloadState { + return this.currentState; + } + + receive(state: PreloadState): void { + if (!this.observing) { + return; + } + + this.currentState = state; + this.onStateChange?.(state); + + if (isTerminal(state)) { + this.remove(); + } + } + + remove(): void { + if (!this.observing) { + return; + } + + this.observing = false; + this.onStateChange = undefined; + + if (activeSubscription === this) { + activeSubscription = undefined; + } + } +} + +function parsePreloadStateEvent( + json: string, +): NativePreloadStateEvent | undefined { + try { + const event: unknown = JSON.parse(json); + if (!event || typeof event !== 'object') { + return undefined; + } + + const {requestId, type, reason, statusCode} = event as Record< + string, + unknown + >; + if (typeof requestId !== 'string' || typeof type !== 'string') { + return undefined; + } + + if ( + type !== 'idle' && + type !== 'loading' && + type !== 'ready' && + type !== 'expired' && + type !== 'failed' + ) { + return undefined; + } + + if (type === 'failed') { + if ( + typeof reason !== 'string' || + !failureReasons.has(reason as PreloadFailureReason) + ) { + return undefined; + } + + if (statusCode !== undefined && typeof statusCode !== 'number') { + return undefined; + } + } + + return { + requestId, + type, + reason: reason as PreloadFailureReason | undefined, + statusCode: statusCode as number | undefined, + }; + } catch { + return undefined; + } +} + +function stateFromNativeEvent(event: NativePreloadStateEvent): PreloadState { + if (event.type === 'failed') { + return { + type: 'failed', + reason: event.reason ?? 'unknown', + ...(event.statusCode === undefined ? {} : {statusCode: event.statusCode}), + }; + } + + return {type: event.type}; +} + +function ensureNativeSubscription(): void { + if (nativeSubscription) { + return; + } + + nativeSubscription = RNShopifyCheckoutKit.onPreloadStateChange(json => { + const event = parsePreloadStateEvent(json); + if (!event || event.requestId !== activeSubscription?.requestId) { + return; + } + + activeSubscription.receive(stateFromNativeEvent(event)); + }); +} + +export function preload( + checkoutUrl: string, + options?: PreloadOptions, +): CheckoutPreloadSubscription { + activeSubscription?.remove(); + ensureNativeSubscription(); + + requestSequence += 1; + const requestId = `${Date.now()}-${requestSequence}`; + const subscription = new PreloadSubscription(requestId, options); + activeSubscription = subscription; + + RNShopifyCheckoutKit.preload(checkoutUrl, requestId); + return subscription; +} + +/** @internal Test-only reset for module-scoped subscription state. */ +export function __resetPreloadForTests(): void { + activeSubscription?.remove(); + activeSubscription = undefined; + nativeSubscription?.remove(); + nativeSubscription = undefined; + requestSequence = 0; +} diff --git a/platforms/react-native/modules/@shopify/checkout-kit-react-native/src/specs/NativeShopifyCheckoutKit.ts b/platforms/react-native/modules/@shopify/checkout-kit-react-native/src/specs/NativeShopifyCheckoutKit.ts index c4bcaee5e..72d80705d 100644 --- a/platforms/react-native/modules/@shopify/checkout-kit-react-native/src/specs/NativeShopifyCheckoutKit.ts +++ b/platforms/react-native/modules/@shopify/checkout-kit-react-native/src/specs/NativeShopifyCheckoutKit.ts @@ -50,12 +50,13 @@ type ConfigurationResultSpec = { export interface Spec extends TurboModule { readonly onDispatch: CodegenTypes.EventEmitter; + readonly onPreloadStateChange: CodegenTypes.EventEmitter; present( checkoutUrl: string, subscribedMethods: string[], ): void; - preload(checkoutUrl: string): void; + preload(checkoutUrl: string, requestId: string): void; dismiss(): void; invalidateCache(): void; setConfig(configuration: ConfigurationSpec): void; diff --git a/platforms/react-native/modules/@shopify/checkout-kit-react-native/tests/context.test.tsx b/platforms/react-native/modules/@shopify/checkout-kit-react-native/tests/context.test.tsx index bb8195e4b..79a465850 100644 --- a/platforms/react-native/modules/@shopify/checkout-kit-react-native/tests/context.test.tsx +++ b/platforms/react-native/modules/@shopify/checkout-kit-react-native/tests/context.test.tsx @@ -11,6 +11,7 @@ import { ColorScheme, type Configuration, } from '../src'; +import {__resetPreloadForTests} from '../src/preload'; const checkoutUrl = 'https://shopify.com/checkout'; const config: Configuration = { @@ -39,6 +40,7 @@ describe('ShopifyCheckoutProvider', () => { ); afterEach(() => { + __resetPreloadForTests(); jest.clearAllMocks(); }); @@ -155,6 +157,7 @@ describe('useShopifyCheckout', () => { ); afterEach(() => { + __resetPreloadForTests(); jest.clearAllMocks(); }); @@ -257,7 +260,7 @@ describe('useShopifyCheckout', () => { ).not.toHaveBeenCalled(); }); - it('provides preload function', () => { + it('provides preload function and forwards observation options', () => { let hookValue: any; const onHookValue = (value: any) => { hookValue = value; @@ -269,16 +272,20 @@ describe('useShopifyCheckout', () => { , ); + const onStateChange = jest.fn(); + let subscription: {state: unknown; remove(): void} | undefined; act(() => { - hookValue.preload(checkoutUrl); + subscription = hookValue.preload(checkoutUrl, {onStateChange}); }); expect(NativeModules.ShopifyCheckoutKit.preload).toHaveBeenCalledWith( checkoutUrl, + expect.any(String), ); + expect(subscription?.state).toEqual({type: 'idle'}); }); - it('does not call preload with empty checkoutUrl', () => { + it('does not preload an empty checkout URL', () => { let hookValue: any; const onHookValue = (value: any) => { hookValue = value; @@ -290,13 +297,13 @@ describe('useShopifyCheckout', () => { , ); + let subscription; act(() => { - hookValue.preload(''); + subscription = hookValue.preload(''); }); - expect( - NativeModules.ShopifyCheckoutKit.preload, - ).not.toHaveBeenCalled(); + expect(subscription).toBeUndefined(); + expect(NativeModules.ShopifyCheckoutKit.preload).not.toHaveBeenCalled(); }); it('provides invalidate function', () => { diff --git a/platforms/react-native/modules/@shopify/checkout-kit-react-native/tests/index.test.ts b/platforms/react-native/modules/@shopify/checkout-kit-react-native/tests/index.test.ts index fc949e42a..277e825c5 100644 --- a/platforms/react-native/modules/@shopify/checkout-kit-react-native/tests/index.test.ts +++ b/platforms/react-native/modules/@shopify/checkout-kit-react-native/tests/index.test.ts @@ -17,6 +17,7 @@ import { type AcceleratedCheckoutCustomer, } from '../src'; import {__resetDispatchEventParityForTests} from '../src/dispatch-events'; +import {__resetPreloadForTests} from '../src/preload'; import type {ApplePayContactField} from '../src/index.d'; import {TurboModuleRegistry, PermissionsAndroid, Platform} from 'react-native'; @@ -154,8 +155,31 @@ function lastDispatch(): Dispatch { return dispatch; } +type PreloadDispatch = (eventJson: string) => void; + +function preloadDispatch(): PreloadDispatch { + const dispatch = NativeModule.onPreloadStateChange.mock.calls[0]?.[0] as + | PreloadDispatch + | undefined; + if (!dispatch) { + throw new Error('Expected preload() to subscribe to preload state events'); + } + return dispatch; +} + +function preloadRequestId(call = 0): string { + const requestId = NativeModule.preload.mock.calls[call]?.[1] as + | string + | undefined; + if (!requestId) { + throw new Error('Expected preload() to receive a request ID'); + } + return requestId; +} + describe('ShopifyCheckoutKit', () => { afterEach(() => { + __resetPreloadForTests(); NativeModule.setConfig.mockReset(); jest.clearAllMocks(); }); @@ -214,10 +238,112 @@ describe('ShopifyCheckoutKit', () => { describe('preload', () => { it('calls `preload` with a checkout URL', () => { const instance = new ShopifyCheckout(); - instance.preload(checkoutUrl); + const subscription = instance.preload(checkoutUrl); expect(NativeModule.preload).toHaveBeenCalledTimes(1); - expect(NativeModule.preload).toHaveBeenCalledWith(checkoutUrl); + expect(NativeModule.preload).toHaveBeenCalledWith( + checkoutUrl, + expect.any(String), + ); + expect(subscription.state).toEqual({type: 'idle'}); + }); + + it('delivers native preload state changes and updates the state snapshot', () => { + const onStateChange = jest.fn(); + const instance = new ShopifyCheckout(); + const subscription = instance.preload(checkoutUrl, {onStateChange}); + const requestId = preloadRequestId(); + + preloadDispatch()(JSON.stringify({requestId, type: 'loading'})); + preloadDispatch()(JSON.stringify({requestId, type: 'ready'})); + + expect(onStateChange).toHaveBeenNthCalledWith(1, {type: 'loading'}); + expect(onStateChange).toHaveBeenNthCalledWith(2, {type: 'ready'}); + expect(subscription.state).toEqual({type: 'ready'}); + }); + + it('normalizes preload failures with HTTP status codes', () => { + const onStateChange = jest.fn(); + const instance = new ShopifyCheckout(); + const subscription = instance.preload(checkoutUrl, {onStateChange}); + + preloadDispatch()( + JSON.stringify({ + requestId: preloadRequestId(), + type: 'failed', + reason: 'httpError', + statusCode: 503, + }), + ); + + expect(onStateChange).toHaveBeenCalledWith({ + type: 'failed', + reason: 'httpError', + statusCode: 503, + }); + expect(subscription.state).toEqual({ + type: 'failed', + reason: 'httpError', + statusCode: 503, + }); + }); + + it('normalizes terminated web content process preload failures', () => { + const onStateChange = jest.fn(); + const instance = new ShopifyCheckout(); + const subscription = instance.preload(checkoutUrl, {onStateChange}); + + preloadDispatch()( + JSON.stringify({ + requestId: preloadRequestId(), + type: 'failed', + reason: 'webContentProcessTerminated', + }), + ); + + expect(onStateChange).toHaveBeenCalledWith({ + type: 'failed', + reason: 'webContentProcessTerminated', + }); + expect(subscription.state).toEqual({ + type: 'failed', + reason: 'webContentProcessTerminated', + }); + }); + + it('uses one native event subscription across repeated preload calls', () => { + const firstOnStateChange = jest.fn(); + const secondOnStateChange = jest.fn(); + const instance = new ShopifyCheckout(); + + instance.preload(checkoutUrl, {onStateChange: firstOnStateChange}); + const firstRequestId = preloadRequestId(0); + instance.preload(checkoutUrl, {onStateChange: secondOnStateChange}); + const secondRequestId = preloadRequestId(1); + + preloadDispatch()( + JSON.stringify({requestId: firstRequestId, type: 'ready'}), + ); + preloadDispatch()( + JSON.stringify({requestId: secondRequestId, type: 'ready'}), + ); + + expect(NativeModule.onPreloadStateChange).toHaveBeenCalledTimes(1); + expect(firstOnStateChange).not.toHaveBeenCalled(); + expect(secondOnStateChange).toHaveBeenCalledWith({type: 'ready'}); + }); + + it('stops delivering state changes after remove', () => { + const onStateChange = jest.fn(); + const instance = new ShopifyCheckout(); + const subscription = instance.preload(checkoutUrl, {onStateChange}); + const requestId = preloadRequestId(); + + subscription.remove(); + preloadDispatch()(JSON.stringify({requestId, type: 'ready'})); + + expect(onStateChange).not.toHaveBeenCalled(); + expect(subscription.state).toEqual({type: 'idle'}); }); }); diff --git a/platforms/react-native/sample/android/app/src/test/java/com/shopify/checkoutkit/reactnativedemo/ShopifyCheckoutKitModuleTest.java b/platforms/react-native/sample/android/app/src/test/java/com/shopify/checkoutkit/reactnativedemo/ShopifyCheckoutKitModuleTest.java index 1a66e8940..4963be0db 100644 --- a/platforms/react-native/sample/android/app/src/test/java/com/shopify/checkoutkit/reactnativedemo/ShopifyCheckoutKitModuleTest.java +++ b/platforms/react-native/sample/android/app/src/test/java/com/shopify/checkoutkit/reactnativedemo/ShopifyCheckoutKitModuleTest.java @@ -13,8 +13,11 @@ import com.shopify.checkoutkit.CheckoutAppearance; import com.shopify.checkoutkit.CheckoutErrorCode; import com.shopify.checkoutkit.CheckoutException; +import com.shopify.checkoutkit.CheckoutPreload; import com.shopify.checkoutkit.ShopifyCheckoutKit; import com.shopify.checkoutkit.LogLevel; +import com.shopify.checkoutkit.PreloadState; +import com.shopify.checkoutkit.PreloadStateListener; import com.shopify.checkoutkit.Preloading; import com.shopify.reactnative.checkoutkit.ShopifyCheckoutKitModule; import com.shopify.reactnative.checkoutkit.CustomCheckoutListener; @@ -50,7 +53,7 @@ public class ShopifyCheckoutKitModuleTest { @Captor private ArgumentCaptor stringCaptor; - private ShopifyCheckoutKitModule shopifyCheckoutKitModule; + private TestShopifyCheckoutKitModule shopifyCheckoutKitModule; private AutoCloseable mocks; // Store initial configuration to restore after each test @@ -73,6 +76,19 @@ public class ShopifyCheckoutKitModuleTest { private static final String DARK_HEADER_BACKGROUND_COLOR = "#000000"; private static final String DARK_HEADER_TEXT_COLOR = "#FFFFFF"; + private static final class TestShopifyCheckoutKitModule extends ShopifyCheckoutKitModule { + private String preloadStateEvent; + + TestShopifyCheckoutKitModule(ReactApplicationContext reactContext) { + super(reactContext); + } + + @Override + protected void emitPreloadStateEvent(String event) { + preloadStateEvent = event; + } + } + @Before public void setup() { mocks = MockitoAnnotations.openMocks(this); @@ -80,7 +96,7 @@ public void setup() { mockedArguments.when(Arguments::createMap).thenAnswer(invocation -> new JavaOnlyMap()); when(mockReactContext.getCurrentActivity()).thenReturn(mockComponentActivity); - shopifyCheckoutKitModule = new ShopifyCheckoutKitModule(mockReactContext); + shopifyCheckoutKitModule = new TestShopifyCheckoutKitModule(mockReactContext); // Capture initial configuration state to restore after each test initialAppearance = ShopifyCheckoutKitModule.checkoutConfig.getAppearance(); @@ -137,22 +153,66 @@ public void testCanPreloadCheckout() { try (MockedStatic mockedShopifyCheckoutKit = Mockito .mockStatic(ShopifyCheckoutKit.class)) { String checkoutUrl = "https://shopify.com"; + CheckoutPreload checkoutPreload = mock(CheckoutPreload.class); + mockedShopifyCheckoutKit + .when(() -> ShopifyCheckoutKit.preload( + eq(checkoutUrl), + eq(mockComponentActivity), + any(PreloadStateListener.class))) + .thenReturn(checkoutPreload); + + shopifyCheckoutKitModule.preload(checkoutUrl, "preload-request"); + + mockedShopifyCheckoutKit.verify(() -> ShopifyCheckoutKit.preload( + eq(checkoutUrl), + eq(mockComponentActivity), + any(PreloadStateListener.class))); + } + } - shopifyCheckoutKitModule.preload(checkoutUrl); - - mockedShopifyCheckoutKit.verify(() -> ShopifyCheckoutKit.preload(checkoutUrl, mockComponentActivity)); + @Test + public void testPreloadSerializesWebContentProcessTerminated() { + try (MockedStatic mockedShopifyCheckoutKit = Mockito + .mockStatic(ShopifyCheckoutKit.class)) { + String checkoutUrl = "https://shopify.com"; + CheckoutPreload checkoutPreload = mock(CheckoutPreload.class); + ArgumentCaptor listenerCaptor = + ArgumentCaptor.forClass(PreloadStateListener.class); + mockedShopifyCheckoutKit + .when(() -> ShopifyCheckoutKit.preload( + eq(checkoutUrl), + eq(mockComponentActivity), + any(PreloadStateListener.class))) + .thenReturn(checkoutPreload); + + shopifyCheckoutKitModule.preload(checkoutUrl, "preload-request"); + + mockedShopifyCheckoutKit.verify(() -> ShopifyCheckoutKit.preload( + eq(checkoutUrl), + eq(mockComponentActivity), + listenerCaptor.capture())); + listenerCaptor.getValue().onStateChanged(new PreloadState.Failed( + PreloadState.FailureReason.WebContentProcessTerminated.INSTANCE)); + + assertThat(shopifyCheckoutKitModule.preloadStateEvent) + .contains("\"requestId\":\"preload-request\"") + .contains("\"type\":\"failed\"") + .contains("\"reason\":\"webContentProcessTerminated\""); } } @Test - public void testPreloadDoesNothingWithoutComponentActivity() { + public void testPreloadEmitsIdleWithoutComponentActivity() { when(mockReactContext.getCurrentActivity()).thenReturn(null); try (MockedStatic mockedShopifyCheckoutKit = Mockito .mockStatic(ShopifyCheckoutKit.class)) { - shopifyCheckoutKitModule.preload("https://shopify.com"); + shopifyCheckoutKitModule.preload("https://shopify.com", "preload-request"); mockedShopifyCheckoutKit.verifyNoInteractions(); + assertThat(shopifyCheckoutKitModule.preloadStateEvent) + .contains("\"requestId\":\"preload-request\"") + .contains("\"type\":\"idle\""); } } @@ -160,12 +220,41 @@ public void testPreloadDoesNothingWithoutComponentActivity() { public void testCanInvalidatePreloadCache() { try (MockedStatic mockedShopifyCheckoutKit = Mockito .mockStatic(ShopifyCheckoutKit.class)) { + CheckoutPreload checkoutPreload = mock(CheckoutPreload.class); + mockedShopifyCheckoutKit + .when(() -> ShopifyCheckoutKit.preload( + anyString(), + eq(mockComponentActivity), + any(PreloadStateListener.class))) + .thenReturn(checkoutPreload); + + shopifyCheckoutKitModule.preload("https://shopify.com", "preload-request"); shopifyCheckoutKitModule.invalidateCache(); + verify(checkoutPreload).setListener(null); mockedShopifyCheckoutKit.verify(ShopifyCheckoutKit::invalidate); } } + @Test + public void testModuleInvalidationDetachesPreloadListener() { + try (MockedStatic mockedShopifyCheckoutKit = Mockito + .mockStatic(ShopifyCheckoutKit.class)) { + CheckoutPreload checkoutPreload = mock(CheckoutPreload.class); + mockedShopifyCheckoutKit + .when(() -> ShopifyCheckoutKit.preload( + anyString(), + eq(mockComponentActivity), + any(PreloadStateListener.class))) + .thenReturn(checkoutPreload); + + shopifyCheckoutKitModule.preload("https://shopify.com", "preload-request"); + shopifyCheckoutKitModule.invalidate(); + + verify(checkoutPreload).setListener(null); + } + } + @Test public void testPresentForwardsOnCloseCallback() { DispatchCallback dispatch = mock(DispatchCallback.class); diff --git a/platforms/react-native/test/rct-integration-app/RCTIntegrationAppTests/ShopifyCheckoutKitTests.swift b/platforms/react-native/test/rct-integration-app/RCTIntegrationAppTests/ShopifyCheckoutKitTests.swift index 027e8c8d9..4f4d0e23b 100644 --- a/platforms/react-native/test/rct-integration-app/RCTIntegrationAppTests/ShopifyCheckoutKitTests.swift +++ b/platforms/react-native/test/rct-integration-app/RCTIntegrationAppTests/ShopifyCheckoutKitTests.swift @@ -315,7 +315,7 @@ class ShopifyCheckoutKitTests: XCTestCase { func testPreloadWithInvalidURLDoesNotRetainCheckoutSheet() { let preloadAttemptCompleted = expectation(description: "preload attempt completed") - shopifyCheckoutKit.preload("") + shopifyCheckoutKit.preload("", requestId: "invalid-url") DispatchQueue.main.async { XCTAssertNil(self.shopifyCheckoutKit.checkoutSheet)