From e6608a8dac189868cbd517e463a8979e9348138d Mon Sep 17 00:00:00 2001 From: Kieran Osgood Date: Thu, 14 May 2026 21:41:46 +0100 Subject: [PATCH 1/3] feat: remove addEventListener API --- platforms/react-native/README.md | 143 ++++------ .../react-native/__mocks__/react-native.ts | 2 - .../checkoutkit/CustomCheckoutListener.java | 44 ++- .../checkoutkit/ShopifyCheckoutKitModule.java | 8 +- .../ios/ShopifyCheckoutKit-Bridging-Header.h | 1 - .../ios/ShopifyCheckoutKit.mm | 5 + .../ios/ShopifyCheckoutKit.swift | 63 ++--- .../checkout-kit-react-native/src/context.tsx | 47 +--- .../checkout-kit-react-native/src/index.d.ts | 82 +++--- .../checkout-kit-react-native/src/index.ts | 155 +++++------ .../src/specs/NativeShopifyCheckoutKit.ts | 11 +- .../tests/context.test.tsx | 54 ++-- .../tests/index.test.ts | 259 ++++++++++-------- .../ShopifyCheckoutKitModuleTest.java | 142 +++++++++- .../ShopifyCheckoutKitTests.swift | 126 +-------- platforms/react-native/sample/src/App.tsx | 30 +- .../sample/src/screens/CartScreen.tsx | 16 +- 17 files changed, 554 insertions(+), 634 deletions(-) diff --git a/platforms/react-native/README.md b/platforms/react-native/README.md index 7deabe0f..6c35e44b 100644 --- a/platforms/react-native/README.md +++ b/platforms/react-native/README.md @@ -43,8 +43,7 @@ experiences. - [When to preload](#when-to-preload) - [Cache invalidation](#cache-invalidation) - [Checkout lifecycle](#checkout-lifecycle) - - [`addEventListener(eventName, callback)`](#addeventlistenereventname-callback) - - [`removeEventListeners(eventName)`](#removeeventlistenerseventname) + - [SDK callbacks on `present()`](#sdk-callbacks-on-present) - [Identity \& customer accounts](#identity--customer-accounts) - [Cart: buyer bag, identity, and preferences](#cart-buyer-bag-identity-and-preferences) - [Multipass](#multipass) @@ -590,60 +589,36 @@ Should you wish to manually clear the preload cache, there is a `ShopifyCheckout ## Checkout lifecycle -There are currently 3 checkout events exposed through the Native Module. You can -subscribe to these events using `addEventListener` and `removeEventListeners` -methods - available on both the context provider as well as the class instance. +Lifecycle callbacks are passed per-call to `present()`. The bridge holds the +handles for the duration of that one presentation and releases them on +terminal events; nothing needs to be subscribed or torn down explicitly. -| Name | Callback | Description | -| ----------- | ----------------------------------------- | ------------------------------------------------------------ | -| `close` | `() => void` | Fired when the checkout has been closed. | -| `completed` | `(event: CheckoutCompletedEvent) => void` | Fired when the checkout has been successfully completed. | -| `error` | `(error: {message: string}) => void` | Fired when a checkout exception has been raised. | - -### `addEventListener(eventName, callback)` - -Subscribing to an event returns an `EmitterSubscription` object, which contains -a `remove()` function to unsubscribe. Here's an example of how you might create -an event listener in a React `useEffect`, ensuring to remove it on unmount. +### SDK callbacks on `present()` ```tsx -// Using hooks -const shopifyCheckout = useShopifyCheckout(); - -useEffect(() => { - const close = shopifyCheckout.addEventListener('close', () => { - // Do something on checkout close - }); - - const completed = shopifyCheckout.addEventListener( - 'completed', - (event: CheckoutCompletedEvent) => { - // Lookup order on checkout completion - const orderId = event.orderDetails.id; - }, - ); - - const error = shopifyCheckout.addEventListener( - 'error', - (error: CheckoutError) => { - // Do something on checkout error - // console.log(error.message) - }, - ); - - return () => { - // It is important to clear the subscription on unmount to prevent memory leaks - close?.remove(); - completed?.remove(); - error?.remove(); - }; -}, [shopifyCheckout]); +shopify.present(checkoutUrl, { + onClose: () => { + // The sheet was dismissed without a terminal error + }, + onFail: (error: CheckoutException) => { + // A terminal error occurred — inspect `error.code`, `error.recoverable`, etc. + }, +}); ``` -### `removeEventListeners(eventName)` +| Name | Callback | Fires | +| ---------------------- | ------------------------------------------ | ---------------------------------------------------------------------------------------------------------------- | +| `onClose` | `() => void` | Once, when the buyer dismisses the sheet without a terminal error. | +| `onFail` | `(error: CheckoutException) => void` | Once, when the checkout terminates with an error. | +| `onGeolocationRequest` | `(event: GeolocationRequestEvent) => void` | Android only. Fired each time the webview requests geolocation permissions. See [Opting out of the default behavior](#opting-out-of-the-default-behavior). | -On the rare occasion that you want to remove all event listeners for a given -`eventName`, you can use the `removeEventListeners(eventName)` method. +`onClose` and `onFail` are mutually exclusive — exactly one of them fires +per `present(...)` call, after which both handles are released. + +> Protocol-level callbacks (`start`, `complete`, `error` on the protocol +> client) are not part of this section and will land in a follow-up release +> alongside a `` component. Checkout completion is not +> currently surfaced through the per-call callbacks. ## Identity & customer accounts @@ -756,15 +731,16 @@ Android differs to iOS in that permission requests must be handled in two places ``` -The Checkout Kit native module will emit a `geolocationRequest` event when the webview requests geolocation -information. By default, the kit will listen for this event and request access to both coarse and fine access when -invoked. +When the webview requests geolocation information, the Checkout Kit native +module surfaces it to JS so the app can respond. By default, the kit handles +the request itself and asks for both coarse and fine access on the buyer's +behalf. The geolocation request flow follows this sequence: 1. When checkout needs location data (e.g., to show nearby pickup points), it triggers a geolocation request. -2. The native module emits a `geolocationRequest` event. -3. If using default behavior, the module automatically handles the Android runtime permission request. +2. If you've passed an `onGeolocationRequest` callback to `present()`, that callback is invoked. +3. Otherwise, with `features.handleGeolocationRequests: true` (the default), the module automatically handles the Android runtime permission request. 4. The result is passed back to checkout, which then proceeds to show relevant pickup points if permission was granted. > [!NOTE] @@ -775,16 +751,40 @@ The geolocation request flow follows this sequence: > [!NOTE] > This section is only applicable for Android. -In order to opt-out of the default permission handling, you can set `features.handleGeolocationRequests` to `false` -when you instantiate the `ShopifyCheckout` class. +There are two ways to opt out, depending on whether you want to override the +behavior for every presentation or just one. -If you're using the sheet programmatically, you can do so by specifying a `features` object as the second argument: +**Per-call override.** Pass an `onGeolocationRequest` callback to +`present()`. When set, the callback fires instead of the default handler +for that one presentation; the consumer is responsible for resolving +permissions and calling `initiateGeolocationRequest(allow)`: + +```tsx +shopify.present(checkoutUrl, { + onGeolocationRequest: async (event: GeolocationRequestEvent) => { + const coarse = 'android.permission.ACCESS_COARSE_LOCATION'; + const fine = 'android.permission.ACCESS_FINE_LOCATION'; + + const results = await PermissionsAndroid.requestMultiple([coarse, fine]); + const granted = + results[coarse] === 'granted' || results[fine] === 'granted'; + + shopify.initiateGeolocationRequest(granted); + }, +}); +``` + +**Process-wide opt-out.** Set `features.handleGeolocationRequests` to +`false` when you instantiate the `ShopifyCheckout` class to disable the +default handler entirely. Use this if you intend to always handle +geolocation yourself but don't want to wire the callback at every call +site. ```tsx const shopifyCheckout = new ShopifyCheckout(config, {handleGeolocationRequests: false}); ``` -If you're using the context provider, you can pass the same `features` object as a prop to the `ShopifyCheckoutProvider` component: +If you're using the context provider, pass the same `features` object as a prop: ```tsx @@ -792,35 +792,12 @@ If you're using the context provider, you can pass the same `features` object as ``` -When opting out, you'll need to implement your own permission handling logic and communicate the result back to the checkout sheet. This can be useful if you want to: +Custom permission handling lets you: - Customize the permission request UI/UX - Coordinate location permissions with other app features - Implement custom fallback behavior when permissions are denied -The steps here to implement your own logic are to: - -1. Listen for the `geolocationRequest` -2. Request the desired permissions -3. Invoke the native callback by calling `initiateGeolocationRequest` with the permission status - -```tsx -// Listen for "geolocationRequest" events -shopify.addEventListener('geolocationRequest', async (event: GeolocationRequestEvent) => { - const coarse = 'android.permission.ACCESS_COARSE_LOCATION'; - const fine = 'android.permission.ACCESS_FINE_LOCATION'; - - // Request one or many permissions at once - const results = await PermissionsAndroid.requestMultiple([coarse, fine]); - - // Check the permission status results - const permissionGranted = results[coarse] === 'granted' || results[fine] === 'granted'; - - // Dispatch an event to the native module to invoke the native callback with the permission status - shopify.initiateGeolocationRequest(permissionGranted); -}) -``` - --- ## Accelerated Checkouts diff --git a/platforms/react-native/__mocks__/react-native.ts b/platforms/react-native/__mocks__/react-native.ts index 70523600..6ec6e733 100644 --- a/platforms/react-native/__mocks__/react-native.ts +++ b/platforms/react-native/__mocks__/react-native.ts @@ -59,8 +59,6 @@ const ShopifyCheckoutKit = { invalidateCache: jest.fn(), getConfig: jest.fn(() => exampleConfig), setConfig: jest.fn(), - addEventListener: jest.fn(), - removeEventListeners: jest.fn(), initiateGeolocationRequest: jest.fn(), configureAcceleratedCheckouts: jest.fn(() => true), isAcceleratedCheckoutAvailable: jest.fn(() => true), diff --git a/platforms/react-native/modules/@shopify/checkout-kit-react-native/android/src/main/java/com/shopify/reactnative/checkoutkit/CustomCheckoutListener.java b/platforms/react-native/modules/@shopify/checkout-kit-react-native/android/src/main/java/com/shopify/reactnative/checkoutkit/CustomCheckoutListener.java index c1ef0a64..34bba710 100644 --- a/platforms/react-native/modules/@shopify/checkout-kit-react-native/android/src/main/java/com/shopify/reactnative/checkoutkit/CustomCheckoutListener.java +++ b/platforms/react-native/modules/@shopify/checkout-kit-react-native/android/src/main/java/com/shopify/reactnative/checkoutkit/CustomCheckoutListener.java @@ -31,8 +31,8 @@ of this software and associated documentation files (the "Software"), to deal import androidx.annotation.Nullable; import com.shopify.checkoutkit.*; +import com.facebook.react.bridge.Callback; import com.facebook.react.modules.core.DeviceEventManagerModule; -import com.facebook.react.bridge.WritableNativeMap; import com.facebook.react.bridge.ReactApplicationContext; import com.fasterxml.jackson.databind.ObjectMapper; import java.io.IOException; @@ -43,13 +43,25 @@ public class CustomCheckoutListener extends DefaultCheckoutListener { private final ReactApplicationContext reactContext; private final ObjectMapper mapper = new ObjectMapper(); + @Nullable + private Callback onCloseCallback; + @Nullable + private Callback onFailCallback; + @Nullable + private Callback onGeolocationRequestCallback; + // Geolocation-specific variables private String geolocationOrigin; private GeolocationPermissions.Callback geolocationCallback; - public CustomCheckoutListener(Context context, ReactApplicationContext reactContext) { + public CustomCheckoutListener(Context context, ReactApplicationContext reactContext, + @Nullable Callback onClose, @Nullable Callback onFail, + @Nullable Callback onGeolocationRequest) { this.reactContext = reactContext; + this.onCloseCallback = onClose; + this.onFailCallback = onFail; + this.onGeolocationRequestCallback = onGeolocationRequest; } // Public methods @@ -85,11 +97,15 @@ public void onGeolocationPermissionsShowPrompt(@NonNull String origin, this.geolocationCallback = callback; this.geolocationOrigin = origin; - // Emit a "geolocationRequest" event to the app. try { Map event = new HashMap<>(); event.put("origin", origin); - sendEventWithStringData("geolocationRequest", mapper.writeValueAsString(event)); + String payload = mapper.writeValueAsString(event); + if (onGeolocationRequestCallback != null) { + onGeolocationRequestCallback.invoke(payload); + } else { + sendEventWithStringData("geolocationRequest", payload); + } } catch (IOException e) { Log.e("ShopifyCheckoutKit", "Error emitting \"geolocationRequest\" event", e); } @@ -106,17 +122,26 @@ public void onGeolocationPermissionsHidePrompt() { @Override public void onCheckoutFailed(CheckoutException checkoutError) { + if (onFailCallback == null) { + return; + } try { String data = mapper.writeValueAsString(populateErrorDetails(checkoutError)); - sendEventWithStringData("error", data); + onFailCallback.invoke(data); } catch (IOException e) { Log.e("ShopifyCheckoutKit", "Error processing checkout failed event", e); + } finally { + onFailCallback = null; } } @Override public void onCheckoutCanceled() { - sendEvent("close", null); + if (onCloseCallback == null) { + return; + } + onCloseCallback.invoke(); + onCloseCallback = null; } // Private @@ -125,6 +150,7 @@ private Map populateErrorDetails(CheckoutException checkoutError Map errorMap = new HashMap(); errorMap.put("__typename", getErrorTypeName(checkoutError)); errorMap.put("message", checkoutError.getErrorDescription()); + errorMap.put("recoverable", checkoutError.isRecoverable()); errorMap.put("code", checkoutError.getErrorCode()); if (checkoutError instanceof HttpException) { @@ -150,12 +176,6 @@ private String getErrorTypeName(CheckoutException error) { } } - private void sendEvent(String eventName, @Nullable WritableNativeMap params) { - reactContext - .getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter.class) - .emit(eventName, params); - } - private void sendEventWithStringData(String name, String data) { reactContext .getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter.class) 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 fce22fd1..45d25eb6 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 @@ -25,6 +25,8 @@ of this software and associated documentation files (the "Software"), to deal import android.app.Activity; import androidx.activity.ComponentActivity; +import androidx.annotation.Nullable; +import com.facebook.react.bridge.Callback; import com.facebook.react.bridge.ReactApplicationContext; import com.facebook.react.bridge.ReactMethod; import com.facebook.react.bridge.Arguments; @@ -77,10 +79,12 @@ public void removeListeners(double count) { } @ReactMethod - public void present(String checkoutURL) { + public void present(String checkoutURL, @Nullable Callback onClose, @Nullable Callback onFail, + @Nullable Callback onGeolocationRequest) { Activity currentActivity = getCurrentActivity(); if (currentActivity instanceof ComponentActivity) { - checkoutListener = new CustomCheckoutListener(currentActivity, this.reactContext); + checkoutListener = new CustomCheckoutListener(currentActivity, this.reactContext, onClose, + onFail, onGeolocationRequest); currentActivity.runOnUiThread(() -> { checkoutSheet = ShopifyCheckoutKit.present(checkoutURL, (ComponentActivity) currentActivity, checkoutListener); diff --git a/platforms/react-native/modules/@shopify/checkout-kit-react-native/ios/ShopifyCheckoutKit-Bridging-Header.h b/platforms/react-native/modules/@shopify/checkout-kit-react-native/ios/ShopifyCheckoutKit-Bridging-Header.h index 3e860d9e..dc8f2de4 100644 --- a/platforms/react-native/modules/@shopify/checkout-kit-react-native/ios/ShopifyCheckoutKit-Bridging-Header.h +++ b/platforms/react-native/modules/@shopify/checkout-kit-react-native/ios/ShopifyCheckoutKit-Bridging-Header.h @@ -23,6 +23,5 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SO #import #import -#import #import #import 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 c94c545d..e0618c76 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 @@ -40,6 +40,11 @@ @interface RCT_EXTERN_MODULE (RCTShopifyCheckoutKit, NativeShopifyCheckoutKitSpe RCT_EXTERN_METHOD(setConfig:(NSDictionary *)configuration) +RCT_EXTERN_METHOD(present:(NSString *)checkoutURL + onClose:(RCTResponseSenderBlock)onClose + onFail:(RCTResponseSenderBlock)onFail + onGeolocationRequest:(RCTResponseSenderBlock)onGeolocationRequest) + @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 d36e1ddd..57968d09 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 @@ -29,19 +29,26 @@ import SwiftUI import UIKit @objc(RCTShopifyCheckoutKit) -class RCTShopifyCheckoutKit: RCTEventEmitter { - private var hasListeners = false - +class RCTShopifyCheckoutKit: NSObject { internal var checkoutSheet: UIViewController? private var acceleratedCheckoutsConfiguration: Any? private var acceleratedCheckoutsApplePayConfiguration: Any? private var defaultLogLevel: LogLevel = .error - override var methodQueue: DispatchQueue! { + // TODO: invoke these once the iOS CheckoutDelegate (or equivalent) lands upstream — until then, + // onClose/onFail callbacks are stored but never fire (Android is the only platform delivering them). + // `pendingGeolocationRequestCallback` is intentionally a no-op on iOS — geolocation permission + // is handled natively, so the callback is stored only to keep the bridge signature symmetric + // with Android. + private var pendingCloseCallback: RCTResponseSenderBlock? + private var pendingFailCallback: RCTResponseSenderBlock? + private var pendingGeolocationRequestCallback: RCTResponseSenderBlock? + + @objc var methodQueue: DispatchQueue { return DispatchQueue.main } - @objc override static func requiresMainQueueSetup() -> Bool { + @objc static func requiresMainQueueSetup() -> Bool { return true } @@ -53,44 +60,7 @@ class RCTShopifyCheckoutKit: RCTEventEmitter { super.init() } - override func supportedEvents() -> [String]! { - return ["close", "error"] - } - - override func startObserving() { - hasListeners = true - } - - override func stopObserving() { - hasListeners = false - } - - // TODO: re-enable when iOS CheckoutDelegate (or equivalent) lands upstream — - // parallels Android's DefaultCheckoutListener.onCheckoutCanceled / onCheckoutFailed. - // Until then, the JS "error" and "close" events stay declared in supportedEvents() - // but native never emits them. - /* - - func checkoutDidFail(error: CheckoutError) { - guard hasListeners else { return } - - sendEvent(withName: "error", body: ShopifyEventSerialization.serialize(checkoutError: error)) - } - - func checkoutDidCancel() { - DispatchQueue.main.async { - if self.hasListeners { - self.sendEvent(withName: "close", body: nil) - } - - self.checkoutSheet?.dismiss(animated: true) - } - } - - func checkoutDidEmitWebPixelEvent(event _: PixelEvent) {} - */ - - @objc override func constantsToExport() -> [AnyHashable: Any]! { + @objc func constantsToExport() -> [AnyHashable: Any]! { return [ "version": ShopifyCheckoutKit.version ] @@ -136,7 +106,12 @@ class RCTShopifyCheckoutKit: RCTEventEmitter { // Retained for compatibility with the generated native module interface. } - @objc func present(_ checkoutURL: String) { + @objc func present(_ checkoutURL: String, onClose: RCTResponseSenderBlock?, onFail: RCTResponseSenderBlock?, + onGeolocationRequest: RCTResponseSenderBlock?) { + pendingCloseCallback = onClose + pendingFailCallback = onFail + pendingGeolocationRequestCallback = onGeolocationRequest + DispatchQueue.main.async { if let url = URL(string: checkoutURL), let viewController = self.getCurrentViewController() { let view = CheckoutViewController(checkout: url) 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 3ed2a913..268c9f0b 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 @@ -23,25 +23,16 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SO import React, {useCallback, useMemo, useRef, useEffect, useState} from 'react'; import type {PropsWithChildren} from 'react'; -import {type EmitterSubscription} from 'react-native'; import {ShopifyCheckout} from './index'; -import type {Features} from './index.d'; -import type { - AddEventListener, - RemoveEventListeners, - CheckoutEvent, - Configuration, -} from './index.d'; +import type {Configuration, Features, PresentCallbacks} from './index.d'; type Maybe = T | undefined; interface Context { acceleratedCheckoutsAvailable: boolean; - addEventListener: AddEventListener; getConfig: () => Configuration | undefined; setConfig: (config: Configuration) => void; - removeEventListeners: RemoveEventListeners; - present: (checkoutUrl: string) => void; + present: (checkoutUrl: string, callbacks?: PresentCallbacks) => void; dismiss: () => void; version: Maybe; } @@ -78,8 +69,8 @@ export function ShopifyCheckoutProvider({ // eslint-disable-next-line no-console console.warn( '[ShopifyCheckoutKit] Providing accessToken with contactFields (email / phoneNumber) is deprecated and will become an error in v4.' + - 'When the user is authenticated with Customer Accounts, provide accessToken' + - 'When the user is otherwise authenticated, provide email/phoneNumber.', + 'When the user is authenticated with Customer Accounts, provide accessToken' + + 'When the user is otherwise authenticated, provide email/phoneNumber.', ); } @@ -89,23 +80,15 @@ export function ShopifyCheckoutProvider({ ); }, [configuration]); - const addEventListener: AddEventListener = useCallback( - (eventName, callback): EmitterSubscription | undefined => { - return instance.current?.addEventListener(eventName, callback); + const present = useCallback( + (checkoutUrl: string, callbacks?: PresentCallbacks) => { + if (checkoutUrl) { + instance.current?.present(checkoutUrl, callbacks); + } }, [], ); - const removeEventListeners = useCallback((eventName: CheckoutEvent) => { - instance.current?.removeEventListeners(eventName); - }, []); - - const present = useCallback((checkoutUrl: string) => { - if (checkoutUrl) { - instance.current?.present(checkoutUrl); - } - }, []); - const dismiss = useCallback(() => { instance.current?.dismiss(); }, []); @@ -121,23 +104,13 @@ export function ShopifyCheckoutProvider({ const context = useMemo((): Context => { return { acceleratedCheckoutsAvailable, - addEventListener, dismiss, setConfig, getConfig, present, - removeEventListeners, version: instance.current?.version, }; - }, [ - acceleratedCheckoutsAvailable, - addEventListener, - dismiss, - removeEventListeners, - getConfig, - setConfig, - present, - ]); + }, [acceleratedCheckoutsAvailable, dismiss, getConfig, setConfig, present]); return ( 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 5b855549..b2a6e82c 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 @@ -21,7 +21,6 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -import type {EmitterSubscription} from 'react-native'; import type {CheckoutException} from './errors'; export type Maybe = T | undefined; @@ -165,22 +164,44 @@ export type Configuration = CommonConfiguration & { } ); -export type CheckoutEvent = 'close' | 'error' | 'geolocationRequest'; - export interface GeolocationRequestEvent { origin: string; } -export type CloseEventCallback = () => void; -export type GeolocationRequestEventCallback = ( - event: GeolocationRequestEvent, -) => void; -export type CheckoutExceptionCallback = (error: CheckoutException) => void; - -export type CheckoutEventCallback = - | CloseEventCallback - | CheckoutExceptionCallback - | GeolocationRequestEventCallback; +/** + * Per-call SDK callbacks for `present(url, callbacks)`. + * + * Exactly one of `onClose` or `onFail` fires per `present(...)` invocation, + * after which the callbacks are released. + * + * `onGeolocationRequest` may fire any number of times during a single + * `present(...)` call while the checkout sheet is open. + */ +export interface PresentCallbacks { + /** + * Fires when the checkout sheet is dismissed without a terminal error. + * Mirrors `DefaultCheckoutEventProcessor.onCheckoutCanceled` on Android + * and the iOS Swift SDK's `onClose` callback. + */ + onClose?: () => void; + /** + * Fires when the checkout sheet terminates with an error. + * Mirrors `DefaultCheckoutEventProcessor.onCheckoutFailed` on Android + * and the iOS Swift SDK's `onFail` callback. + */ + onFail?: (error: CheckoutException) => void; + /** + * Fires when the checkout sheet requests geolocation permissions. + * Only Android currently delivers this callback; on iOS the + * `present()` call accepts the handle but never invokes it. + * + * When set, this overrides the default internal handler driven by + * `features.handleGeolocationRequests`. The consumer is responsible + * for calling `initiateGeolocationRequest(allow)` once permissions + * have been resolved. + */ + onGeolocationRequest?: (event: GeolocationRequestEvent) => void; +} /** * Available wallet types for accelerated checkout @@ -253,26 +274,6 @@ export interface AcceleratedCheckoutConfiguration { }; } -function addEventListener( - event: 'close', - callback: () => void, -): Maybe; - -function addEventListener( - event: 'error', - callback: CheckoutExceptionCallback, -): Maybe; - -function addEventListener( - event: 'geolocationRequest', - callback: GeolocationRequestEventCallback, -): Maybe; - -function removeEventListeners(event: CheckoutEvent): void; - -export type AddEventListener = typeof addEventListener; -export type RemoveEventListeners = typeof removeEventListeners; - export interface ShopifyCheckoutKit { /** * The version number of the Shopify Checkout SDK. @@ -280,8 +281,13 @@ export interface ShopifyCheckoutKit { readonly version: string; /** * Present the checkout. + * + * @param checkoutURL The URL of the checkout to display. + * @param callbacks Optional per-call SDK callbacks. Exactly one of + * `onClose` or `onFail` fires per call, after which the callbacks are + * released. */ - present(checkoutURL: string): void; + present(checkoutURL: string, callbacks?: PresentCallbacks): void; /** * Configure the checkout. See README.md for more details. */ @@ -290,14 +296,6 @@ export interface ShopifyCheckoutKit { * Return the current config for the checkout. See README.md for more details. */ getConfig(): Configuration; - /** - * Listen for checkout events - */ - addEventListener: AddEventListener; - /** - * Remove subscriptions to checkout events. - */ - removeEventListeners: RemoveEventListeners; /** * Cleans up any event callbacks to prevent memory leaks. */ 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 b4a69adf..3061c2f6 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,22 +22,17 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SO */ import {NativeEventEmitter, PermissionsAndroid, Platform} from 'react-native'; -import type { - EmitterSubscription, - EventSubscription, - PermissionStatus, -} from 'react-native'; +import type {EventSubscription, PermissionStatus} from 'react-native'; import RNShopifyCheckoutKit from './specs/NativeShopifyCheckoutKit'; import {ShopifyCheckoutProvider, useShopifyCheckout} from './context'; import {ApplePayContactField, ColorScheme, LogLevel} from './index.d'; import type { AcceleratedCheckoutConfiguration, - CheckoutEvent, - CheckoutEventCallback, Configuration, Features, GeolocationRequestEvent, Maybe, + PresentCallbacks, ShopifyCheckoutKit, } from './index.d'; import {AcceleratedCheckoutWallet} from './index.d'; @@ -52,7 +47,10 @@ import { GenericError, } from './errors.d'; import {CheckoutErrorCode} from './errors.d'; -import {ApplePayLabel, ApplePayStyle} from './components/AcceleratedCheckoutButtons'; +import { + ApplePayLabel, + ApplePayStyle, +} from './components/AcceleratedCheckoutButtons'; import type { AcceleratedCheckoutButtonsProps, RenderStateChangeEvent, @@ -125,11 +123,23 @@ class ShopifyCheckout implements ShopifyCheckoutKit { } /** - * Presents the checkout sheet for a given checkout URL + * Presents the checkout sheet for a given checkout URL. + * + * Exactly one of `callbacks.onClose` or `callbacks.onFail` fires per + * call, after which the native bridge releases both handles. + * * @param checkoutUrl The URL of the checkout to display + * @param callbacks Optional per-call SDK callbacks */ - public present(checkoutUrl: string): void { - RNShopifyCheckoutKit.present(checkoutUrl); + public present(checkoutUrl: string, callbacks?: PresentCallbacks): void { + RNShopifyCheckoutKit.present( + checkoutUrl, + callbacks?.onClose ?? null, + callbacks?.onFail ? this.wrapFailCallback(callbacks.onFail) : null, + callbacks?.onGeolocationRequest + ? this.wrapGeolocationCallback(callbacks.onGeolocationRequest) + : null, + ); } /** @@ -153,47 +163,6 @@ class ShopifyCheckout implements ShopifyCheckoutKit { RNShopifyCheckoutKit.setConfig(configuration); } - /** - * Adds an event listener for checkout events - * @param event The type of event to listen for - * @param callback Function to be called when the event occurs - * @returns An EmitterSubscription that can be used to remove the listener - */ - public addEventListener( - event: CheckoutEvent, - callback: CheckoutEventCallback, - ): EmitterSubscription | undefined { - let eventCallback; - - switch (event) { - case 'error': - eventCallback = this.interceptEventEmission( - 'error', - callback, - this.parseCheckoutError, - ); - break; - case 'geolocationRequest': - eventCallback = this.interceptEventEmission( - 'geolocationRequest', - callback, - ); - break; - default: - eventCallback = callback; - } - - return ShopifyCheckout.eventEmitter.addListener(event, eventCallback); - } - - /** - * Removes all event listeners for a specific event type - * @param event The type of event to remove listeners for - */ - public removeEventListeners(event: CheckoutEvent) { - ShopifyCheckout.eventEmitter.removeAllListeners(event); - } - /** * Cleans up resources and event listeners used by the checkout sheet */ @@ -338,10 +307,12 @@ class ShopifyCheckout implements ShopifyCheckoutKit { } /** - * Sets up geolocation request handling for Android devices + * Sets up geolocation request handling for Android devices. + * Uses the internal NativeEventEmitter directly because the public + * listener API has been removed. */ private subscribeToGeolocationRequestPrompts() { - this.geolocationCallback = this.addEventListener( + this.geolocationCallback = ShopifyCheckout.eventEmitter.addListener( 'geolocationRequest', async () => { const coarseOrFineGrainAccessGranted = await this.requestGeolocation(); @@ -436,46 +407,49 @@ class ShopifyCheckout implements ShopifyCheckoutKit { } /** - * Handles event emission parsing and transformation - * @param event The type of event being intercepted - * @param callback The callback to execute with the parsed data - * @param transformData Optional function to transform the event data - * @returns Function that handles the event emission + * Wraps a consumer-provided `onFail` callback so the native bridge can + * hand it the raw JSON error payload it serializes today. Invalid JSON + * is reported via `LifecycleEventParseError`; the user callback only + * fires on a successful parse. + */ + private wrapFailCallback( + onFail: NonNullable, + ): (raw: string) => void { + return (raw: string) => { + try { + const parsed = JSON.parse(raw); + onFail(this.parseCheckoutError(parsed)); + } catch { + const parseError = new LifecycleEventParseError( + 'Failed to parse "onFail" callback payload: Invalid JSON', + {cause: 'Invalid JSON'}, + ); + // eslint-disable-next-line no-console + console.error(parseError, raw); + } + }; + } + + /** + * Wraps a consumer-provided `onGeolocationRequest` callback so the + * native bridge can hand it the raw JSON origin payload. Invalid JSON + * is reported via `LifecycleEventParseError`; the user callback only + * fires on a successful parse. */ - private interceptEventEmission( - event: CheckoutEvent, - callback: CheckoutEventCallback, - transformData?: (data: any) => any, - ): (eventData: string | typeof callback) => void { - return (eventData: string | typeof callback): void => { + private wrapGeolocationCallback( + onGeolocationRequest: NonNullable, + ): (raw: string) => void { + return (raw: string) => { try { - if (typeof eventData === 'string') { - try { - let parsed = JSON.parse(eventData); - parsed = transformData?.(parsed) ?? parsed; - callback(parsed); - } catch (error) { - const parseError = new LifecycleEventParseError( - `Failed to parse "${event}" event data: Invalid JSON`, - { - cause: 'Invalid JSON', - }, - ); - // eslint-disable-next-line no-console - console.error(parseError, eventData); - } - } else if (eventData && typeof eventData === 'object') { - callback(transformData?.(eventData) ?? eventData); - } - } catch (error) { + const parsed = JSON.parse(raw); + onGeolocationRequest(parsed); + } catch { const parseError = new LifecycleEventParseError( - `Failed to parse "${event}" event data`, - { - cause: 'Unknown', - }, + 'Failed to parse "onGeolocationRequest" callback payload: Invalid JSON', + {cause: 'Invalid JSON'}, ); // eslint-disable-next-line no-console - console.error(parseError); + console.error(parseError, raw); } }; } @@ -521,12 +495,11 @@ export { export type { AcceleratedCheckoutButtonsProps, AcceleratedCheckoutConfiguration, - CheckoutEvent, - CheckoutEventCallback, CheckoutException, Configuration, Features, GeolocationRequestEvent, + PresentCallbacks, RenderStateChangeEvent, }; 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 b85bd8ba..9e4f348c 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 @@ -70,7 +70,12 @@ type ConfigurationResultSpec = { }; export interface Spec extends TurboModule { - present(checkoutUrl: string): void; + present( + checkoutUrl: string, + onClose: (() => void) | null, + onFail: ((errorJson: string) => void) | null, + onGeolocationRequest: ((originJson: string) => void) | null, + ): void; dismiss(): void; setConfig(configuration: ConfigurationSpec): void; getConfig(): ConfigurationResultSpec; @@ -92,6 +97,4 @@ export interface Spec extends TurboModule { getConstants(): {version: string}; } -export default TurboModuleRegistry.getEnforcing( - 'ShopifyCheckoutKit', -); +export default TurboModuleRegistry.getEnforcing('ShopifyCheckoutKit'); 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 78d0c178..fcf0a3f0 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 @@ -154,23 +154,7 @@ describe('useShopifyCheckout', () => { jest.clearAllMocks(); }); - it('provides addEventListener function', () => { - let hookValue: any; - const onHookValue = (value: any) => { - hookValue = value; - }; - - render( - - - , - ); - - expect(hookValue.addEventListener).toBeDefined(); - expect(typeof hookValue.addEventListener).toBe('function'); - }); - - it('provides removeEventListeners function', () => { + it('provides present function and calls it with checkoutUrl and null callbacks when none are passed', () => { let hookValue: any; const onHookValue = (value: any) => { hookValue = value; @@ -183,13 +167,18 @@ describe('useShopifyCheckout', () => { ); act(() => { - hookValue.removeEventListeners('close'); + hookValue.present(checkoutUrl); }); - expect(hookValue.removeEventListeners).toBeDefined(); + expect(NativeModules.ShopifyCheckoutKit.present).toHaveBeenCalledWith( + checkoutUrl, + null, + null, + null, + ); }); - it('provides present function and calls it with checkoutUrl', () => { + it('forwards onClose, onFail, and onGeolocationRequest callbacks through present', () => { let hookValue: any; const onHookValue = (value: any) => { hookValue = value; @@ -201,12 +190,19 @@ describe('useShopifyCheckout', () => { , ); + const onClose = jest.fn(); + const onFail = jest.fn(); + const onGeolocationRequest = jest.fn(); + act(() => { - hookValue.present(checkoutUrl); + hookValue.present(checkoutUrl, {onClose, onFail, onGeolocationRequest}); }); expect(NativeModules.ShopifyCheckoutKit.present).toHaveBeenCalledWith( checkoutUrl, + expect.any(Function), + expect.any(Function), + expect.any(Function), ); }); @@ -309,22 +305,6 @@ describe('useShopifyCheckout', () => { expect(hookValue.version).toBe('0.7.0'); }); - it('addEventListener returns subscription object', () => { - let hookValue: any; - const onHookValue = (value: any) => { - hookValue = value; - }; - - render( - - - , - ); - - const subscription = hookValue.addEventListener('close', jest.fn()); - expect(subscription).toBeDefined(); - expect(subscription.remove).toBeDefined(); - }); }); describe('ShopifyCheckoutContext without provider', () => { 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 d3c267ee..d9de56d0 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 @@ -75,16 +75,12 @@ describe('ShopifyCheckoutKit', () => { describe('instantiation', () => { it('calls `setConfig` with the specified config on instantiation', () => { new ShopifyCheckout(config); - expect( - NativeModule.setConfig, - ).toHaveBeenCalledWith(config); + expect(NativeModule.setConfig).toHaveBeenCalledWith(config); }); it('does not call `setConfig` if no config was specified on instantiation', () => { new ShopifyCheckout(); - expect( - NativeModule.setConfig, - ).not.toHaveBeenCalled(); + expect(NativeModule.setConfig).not.toHaveBeenCalled(); }); }); @@ -92,12 +88,8 @@ describe('ShopifyCheckoutKit', () => { it('calls the `setConfig` on the Native Module', () => { const instance = new ShopifyCheckout(); instance.setConfig(config); - expect( - NativeModule.setConfig, - ).toHaveBeenCalledTimes(1); - expect( - NativeModule.setConfig, - ).toHaveBeenCalledWith(config); + expect(NativeModule.setConfig).toHaveBeenCalledTimes(1); + expect(NativeModule.setConfig).toHaveBeenCalledWith(config); }); it('calls `setConfig` with logLevel configuration', () => { @@ -107,77 +99,112 @@ describe('ShopifyCheckoutKit', () => { logLevel: LogLevel.debug, }; instance.setConfig(configWithLogLevel); - expect( - NativeModule.setConfig, - ).toHaveBeenCalledWith(configWithLogLevel); + expect(NativeModule.setConfig).toHaveBeenCalledWith(configWithLogLevel); }); }); describe('present', () => { - it('calls `present` with a checkout URL', () => { + it('calls `present` with the checkout URL and null callbacks when none are provided', () => { const instance = new ShopifyCheckout(); instance.present(checkoutUrl); - expect( - NativeModule.present, - ).toHaveBeenCalledTimes(1); - expect( - NativeModule.present, - ).toHaveBeenCalledWith(checkoutUrl); + expect(NativeModule.present).toHaveBeenCalledTimes(1); + expect(NativeModule.present).toHaveBeenCalledWith( + checkoutUrl, + null, + null, + null, + ); }); - }); - describe('dismiss', () => { - it('calls `dismiss`', () => { + it('forwards the `onClose` callback to native and invokes the user handler when fired', () => { const instance = new ShopifyCheckout(); - instance.dismiss(); - expect( - NativeModule.dismiss, - ).toHaveBeenCalledTimes(1); + const onClose = jest.fn(); + instance.present(checkoutUrl, {onClose}); + expect(NativeModule.present).toHaveBeenCalledWith( + checkoutUrl, + expect.any(Function), + null, + null, + ); + const nativeOnClose = NativeModule.present.mock.calls[0][1] as () => void; + nativeOnClose(); + expect(onClose).toHaveBeenCalledTimes(1); }); - }); - describe('getConfig', () => { - it('returns the parsed config from the Native Module', () => { + it('forwards an `onFail` JSON wrapper to native when `onFail` is provided', () => { const instance = new ShopifyCheckout(); - expect(instance.getConfig()).toStrictEqual({ - colorScheme: ColorScheme.automatic, - logLevel: LogLevel.error, - }); - expect( - NativeModule.getConfig, - ).toHaveBeenCalledTimes(1); + const onFail = jest.fn(); + instance.present(checkoutUrl, {onFail}); + expect(NativeModule.present).toHaveBeenCalledWith( + checkoutUrl, + null, + expect.any(Function), + null, + ); }); - }); - describe('addEventListener', () => { - it('creates a new event listener for a specific event', () => { + it('forwards an `onGeolocationRequest` JSON wrapper to native when `onGeolocationRequest` is provided', () => { const instance = new ShopifyCheckout(); - const eventName = 'close'; - const callback = jest.fn(); - instance.addEventListener(eventName, callback); - expect(eventEmitter.addListener).toHaveBeenCalledWith( - eventName, - callback, + const onGeolocationRequest = jest.fn(); + instance.present(checkoutUrl, {onGeolocationRequest}); + expect(NativeModule.present).toHaveBeenCalledWith( + checkoutUrl, + null, + null, + expect.any(Function), ); }); - describe('Error Event', () => { + describe('onGeolocationRequest callback', () => { + it('parses the native JSON payload and surfaces the typed event to the consumer', () => { + const instance = new ShopifyCheckout(); + const onGeolocationRequest = jest.fn(); + instance.present(checkoutUrl, {onGeolocationRequest}); + const nativeOnGeolocationRequest = NativeModule.present.mock + .calls[0][3] as (raw: string) => void; + nativeOnGeolocationRequest( + JSON.stringify({origin: 'https://shopify.com'}), + ); + expect(onGeolocationRequest).toHaveBeenCalledWith({ + origin: 'https://shopify.com', + }); + }); + + it('logs a LifecycleEventParseError and does not invoke `onGeolocationRequest` when payload is invalid JSON', () => { + const instance = new ShopifyCheckout(); + const onGeolocationRequest = jest.fn(); + instance.present(checkoutUrl, {onGeolocationRequest}); + const nativeOnGeolocationRequest = NativeModule.present.mock + .calls[0][3] as (raw: string) => void; + nativeOnGeolocationRequest('not-json'); + expect(onGeolocationRequest).not.toHaveBeenCalled(); + expect(console.error).toHaveBeenCalledWith( + expect.any(LifecycleEventParseError), + 'not-json', + ); + }); + }); + + describe('onFail callback', () => { const internalError = { __typename: CheckoutNativeErrorType.InternalError, message: 'Something went wrong', code: CheckoutErrorCode.unknown, + recoverable: true, }; const configError = { __typename: CheckoutNativeErrorType.ConfigurationError, message: 'Storefront Password Required', code: CheckoutErrorCode.storefrontPasswordRequired, + recoverable: false, }; const clientError = { __typename: CheckoutNativeErrorType.CheckoutClientError, message: 'Storefront Password Required', code: CheckoutErrorCode.storefrontPasswordRequired, + recoverable: false, }; const networkError = { @@ -185,12 +212,14 @@ describe('ShopifyCheckoutKit', () => { message: 'Checkout not found', code: CheckoutErrorCode.httpError, statusCode: 400, + recoverable: false, }; const expiredError = { __typename: CheckoutNativeErrorType.CheckoutExpiredError, message: 'Customer Account Required', code: CheckoutErrorCode.cartExpired, + recoverable: false, }; it.each([ @@ -200,7 +229,7 @@ describe('ShopifyCheckoutKit', () => { {error: networkError, constructor: CheckoutHTTPError}, {error: expiredError, constructor: CheckoutExpiredError}, ])( - `correctly parses error $error`, + `parses the native JSON payload into a typed CheckoutException ($error.__typename)`, ({ error, constructor, @@ -209,58 +238,70 @@ describe('ShopifyCheckoutKit', () => { constructor: new (...args: any[]) => any; }) => { const instance = new ShopifyCheckout(); - const eventName = 'error'; - const callback = jest.fn(); - instance.addEventListener(eventName, callback); - NativeModule.addEventListener( - eventName, - callback, - ); - expect(eventEmitter.addListener).toHaveBeenCalledWith( - 'error', - expect.any(Function), - ); - eventEmitter.emit('error', error); - const calledWith = callback.mock.calls[0][0]; + const onFail = jest.fn(); + instance.present(checkoutUrl, {onFail}); + const nativeOnFail = NativeModule.present.mock.calls[0][2] as ( + raw: string, + ) => void; + nativeOnFail(JSON.stringify(error)); + const calledWith = onFail.mock.calls[0][0]; expect(calledWith).toBeInstanceOf(constructor); expect(calledWith).not.toHaveProperty('__typename'); expect(calledWith).toHaveProperty('code'); expect(calledWith).toHaveProperty('message'); + expect(calledWith).toHaveProperty('recoverable'); }, ); - it('returns an unknown generic error if the error cannot be parsed', () => { + it('falls back to GenericError when the payload has no recognised __typename', () => { const instance = new ShopifyCheckout(); - const eventName = 'error'; - const callback = jest.fn(); - instance.addEventListener(eventName, callback); - NativeModule.addEventListener( - eventName, - callback, - ); + const onFail = jest.fn(); + instance.present(checkoutUrl, {onFail}); const error = { __typename: 'UnknownError', message: 'Something went wrong', }; - expect(eventEmitter.addListener).toHaveBeenCalledWith( - 'error', - expect.any(Function), - ); - eventEmitter.emit('error', error); - const calledWith = callback.mock.calls[0][0]; + const nativeOnFail = NativeModule.present.mock.calls[0][2] as ( + raw: string, + ) => void; + nativeOnFail(JSON.stringify(error)); + const calledWith = onFail.mock.calls[0][0]; expect(calledWith).toBeInstanceOf(GenericError); - expect(callback).toHaveBeenCalledWith(new GenericError(error as any)); + }); + + it('logs a LifecycleEventParseError and does not invoke `onFail` when payload is invalid JSON', () => { + const instance = new ShopifyCheckout(); + const onFail = jest.fn(); + instance.present(checkoutUrl, {onFail}); + const nativeOnFail = NativeModule.present.mock.calls[0][2] as ( + raw: string, + ) => void; + nativeOnFail('not-json'); + expect(onFail).not.toHaveBeenCalled(); + expect(console.error).toHaveBeenCalledWith( + expect.any(LifecycleEventParseError), + 'not-json', + ); }); }); }); - describe('removeEventListeners', () => { - it('Removes all listeners for a specific event', () => { + describe('dismiss', () => { + it('calls `dismiss`', () => { + const instance = new ShopifyCheckout(); + instance.dismiss(); + expect(NativeModule.dismiss).toHaveBeenCalledTimes(1); + }); + }); + + describe('getConfig', () => { + it('returns the parsed config from the Native Module', () => { const instance = new ShopifyCheckout(); - instance.addEventListener('close', () => {}); - instance.addEventListener('close', () => {}); - instance.removeEventListeners('close'); - expect(eventEmitter.removeAllListeners).toHaveBeenCalledWith('close'); + expect(instance.getConfig()).toStrictEqual({ + colorScheme: ColorScheme.automatic, + logLevel: LogLevel.error, + }); + expect(NativeModule.getConfig).toHaveBeenCalledTimes(1); }); }); @@ -327,9 +368,9 @@ describe('ShopifyCheckoutKit', () => { 'android.permission.ACCESS_COARSE_LOCATION', 'android.permission.ACCESS_FINE_LOCATION', ]); - expect( - NativeModule.initiateGeolocationRequest, - ).toHaveBeenCalledWith(true); + expect(NativeModule.initiateGeolocationRequest).toHaveBeenCalledWith( + true, + ); }); it('handles geolocation permission denial correctly', async () => { @@ -352,9 +393,9 @@ describe('ShopifyCheckoutKit', () => { 'android.permission.ACCESS_COARSE_LOCATION', 'android.permission.ACCESS_FINE_LOCATION', ]); - expect( - NativeModule.initiateGeolocationRequest, - ).toHaveBeenCalledWith(false); + expect(NativeModule.initiateGeolocationRequest).toHaveBeenCalledWith( + false, + ); }); it('cleans up geolocation callback on teardown', () => { @@ -397,9 +438,7 @@ describe('ShopifyCheckoutKit', () => { await emitGeolocationRequest(); - expect( - NativeModule.initiateGeolocationRequest, - ).not.toHaveBeenCalled(); + expect(NativeModule.initiateGeolocationRequest).not.toHaveBeenCalled(); }); it('tears down gracefully', () => { @@ -510,9 +549,7 @@ describe('ShopifyCheckoutKit', () => { instance.configureAcceleratedCheckouts(acceleratedConfig); expect(result).toBe(true); - expect( - NativeModule.configureAcceleratedCheckouts, - ).toHaveBeenCalledWith( + expect(NativeModule.configureAcceleratedCheckouts).toHaveBeenCalledWith( 'test-shop.myshopify.com', 'shpat_test_token', 'test@example.com', @@ -534,9 +571,7 @@ describe('ShopifyCheckoutKit', () => { instance.configureAcceleratedCheckouts(minimalConfig); - expect( - NativeModule.configureAcceleratedCheckouts, - ).toHaveBeenCalledWith( + expect(NativeModule.configureAcceleratedCheckouts).toHaveBeenCalledWith( 'test-shop.myshopify.com', 'shpat_test_token', null, @@ -569,9 +604,9 @@ describe('ShopifyCheckoutKit', () => { }; const expectedError = new Error('`storefrontDomain` is required'); - expect( - instance.configureAcceleratedCheckouts(invalidConfig), - ).toBe(false); + expect(instance.configureAcceleratedCheckouts(invalidConfig)).toBe( + false, + ); expect(console.error).toHaveBeenCalledWith( '[ShopifyCheckoutKit] Failed to configure accelerated checkouts with', expectedError, @@ -587,9 +622,9 @@ describe('ShopifyCheckoutKit', () => { const expectedError = new Error('`storefrontAccessToken` is required'); - expect( - instance.configureAcceleratedCheckouts(invalidConfig), - ).toBe(false); + expect(instance.configureAcceleratedCheckouts(invalidConfig)).toBe( + false, + ); expect(console.error).toHaveBeenCalledWith( '[ShopifyCheckoutKit] Failed to configure accelerated checkouts with', expectedError, @@ -612,9 +647,9 @@ describe('ShopifyCheckoutKit', () => { '`wallets.applePay.merchantIdentifier` is required', ); - expect( - instance.configureAcceleratedCheckouts(invalidConfig), - ).toBe(false); + expect(instance.configureAcceleratedCheckouts(invalidConfig)).toBe( + false, + ); expect(console.error).toHaveBeenCalledWith( '[ShopifyCheckoutKit] Failed to configure accelerated checkouts with', expectedError, @@ -698,9 +733,7 @@ describe('ShopifyCheckoutKit', () => { }, }); - expect( - NativeModule.configureAcceleratedCheckouts, - ).toHaveBeenCalledWith( + expect(NativeModule.configureAcceleratedCheckouts).toHaveBeenCalledWith( 'test-shop.myshopify.com', 'shpat_test_token', 'test@example.com', @@ -726,9 +759,7 @@ describe('ShopifyCheckoutKit', () => { }, }); - expect( - NativeModule.configureAcceleratedCheckouts, - ).toHaveBeenCalledWith( + expect(NativeModule.configureAcceleratedCheckouts).toHaveBeenCalledWith( 'test-shop.myshopify.com', 'shpat_test_token', 'test@example.com', diff --git a/platforms/react-native/sample/android/app/src/test/java/com/shopify/checkoutkitreactnative/ShopifyCheckoutKitModuleTest.java b/platforms/react-native/sample/android/app/src/test/java/com/shopify/checkoutkitreactnative/ShopifyCheckoutKitModuleTest.java index eee9eb01..d1736fc1 100644 --- a/platforms/react-native/sample/android/app/src/test/java/com/shopify/checkoutkitreactnative/ShopifyCheckoutKitModuleTest.java +++ b/platforms/react-native/sample/android/app/src/test/java/com/shopify/checkoutkitreactnative/ShopifyCheckoutKitModuleTest.java @@ -1,8 +1,11 @@ package com.shopify.checkoutkitreactnative; +import android.webkit.GeolocationPermissions; + import androidx.activity.ComponentActivity; import com.facebook.react.bridge.Arguments; +import com.facebook.react.bridge.Callback; import com.facebook.react.bridge.JavaOnlyMap; import com.facebook.react.bridge.Promise; import com.facebook.react.bridge.ReactApplicationContext; @@ -113,7 +116,7 @@ public void testCanPresentCheckout() { try (MockedStatic mockedShopifyCheckoutKit = Mockito .mockStatic(ShopifyCheckoutKit.class)) { String checkoutUrl = "https://shopify.com"; - shopifyCheckoutKitModule.present(checkoutUrl); + shopifyCheckoutKitModule.present(checkoutUrl, null, null, null); verify(mockComponentActivity).runOnUiThread(runnableCaptor.capture()); runnableCaptor.getValue().run(); @@ -124,6 +127,79 @@ public void testCanPresentCheckout() { } } + @Test + public void testPresentForwardsOnCloseCallback() { + Callback onClose = mock(Callback.class); + CustomCheckoutListener processor = new CustomCheckoutListener(mockContext, mockReactContext, + onClose, null, null); + + processor.onCheckoutCanceled(); + + verify(onClose).invoke(); + } + + @Test + public void testOnCloseCallbackIsSingleShot() { + Callback onClose = mock(Callback.class); + CustomCheckoutListener processor = new CustomCheckoutListener(mockContext, mockReactContext, + onClose, null, null); + + processor.onCheckoutCanceled(); + processor.onCheckoutCanceled(); + + verify(onClose, times(1)).invoke(); + } + + @Test + public void testGeolocationCallbackReceivesOriginJsonWhenSet() { + Callback onGeolocationRequest = mock(Callback.class); + GeolocationPermissions.Callback permissionsCallback = mock(GeolocationPermissions.Callback.class); + CustomCheckoutListener processor = new CustomCheckoutListener(mockContext, mockReactContext, + null, null, onGeolocationRequest); + + processor.onGeolocationPermissionsShowPrompt("https://shopify.com", permissionsCallback); + + ArgumentCaptor args = ArgumentCaptor.forClass(Object[].class); + verify(onGeolocationRequest).invoke(args.capture()); + assertThat((String) args.getValue()[0]).contains("https://shopify.com", "origin"); + verify(mockEventEmitter, never()).emit(eq("geolocationRequest"), any()); + } + + @Test + public void testGeolocationCallbackMayFireMultipleTimes() { + Callback onGeolocationRequest = mock(Callback.class); + GeolocationPermissions.Callback permissionsCallback = mock(GeolocationPermissions.Callback.class); + CustomCheckoutListener processor = new CustomCheckoutListener(mockContext, mockReactContext, + null, null, onGeolocationRequest); + + processor.onGeolocationPermissionsShowPrompt("https://shopify.com", permissionsCallback); + processor.onGeolocationPermissionsShowPrompt("https://shopify.com", permissionsCallback); + + verify(onGeolocationRequest, times(2)).invoke(any(Object[].class)); + } + + @Test + public void testGeolocationFallsBackToEventEmitterWhenNoCallbackSet() { + GeolocationPermissions.Callback permissionsCallback = mock(GeolocationPermissions.Callback.class); + CustomCheckoutListener processor = new CustomCheckoutListener(mockContext, mockReactContext, + null, null, null); + + processor.onGeolocationPermissionsShowPrompt("https://shopify.com", permissionsCallback); + + verify(mockEventEmitter).emit(eq("geolocationRequest"), stringCaptor.capture()); + assertThat(stringCaptor.getValue()).contains("https://shopify.com", "origin"); + } + + @Test + public void testCheckoutCanceledWithNoCloseCallbackDoesNotEmitCloseEvent() { + CustomCheckoutListener processor = new CustomCheckoutListener(mockContext, mockReactContext, + null, null, null); + + processor.onCheckoutCanceled(); + + verify(mockEventEmitter, never()).emit(eq("close"), any()); + } + /** * Module name and version */ @@ -442,24 +518,28 @@ public void testGetConfigReturnsDefaultLogLevel() { @Test public void testCanProcessCheckoutExpiredErrors() { - CustomCheckoutListener processor = new CustomCheckoutListener(mockContext, mockReactContext); + Callback onFail = mock(Callback.class); + CustomCheckoutListener processor = new CustomCheckoutListener(mockContext, mockReactContext, + null, onFail, null); - // Use minimal mocking - just enough to test the processing logic CheckoutExpiredException mockException = mock(CheckoutExpiredException.class); when(mockException.getErrorDescription()).thenReturn("Cart has expired"); when(mockException.getErrorCode()).thenReturn("cart_expired"); processor.onCheckoutFailed(mockException); - verify(mockEventEmitter).emit(eq("error"), stringCaptor.capture()); + ArgumentCaptor args = ArgumentCaptor.forClass(Object[].class); + verify(onFail).invoke(args.capture()); - assertThat(stringCaptor.getValue()) - .contains("CheckoutExpiredError", "Cart has expired", "cart_expired"); + assertThat((String) args.getValue()[0]) + .contains("CheckoutExpiredError", "Cart has expired", "cart_expired", "\"recoverable\":false"); } @Test public void testCanProcessClientErrors() { - CustomCheckoutListener processor = new CustomCheckoutListener(mockContext, mockReactContext); + Callback onFail = mock(Callback.class); + CustomCheckoutListener processor = new CustomCheckoutListener(mockContext, mockReactContext, + null, onFail, null); ClientException mockException = mock(ClientException.class); when(mockException.getErrorDescription()).thenReturn("Customer account required"); @@ -467,15 +547,19 @@ public void testCanProcessClientErrors() { processor.onCheckoutFailed(mockException); - verify(mockEventEmitter).emit(eq("error"), stringCaptor.capture()); + ArgumentCaptor args = ArgumentCaptor.forClass(Object[].class); + verify(onFail).invoke(args.capture()); - assertThat(stringCaptor.getValue()) - .contains("CheckoutClientError", "Customer account required", "customer_account_required"); + assertThat((String) args.getValue()[0]) + .contains("CheckoutClientError", "Customer account required", "customer_account_required", + "\"recoverable\":true"); } @Test public void testCanProcessHttpErrors() { - CustomCheckoutListener processor = new CustomCheckoutListener(mockContext, mockReactContext); + Callback onFail = mock(Callback.class); + CustomCheckoutListener processor = new CustomCheckoutListener(mockContext, mockReactContext, + null, onFail, null); HttpException mockException = mock(HttpException.class); when(mockException.getErrorDescription()).thenReturn("Not Found"); @@ -484,10 +568,40 @@ public void testCanProcessHttpErrors() { processor.onCheckoutFailed(mockException); - verify(mockEventEmitter).emit(eq("error"), stringCaptor.capture()); + ArgumentCaptor args = ArgumentCaptor.forClass(Object[].class); + verify(onFail).invoke(args.capture()); + + assertThat((String) args.getValue()[0]) + .contains("CheckoutHTTPError", "Not Found", "http_error", "\"statusCode\":404", "\"recoverable\":false"); + } + + @Test + public void testOnFailCallbackIsSingleShot() { + Callback onFail = mock(Callback.class); + CustomCheckoutListener processor = new CustomCheckoutListener(mockContext, mockReactContext, + null, onFail, null); + + CheckoutExpiredException mockException = mock(CheckoutExpiredException.class); + when(mockException.getErrorDescription()).thenReturn("Cart has expired"); + when(mockException.getErrorCode()).thenReturn("cart_expired"); + when(mockException.isRecoverable()).thenReturn(false); + + processor.onCheckoutFailed(mockException); + processor.onCheckoutFailed(mockException); + + verify(onFail, times(1)).invoke(any(Object[].class)); + } + + @Test + public void testCheckoutFailedWithNoFailCallbackDoesNotEmitFailEvent() { + CustomCheckoutListener processor = new CustomCheckoutListener(mockContext, mockReactContext, + null, null, null); + + CheckoutExpiredException mockException = mock(CheckoutExpiredException.class); + + processor.onCheckoutFailed(mockException); - assertThat(stringCaptor.getValue()) - .contains("CheckoutHTTPError", "Not Found", "http_error", "\"statusCode\":404"); + verify(mockEventEmitter, never()).emit(eq("error"), any()); } /** diff --git a/platforms/react-native/sample/ios/ReactNativeTests/ShopifyCheckoutKitTests.swift b/platforms/react-native/sample/ios/ReactNativeTests/ShopifyCheckoutKitTests.swift index 805213ce..4a287482 100644 --- a/platforms/react-native/sample/ios/ReactNativeTests/ShopifyCheckoutKitTests.swift +++ b/platforms/react-native/sample/ios/ReactNativeTests/ShopifyCheckoutKitTests.swift @@ -303,127 +303,7 @@ class ShopifyCheckoutKitTests: XCTestCase { XCTAssertEqual(result?["logLevel"] as? String, "error") } - // TODO: re-enable when iOS CheckoutDelegate (or equivalent) lands upstream — - // parallels Android's DefaultCheckoutListener.onCheckoutCanceled / onCheckoutFailed. - /* - /// checkoutDidComplete - func testCheckoutDidCompleteSendsEvent() { - let event = CheckoutCompletedEvent( - orderDetails: CheckoutCompletedEvent.OrderDetails( - billingAddress: CheckoutCompletedEvent.Address( - address1: "650 King Street", - address2: nil, - city: "Toronto", - countryCode: "CA", - firstName: "Evelyn", - lastName: "Hartley", - name: "Shopify", - phone: nil, - postalCode: nil, - referenceId: nil, - zoneCode: "ON" - ), - cart: CheckoutCompletedEvent.CartInfo( - lines: [], - price: CheckoutCompletedEvent.Price( - discounts: nil, - shipping: CheckoutCompletedEvent.Money(amount: nil, currencyCode: nil), - subtotal: CheckoutCompletedEvent.Money(amount: nil, currencyCode: nil), - taxes: CheckoutCompletedEvent.Money(amount: nil, currencyCode: nil), - total: CheckoutCompletedEvent.Money(amount: nil, currencyCode: nil) - ), - token: "token" - ), - deliveries: nil, - email: "test@shopify.com", - id: "test-order-id", - paymentMethods: nil, - phone: nil - ) - ) - let mock = mockSendEvent(eventName: "completed") - - mock.startObserving() - mock.checkoutDidComplete(event: event) - - XCTAssertTrue(mock.didSendEvent) - if let eventBody = mock.eventBody as? CheckoutCompletedEvent { - XCTAssertEqual(eventBody.orderDetails.id, "test-order-id") - XCTAssertEqual(eventBody.orderDetails.billingAddress?.address1, "650 King Street") - XCTAssertEqual(eventBody.orderDetails.billingAddress?.name, "Shopify") - XCTAssertEqual(eventBody.orderDetails.email, "test@shopify.com") - XCTAssertEqual(eventBody.orderDetails.cart.token, "token") - } - } - - /// checkoutDidCancel - func testCheckoutDidCancelSendsEvent() { - let mock = mockAsyncSendEvent(eventName: "close") - - let expectation = self.expectation(description: "CheckoutDidCancel") - - mock.sendEventImplementation = { name, _ in - if name == "close" { - mock.didSendEvent = true - expectation.fulfill() - } - } - - mock.checkoutSheet = MockCheckout() - mock.startObserving() - mock.checkoutDidCancel() - - // Wait for the expectation to be fulfilled - waitForExpectations(timeout: 1, handler: nil) - - XCTAssertTrue(mock.didSendEvent) - - // swiftlint:disable:next force_cast - XCTAssertTrue((mock.checkoutSheet as! MockCheckout).dismissWasCalled) - } - */ - - private func mockSendEvent(eventName: String) -> RCTShopifyCheckoutKitMock { - let mock = RCTShopifyCheckoutKitMock() - mock.eventName = eventName - return mock - } - - private func mockAsyncSendEvent(eventName: String) -> AsyncRCTShopifyCheckoutKitMock { - let mock = AsyncRCTShopifyCheckoutKitMock() - mock.eventName = eventName - return mock - } -} - -class RCTShopifyCheckoutKitMock: RCTShopifyCheckoutKit { - var didSendEvent = false - var eventName: String? - var eventBody: Any! - - override func sendEvent(withName name: String!, body: Any!) { - if name == eventName { - didSendEvent = true - eventBody = body - } - } -} - -class AsyncRCTShopifyCheckoutKitMock: RCTShopifyCheckoutKit { - var didSendEvent = false - var eventName: String? - var sendEventImplementation: ((String?, Any?) -> Void)? - - override func sendEvent(withName name: String!, body: Any!) { - sendEventImplementation?(name, body) - } -} - -class MockCheckout: UIViewController { - var dismissWasCalled = false - - override func dismiss(animated flag: Bool, completion: (() -> Void)? = nil) { - dismissWasCalled = true - super.dismiss(animated: flag, completion: completion) - } + // TODO: re-enable terminal-event tests (checkoutDidComplete, checkoutDidCancel, checkoutDidFail) + // once the iOS CheckoutDelegate lands upstream — parallels Android's + // DefaultCheckoutListener.onCheckoutCanceled / onCheckoutFailed. } diff --git a/platforms/react-native/sample/src/App.tsx b/platforms/react-native/sample/src/App.tsx index 0a8e78e3..770134d5 100644 --- a/platforms/react-native/sample/src/App.tsx +++ b/platforms/react-native/sample/src/App.tsx @@ -47,7 +47,6 @@ import { ShopifyCheckoutProvider, useShopifyCheckout, } from '@shopify/checkout-kit-react-native'; -import type {CheckoutException} from '@shopify/checkout-kit-react-native'; import {ConfigProvider, useConfig} from './context/Config'; import {BuyerIdentityMode} from './auth/types'; import { @@ -204,27 +203,6 @@ const checkoutKitConfigDefaults: Configuration = { }; function AppWithContext({children}: PropsWithChildren) { - const shopify = useShopifyCheckout(); - const eventHandlers = useShopifyEventHandlers(); - - useEffect(() => { - const close = shopify.addEventListener('close', () => { - eventHandlers.onCancel?.(); - }); - - const error = shopify.addEventListener( - 'error', - (error: CheckoutException) => { - eventHandlers.onFail?.(error); - }, - ); - - return () => { - close?.remove(); - error?.remove(); - }; - }, [shopify, eventHandlers]); - return ( @@ -458,6 +436,7 @@ function Routes() { const navigation = useNavigation>(); const {url: initialUrl} = useInitialURL(); const shopify = useShopifyCheckout(); + const eventHandlers = useShopifyEventHandlers('UniversalLink'); useEffect(() => { async function handleUniversalLink(url: string) { @@ -466,7 +445,10 @@ function Routes() { switch (true) { // Checkout URLs case storefrontUrl.isCheckout() && !storefrontUrl.isThankYouPage(): - shopify.present(url); + shopify.present(url, { + onClose: () => eventHandlers.onCancel?.(), + onFail: error => eventHandlers.onFail?.(error), + }); return; // Cart URLs case storefrontUrl.isCart(): @@ -494,7 +476,7 @@ function Routes() { return () => { subscription.remove(); }; - }, [initialUrl, shopify, navigation]); + }, [initialUrl, shopify, navigation, eventHandlers]); return ( diff --git a/platforms/react-native/sample/src/screens/CartScreen.tsx b/platforms/react-native/sample/src/screens/CartScreen.tsx index b08705c3..489c8147 100644 --- a/platforms/react-native/sample/src/screens/CartScreen.tsx +++ b/platforms/react-native/sample/src/screens/CartScreen.tsx @@ -57,7 +57,12 @@ function CartScreen(): React.JSX.Element { useCart(); const {queries} = useShopify(); const {appConfig} = useConfig(); - const eventHandlers = useShopifyEventHandlers( + // Separate handler instances so debug logs are labelled with the actual + // surface that emitted the event. Otherwise an `onClose` from the + // `ShopifyCheckout.present()` sheet would log under the + // `AcceleratedCheckoutButtons` namespace and confuse anyone debugging. + const sheetEventHandlers = useShopifyEventHandlers('Cart - CheckoutSheet'); + const acceleratedCheckoutEventHandlers = useShopifyEventHandlers( 'Cart - AcceleratedCheckoutButtons', ); @@ -87,7 +92,10 @@ function CartScreen(): React.JSX.Element { const presentCheckout = async () => { if (checkoutURL) { - ShopifyCheckout.present(checkoutURL); + ShopifyCheckout.present(checkoutURL, { + onClose: () => sheetEventHandlers.onCancel?.(), + onFail: error => sheetEventHandlers.onFail?.(error), + }); } }; @@ -167,7 +175,7 @@ function CartScreen(): React.JSX.Element { Date: Thu, 14 May 2026 23:55:03 +0100 Subject: [PATCH 2/3] feat: explore dynamic dispatch for checkout delegate --- .../checkoutkit/CustomCheckoutListener.java | 74 ++--- .../checkoutkit/ShopifyCheckoutKitModule.java | 6 +- .../ios/ShopifyCheckoutKit.mm | 4 +- .../ios/ShopifyCheckoutKit.swift | 19 +- .../checkout-kit-react-native/src/index.ts | 161 ++++++++--- .../src/specs/NativeShopifyCheckoutKit.ts | 4 +- .../tests/context.test.tsx | 8 +- .../tests/index.test.ts | 273 ++++++++++-------- .../ShopifyCheckoutKitModuleTest.java | 93 +++--- 9 files changed, 369 insertions(+), 273 deletions(-) diff --git a/platforms/react-native/modules/@shopify/checkout-kit-react-native/android/src/main/java/com/shopify/reactnative/checkoutkit/CustomCheckoutListener.java b/platforms/react-native/modules/@shopify/checkout-kit-react-native/android/src/main/java/com/shopify/reactnative/checkoutkit/CustomCheckoutListener.java index 34bba710..8756362a 100644 --- a/platforms/react-native/modules/@shopify/checkout-kit-react-native/android/src/main/java/com/shopify/reactnative/checkoutkit/CustomCheckoutListener.java +++ b/platforms/react-native/modules/@shopify/checkout-kit-react-native/android/src/main/java/com/shopify/reactnative/checkoutkit/CustomCheckoutListener.java @@ -35,6 +35,7 @@ of this software and associated documentation files (the "Software"), to deal import com.facebook.react.modules.core.DeviceEventManagerModule; import com.facebook.react.bridge.ReactApplicationContext; import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; import java.io.IOException; import java.util.HashMap; import java.util.Map; @@ -44,11 +45,7 @@ public class CustomCheckoutListener extends DefaultCheckoutListener { private final ObjectMapper mapper = new ObjectMapper(); @Nullable - private Callback onCloseCallback; - @Nullable - private Callback onFailCallback; - @Nullable - private Callback onGeolocationRequestCallback; + private Callback dispatchCallback; // Geolocation-specific variables @@ -56,12 +53,9 @@ public class CustomCheckoutListener extends DefaultCheckoutListener { private GeolocationPermissions.Callback geolocationCallback; public CustomCheckoutListener(Context context, ReactApplicationContext reactContext, - @Nullable Callback onClose, @Nullable Callback onFail, - @Nullable Callback onGeolocationRequest) { + @Nullable Callback dispatch) { this.reactContext = reactContext; - this.onCloseCallback = onClose; - this.onFailCallback = onFail; - this.onGeolocationRequestCallback = onGeolocationRequest; + this.dispatchCallback = dispatch; } // Public methods @@ -77,35 +71,29 @@ public void invokeGeolocationCallback(boolean allow) { // Lifecycle events /** - * This method is called when the checkout sheet webpage requests geolocation - * permissions. - * - * Since the app needs to request permissions first before granting, we store - * the callback and origin in memory and emit a "geolocationRequest" event to - * the app. The app will then request the necessary geolocation permissions - * and invoke the native callback with the result. + * Called when the checkout sheet's webpage requests geolocation + * permissions. The platform callback is stored in memory; the dispatcher + * is invoked with a `geolocationRequest` envelope so JS can either route + * to a per-call handler or run the default permission flow. * - * @param origin - The origin of the request - * @param callback - The callback to invoke when the app requests permissions + * Multi-shot — the same checkout sheet may request geolocation multiple + * times during a single `present()` call, so the dispatcher is not + * nulled after invocation. */ @Override public void onGeolocationPermissionsShowPrompt(@NonNull String origin, @NonNull GeolocationPermissions.Callback callback) { - // Store the callback and origin in memory. The kit will wait for the app to - // request permissions first before granting. this.geolocationCallback = callback; this.geolocationOrigin = origin; + if (dispatchCallback == null) { + return; + } try { - Map event = new HashMap<>(); - event.put("origin", origin); - String payload = mapper.writeValueAsString(event); - if (onGeolocationRequestCallback != null) { - onGeolocationRequestCallback.invoke(payload); - } else { - sendEventWithStringData("geolocationRequest", payload); - } + Map payload = new HashMap<>(); + payload.put("origin", origin); + dispatchCallback.invoke(buildEnvelope("geolocationRequest", payload)); } catch (IOException e) { Log.e("ShopifyCheckoutKit", "Error emitting \"geolocationRequest\" event", e); } @@ -115,37 +103,49 @@ public void onGeolocationPermissionsShowPrompt(@NonNull String origin, public void onGeolocationPermissionsHidePrompt() { super.onGeolocationPermissionsHidePrompt(); - // Reset the geolocation callback and origin when the prompt is hidden. this.geolocationCallback = null; this.geolocationOrigin = null; } @Override public void onCheckoutFailed(CheckoutException checkoutError) { - if (onFailCallback == null) { + if (dispatchCallback == null) { return; } try { - String data = mapper.writeValueAsString(populateErrorDetails(checkoutError)); - onFailCallback.invoke(data); + dispatchCallback.invoke(buildEnvelope("fail", populateErrorDetails(checkoutError))); } catch (IOException e) { Log.e("ShopifyCheckoutKit", "Error processing checkout failed event", e); } finally { - onFailCallback = null; + dispatchCallback = null; } } @Override public void onCheckoutCanceled() { - if (onCloseCallback == null) { + if (dispatchCallback == null) { return; } - onCloseCallback.invoke(); - onCloseCallback = null; + try { + dispatchCallback.invoke(buildEnvelope("close", null)); + } catch (IOException e) { + Log.e("ShopifyCheckoutKit", "Error processing checkout canceled event", e); + } finally { + dispatchCallback = null; + } } // Private + private String buildEnvelope(String type, @Nullable Object payload) throws IOException { + ObjectNode envelope = mapper.createObjectNode(); + envelope.put("type", type); + if (payload != null) { + envelope.set("payload", mapper.valueToTree(payload)); + } + return mapper.writeValueAsString(envelope); + } + private Map populateErrorDetails(CheckoutException checkoutError) { Map errorMap = new HashMap(); errorMap.put("__typename", getErrorTypeName(checkoutError)); 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 45d25eb6..31b8fc4f 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 @@ -79,12 +79,10 @@ public void removeListeners(double count) { } @ReactMethod - public void present(String checkoutURL, @Nullable Callback onClose, @Nullable Callback onFail, - @Nullable Callback onGeolocationRequest) { + public void present(String checkoutURL, @Nullable Callback dispatch) { Activity currentActivity = getCurrentActivity(); if (currentActivity instanceof ComponentActivity) { - checkoutListener = new CustomCheckoutListener(currentActivity, this.reactContext, onClose, - onFail, onGeolocationRequest); + checkoutListener = new CustomCheckoutListener(currentActivity, this.reactContext, dispatch); currentActivity.runOnUiThread(() -> { checkoutSheet = ShopifyCheckoutKit.present(checkoutURL, (ComponentActivity) currentActivity, checkoutListener); 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 e0618c76..77062f64 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 @@ -41,9 +41,7 @@ @interface RCT_EXTERN_MODULE (RCTShopifyCheckoutKit, NativeShopifyCheckoutKitSpe RCT_EXTERN_METHOD(setConfig:(NSDictionary *)configuration) RCT_EXTERN_METHOD(present:(NSString *)checkoutURL - onClose:(RCTResponseSenderBlock)onClose - onFail:(RCTResponseSenderBlock)onFail - onGeolocationRequest:(RCTResponseSenderBlock)onGeolocationRequest) + dispatch:(RCTResponseSenderBlock)dispatch) @end 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 57968d09..ce2dff3e 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 @@ -35,14 +35,10 @@ class RCTShopifyCheckoutKit: NSObject { private var acceleratedCheckoutsApplePayConfiguration: Any? private var defaultLogLevel: LogLevel = .error - // TODO: invoke these once the iOS CheckoutDelegate (or equivalent) lands upstream — until then, - // onClose/onFail callbacks are stored but never fire (Android is the only platform delivering them). - // `pendingGeolocationRequestCallback` is intentionally a no-op on iOS — geolocation permission - // is handled natively, so the callback is stored only to keep the bridge signature symmetric - // with Android. - private var pendingCloseCallback: RCTResponseSenderBlock? - private var pendingFailCallback: RCTResponseSenderBlock? - private var pendingGeolocationRequestCallback: RCTResponseSenderBlock? + // TODO: invoke once the iOS CheckoutDelegate (or equivalent) lands upstream — until then, + // the dispatcher is stored but never fired (Android is the only platform delivering events). + // When wired, dispatch envelope JSON strings of the shape `{"type":"close"|"fail","payload":...}`. + private var pendingDispatchCallback: RCTResponseSenderBlock? @objc var methodQueue: DispatchQueue { return DispatchQueue.main @@ -106,11 +102,8 @@ class RCTShopifyCheckoutKit: NSObject { // Retained for compatibility with the generated native module interface. } - @objc func present(_ checkoutURL: String, onClose: RCTResponseSenderBlock?, onFail: RCTResponseSenderBlock?, - onGeolocationRequest: RCTResponseSenderBlock?) { - pendingCloseCallback = onClose - pendingFailCallback = onFail - pendingGeolocationRequestCallback = onGeolocationRequest + @objc func present(_ checkoutURL: String, dispatch: RCTResponseSenderBlock?) { + pendingDispatchCallback = dispatch DispatchQueue.main.async { if let url = URL(string: checkoutURL), let viewController = self.getCurrentViewController() { 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 3061c2f6..f0ca8f27 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 @@ -21,8 +21,8 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -import {NativeEventEmitter, PermissionsAndroid, Platform} from 'react-native'; -import type {EventSubscription, PermissionStatus} from 'react-native'; +import {PermissionsAndroid, Platform} from 'react-native'; +import type {PermissionStatus} from 'react-native'; import RNShopifyCheckoutKit from './specs/NativeShopifyCheckoutKit'; import {ShopifyCheckoutProvider, useShopifyCheckout} from './context'; import {ApplePayContactField, ColorScheme, LogLevel} from './index.d'; @@ -31,7 +31,6 @@ import type { Configuration, Features, GeolocationRequestEvent, - Maybe, PresentCallbacks, ShopifyCheckoutKit, } from './index.d'; @@ -70,12 +69,7 @@ const colorSchemeValues: ReadonlySet = new Set( const logLevelValues: ReadonlySet = new Set(Object.values(LogLevel)); class ShopifyCheckout implements ShopifyCheckoutKit { - private static eventEmitter: NativeEventEmitter = new NativeEventEmitter( - RNShopifyCheckoutKit, - ); - private features: Features; - private geolocationCallback: Maybe; private _acceleratedCheckoutsReady = false; @@ -106,13 +100,6 @@ class ShopifyCheckout implements ShopifyCheckoutKit { if (configuration != null) { this.setConfig(configuration); } - - if ( - Platform.OS === 'android' && - this.featureEnabled('handleGeolocationRequests') - ) { - this.subscribeToGeolocationRequestPrompts(); - } } /** @@ -132,14 +119,7 @@ class ShopifyCheckout implements ShopifyCheckoutKit { * @param callbacks Optional per-call SDK callbacks */ public present(checkoutUrl: string, callbacks?: PresentCallbacks): void { - RNShopifyCheckoutKit.present( - checkoutUrl, - callbacks?.onClose ?? null, - callbacks?.onFail ? this.wrapFailCallback(callbacks.onFail) : null, - callbacks?.onGeolocationRequest - ? this.wrapGeolocationCallback(callbacks.onGeolocationRequest) - : null, - ); + RNShopifyCheckoutKit.present(checkoutUrl, this.buildDispatcher(callbacks)); } /** @@ -164,11 +144,11 @@ class ShopifyCheckout implements ShopifyCheckoutKit { } /** - * Cleans up resources and event listeners used by the checkout sheet + * 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. */ - public teardown() { - this.geolocationCallback?.remove(); - } + public teardown() {} /** * Configure AcceleratedCheckouts for Shop Pay and Apple Pay buttons @@ -307,19 +287,69 @@ class ShopifyCheckout implements ShopifyCheckoutKit { } /** - * Sets up geolocation request handling for Android devices. - * Uses the internal NativeEventEmitter directly because the public - * listener API has been removed. + * Builds the single per-call dispatcher passed to the native bridge. + * Returns null when there is nothing for the bridge to deliver back — + * no user callbacks and no default-handler responsibilities — so the + * native side can skip serializing envelopes. */ - private subscribeToGeolocationRequestPrompts() { - this.geolocationCallback = ShopifyCheckout.eventEmitter.addListener( - 'geolocationRequest', - async () => { - const coarseOrFineGrainAccessGranted = await this.requestGeolocation(); - - this.initiateGeolocationRequest(coarseOrFineGrainAccessGranted); - }, - ); + private buildDispatcher( + callbacks: PresentCallbacks | undefined, + ): ((envelopeJson: string) => void) | null { + const needsDefaultGeolocation = + Platform.OS === 'android' && + this.featureEnabled('handleGeolocationRequests'); + + if (!callbacks && !needsDefaultGeolocation) { + return null; + } + + return (envelopeJson: string) => { + let envelope: {type?: string; payload?: unknown}; + try { + envelope = JSON.parse(envelopeJson); + } catch { + const parseError = new LifecycleEventParseError( + 'Failed to parse present() dispatcher envelope: Invalid JSON', + {cause: 'Invalid JSON'}, + ); + // eslint-disable-next-line no-console + console.error(parseError, envelopeJson); + return; + } + + switch (envelope.type) { + case 'close': + callbacks?.onClose?.(); + return; + case 'fail': + if (callbacks?.onFail) { + callbacks.onFail( + this.parseCheckoutError(envelope.payload as CheckoutNativeError), + ); + } + return; + case 'geolocationRequest': + if (callbacks?.onGeolocationRequest) { + callbacks.onGeolocationRequest( + envelope.payload as GeolocationRequestEvent, + ); + } else if (needsDefaultGeolocation) { + this.handleDefaultGeolocationRequest(); + } + return; + default: + return; + } + }; + } + + /** + * Default Android geolocation handler — requests platform permissions + * and forwards the resolved grant state back to the native SDK. + */ + private async handleDefaultGeolocationRequest() { + const allowed = await this.requestGeolocation(); + this.initiateGeolocationRequest(allowed); } /** @@ -406,6 +436,7 @@ class ShopifyCheckout implements ShopifyCheckoutKit { } } +<<<<<<< HEAD /** * Wraps a consumer-provided `onFail` callback so the native bridge can * hand it the raw JSON error payload it serializes today. Invalid JSON @@ -453,6 +484,58 @@ class ShopifyCheckout implements ShopifyCheckoutKit { } }; } +||||||| parent of 2b6a1474 (feat: explore dynamic dispatch for checkout delegate) + /** + * Wraps a consumer-provided `onFail` callback so the native bridge can + * hand it the raw JSON error payload it serializes today. Invalid JSON + * is reported via `LifecycleEventParseError`; the user callback only + * fires on a successful parse. + */ + private wrapFailCallback( + onFail: NonNullable, + ): (raw: string) => void { + return (raw: string) => { + try { + const parsed = JSON.parse(raw); + onFail(this.parseCheckoutError(parsed)); + } catch { + const parseError = new LifecycleEventParseError( + 'Failed to parse "onFail" callback payload: Invalid JSON', + {cause: 'Invalid JSON'}, + ); + // eslint-disable-next-line no-console + console.error(parseError, raw); + } + }; + } + + /** + * Wraps a consumer-provided `onGeolocationRequest` callback so the + * native bridge can hand it the raw JSON origin payload. Invalid JSON + * is reported via `LifecycleEventParseError`; the user callback only + * fires on a successful parse. + */ + private wrapGeolocationCallback( + onGeolocationRequest: NonNullable< + PresentCallbacks['onGeolocationRequest'] + >, + ): (raw: string) => void { + return (raw: string) => { + try { + const parsed = JSON.parse(raw); + onGeolocationRequest(parsed); + } catch { + const parseError = new LifecycleEventParseError( + 'Failed to parse "onGeolocationRequest" callback payload: Invalid JSON', + {cause: 'Invalid JSON'}, + ); + // eslint-disable-next-line no-console + console.error(parseError, raw); + } + }; + } +======= +>>>>>>> 2b6a1474 (feat: explore dynamic dispatch for checkout delegate) } export class LifecycleEventParseError extends Error { 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 9e4f348c..b72fb1d5 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 @@ -72,9 +72,7 @@ type ConfigurationResultSpec = { export interface Spec extends TurboModule { present( checkoutUrl: string, - onClose: (() => void) | null, - onFail: ((errorJson: string) => void) | null, - onGeolocationRequest: ((originJson: string) => void) | null, + dispatch: ((envelopeJson: string) => void) | null, ): void; dismiss(): 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 fcf0a3f0..5ae0223d 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 @@ -154,7 +154,7 @@ describe('useShopifyCheckout', () => { jest.clearAllMocks(); }); - it('provides present function and calls it with checkoutUrl and null callbacks when none are passed', () => { + it('provides present function and calls it with checkoutUrl and a null dispatcher when no callbacks are passed', () => { let hookValue: any; const onHookValue = (value: any) => { hookValue = value; @@ -173,12 +173,10 @@ describe('useShopifyCheckout', () => { expect(NativeModules.ShopifyCheckoutKit.present).toHaveBeenCalledWith( checkoutUrl, null, - null, - null, ); }); - it('forwards onClose, onFail, and onGeolocationRequest callbacks through present', () => { + it('forwards a dispatcher to native when callbacks are supplied', () => { let hookValue: any; const onHookValue = (value: any) => { hookValue = value; @@ -201,8 +199,6 @@ describe('useShopifyCheckout', () => { expect(NativeModules.ShopifyCheckoutKit.present).toHaveBeenCalledWith( checkoutUrl, expect.any(Function), - expect.any(Function), - expect.any(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 d9de56d0..0d7df35c 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 @@ -63,10 +63,21 @@ describe('Exports', () => { }); }); -describe('ShopifyCheckoutKit', () => { - // @ts-expect-error "eventEmitter is private" - const eventEmitter = ShopifyCheckout.eventEmitter; +type Dispatch = (envelopeJson: string) => void; + +function lastDispatch(): Dispatch { + const dispatch = NativeModule.present.mock.calls[ + NativeModule.present.mock.calls.length - 1 + ][1] as Dispatch | null; + if (!dispatch) { + throw new Error( + 'Expected the last present() call to receive a non-null dispatcher', + ); + } + return dispatch; +} +describe('ShopifyCheckoutKit', () => { afterEach(() => { NativeModule.setConfig.mockReset(); jest.clearAllMocks(); @@ -104,22 +115,28 @@ describe('ShopifyCheckoutKit', () => { }); describe('present', () => { - it('calls `present` with the checkout URL and null callbacks when none are provided', () => { + it('calls `present` with a null dispatcher when no callbacks are provided on iOS', () => { + Platform.OS = 'ios'; const instance = new ShopifyCheckout(); instance.present(checkoutUrl); expect(NativeModule.present).toHaveBeenCalledTimes(1); + expect(NativeModule.present).toHaveBeenCalledWith(checkoutUrl, null); + }); + + it('calls `present` with a dispatcher when callbacks are provided', () => { + const instance = new ShopifyCheckout(); + instance.present(checkoutUrl, {onClose: jest.fn()}); expect(NativeModule.present).toHaveBeenCalledWith( checkoutUrl, - null, - null, - null, + expect.any(Function), ); }); - it('forwards the `onClose` callback to native and invokes the user handler when fired', () => { + it('invokes `onClose` when the dispatcher receives a close envelope', () => { const instance = new ShopifyCheckout(); const onClose = jest.fn(); instance.present(checkoutUrl, {onClose}); +<<<<<<< HEAD expect(NativeModule.present).toHaveBeenCalledWith( checkoutUrl, expect.any(Function), @@ -128,61 +145,26 @@ describe('ShopifyCheckoutKit', () => { ); const nativeOnClose = NativeModule.present.mock.calls[0][1] as () => void; nativeOnClose(); - expect(onClose).toHaveBeenCalledTimes(1); - }); - - it('forwards an `onFail` JSON wrapper to native when `onFail` is provided', () => { - const instance = new ShopifyCheckout(); - const onFail = jest.fn(); - instance.present(checkoutUrl, {onFail}); +||||||| parent of 2b6a1474 (feat: explore dynamic dispatch for checkout delegate) expect(NativeModule.present).toHaveBeenCalledWith( checkoutUrl, - null, expect.any(Function), null, - ); - }); - - it('forwards an `onGeolocationRequest` JSON wrapper to native when `onGeolocationRequest` is provided', () => { - const instance = new ShopifyCheckout(); - const onGeolocationRequest = jest.fn(); - instance.present(checkoutUrl, {onGeolocationRequest}); - expect(NativeModule.present).toHaveBeenCalledWith( - checkoutUrl, null, - null, - expect.any(Function), ); + const nativeOnClose = NativeModule.present.mock + .calls[0][1] as () => void; + nativeOnClose(); +======= + lastDispatch()(JSON.stringify({type: 'close'})); +>>>>>>> 2b6a1474 (feat: explore dynamic dispatch for checkout delegate) + expect(onClose).toHaveBeenCalledTimes(1); }); - describe('onGeolocationRequest callback', () => { - it('parses the native JSON payload and surfaces the typed event to the consumer', () => { - const instance = new ShopifyCheckout(); - const onGeolocationRequest = jest.fn(); - instance.present(checkoutUrl, {onGeolocationRequest}); - const nativeOnGeolocationRequest = NativeModule.present.mock - .calls[0][3] as (raw: string) => void; - nativeOnGeolocationRequest( - JSON.stringify({origin: 'https://shopify.com'}), - ); - expect(onGeolocationRequest).toHaveBeenCalledWith({ - origin: 'https://shopify.com', - }); - }); - - it('logs a LifecycleEventParseError and does not invoke `onGeolocationRequest` when payload is invalid JSON', () => { - const instance = new ShopifyCheckout(); - const onGeolocationRequest = jest.fn(); - instance.present(checkoutUrl, {onGeolocationRequest}); - const nativeOnGeolocationRequest = NativeModule.present.mock - .calls[0][3] as (raw: string) => void; - nativeOnGeolocationRequest('not-json'); - expect(onGeolocationRequest).not.toHaveBeenCalled(); - expect(console.error).toHaveBeenCalledWith( - expect.any(LifecycleEventParseError), - 'not-json', - ); - }); + it('ignores a close envelope when no `onClose` handler was provided', () => { + const instance = new ShopifyCheckout(); + instance.present(checkoutUrl, {onFail: jest.fn()}); + expect(() => lastDispatch()(JSON.stringify({type: 'close'}))).not.toThrow(); }); describe('onFail callback', () => { @@ -229,7 +211,7 @@ describe('ShopifyCheckoutKit', () => { {error: networkError, constructor: CheckoutHTTPError}, {error: expiredError, constructor: CheckoutExpiredError}, ])( - `parses the native JSON payload into a typed CheckoutException ($error.__typename)`, + `parses the fail envelope payload into a typed CheckoutException ($error.__typename)`, ({ error, constructor, @@ -240,10 +222,7 @@ describe('ShopifyCheckoutKit', () => { const instance = new ShopifyCheckout(); const onFail = jest.fn(); instance.present(checkoutUrl, {onFail}); - const nativeOnFail = NativeModule.present.mock.calls[0][2] as ( - raw: string, - ) => void; - nativeOnFail(JSON.stringify(error)); + lastDispatch()(JSON.stringify({type: 'fail', payload: error})); const calledWith = onFail.mock.calls[0][0]; expect(calledWith).toBeInstanceOf(constructor); expect(calledWith).not.toHaveProperty('__typename'); @@ -261,28 +240,64 @@ describe('ShopifyCheckoutKit', () => { __typename: 'UnknownError', message: 'Something went wrong', }; - const nativeOnFail = NativeModule.present.mock.calls[0][2] as ( - raw: string, - ) => void; - nativeOnFail(JSON.stringify(error)); + lastDispatch()(JSON.stringify({type: 'fail', payload: error})); const calledWith = onFail.mock.calls[0][0]; expect(calledWith).toBeInstanceOf(GenericError); }); - it('logs a LifecycleEventParseError and does not invoke `onFail` when payload is invalid JSON', () => { + it('ignores a fail envelope when no `onFail` handler was provided', () => { const instance = new ShopifyCheckout(); - const onFail = jest.fn(); - instance.present(checkoutUrl, {onFail}); - const nativeOnFail = NativeModule.present.mock.calls[0][2] as ( - raw: string, - ) => void; - nativeOnFail('not-json'); - expect(onFail).not.toHaveBeenCalled(); + const onClose = jest.fn(); + instance.present(checkoutUrl, {onClose}); + expect(() => + lastDispatch()( + JSON.stringify({type: 'fail', payload: internalError}), + ), + ).not.toThrow(); + }); + }); + + describe('onGeolocationRequest callback', () => { + it('parses the geolocationRequest envelope payload and surfaces the typed event', () => { + const instance = new ShopifyCheckout(); + const onGeolocationRequest = jest.fn(); + instance.present(checkoutUrl, {onGeolocationRequest}); + lastDispatch()( + JSON.stringify({ + type: 'geolocationRequest', + payload: {origin: 'https://shopify.com'}, + }), + ); + expect(onGeolocationRequest).toHaveBeenCalledWith({ + origin: 'https://shopify.com', + }); + }); + }); + + describe('envelope parsing', () => { + it('logs a LifecycleEventParseError when the envelope is invalid JSON', () => { + const instance = new ShopifyCheckout(); + const onClose = jest.fn(); + instance.present(checkoutUrl, {onClose}); + lastDispatch()('not-json'); + expect(onClose).not.toHaveBeenCalled(); expect(console.error).toHaveBeenCalledWith( expect.any(LifecycleEventParseError), 'not-json', ); }); + + it('silently ignores envelopes with unknown `type` values', () => { + const instance = new ShopifyCheckout(); + const onClose = jest.fn(); + const onFail = jest.fn(); + instance.present(checkoutUrl, {onClose, onFail}); + expect(() => + lastDispatch()(JSON.stringify({type: 'unknown', payload: {}})), + ).not.toThrow(); + expect(onClose).not.toHaveBeenCalled(); + expect(onFail).not.toHaveBeenCalled(); + }); }); }); @@ -306,15 +321,13 @@ describe('ShopifyCheckoutKit', () => { }); describe('Geolocation', () => { - const defaultConfig = {}; + const geolocationEnvelope = JSON.stringify({ + type: 'geolocationRequest', + payload: {origin: 'https://shopify.com'}, + }); - async function emitGeolocationRequest() { - await new Promise(resolve => { - eventEmitter.emit('geolocationRequest', { - origin: 'https://shopify.com', - }); - setTimeout(resolve); - }); + async function flush() { + await new Promise(resolve => setTimeout(resolve)); } describe('Android', () => { @@ -328,24 +341,21 @@ describe('ShopifyCheckoutKit', () => { Platform.OS = originalPlatform; }); - it('subscribes to geolocation requests on Android when feature is enabled', () => { - new ShopifyCheckout(defaultConfig); - - expect(eventEmitter.addListener).toHaveBeenCalledWith( - 'geolocationRequest', + it('passes a dispatcher when the default handler is enabled, even without callbacks', () => { + const instance = new ShopifyCheckout(); + instance.present(checkoutUrl); + expect(NativeModule.present).toHaveBeenCalledWith( + checkoutUrl, expect.any(Function), ); }); - it('does not subscribe to geolocation requests when feature is disabled', () => { - new ShopifyCheckout(defaultConfig, { + it('passes a null dispatcher when no callbacks and the default handler is disabled', () => { + const instance = new ShopifyCheckout(undefined, { handleGeolocationRequests: false, }); - - expect(eventEmitter.addListener).not.toHaveBeenCalledWith( - 'geolocationRequest', - expect.any(Function), - ); + instance.present(checkoutUrl); + expect(NativeModule.present).toHaveBeenCalledWith(checkoutUrl, null); }); it('handles geolocation permission grant correctly', async () => { @@ -360,9 +370,10 @@ describe('ShopifyCheckoutKit', () => { } ).mockResolvedValue(mockPermissions); - new ShopifyCheckout(); - - await emitGeolocationRequest(); + const instance = new ShopifyCheckout(); + instance.present(checkoutUrl); + lastDispatch()(geolocationEnvelope); + await flush(); expect(PermissionsAndroid.requestMultiple).toHaveBeenCalledWith([ 'android.permission.ACCESS_COARSE_LOCATION', @@ -385,9 +396,10 @@ describe('ShopifyCheckoutKit', () => { } ).mockResolvedValue(mockPermissions); - new ShopifyCheckout(); - - await emitGeolocationRequest(); + const instance = new ShopifyCheckout(); + instance.present(checkoutUrl); + lastDispatch()(geolocationEnvelope); + await flush(); expect(PermissionsAndroid.requestMultiple).toHaveBeenCalledWith([ 'android.permission.ACCESS_COARSE_LOCATION', @@ -398,18 +410,34 @@ describe('ShopifyCheckoutKit', () => { ); }); - it('cleans up geolocation callback on teardown', () => { - const sheet = new ShopifyCheckout(); - const mockRemove = jest.fn(); + it('prefers a per-call `onGeolocationRequest` handler over the default handler', async () => { + const instance = new ShopifyCheckout(); + const onGeolocationRequest = jest.fn(); + instance.present(checkoutUrl, {onGeolocationRequest}); + lastDispatch()(geolocationEnvelope); + await flush(); - // @ts-expect-error - sheet.geolocationCallback = { - remove: mockRemove, - }; + expect(onGeolocationRequest).toHaveBeenCalledWith({ + origin: 'https://shopify.com', + }); + expect(PermissionsAndroid.requestMultiple).not.toHaveBeenCalled(); + expect( + NativeModule.initiateGeolocationRequest, + ).not.toHaveBeenCalled(); + }); - sheet.teardown(); + it('does not run the default handler when the feature is disabled', async () => { + const instance = new ShopifyCheckout(undefined, { + handleGeolocationRequests: false, + }); + instance.present(checkoutUrl, {onClose: jest.fn()}); + lastDispatch()(geolocationEnvelope); + await flush(); - expect(mockRemove).toHaveBeenCalled(); + expect(PermissionsAndroid.requestMultiple).not.toHaveBeenCalled(); + expect( + NativeModule.initiateGeolocationRequest, + ).not.toHaveBeenCalled(); }); }); @@ -424,21 +452,30 @@ describe('ShopifyCheckoutKit', () => { Platform.OS = originalPlatform; }); - it('does not subscribe to geolocation requests', () => { - new ShopifyCheckout(); - - expect(eventEmitter.addListener).not.toHaveBeenCalledWith( - 'geolocationRequest', - expect.any(Function), - ); + it('passes a null dispatcher by default — no default geolocation handling on iOS', () => { + const instance = new ShopifyCheckout(); + instance.present(checkoutUrl); + expect(NativeModule.present).toHaveBeenCalledWith(checkoutUrl, null); }); - it('does not call the native function, even if an event is emitted', async () => { - new ShopifyCheckout(); - - await emitGeolocationRequest(); + it('does not run the default geolocation handler on iOS even if dispatcher fires', async () => { + const instance = new ShopifyCheckout(); + instance.present(checkoutUrl, {onClose: jest.fn()}); + lastDispatch()(geolocationEnvelope); + await flush(); +<<<<<<< HEAD expect(NativeModule.initiateGeolocationRequest).not.toHaveBeenCalled(); +||||||| parent of 2b6a1474 (feat: explore dynamic dispatch for checkout delegate) + expect( + NativeModule.initiateGeolocationRequest, + ).not.toHaveBeenCalled(); +======= + expect(PermissionsAndroid.requestMultiple).not.toHaveBeenCalled(); + expect( + NativeModule.initiateGeolocationRequest, + ).not.toHaveBeenCalled(); +>>>>>>> 2b6a1474 (feat: explore dynamic dispatch for checkout delegate) }); it('tears down gracefully', () => { diff --git a/platforms/react-native/sample/android/app/src/test/java/com/shopify/checkoutkitreactnative/ShopifyCheckoutKitModuleTest.java b/platforms/react-native/sample/android/app/src/test/java/com/shopify/checkoutkitreactnative/ShopifyCheckoutKitModuleTest.java index d1736fc1..54cbac18 100644 --- a/platforms/react-native/sample/android/app/src/test/java/com/shopify/checkoutkitreactnative/ShopifyCheckoutKitModuleTest.java +++ b/platforms/react-native/sample/android/app/src/test/java/com/shopify/checkoutkitreactnative/ShopifyCheckoutKitModuleTest.java @@ -116,7 +116,7 @@ public void testCanPresentCheckout() { try (MockedStatic mockedShopifyCheckoutKit = Mockito .mockStatic(ShopifyCheckoutKit.class)) { String checkoutUrl = "https://shopify.com"; - shopifyCheckoutKitModule.present(checkoutUrl, null, null, null); + shopifyCheckoutKitModule.present(checkoutUrl, null); verify(mockComponentActivity).runOnUiThread(runnableCaptor.capture()); runnableCaptor.getValue().run(); @@ -129,71 +129,67 @@ public void testCanPresentCheckout() { @Test public void testPresentForwardsOnCloseCallback() { - Callback onClose = mock(Callback.class); - CustomCheckoutListener processor = new CustomCheckoutListener(mockContext, mockReactContext, - onClose, null, null); + Callback dispatch = mock(Callback.class); + CustomCheckoutListener processor = new CustomCheckoutListener(mockContext, mockReactContext, dispatch); processor.onCheckoutCanceled(); - verify(onClose).invoke(); + ArgumentCaptor args = ArgumentCaptor.forClass(Object[].class); + verify(dispatch).invoke(args.capture()); + assertThat((String) args.getValue()[0]).contains("\"type\":\"close\""); } @Test public void testOnCloseCallbackIsSingleShot() { - Callback onClose = mock(Callback.class); - CustomCheckoutListener processor = new CustomCheckoutListener(mockContext, mockReactContext, - onClose, null, null); + Callback dispatch = mock(Callback.class); + CustomCheckoutListener processor = new CustomCheckoutListener(mockContext, mockReactContext, dispatch); processor.onCheckoutCanceled(); processor.onCheckoutCanceled(); - verify(onClose, times(1)).invoke(); + verify(dispatch, times(1)).invoke(any(Object[].class)); } @Test - public void testGeolocationCallbackReceivesOriginJsonWhenSet() { - Callback onGeolocationRequest = mock(Callback.class); + public void testGeolocationDispatchesEnvelopeWithOrigin() { + Callback dispatch = mock(Callback.class); GeolocationPermissions.Callback permissionsCallback = mock(GeolocationPermissions.Callback.class); - CustomCheckoutListener processor = new CustomCheckoutListener(mockContext, mockReactContext, - null, null, onGeolocationRequest); + CustomCheckoutListener processor = new CustomCheckoutListener(mockContext, mockReactContext, dispatch); processor.onGeolocationPermissionsShowPrompt("https://shopify.com", permissionsCallback); ArgumentCaptor args = ArgumentCaptor.forClass(Object[].class); - verify(onGeolocationRequest).invoke(args.capture()); - assertThat((String) args.getValue()[0]).contains("https://shopify.com", "origin"); + verify(dispatch).invoke(args.capture()); + assertThat((String) args.getValue()[0]) + .contains("\"type\":\"geolocationRequest\"", "\"origin\":\"https://shopify.com\""); verify(mockEventEmitter, never()).emit(eq("geolocationRequest"), any()); } @Test - public void testGeolocationCallbackMayFireMultipleTimes() { - Callback onGeolocationRequest = mock(Callback.class); + public void testGeolocationDispatchIsMultiShot() { + Callback dispatch = mock(Callback.class); GeolocationPermissions.Callback permissionsCallback = mock(GeolocationPermissions.Callback.class); - CustomCheckoutListener processor = new CustomCheckoutListener(mockContext, mockReactContext, - null, null, onGeolocationRequest); + CustomCheckoutListener processor = new CustomCheckoutListener(mockContext, mockReactContext, dispatch); processor.onGeolocationPermissionsShowPrompt("https://shopify.com", permissionsCallback); processor.onGeolocationPermissionsShowPrompt("https://shopify.com", permissionsCallback); - verify(onGeolocationRequest, times(2)).invoke(any(Object[].class)); + verify(dispatch, times(2)).invoke(any(Object[].class)); } @Test - public void testGeolocationFallsBackToEventEmitterWhenNoCallbackSet() { + public void testGeolocationWithNoDispatchCallbackDoesNotInvoke() { GeolocationPermissions.Callback permissionsCallback = mock(GeolocationPermissions.Callback.class); - CustomCheckoutListener processor = new CustomCheckoutListener(mockContext, mockReactContext, - null, null, null); + CustomCheckoutListener processor = new CustomCheckoutListener(mockContext, mockReactContext, null); processor.onGeolocationPermissionsShowPrompt("https://shopify.com", permissionsCallback); - verify(mockEventEmitter).emit(eq("geolocationRequest"), stringCaptor.capture()); - assertThat(stringCaptor.getValue()).contains("https://shopify.com", "origin"); + verify(mockEventEmitter, never()).emit(eq("geolocationRequest"), any()); } @Test - public void testCheckoutCanceledWithNoCloseCallbackDoesNotEmitCloseEvent() { - CustomCheckoutListener processor = new CustomCheckoutListener(mockContext, mockReactContext, - null, null, null); + public void testCheckoutCanceledWithNoDispatchCallbackDoesNotEmitCloseEvent() { + CustomCheckoutListener processor = new CustomCheckoutListener(mockContext, mockReactContext, null); processor.onCheckoutCanceled(); @@ -518,9 +514,8 @@ public void testGetConfigReturnsDefaultLogLevel() { @Test public void testCanProcessCheckoutExpiredErrors() { - Callback onFail = mock(Callback.class); - CustomCheckoutListener processor = new CustomCheckoutListener(mockContext, mockReactContext, - null, onFail, null); + Callback dispatch = mock(Callback.class); + CustomCheckoutListener processor = new CustomCheckoutListener(mockContext, mockReactContext, dispatch); CheckoutExpiredException mockException = mock(CheckoutExpiredException.class); when(mockException.getErrorDescription()).thenReturn("Cart has expired"); @@ -529,17 +524,17 @@ public void testCanProcessCheckoutExpiredErrors() { processor.onCheckoutFailed(mockException); ArgumentCaptor args = ArgumentCaptor.forClass(Object[].class); - verify(onFail).invoke(args.capture()); + verify(dispatch).invoke(args.capture()); assertThat((String) args.getValue()[0]) - .contains("CheckoutExpiredError", "Cart has expired", "cart_expired", "\"recoverable\":false"); + .contains("\"type\":\"fail\"", "CheckoutExpiredError", "Cart has expired", "cart_expired", + "\"recoverable\":false"); } @Test public void testCanProcessClientErrors() { - Callback onFail = mock(Callback.class); - CustomCheckoutListener processor = new CustomCheckoutListener(mockContext, mockReactContext, - null, onFail, null); + Callback dispatch = mock(Callback.class); + CustomCheckoutListener processor = new CustomCheckoutListener(mockContext, mockReactContext, dispatch); ClientException mockException = mock(ClientException.class); when(mockException.getErrorDescription()).thenReturn("Customer account required"); @@ -548,18 +543,17 @@ public void testCanProcessClientErrors() { processor.onCheckoutFailed(mockException); ArgumentCaptor args = ArgumentCaptor.forClass(Object[].class); - verify(onFail).invoke(args.capture()); + verify(dispatch).invoke(args.capture()); assertThat((String) args.getValue()[0]) - .contains("CheckoutClientError", "Customer account required", "customer_account_required", + .contains("\"type\":\"fail\"", "CheckoutClientError", "Customer account required", "customer_account_required", "\"recoverable\":true"); } @Test public void testCanProcessHttpErrors() { - Callback onFail = mock(Callback.class); - CustomCheckoutListener processor = new CustomCheckoutListener(mockContext, mockReactContext, - null, onFail, null); + Callback dispatch = mock(Callback.class); + CustomCheckoutListener processor = new CustomCheckoutListener(mockContext, mockReactContext, dispatch); HttpException mockException = mock(HttpException.class); when(mockException.getErrorDescription()).thenReturn("Not Found"); @@ -569,17 +563,17 @@ public void testCanProcessHttpErrors() { processor.onCheckoutFailed(mockException); ArgumentCaptor args = ArgumentCaptor.forClass(Object[].class); - verify(onFail).invoke(args.capture()); + verify(dispatch).invoke(args.capture()); assertThat((String) args.getValue()[0]) - .contains("CheckoutHTTPError", "Not Found", "http_error", "\"statusCode\":404", "\"recoverable\":false"); + .contains("\"type\":\"fail\"", "CheckoutHTTPError", "Not Found", "http_error", "\"statusCode\":404", + "\"recoverable\":false"); } @Test public void testOnFailCallbackIsSingleShot() { - Callback onFail = mock(Callback.class); - CustomCheckoutListener processor = new CustomCheckoutListener(mockContext, mockReactContext, - null, onFail, null); + Callback dispatch = mock(Callback.class); + CustomCheckoutListener processor = new CustomCheckoutListener(mockContext, mockReactContext, dispatch); CheckoutExpiredException mockException = mock(CheckoutExpiredException.class); when(mockException.getErrorDescription()).thenReturn("Cart has expired"); @@ -589,13 +583,12 @@ public void testOnFailCallbackIsSingleShot() { processor.onCheckoutFailed(mockException); processor.onCheckoutFailed(mockException); - verify(onFail, times(1)).invoke(any(Object[].class)); + verify(dispatch, times(1)).invoke(any(Object[].class)); } @Test - public void testCheckoutFailedWithNoFailCallbackDoesNotEmitFailEvent() { - CustomCheckoutListener processor = new CustomCheckoutListener(mockContext, mockReactContext, - null, null, null); + public void testCheckoutFailedWithNoDispatchCallbackDoesNotEmitFailEvent() { + CustomCheckoutListener processor = new CustomCheckoutListener(mockContext, mockReactContext, null); CheckoutExpiredException mockException = mock(CheckoutExpiredException.class); From 5b6cd971b79662fcd3c08262b779262a7a45d93e Mon Sep 17 00:00:00 2001 From: Kieran Osgood Date: Wed, 20 May 2026 11:13:10 +0100 Subject: [PATCH 3/3] fix(rn): align error payloads with native SDK APIs --- platforms/react-native/README.md | 28 +- .../react-native/__mocks__/react-native.ts | 7 +- .../checkoutkit/CustomCheckoutListener.java | 50 +-- .../checkoutkit/DispatchEventTypes.java | 49 +++ .../checkoutkit/ShopifyCheckoutKitModule.java | 21 +- .../api/checkout-kit-react-native.api.md | 39 ++- .../ios/ShopifyCheckoutKit.swift | 164 +++++++++- .../package.snapshot.json | 8 + .../src/dispatch-events.ts | 143 +++++++++ .../checkout-kit-react-native/src/index.d.ts | 20 +- .../checkout-kit-react-native/src/index.ts | 287 +++++++++--------- .../src/specs/NativeShopifyCheckoutKit.ts | 12 +- .../tests/index.test.ts | 187 +++++++++--- .../sample/android/app/build.gradle | 1 + .../ShopifyCheckoutKitModuleTest.java | 92 ++++-- .../CheckoutDidFailTests.swift | 179 +---------- .../ShopifyCheckoutKitTests.swift | 17 ++ 17 files changed, 831 insertions(+), 473 deletions(-) create mode 100644 platforms/react-native/modules/@shopify/checkout-kit-react-native/android/src/main/java/com/shopify/reactnative/checkoutkit/DispatchEventTypes.java create mode 100644 platforms/react-native/modules/@shopify/checkout-kit-react-native/src/dispatch-events.ts diff --git a/platforms/react-native/README.md b/platforms/react-native/README.md index 6c35e44b..11b4e0c5 100644 --- a/platforms/react-native/README.md +++ b/platforms/react-native/README.md @@ -601,7 +601,7 @@ shopify.present(checkoutUrl, { // The sheet was dismissed without a terminal error }, onFail: (error: CheckoutException) => { - // A terminal error occurred — inspect `error.code`, `error.recoverable`, etc. + // A terminal error occurred — inspect `error.code`, `error.message`, etc. }, }); ``` @@ -739,9 +739,9 @@ behalf. The geolocation request flow follows this sequence: 1. When checkout needs location data (e.g., to show nearby pickup points), it triggers a geolocation request. -2. If you've passed an `onGeolocationRequest` callback to `present()`, that callback is invoked. +2. If you've passed an `onGeolocationRequest` callback to `present()`, that callback is invoked. Request or check Android permissions, then call `event.respond(allow)`. 3. Otherwise, with `features.handleGeolocationRequests: true` (the default), the module automatically handles the Android runtime permission request. -4. The result is passed back to checkout, which then proceeds to show relevant pickup points if permission was granted. +4. The response is passed back to checkout, which then proceeds to show relevant pickup points if permission was granted. > [!NOTE] > If the user denies location permissions, the checkout will still function but will not be able to show nearby pickup points. Users can manually enter their location instead. @@ -751,13 +751,14 @@ The geolocation request flow follows this sequence: > [!NOTE] > This section is only applicable for Android. -There are two ways to opt out, depending on whether you want to override the -behavior for every presentation or just one. +There are two ways to customize Android geolocation handling, depending on +whether you want to override the behavior for one presentation or disable the +fallback globally. **Per-call override.** Pass an `onGeolocationRequest` callback to `present()`. When set, the callback fires instead of the default handler for that one presentation; the consumer is responsible for resolving -permissions and calling `initiateGeolocationRequest(allow)`: +permissions and calling `event.respond(allow)`: ```tsx shopify.present(checkoutUrl, { @@ -769,16 +770,19 @@ shopify.present(checkoutUrl, { const granted = results[coarse] === 'granted' || results[fine] === 'granted'; - shopify.initiateGeolocationRequest(granted); + event.respond(granted); }, }); ``` -**Process-wide opt-out.** Set `features.handleGeolocationRequests` to -`false` when you instantiate the `ShopifyCheckout` class to disable the -default handler entirely. Use this if you intend to always handle -geolocation yourself but don't want to wire the callback at every call -site. +`event.respond(...)` resolves checkout's pending WebView geolocation request. +It does not request OS permissions by itself. + +**Process-wide default-handler opt-out.** Set +`features.handleGeolocationRequests` to `false` when you instantiate the +`ShopifyCheckout` class to disable the default handler entirely. When this is +set, pass `onGeolocationRequest` to any `present()` call that may need +geolocation; otherwise the checkout geolocation request will not be resolved. ```tsx const shopifyCheckout = new ShopifyCheckout(config, {handleGeolocationRequests: false}); diff --git a/platforms/react-native/__mocks__/react-native.ts b/platforms/react-native/__mocks__/react-native.ts index 6ec6e733..a0faa309 100644 --- a/platforms/react-native/__mocks__/react-native.ts +++ b/platforms/react-native/__mocks__/react-native.ts @@ -52,14 +52,17 @@ const exampleConfig = { const ShopifyCheckoutKit = { version: '0.7.0', - getConstants: jest.fn(() => ({version: '0.7.0'})), + getConstants: jest.fn(() => ({ + version: '0.7.0', + dispatchEventTypes: ['close', 'fail', 'geolocationRequest'], + })), preload: jest.fn(), present: jest.fn(), dismiss: jest.fn(), invalidateCache: jest.fn(), getConfig: jest.fn(() => exampleConfig), setConfig: jest.fn(), - initiateGeolocationRequest: jest.fn(), + respondToGeolocationRequest: jest.fn(), configureAcceleratedCheckouts: jest.fn(() => true), isAcceleratedCheckoutAvailable: jest.fn(() => true), isApplePayAvailable: jest.fn(() => true), diff --git a/platforms/react-native/modules/@shopify/checkout-kit-react-native/android/src/main/java/com/shopify/reactnative/checkoutkit/CustomCheckoutListener.java b/platforms/react-native/modules/@shopify/checkout-kit-react-native/android/src/main/java/com/shopify/reactnative/checkoutkit/CustomCheckoutListener.java index 8756362a..133de8e4 100644 --- a/platforms/react-native/modules/@shopify/checkout-kit-react-native/android/src/main/java/com/shopify/reactnative/checkoutkit/CustomCheckoutListener.java +++ b/platforms/react-native/modules/@shopify/checkout-kit-react-native/android/src/main/java/com/shopify/reactnative/checkoutkit/CustomCheckoutListener.java @@ -23,7 +23,6 @@ of this software and associated documentation files (the "Software"), to deal package com.shopify.reactnative.checkoutkit; -import android.content.Context; import android.util.Log; import android.webkit.GeolocationPermissions; @@ -32,8 +31,6 @@ of this software and associated documentation files (the "Software"), to deal import com.shopify.checkoutkit.*; import com.facebook.react.bridge.Callback; -import com.facebook.react.modules.core.DeviceEventManagerModule; -import com.facebook.react.bridge.ReactApplicationContext; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.node.ObjectNode; import java.io.IOException; @@ -41,7 +38,8 @@ of this software and associated documentation files (the "Software"), to deal import java.util.Map; public class CustomCheckoutListener extends DefaultCheckoutListener { - private final ReactApplicationContext reactContext; + private static final String TAG = "ShopifyCheckoutKit"; + private final ObjectMapper mapper = new ObjectMapper(); @Nullable @@ -52,9 +50,7 @@ public class CustomCheckoutListener extends DefaultCheckoutListener { private String geolocationOrigin; private GeolocationPermissions.Callback geolocationCallback; - public CustomCheckoutListener(Context context, ReactApplicationContext reactContext, - @Nullable Callback dispatch) { - this.reactContext = reactContext; + public CustomCheckoutListener(@Nullable Callback dispatch) { this.dispatchCallback = dispatch; } @@ -68,6 +64,12 @@ public void invokeGeolocationCallback(boolean allow) { } } + public void release() { + dispatchCallback = null; + geolocationCallback = null; + geolocationOrigin = null; + } + // Lifecycle events /** @@ -88,14 +90,18 @@ public void onGeolocationPermissionsShowPrompt(@NonNull String origin, this.geolocationOrigin = origin; if (dispatchCallback == null) { + // Multi-shot geolocation requests can in principle arrive after a + // terminal event has nulled the dispatcher. Log so the silence is + // observable rather than mystifying. + Log.w(TAG, "Dropping geolocationRequest \u2014 dispatcher already released by a terminal event."); return; } try { Map payload = new HashMap<>(); payload.put("origin", origin); - dispatchCallback.invoke(buildEnvelope("geolocationRequest", payload)); + dispatchCallback.invoke(buildEnvelope(DispatchEventTypes.GEOLOCATION_REQUEST, payload)); } catch (IOException e) { - Log.e("ShopifyCheckoutKit", "Error emitting \"geolocationRequest\" event", e); + Log.e(TAG, "Error emitting \"geolocationRequest\" event", e); } } @@ -109,29 +115,33 @@ public void onGeolocationPermissionsHidePrompt() { @Override public void onCheckoutFailed(CheckoutException checkoutError) { - if (dispatchCallback == null) { + Callback dispatch = dispatchCallback; + if (dispatch == null) { + release(); return; } try { - dispatchCallback.invoke(buildEnvelope("fail", populateErrorDetails(checkoutError))); + dispatch.invoke(buildEnvelope(DispatchEventTypes.FAIL, populateErrorDetails(checkoutError))); } catch (IOException e) { - Log.e("ShopifyCheckoutKit", "Error processing checkout failed event", e); + Log.e(TAG, "Error processing checkout failed event", e); } finally { - dispatchCallback = null; + release(); } } @Override public void onCheckoutCanceled() { - if (dispatchCallback == null) { + Callback dispatch = dispatchCallback; + if (dispatch == null) { + release(); return; } try { - dispatchCallback.invoke(buildEnvelope("close", null)); + dispatch.invoke(buildEnvelope(DispatchEventTypes.CLOSE, null)); } catch (IOException e) { - Log.e("ShopifyCheckoutKit", "Error processing checkout canceled event", e); + Log.e(TAG, "Error processing checkout canceled event", e); } finally { - dispatchCallback = null; + release(); } } @@ -150,7 +160,6 @@ private Map populateErrorDetails(CheckoutException checkoutError Map errorMap = new HashMap(); errorMap.put("__typename", getErrorTypeName(checkoutError)); errorMap.put("message", checkoutError.getErrorDescription()); - errorMap.put("recoverable", checkoutError.isRecoverable()); errorMap.put("code", checkoutError.getErrorCode()); if (checkoutError instanceof HttpException) { @@ -176,9 +185,4 @@ private String getErrorTypeName(CheckoutException error) { } } - private void sendEventWithStringData(String name, String data) { - reactContext - .getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter.class) - .emit(name, data); - } } diff --git a/platforms/react-native/modules/@shopify/checkout-kit-react-native/android/src/main/java/com/shopify/reactnative/checkoutkit/DispatchEventTypes.java b/platforms/react-native/modules/@shopify/checkout-kit-react-native/android/src/main/java/com/shopify/reactnative/checkoutkit/DispatchEventTypes.java new file mode 100644 index 00000000..2a684c33 --- /dev/null +++ b/platforms/react-native/modules/@shopify/checkout-kit-react-native/android/src/main/java/com/shopify/reactnative/checkoutkit/DispatchEventTypes.java @@ -0,0 +1,49 @@ +/* +MIT License + +Copyright 2023 - Present, Shopify Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +*/ + +package com.shopify.reactnative.checkoutkit; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +/** + * Canonical list of SDK lifecycle event types emitted by the + * per-{@code present()} dispatcher. + * + * Mirrors {@code SDK_LIFECYCLE_EVENT_TYPES} in the JS package and + * {@code DispatchEventType} on iOS. Exposed to JS via + * {@code getTypedExportedConstants()} so the JS layer can verify the + * two sides agree at construction time. + */ +public final class DispatchEventTypes { + public static final String CLOSE = "close"; + public static final String FAIL = "fail"; + public static final String GEOLOCATION_REQUEST = "geolocationRequest"; + + public static final List ALL = Collections.unmodifiableList( + Arrays.asList(CLOSE, FAIL, GEOLOCATION_REQUEST)); + + private DispatchEventTypes() {} +} 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 31b8fc4f..120eec1c 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 @@ -65,6 +65,9 @@ public ShopifyCheckoutKitModule(ReactApplicationContext reactContext) { protected Map getTypedExportedConstants() { final Map constants = new HashMap<>(); constants.put("version", ShopifyCheckoutKit.version); + // Exposed so the JS layer can verify the SDK lifecycle event set + // it was built against matches what this native module emits. + constants.put("dispatchEventTypes", DispatchEventTypes.ALL); return constants; } @@ -80,24 +83,36 @@ public void removeListeners(double count) { @ReactMethod public void present(String checkoutURL, @Nullable Callback dispatch) { + releaseCheckoutListener(); + Activity currentActivity = getCurrentActivity(); if (currentActivity instanceof ComponentActivity) { - checkoutListener = new CustomCheckoutListener(currentActivity, this.reactContext, dispatch); + CustomCheckoutListener listener = new CustomCheckoutListener(dispatch); + checkoutListener = listener; currentActivity.runOnUiThread(() -> { checkoutSheet = ShopifyCheckoutKit.present(checkoutURL, (ComponentActivity) currentActivity, - checkoutListener); + listener); }); } } @ReactMethod public void dismiss() { + releaseCheckoutListener(); + if (checkoutSheet != null) { checkoutSheet.dismiss(); checkoutSheet = null; } } + private void releaseCheckoutListener() { + if (checkoutListener != null) { + checkoutListener.release(); + checkoutListener = null; + } + } + @ReactMethod(isBlockingSynchronousMethod = true) public WritableMap getConfig() { WritableMap resultConfig = Arguments.createMap(); @@ -170,7 +185,7 @@ public boolean isApplePayAvailable() { } @ReactMethod - public void initiateGeolocationRequest(boolean allow) { + public void respondToGeolocationRequest(boolean allow) { if (checkoutListener != null) { checkoutListener.invokeGeolocationCallback(allow); } 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 52f0d49f..bb39293b 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 @@ -4,7 +4,6 @@ ```ts -import type { EmitterSubscription } from 'react-native'; import type { PropsWithChildren } from 'react'; import { default as React_2 } from 'react'; @@ -134,19 +133,6 @@ export enum CheckoutErrorCode { unknown = 'unknown', } -// @public (undocumented) -export type CheckoutEvent = 'close' | 'error' | 'geolocationRequest'; - -// Warning: (ae-forgotten-export) The symbol "CloseEventCallback" needs to be exported by the entry point index.d.ts -// Warning: (ae-forgotten-export) The symbol "CheckoutExceptionCallback" needs to be exported by the entry point index.d.ts -// Warning: (ae-forgotten-export) The symbol "GeolocationRequestEventCallback" needs to be exported by the entry point index.d.ts -// -// @public (undocumented) -export type CheckoutEventCallback = -| CloseEventCallback -| CheckoutExceptionCallback -| GeolocationRequestEventCallback; - // @public (undocumented) export type CheckoutException = | CheckoutClientError @@ -217,6 +203,11 @@ export type Configuration = CommonConfiguration & { // @public (undocumented) export class ConfigurationError extends GenericErrorWithCode {} +// @public +export class DispatchEventParityError extends Error { + constructor(message: string); +} + // @public export interface Features { handleGeolocationRequests: boolean; @@ -243,8 +234,8 @@ export class GenericError { // @public (undocumented) export interface GeolocationRequestEvent { - // (undocumented) origin: string; + respond: (allow: boolean) => void; } // @public (undocumented) @@ -270,6 +261,13 @@ export enum LogLevel { error = 'error', } +// @public +export interface PresentCallbacks { + onClose?: () => void; + onFail?: (error: CheckoutException) => void; + onGeolocationRequest?: (event: GeolocationRequestEvent) => void; +} + // @public (undocumented) export enum RenderState { // (undocumented) @@ -295,14 +293,11 @@ export class ShopifyCheckout implements ShopifyCheckoutKit { constructor(configuration?: Configuration, features?: Partial); // (undocumented) get acceleratedCheckoutsReady(): boolean; - addEventListener(event: CheckoutEvent, callback: CheckoutEventCallback): EmitterSubscription | undefined; configureAcceleratedCheckouts(config: AcceleratedCheckoutConfiguration): boolean; dismiss(): void; getConfig(): Configuration; - initiateGeolocationRequest(allow: boolean): Promise; isAcceleratedCheckoutAvailable(): boolean; - present(checkoutUrl: string): void; - removeEventListeners(event: CheckoutEvent): void; + present(checkoutUrl: string, callbacks?: PresentCallbacks): void; setConfig(configuration: Configuration): void; teardown(): void; // (undocumented) @@ -321,9 +316,9 @@ export function useShopifyCheckout(): Context; // Warnings were encountered during analysis: // -// lib/typescript/src/_types/index.d.ts:149:11 - (ae-forgotten-export) The symbol "IosColors" needs to be exported by the entry point index.d.ts -// lib/typescript/src/_types/index.d.ts:150:11 - (ae-forgotten-export) The symbol "AndroidColors" needs to be exported by the entry point index.d.ts -// lib/typescript/src/_types/index.d.ts:163:11 - (ae-forgotten-export) The symbol "AndroidAutomaticColors" needs to be exported by the entry point index.d.ts +// lib/typescript/src/_types/index.d.ts:148:11 - (ae-forgotten-export) The symbol "IosColors" needs to be exported by the entry point index.d.ts +// lib/typescript/src/_types/index.d.ts:149:11 - (ae-forgotten-export) The symbol "AndroidColors" needs to be exported by the entry point index.d.ts +// lib/typescript/src/_types/index.d.ts:162:11 - (ae-forgotten-export) The symbol "AndroidAutomaticColors" needs to be exported by the entry point index.d.ts // (No @packageDocumentation comment for this package) 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 ce2dff3e..76ac0da7 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 @@ -28,6 +28,19 @@ import ShopifyCheckoutKit import SwiftUI import UIKit +/// Canonical list of SDK lifecycle event types emitted by the +/// per-`present()` dispatcher. +/// +/// Mirrors `SDK_LIFECYCLE_EVENT_TYPES` in the JS package and +/// `DispatchEventTypes` on Android. Exposed to JS via +/// `constantsToExport()` so the JS layer can verify the two sides +/// agree at construction time. +enum DispatchEventType: String, CaseIterable { + case close + case fail + case geolocationRequest +} + @objc(RCTShopifyCheckoutKit) class RCTShopifyCheckoutKit: NSObject { internal var checkoutSheet: UIViewController? @@ -35,9 +48,11 @@ class RCTShopifyCheckoutKit: NSObject { private var acceleratedCheckoutsApplePayConfiguration: Any? private var defaultLogLevel: LogLevel = .error - // TODO: invoke once the iOS CheckoutDelegate (or equivalent) lands upstream — until then, - // the dispatcher is stored but never fired (Android is the only platform delivering events). - // When wired, dispatch envelope JSON strings of the shape `{"type":"close"|"fail","payload":...}`. + /// Per-call dispatcher passed in from JS. Holds onto an + /// `RCTResponseSenderBlock` for the duration of one `present()` call; + /// nulled on the first terminal SDK lifecycle event so a single + /// presentation can only ever fire `close` or `fail` once. Matches + /// the Android `CustomCheckoutListener.dispatchCallback` lifecycle. private var pendingDispatchCallback: RCTResponseSenderBlock? @objc var methodQueue: DispatchQueue { @@ -58,7 +73,10 @@ class RCTShopifyCheckoutKit: NSObject { @objc func constantsToExport() -> [AnyHashable: Any]! { return [ - "version": ShopifyCheckoutKit.version + "version": ShopifyCheckoutKit.version, + // Surfaced so the JS layer can verify the SDK lifecycle event set + // it was built against matches what this native module emits. + "dispatchEventTypes": DispatchEventType.allCases.map { $0.rawValue } ] } @@ -94,7 +112,9 @@ class RCTShopifyCheckoutKit: NSObject { @objc func dismiss() { DispatchQueue.main.async { + self.pendingDispatchCallback = nil self.checkoutSheet?.dismiss(animated: true) + self.checkoutSheet = nil } } @@ -103,14 +123,17 @@ class RCTShopifyCheckoutKit: NSObject { } @objc func present(_ checkoutURL: String, dispatch: RCTResponseSenderBlock?) { - pendingDispatchCallback = dispatch - DispatchQueue.main.async { - if let url = URL(string: checkoutURL), let viewController = self.getCurrentViewController() { - let view = CheckoutViewController(checkout: url) - viewController.present(view, animated: true) - self.checkoutSheet = view + self.pendingDispatchCallback = nil + + guard let url = URL(string: checkoutURL), let viewController = self.getCurrentViewController() else { + return } + + self.pendingDispatchCallback = dispatch + let view = CheckoutViewController(checkout: url, delegate: self) + viewController.present(view, animated: true) + self.checkoutSheet = view } } @@ -242,7 +265,7 @@ class RCTShopifyCheckoutKit: NSObject { return NSNumber(value: available) } - @objc func initiateGeolocationRequest(_ allow: Bool) { + @objc func respondToGeolocationRequest(_ allow: Bool) { // No-op on iOS — geolocation permission is handled natively } @@ -271,3 +294,122 @@ class RCTShopifyCheckoutKit: NSObject { } } } + +// MARK: - CheckoutDelegate + +extension RCTShopifyCheckoutKit: CheckoutDelegate { + /// Fired by the iOS SDK when the buyer dismisses the checkout sheet + /// without a terminal error. Mirrors + /// `CustomCheckoutListener.onCheckoutCanceled()` on Android. + /// + /// Unlike Android — where the dialog handles its own dismissal before + /// notifying the listener — the iOS SDK invokes this delegate from + /// `CheckoutWebViewController.@IBAction close()` and `presentationControllerDidDismiss` + /// without dismissing the presented view controller itself. Without + /// the explicit `dismiss(animated:)` below, tapping the X in the + /// sheet header fires `onClose` to JS but leaves the sheet visible. + func checkoutDidCancel() { + emitDispatchEnvelope(type: .close, payload: nil) + dismissCheckoutSheet() + } + + /// Fired by the iOS SDK when checkout terminates with an error. + /// Mirrors `CustomCheckoutListener.onCheckoutFailed()` on Android. + /// The error is serialised into the JS-side `CheckoutNativeError` + /// shape (`__typename` / `message` / `code` / optional + /// `statusCode`) so it can be coerced into the matching + /// `CheckoutException` subclass on the JS side. + /// + /// The sheet is left visible — consumers may want to render a + /// recovery UI on top of the still-presented checkout, or decide to + /// dismiss it explicitly via `ShopifyCheckoutKit.dismiss()` from + /// their `onFail` handler. Mirrors the Android behaviour where + /// `onCheckoutFailed` also does not auto-dismiss the dialog. + func checkoutDidFail(error: CheckoutError) { + emitDispatchEnvelope(type: .fail, payload: Self.errorPayload(from: error)) + } + + /// Dismisses the currently-presented checkout sheet on the main + /// queue and releases our reference to it. Safe to call when no + /// sheet is presented — `checkoutSheet` will simply be `nil`. + private func dismissCheckoutSheet() { + DispatchQueue.main.async { [weak self] in + self?.checkoutSheet?.dismiss(animated: true) + self?.checkoutSheet = nil + } + } +} + +// MARK: - Dispatch envelope helpers + +private extension RCTShopifyCheckoutKit { + /// Builds a `{ "type": ..., "payload": ... }` envelope and forwards + /// it to the pending JS dispatcher. SDK lifecycle envelopes are + /// single-shot: the callback is released after emission so the same + /// presentation can only fire one terminal event. + func emitDispatchEnvelope(type: DispatchEventType, payload: [String: Any]?) { + guard let dispatch = pendingDispatchCallback else { return } + // Single-shot for SDK lifecycle events — release before invoking + // so a delegate callback that re-enters this code path (e.g. via + // a synchronous JS callback that triggers `dismiss()`) cannot + // emit a second envelope on the same handle. + pendingDispatchCallback = nil + + var envelope: [String: Any] = ["type": type.rawValue] + if let payload { + envelope["payload"] = payload + } + + do { + let data = try JSONSerialization.data(withJSONObject: envelope, options: []) + guard let json = String(data: data, encoding: .utf8) else { + NSLog("[ShopifyCheckoutKit] Failed to encode dispatch envelope for \(type.rawValue): non-UTF8 result") + return + } + dispatch([json]) + } catch { + NSLog("[ShopifyCheckoutKit] Failed to serialize dispatch envelope for \(type.rawValue): \(error)") + } + } + + /// Maps an iOS `CheckoutError` into the JSON-friendly dictionary + /// shape the JS dispatcher expects. Field names match Android's + /// `CustomCheckoutListener.populateErrorDetails` so the JS-side + /// `parseCheckoutError` works identically on both platforms. + static func errorPayload(from error: CheckoutError) -> [String: Any] { + switch error { + case let .sdkError(underlying): + return [ + "__typename": "InternalError", + "message": underlying.localizedDescription, + "code": CheckoutErrorCode.unknown.rawValue + ] + + case let .checkoutUnavailable(message, code): + switch code { + case let .clientError(clientCode): + return [ + "__typename": "CheckoutClientError", + "message": message, + "code": clientCode.rawValue + ] + case let .httpError(statusCode): + return [ + "__typename": "CheckoutHTTPError", + "message": message, + // Matches the JS-side `CheckoutErrorCode.httpError` + // string and Android's HttpException code value. + "code": "http_error", + "statusCode": statusCode + ] + } + + case let .checkoutExpired(message, code): + return [ + "__typename": "CheckoutExpiredError", + "message": message, + "code": code.rawValue + ] + } + } +} 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 25fee8c1..792be1dd 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 @@ -5,6 +5,7 @@ "android/src/main/AndroidManifest.xml", "android/src/main/AndroidManifestNew.xml", "android/src/main/java/com/shopify/reactnative/checkoutkit/CustomCheckoutListener.java", + "android/src/main/java/com/shopify/reactnative/checkoutkit/DispatchEventTypes.java", "android/src/main/java/com/shopify/reactnative/checkoutkit/ShopifyCheckoutKitModule.java", "android/src/main/java/com/shopify/reactnative/checkoutkit/ShopifyCheckoutKitPackage.java", "ios/AcceleratedCheckoutButtons.swift", @@ -18,6 +19,8 @@ "lib/commonjs/components/AcceleratedCheckoutButtons.js.map", "lib/commonjs/context.js", "lib/commonjs/context.js.map", + "lib/commonjs/dispatch-events.js", + "lib/commonjs/dispatch-events.js.map", "lib/commonjs/errors.d.js", "lib/commonjs/errors.d.js.map", "lib/commonjs/index.d.js", @@ -32,6 +35,8 @@ "lib/module/components/AcceleratedCheckoutButtons.js.map", "lib/module/context.js", "lib/module/context.js.map", + "lib/module/dispatch-events.js", + "lib/module/dispatch-events.js.map", "lib/module/errors.d.js", "lib/module/errors.d.js.map", "lib/module/index.d.js", @@ -46,6 +51,8 @@ "lib/typescript/src/components/AcceleratedCheckoutButtons.d.ts.map", "lib/typescript/src/context.d.ts", "lib/typescript/src/context.d.ts.map", + "lib/typescript/src/dispatch-events.d.ts", + "lib/typescript/src/dispatch-events.d.ts.map", "lib/typescript/src/index.d.ts", "lib/typescript/src/index.d.ts.map", "lib/typescript/src/specs/NativeShopifyCheckoutKit.d.ts", @@ -57,6 +64,7 @@ "RNShopifyCheckoutKit.podspec", "src/components/AcceleratedCheckoutButtons.tsx", "src/context.tsx", + "src/dispatch-events.ts", "src/errors.d.ts", "src/index.d.ts", "src/index.ts", diff --git a/platforms/react-native/modules/@shopify/checkout-kit-react-native/src/dispatch-events.ts b/platforms/react-native/modules/@shopify/checkout-kit-react-native/src/dispatch-events.ts new file mode 100644 index 00000000..f113a07d --- /dev/null +++ b/platforms/react-native/modules/@shopify/checkout-kit-react-native/src/dispatch-events.ts @@ -0,0 +1,143 @@ +/* +MIT License + +Copyright 2023 - Present, Shopify Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +*/ + +/** + * Canonical list of SDK lifecycle event types delivered through the + * per-`present()` dispatcher. + * + * The set must be kept in sync with the native equivalents: + * - android: `DispatchEventTypes.ALL` (Java) + * - ios: `DispatchEventType.allCases` (Swift) + * + * Drift is detected at runtime by `verifyDispatchEventParity`, which is + * invoked from the `ShopifyCheckout` constructor against the + * `dispatchEventTypes` array reported by `RNShopifyCheckoutKit.getConstants()`. + */ +export const SDK_LIFECYCLE_EVENT_TYPES = [ + 'close', + 'fail', + 'geolocationRequest', +] as const; + +export type SdkLifecycleEventType = (typeof SDK_LIFECYCLE_EVENT_TYPES)[number]; + +const sdkLifecycleEventSet: ReadonlySet = new Set( + SDK_LIFECYCLE_EVENT_TYPES, +); + +export function isSdkLifecycleEventType( + value: string, +): value is SdkLifecycleEventType { + return sdkLifecycleEventSet.has(value); +} + +/** + * Thrown when the SDK lifecycle event list reported by the native + * module does not match the list this JS package was built against. + * + * This almost always means the bundled native module is older or newer + * than the JS package — the host app needs a clean native rebuild. + */ +export class DispatchEventParityError extends Error { + constructor(message: string) { + super(message); + this.name = 'DispatchEventParityError'; + + if (Error.captureStackTrace) { + Error.captureStackTrace(this, DispatchEventParityError); + } + } +} + +let parityVerified = false; + +/** + * Compares the JS-side SDK lifecycle event list against the list the + * native module reports through `getConstants()`. Throws a + * `DispatchEventParityError` describing the diff on mismatch — the + * dispatch contract is unsafe to use otherwise. + * + * Set-equality, order-independent. Memoised: runs at most once per JS + * process. Use `__resetDispatchEventParityForTests` to reset in tests. + */ +export function verifyDispatchEventParity( + nativeTypes: readonly string[] | undefined | null, +): void { + if (parityVerified) return; + + if (!Array.isArray(nativeTypes)) { + throw new DispatchEventParityError( + buildMessage( + 'native module did not report a `dispatchEventTypes` array in getConstants(). ' + + 'The bundled native module is likely older than this JS package.', + ), + ); + } + + const jsSet = new Set(SDK_LIFECYCLE_EVENT_TYPES); + const nativeSet = new Set(nativeTypes); + + const missingFromJs = [...nativeSet].filter(t => !jsSet.has(t)).sort(); + const missingFromNative = [...jsSet].filter(t => !nativeSet.has(t)).sort(); + + if (missingFromJs.length === 0 && missingFromNative.length === 0) { + parityVerified = true; + return; + } + + const lines = [ + `js = [${[...jsSet].sort().join(', ')}]`, + `native = [${[...nativeSet].sort().join(', ')}]`, + ]; + if (missingFromJs.length > 0) { + lines.push(`events missing from js: ${missingFromJs.join(', ')}`); + } + if (missingFromNative.length > 0) { + lines.push(`events missing from native: ${missingFromNative.join(', ')}`); + } + + throw new DispatchEventParityError(buildMessage(lines.join('\n '))); +} + +function buildMessage(detail: string): string { + return ( + '[ShopifyCheckoutKit] SDK lifecycle event list out of sync between JS ' + + "and native. Rebuild your host app so the bundled native module matches " + + "this version of '@shopify/checkout-kit-react-native'.\n " + + detail + ); +} + +/** + * Test-only — resets the cached verification flag so unit tests can + * exercise both success and failure paths in isolation. Not part of + * the public API. + */ +export function __resetDispatchEventParityForTests(): void { + if (typeof process !== 'undefined' && process.env.NODE_ENV !== 'test') { + // eslint-disable-next-line no-console + console.warn('[ShopifyCheckoutKit] Test-only function called in production'); + return; + } + parityVerified = false; +} 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 b2a6e82c..31df3b23 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 @@ -30,9 +30,9 @@ export type Maybe = T | undefined; */ export interface Features { /** - * When enabled, the checkout will handle geolocation permission requests internally. - * If disabled, geolocation requests will emit a 'geolocationRequest' event that - * must be handled by the application. + * When enabled, checkout handles Android geolocation permission requests internally. + * If disabled, applications must pass an `onGeolocationRequest` callback to + * `present()` and resolve each request with `event.respond(allow)`. */ handleGeolocationRequests: boolean; } @@ -165,7 +165,18 @@ export type Configuration = CommonConfiguration & { ); export interface GeolocationRequestEvent { + /** + * The WebView origin requesting geolocation access. + */ origin: string; + /** + * Resolves the pending Android WebView geolocation request. + * + * This does not request OS location permissions. Consumers should request or + * check Android permissions first, then call `respond(...)` with the resolved + * allow/deny value. + */ + respond: (allow: boolean) => void; } /** @@ -197,8 +208,7 @@ export interface PresentCallbacks { * * When set, this overrides the default internal handler driven by * `features.handleGeolocationRequests`. The consumer is responsible - * for calling `initiateGeolocationRequest(allow)` once permissions - * have been resolved. + * for resolving Android permissions and calling `event.respond(allow)`. */ onGeolocationRequest?: (event: GeolocationRequestEvent) => void; } 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 f0ca8f27..348b8400 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 @@ -26,6 +26,12 @@ import type {PermissionStatus} from 'react-native'; import RNShopifyCheckoutKit from './specs/NativeShopifyCheckoutKit'; import {ShopifyCheckoutProvider, useShopifyCheckout} from './context'; import {ApplePayContactField, ColorScheme, LogLevel} from './index.d'; +import { + DispatchEventParityError, + isSdkLifecycleEventType, + verifyDispatchEventParity, + type SdkLifecycleEventType, +} from './dispatch-events'; import type { AcceleratedCheckoutConfiguration, Configuration, @@ -75,7 +81,8 @@ class ShopifyCheckout implements ShopifyCheckoutKit { // TurboModule constants are immutable for the lifetime of the process — // capture once so `version` (and any future constants) can be read without - // re-crossing the JSI boundary on every access. + // re-crossing the JSI boundary on every access. Reading them here is also + // the moment we verify that the JS and native dispatch contracts agree. private readonly constants = RNShopifyCheckoutKit.getConstants(); public get acceleratedCheckoutsReady(): boolean { @@ -95,6 +102,11 @@ class ShopifyCheckout implements ShopifyCheckoutKit { configuration?: Configuration, features: Partial = defaultFeatures, ) { + // Fail fast if the bundled native module disagrees with this JS + // package about which SDK lifecycle events exist. Memoised, so this + // is a one-time cost per JS process. + verifyDispatchEventParity(this.constants.dispatchEventTypes); + this.features = {...defaultFeatures, ...features}; if (configuration != null) { @@ -196,16 +208,6 @@ class ShopifyCheckout implements ShopifyCheckoutKit { return RNShopifyCheckoutKit.isAcceleratedCheckoutAvailable(); } - /** - * Initiates a geolocation request for Android devices - * Only needed if features.handleGeolocationRequests is false - */ - public async initiateGeolocationRequest(allow: boolean) { - if (Platform.OS === 'android') { - RNShopifyCheckoutKit.initiateGeolocationRequest?.(allow); - } - } - // --- private /** @@ -286,6 +288,17 @@ class ShopifyCheckout implements ShopifyCheckoutKit { return this.features[feature] ?? true; } + /** + * Resolves the pending Android WebView geolocation permission request. + * This does not request OS location permissions; callers should check + * or request Android permissions before responding. + */ + private respondToGeolocationRequest(allow: boolean): void { + if (Platform.OS === 'android') { + RNShopifyCheckoutKit.respondToGeolocationRequest?.(allow); + } + } + /** * Builds the single per-call dispatcher passed to the native bridge. * Returns null when there is nothing for the bridge to deliver back — @@ -304,43 +317,95 @@ class ShopifyCheckout implements ShopifyCheckoutKit { } return (envelopeJson: string) => { - let envelope: {type?: string; payload?: unknown}; + let envelope: unknown; try { envelope = JSON.parse(envelopeJson); } catch { - const parseError = new LifecycleEventParseError( - 'Failed to parse present() dispatcher envelope: Invalid JSON', - {cause: 'Invalid JSON'}, + logParseError('envelope is not valid JSON', envelopeJson); + return; + } + + if (!isPlainObject(envelope) || typeof envelope.type !== 'string') { + logParseError( + 'envelope is missing a string `type` discriminator', + envelopeJson, ); - // eslint-disable-next-line no-console - console.error(parseError, envelopeJson); return; } - switch (envelope.type) { - case 'close': - callbacks?.onClose?.(); - return; - case 'fail': - if (callbacks?.onFail) { - callbacks.onFail( - this.parseCheckoutError(envelope.payload as CheckoutNativeError), - ); - } - return; - case 'geolocationRequest': - if (callbacks?.onGeolocationRequest) { - callbacks.onGeolocationRequest( - envelope.payload as GeolocationRequestEvent, - ); - } else if (needsDefaultGeolocation) { - this.handleDefaultGeolocationRequest(); - } + const {type, payload} = envelope; + + if (isSdkLifecycleEventType(type)) { + this.routeSdkLifecycleEvent( + type, + payload, + envelopeJson, + callbacks, + needsDefaultGeolocation, + ); + return; + } + + // Loud default. The parity check at construction time should have + // already caught a native/JS mismatch — hitting this branch means + // either the bundled native module emitted something we do not + // recognise, or we are missing a handler for a future event. + // eslint-disable-next-line no-console + console.warn( + `[ShopifyCheckoutKit] Ignoring dispatch envelope with unknown type "${type}". ` + + 'The native module emitted an event the JS layer does not know how to handle. ' + + 'Confirm both sides are on compatible versions.', + ); + }; + } + + /** + * Routes a validated SDK lifecycle envelope to the matching user + * callback (or the default Android geolocation handler). Payload + * shapes are validated per case before invoking consumer code so a + * native-side regression surfaces as a `LifecycleEventParseError` + * with the offending raw envelope attached. + */ + private routeSdkLifecycleEvent( + type: SdkLifecycleEventType, + payload: unknown, + envelopeJson: string, + callbacks: PresentCallbacks | undefined, + needsDefaultGeolocation: boolean, + ): void { + switch (type) { + case 'close': + callbacks?.onClose?.(); + return; + case 'fail': { + const failPayload = validateFailPayload(payload); + if (failPayload == null) { + logParseError('`fail` envelope payload is malformed', envelopeJson); return; - default: + } + callbacks?.onFail?.(this.parseCheckoutError(failPayload)); + return; + } + case 'geolocationRequest': { + const geoPayload = validateGeolocationRequestPayload(payload); + if (geoPayload == null) { + logParseError( + '`geolocationRequest` envelope payload is malformed', + envelopeJson, + ); return; + } + if (callbacks?.onGeolocationRequest) { + callbacks.onGeolocationRequest({ + ...geoPayload, + respond: allow => this.respondToGeolocationRequest(allow), + }); + } else if (needsDefaultGeolocation) { + this.handleDefaultGeolocationRequest(); + } + return; } - }; + } } /** @@ -349,7 +414,7 @@ class ShopifyCheckout implements ShopifyCheckoutKit { */ private async handleDefaultGeolocationRequest() { const allowed = await this.requestGeolocation(); - this.initiateGeolocationRequest(allowed); + this.respondToGeolocationRequest(allowed); } /** @@ -435,107 +500,6 @@ class ShopifyCheckout implements ShopifyCheckoutKit { return new GenericError(exception); } } - -<<<<<<< HEAD - /** - * Wraps a consumer-provided `onFail` callback so the native bridge can - * hand it the raw JSON error payload it serializes today. Invalid JSON - * is reported via `LifecycleEventParseError`; the user callback only - * fires on a successful parse. - */ - private wrapFailCallback( - onFail: NonNullable, - ): (raw: string) => void { - return (raw: string) => { - try { - const parsed = JSON.parse(raw); - onFail(this.parseCheckoutError(parsed)); - } catch { - const parseError = new LifecycleEventParseError( - 'Failed to parse "onFail" callback payload: Invalid JSON', - {cause: 'Invalid JSON'}, - ); - // eslint-disable-next-line no-console - console.error(parseError, raw); - } - }; - } - - /** - * Wraps a consumer-provided `onGeolocationRequest` callback so the - * native bridge can hand it the raw JSON origin payload. Invalid JSON - * is reported via `LifecycleEventParseError`; the user callback only - * fires on a successful parse. - */ - private wrapGeolocationCallback( - onGeolocationRequest: NonNullable, - ): (raw: string) => void { - return (raw: string) => { - try { - const parsed = JSON.parse(raw); - onGeolocationRequest(parsed); - } catch { - const parseError = new LifecycleEventParseError( - 'Failed to parse "onGeolocationRequest" callback payload: Invalid JSON', - {cause: 'Invalid JSON'}, - ); - // eslint-disable-next-line no-console - console.error(parseError, raw); - } - }; - } -||||||| parent of 2b6a1474 (feat: explore dynamic dispatch for checkout delegate) - /** - * Wraps a consumer-provided `onFail` callback so the native bridge can - * hand it the raw JSON error payload it serializes today. Invalid JSON - * is reported via `LifecycleEventParseError`; the user callback only - * fires on a successful parse. - */ - private wrapFailCallback( - onFail: NonNullable, - ): (raw: string) => void { - return (raw: string) => { - try { - const parsed = JSON.parse(raw); - onFail(this.parseCheckoutError(parsed)); - } catch { - const parseError = new LifecycleEventParseError( - 'Failed to parse "onFail" callback payload: Invalid JSON', - {cause: 'Invalid JSON'}, - ); - // eslint-disable-next-line no-console - console.error(parseError, raw); - } - }; - } - - /** - * Wraps a consumer-provided `onGeolocationRequest` callback so the - * native bridge can hand it the raw JSON origin payload. Invalid JSON - * is reported via `LifecycleEventParseError`; the user callback only - * fires on a successful parse. - */ - private wrapGeolocationCallback( - onGeolocationRequest: NonNullable< - PresentCallbacks['onGeolocationRequest'] - >, - ): (raw: string) => void { - return (raw: string) => { - try { - const parsed = JSON.parse(raw); - onGeolocationRequest(parsed); - } catch { - const parseError = new LifecycleEventParseError( - 'Failed to parse "onGeolocationRequest" callback payload: Invalid JSON', - {cause: 'Invalid JSON'}, - ); - // eslint-disable-next-line no-console - console.error(parseError, raw); - } - }; - } -======= ->>>>>>> 2b6a1474 (feat: explore dynamic dispatch for checkout delegate) } export class LifecycleEventParseError extends Error { @@ -549,6 +513,48 @@ export class LifecycleEventParseError extends Error { } } +// --- internal helpers --- + +type GeolocationRequestPayload = Pick; + +function isPlainObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +/** + * Narrow validator for `fail` envelope payloads. Only confirms the + * shape the JS dispatcher relies on — full coercion happens later in + * `parseCheckoutError`. Returns `null` on shape mismatch so the caller + * can log a `LifecycleEventParseError` instead of crashing user code. + */ +function validateFailPayload(payload: unknown): CheckoutNativeError | null { + if (!isPlainObject(payload)) return null; + if (typeof payload.__typename !== 'string') return null; + if (typeof payload.message !== 'string') return null; + if (typeof payload.code !== 'string') return null; + if ('statusCode' in payload && typeof payload.statusCode !== 'number') { + return null; + } + return payload as unknown as CheckoutNativeError; +} + +function validateGeolocationRequestPayload( + payload: unknown, +): GeolocationRequestPayload | null { + if (!isPlainObject(payload)) return null; + if (typeof payload.origin !== 'string') return null; + return {origin: payload.origin}; +} + +function logParseError(detail: string, raw: string): void { + const err = new LifecycleEventParseError( + `Failed to handle present() dispatcher envelope: ${detail}`, + {cause: detail}, + ); + // eslint-disable-next-line no-console + console.error(err, raw); +} + // API export { AcceleratedCheckoutWallet, @@ -556,6 +562,7 @@ export { ApplePayLabel, ApplePayStyle, ColorScheme, + DispatchEventParityError, LogLevel, ShopifyCheckout, ShopifyCheckoutProvider, 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 b72fb1d5..08979505 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 @@ -89,10 +89,18 @@ export interface Spec extends TurboModule { ): boolean; isAcceleratedCheckoutAvailable(): boolean; isApplePayAvailable(): boolean; - initiateGeolocationRequest(allow: boolean): void; + respondToGeolocationRequest(allow: boolean): void; addListener(eventName: string): void; removeListeners(count: number): void; - getConstants(): {version: string}; + getConstants(): { + version: string; + /** + * SDK lifecycle event types the native dispatcher may emit. Compared + * against the JS-side canonical list at construction time; a mismatch + * indicates the host app needs a native rebuild. + */ + dispatchEventTypes: string[]; + }; } export default TurboModuleRegistry.getEnforcing('ShopifyCheckoutKit'); 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 0d7df35c..b317943d 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 @@ -2,6 +2,7 @@ /* eslint-disable no-console */ import { + DispatchEventParityError, LifecycleEventParseError, ShopifyCheckout, CheckoutErrorCode, @@ -19,6 +20,7 @@ import { type Configuration, type AcceleratedCheckoutConfiguration, } from '../src'; +import {__resetDispatchEventParityForTests} from '../src/dispatch-events'; import type {ApplePayContactField} from '../src/index.d'; import {TurboModuleRegistry, PermissionsAndroid, Platform} from 'react-native'; @@ -39,6 +41,16 @@ global.console = { warn: jest.fn(), }; +beforeEach(() => { + // Parity verification is memoised per-process; reset it so each test + // exercises a fresh check against whatever mock constants are in play. + __resetDispatchEventParityForTests(); + NativeModule.getConstants.mockReturnValue({ + version: '0.7.0', + dispatchEventTypes: ['close', 'fail', 'geolocationRequest'], + }); +}); + describe('Exports', () => { describe('AcceleratedCheckoutWallet enum', () => { it('exports correct wallet types', () => { @@ -136,35 +148,16 @@ describe('ShopifyCheckoutKit', () => { const instance = new ShopifyCheckout(); const onClose = jest.fn(); instance.present(checkoutUrl, {onClose}); -<<<<<<< HEAD - expect(NativeModule.present).toHaveBeenCalledWith( - checkoutUrl, - expect.any(Function), - null, - null, - ); - const nativeOnClose = NativeModule.present.mock.calls[0][1] as () => void; - nativeOnClose(); -||||||| parent of 2b6a1474 (feat: explore dynamic dispatch for checkout delegate) - expect(NativeModule.present).toHaveBeenCalledWith( - checkoutUrl, - expect.any(Function), - null, - null, - ); - const nativeOnClose = NativeModule.present.mock - .calls[0][1] as () => void; - nativeOnClose(); -======= lastDispatch()(JSON.stringify({type: 'close'})); ->>>>>>> 2b6a1474 (feat: explore dynamic dispatch for checkout delegate) expect(onClose).toHaveBeenCalledTimes(1); }); it('ignores a close envelope when no `onClose` handler was provided', () => { const instance = new ShopifyCheckout(); instance.present(checkoutUrl, {onFail: jest.fn()}); - expect(() => lastDispatch()(JSON.stringify({type: 'close'}))).not.toThrow(); + expect(() => + lastDispatch()(JSON.stringify({type: 'close'})), + ).not.toThrow(); }); describe('onFail callback', () => { @@ -172,21 +165,18 @@ describe('ShopifyCheckoutKit', () => { __typename: CheckoutNativeErrorType.InternalError, message: 'Something went wrong', code: CheckoutErrorCode.unknown, - recoverable: true, }; const configError = { __typename: CheckoutNativeErrorType.ConfigurationError, message: 'Storefront Password Required', code: CheckoutErrorCode.storefrontPasswordRequired, - recoverable: false, }; const clientError = { __typename: CheckoutNativeErrorType.CheckoutClientError, message: 'Storefront Password Required', code: CheckoutErrorCode.storefrontPasswordRequired, - recoverable: false, }; const networkError = { @@ -194,14 +184,12 @@ describe('ShopifyCheckoutKit', () => { message: 'Checkout not found', code: CheckoutErrorCode.httpError, statusCode: 400, - recoverable: false, }; const expiredError = { __typename: CheckoutNativeErrorType.CheckoutExpiredError, message: 'Customer Account Required', code: CheckoutErrorCode.cartExpired, - recoverable: false, }; it.each([ @@ -228,17 +216,19 @@ describe('ShopifyCheckoutKit', () => { expect(calledWith).not.toHaveProperty('__typename'); expect(calledWith).toHaveProperty('code'); expect(calledWith).toHaveProperty('message'); - expect(calledWith).toHaveProperty('recoverable'); }, ); - it('falls back to GenericError when the payload has no recognised __typename', () => { + it('falls back to GenericError when the payload has an unrecognised __typename', () => { const instance = new ShopifyCheckout(); const onFail = jest.fn(); instance.present(checkoutUrl, {onFail}); + // Native always emits the three core fields; an unfamiliar + // `__typename` should still flow through as GenericError. const error = { - __typename: 'UnknownError', + __typename: 'SomeNewErrorType', message: 'Something went wrong', + code: CheckoutErrorCode.unknown, }; lastDispatch()(JSON.stringify({type: 'fail', payload: error})); const calledWith = onFail.mock.calls[0][0]; @@ -270,6 +260,7 @@ describe('ShopifyCheckoutKit', () => { ); expect(onGeolocationRequest).toHaveBeenCalledWith({ origin: 'https://shopify.com', + respond: expect.any(Function), }); }); }); @@ -287,7 +278,7 @@ describe('ShopifyCheckoutKit', () => { ); }); - it('silently ignores envelopes with unknown `type` values', () => { + it('warns via console.warn for envelopes with unknown `type` values', () => { const instance = new ShopifyCheckout(); const onClose = jest.fn(); const onFail = jest.fn(); @@ -297,6 +288,101 @@ describe('ShopifyCheckoutKit', () => { ).not.toThrow(); expect(onClose).not.toHaveBeenCalled(); expect(onFail).not.toHaveBeenCalled(); + expect(console.warn).toHaveBeenCalledWith( + expect.stringContaining('unknown type "unknown"'), + ); + }); + + it('logs a LifecycleEventParseError when the envelope is missing a string `type`', () => { + const instance = new ShopifyCheckout(); + instance.present(checkoutUrl, {onClose: jest.fn()}); + lastDispatch()(JSON.stringify({payload: {}})); + expect(console.error).toHaveBeenCalledWith( + expect.any(LifecycleEventParseError), + expect.any(String), + ); + }); + + it('logs a LifecycleEventParseError when a `fail` envelope payload is malformed', () => { + const instance = new ShopifyCheckout(); + const onFail = jest.fn(); + instance.present(checkoutUrl, {onFail}); + lastDispatch()( + JSON.stringify({type: 'fail', payload: {message: 'no typename'}}), + ); + expect(onFail).not.toHaveBeenCalled(); + expect(console.error).toHaveBeenCalledWith( + expect.any(LifecycleEventParseError), + expect.any(String), + ); + }); + + it('logs a LifecycleEventParseError when a `geolocationRequest` envelope payload is malformed', () => { + const instance = new ShopifyCheckout(); + const onGeolocationRequest = jest.fn(); + instance.present(checkoutUrl, {onGeolocationRequest}); + lastDispatch()( + JSON.stringify({type: 'geolocationRequest', payload: {}}), + ); + expect(onGeolocationRequest).not.toHaveBeenCalled(); + expect(console.error).toHaveBeenCalledWith( + expect.any(LifecycleEventParseError), + expect.any(String), + ); + }); + }); + + describe('SDK lifecycle event parity', () => { + it('throws DispatchEventParityError when native reports an extra event', () => { + NativeModule.getConstants.mockReturnValue({ + version: '0.7.0', + dispatchEventTypes: [ + 'close', + 'fail', + 'geolocationRequest', + 'newFutureEvent', + ], + }); + expect(() => new ShopifyCheckout()).toThrow(DispatchEventParityError); + }); + + it('throws DispatchEventParityError when native reports a missing event', () => { + NativeModule.getConstants.mockReturnValue({ + version: '0.7.0', + dispatchEventTypes: ['close', 'fail'], + }); + expect(() => new ShopifyCheckout()).toThrow(DispatchEventParityError); + }); + + it('throws DispatchEventParityError when native does not report the constant at all', () => { + NativeModule.getConstants.mockReturnValue({version: '0.7.0'} as any); + expect(() => new ShopifyCheckout()).toThrow(DispatchEventParityError); + }); + + it('accepts the canonical native list regardless of order', () => { + NativeModule.getConstants.mockReturnValue({ + version: '0.7.0', + dispatchEventTypes: ['geolocationRequest', 'fail', 'close'], + }); + expect(() => new ShopifyCheckout()).not.toThrow(); + }); + + it('only verifies once per JS process — a second instance reuses the cached result', () => { + new ShopifyCheckout(); + const firstCallCount = NativeModule.getConstants.mock.calls.length; + + // Mutate the native list after the first verification has been + // cached. A second instance must NOT re-throw — verification is + // memoised by design (the value is process-immutable on real + // TurboModules). + NativeModule.getConstants.mockReturnValue({ + version: '0.7.0', + dispatchEventTypes: ['close'], + }); + expect(() => new ShopifyCheckout()).not.toThrow(); + expect(NativeModule.getConstants.mock.calls.length).toBeGreaterThan( + firstCallCount, + ); }); }); }); @@ -379,7 +465,7 @@ describe('ShopifyCheckoutKit', () => { 'android.permission.ACCESS_COARSE_LOCATION', 'android.permission.ACCESS_FINE_LOCATION', ]); - expect(NativeModule.initiateGeolocationRequest).toHaveBeenCalledWith( + expect(NativeModule.respondToGeolocationRequest).toHaveBeenCalledWith( true, ); }); @@ -405,7 +491,7 @@ describe('ShopifyCheckoutKit', () => { 'android.permission.ACCESS_COARSE_LOCATION', 'android.permission.ACCESS_FINE_LOCATION', ]); - expect(NativeModule.initiateGeolocationRequest).toHaveBeenCalledWith( + expect(NativeModule.respondToGeolocationRequest).toHaveBeenCalledWith( false, ); }); @@ -419,11 +505,24 @@ describe('ShopifyCheckoutKit', () => { expect(onGeolocationRequest).toHaveBeenCalledWith({ origin: 'https://shopify.com', + respond: expect.any(Function), }); expect(PermissionsAndroid.requestMultiple).not.toHaveBeenCalled(); - expect( - NativeModule.initiateGeolocationRequest, - ).not.toHaveBeenCalled(); + expect(NativeModule.respondToGeolocationRequest).not.toHaveBeenCalled(); + }); + + it('responds to the pending native geolocation request from the event', () => { + const instance = new ShopifyCheckout(); + const onGeolocationRequest = jest.fn(); + instance.present(checkoutUrl, {onGeolocationRequest}); + lastDispatch()(geolocationEnvelope); + + const event = onGeolocationRequest.mock.calls[0][0]; + event.respond(true); + + expect(NativeModule.respondToGeolocationRequest).toHaveBeenCalledWith( + true, + ); }); it('does not run the default handler when the feature is disabled', async () => { @@ -435,9 +534,7 @@ describe('ShopifyCheckoutKit', () => { await flush(); expect(PermissionsAndroid.requestMultiple).not.toHaveBeenCalled(); - expect( - NativeModule.initiateGeolocationRequest, - ).not.toHaveBeenCalled(); + expect(NativeModule.respondToGeolocationRequest).not.toHaveBeenCalled(); }); }); @@ -464,18 +561,8 @@ describe('ShopifyCheckoutKit', () => { lastDispatch()(geolocationEnvelope); await flush(); -<<<<<<< HEAD - expect(NativeModule.initiateGeolocationRequest).not.toHaveBeenCalled(); -||||||| parent of 2b6a1474 (feat: explore dynamic dispatch for checkout delegate) - expect( - NativeModule.initiateGeolocationRequest, - ).not.toHaveBeenCalled(); -======= expect(PermissionsAndroid.requestMultiple).not.toHaveBeenCalled(); - expect( - NativeModule.initiateGeolocationRequest, - ).not.toHaveBeenCalled(); ->>>>>>> 2b6a1474 (feat: explore dynamic dispatch for checkout delegate) + expect(NativeModule.respondToGeolocationRequest).not.toHaveBeenCalled(); }); it('tears down gracefully', () => { diff --git a/platforms/react-native/sample/android/app/build.gradle b/platforms/react-native/sample/android/app/build.gradle index 1080dd0b..0f69919b 100644 --- a/platforms/react-native/sample/android/app/build.gradle +++ b/platforms/react-native/sample/android/app/build.gradle @@ -170,6 +170,7 @@ dependencies { androidTestImplementation "org.mockito:mockito-android:4.11.0" testImplementation "org.mockito:mockito-inline:5.2.0" testImplementation "org.assertj:assertj-core:3.27.6" + testImplementation "org.robolectric:robolectric:4.16.1" testImplementation(shopifySdkArtifact) if (hermesEnabled.toBoolean()) { diff --git a/platforms/react-native/sample/android/app/src/test/java/com/shopify/checkoutkitreactnative/ShopifyCheckoutKitModuleTest.java b/platforms/react-native/sample/android/app/src/test/java/com/shopify/checkoutkitreactnative/ShopifyCheckoutKitModuleTest.java index 54cbac18..52343330 100644 --- a/platforms/react-native/sample/android/app/src/test/java/com/shopify/checkoutkitreactnative/ShopifyCheckoutKitModuleTest.java +++ b/platforms/react-native/sample/android/app/src/test/java/com/shopify/checkoutkitreactnative/ShopifyCheckoutKitModuleTest.java @@ -32,15 +32,15 @@ import org.mockito.Mock; import org.mockito.MockedStatic; import org.mockito.Mockito; -import org.mockito.junit.MockitoJUnitRunner; +import org.mockito.MockitoAnnotations; +import org.robolectric.RobolectricTestRunner; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.Mockito.*; -import android.content.Context; -@RunWith(MockitoJUnitRunner.class) +@RunWith(RobolectricTestRunner.class) public class ShopifyCheckoutKitModuleTest { @Mock private ReactApplicationContext mockReactContext; @@ -48,8 +48,6 @@ public class ShopifyCheckoutKitModuleTest { private ComponentActivity mockComponentActivity; @Mock private DeviceEventManagerModule.RCTDeviceEventEmitter mockEventEmitter; - @Mock - private Context mockContext; @Captor ArgumentCaptor runnableCaptor; @@ -57,6 +55,7 @@ public class ShopifyCheckoutKitModuleTest { private ArgumentCaptor stringCaptor; private ShopifyCheckoutKitModule shopifyCheckoutKitModule; + private AutoCloseable mocks; // Store initial configuration to restore after each test private ColorScheme initialColorScheme; @@ -79,12 +78,17 @@ public class ShopifyCheckoutKitModuleTest { @Before public void setup() { + mocks = MockitoAnnotations.openMocks(this); mockedArguments = Mockito.mockStatic(Arguments.class); mockedArguments.when(Arguments::createMap).thenAnswer(invocation -> new JavaOnlyMap()); when(mockReactContext.getCurrentActivity()).thenReturn(mockComponentActivity); - when(mockReactContext.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter.class)) - .thenReturn(mockEventEmitter); + // Note: the old `CustomCheckoutListener` used `reactContext.getJSModule(...)` + // to emit DeviceEventManagerModule events. Both the field and the method + // call are gone now, replaced by the per-`present()` dispatcher callback, + // so no `getJSModule(...)` stub is required here. `mockEventEmitter` is + // still referenced from a few `verify(..., never()).emit(...)` assertions + // below that defensively confirm the legacy emit path stays dead. shopifyCheckoutKitModule = new ShopifyCheckoutKitModule(mockReactContext); // Capture initial configuration state to restore after each test @@ -93,11 +97,14 @@ public void setup() { } @After - public void tearDown() { + public void tearDown() throws Exception { // Close mocked static if (mockedArguments != null) { mockedArguments.close(); } + if (mocks != null) { + mocks.close(); + } // Reset configuration to initial state after each test ShopifyCheckoutKit.configure(configuration -> { @@ -130,7 +137,7 @@ public void testCanPresentCheckout() { @Test public void testPresentForwardsOnCloseCallback() { Callback dispatch = mock(Callback.class); - CustomCheckoutListener processor = new CustomCheckoutListener(mockContext, mockReactContext, dispatch); + CustomCheckoutListener processor = new CustomCheckoutListener(dispatch); processor.onCheckoutCanceled(); @@ -142,7 +149,7 @@ public void testPresentForwardsOnCloseCallback() { @Test public void testOnCloseCallbackIsSingleShot() { Callback dispatch = mock(Callback.class); - CustomCheckoutListener processor = new CustomCheckoutListener(mockContext, mockReactContext, dispatch); + CustomCheckoutListener processor = new CustomCheckoutListener(dispatch); processor.onCheckoutCanceled(); processor.onCheckoutCanceled(); @@ -150,11 +157,48 @@ public void testOnCloseCallbackIsSingleShot() { verify(dispatch, times(1)).invoke(any(Object[].class)); } + @Test + public void testReleaseDropsPendingDispatchCallback() { + Callback dispatch = mock(Callback.class); + CustomCheckoutListener processor = new CustomCheckoutListener(dispatch); + + processor.release(); + processor.onCheckoutCanceled(); + + verify(dispatch, never()).invoke(any(Object[].class)); + } + + @Test + public void testReleaseClearsPendingGeolocationCallback() { + Callback dispatch = mock(Callback.class); + GeolocationPermissions.Callback permissionsCallback = mock(GeolocationPermissions.Callback.class); + CustomCheckoutListener processor = new CustomCheckoutListener(dispatch); + + processor.onGeolocationPermissionsShowPrompt("https://shopify.com", permissionsCallback); + processor.release(); + processor.invokeGeolocationCallback(true); + + verify(permissionsCallback, never()).invoke(anyString(), anyBoolean(), anyBoolean()); + } + + @Test + public void testTerminalEventClearsPendingGeolocationCallback() { + Callback dispatch = mock(Callback.class); + GeolocationPermissions.Callback permissionsCallback = mock(GeolocationPermissions.Callback.class); + CustomCheckoutListener processor = new CustomCheckoutListener(dispatch); + + processor.onGeolocationPermissionsShowPrompt("https://shopify.com", permissionsCallback); + processor.onCheckoutCanceled(); + processor.invokeGeolocationCallback(true); + + verify(permissionsCallback, never()).invoke(anyString(), anyBoolean(), anyBoolean()); + } + @Test public void testGeolocationDispatchesEnvelopeWithOrigin() { Callback dispatch = mock(Callback.class); GeolocationPermissions.Callback permissionsCallback = mock(GeolocationPermissions.Callback.class); - CustomCheckoutListener processor = new CustomCheckoutListener(mockContext, mockReactContext, dispatch); + CustomCheckoutListener processor = new CustomCheckoutListener(dispatch); processor.onGeolocationPermissionsShowPrompt("https://shopify.com", permissionsCallback); @@ -169,7 +213,7 @@ public void testGeolocationDispatchesEnvelopeWithOrigin() { public void testGeolocationDispatchIsMultiShot() { Callback dispatch = mock(Callback.class); GeolocationPermissions.Callback permissionsCallback = mock(GeolocationPermissions.Callback.class); - CustomCheckoutListener processor = new CustomCheckoutListener(mockContext, mockReactContext, dispatch); + CustomCheckoutListener processor = new CustomCheckoutListener(dispatch); processor.onGeolocationPermissionsShowPrompt("https://shopify.com", permissionsCallback); processor.onGeolocationPermissionsShowPrompt("https://shopify.com", permissionsCallback); @@ -180,7 +224,7 @@ public void testGeolocationDispatchIsMultiShot() { @Test public void testGeolocationWithNoDispatchCallbackDoesNotInvoke() { GeolocationPermissions.Callback permissionsCallback = mock(GeolocationPermissions.Callback.class); - CustomCheckoutListener processor = new CustomCheckoutListener(mockContext, mockReactContext, null); + CustomCheckoutListener processor = new CustomCheckoutListener(null); processor.onGeolocationPermissionsShowPrompt("https://shopify.com", permissionsCallback); @@ -189,7 +233,7 @@ public void testGeolocationWithNoDispatchCallbackDoesNotInvoke() { @Test public void testCheckoutCanceledWithNoDispatchCallbackDoesNotEmitCloseEvent() { - CustomCheckoutListener processor = new CustomCheckoutListener(mockContext, mockReactContext, null); + CustomCheckoutListener processor = new CustomCheckoutListener(null); processor.onCheckoutCanceled(); @@ -515,7 +559,7 @@ public void testGetConfigReturnsDefaultLogLevel() { @Test public void testCanProcessCheckoutExpiredErrors() { Callback dispatch = mock(Callback.class); - CustomCheckoutListener processor = new CustomCheckoutListener(mockContext, mockReactContext, dispatch); + CustomCheckoutListener processor = new CustomCheckoutListener(dispatch); CheckoutExpiredException mockException = mock(CheckoutExpiredException.class); when(mockException.getErrorDescription()).thenReturn("Cart has expired"); @@ -527,14 +571,13 @@ public void testCanProcessCheckoutExpiredErrors() { verify(dispatch).invoke(args.capture()); assertThat((String) args.getValue()[0]) - .contains("\"type\":\"fail\"", "CheckoutExpiredError", "Cart has expired", "cart_expired", - "\"recoverable\":false"); + .contains("\"type\":\"fail\"", "CheckoutExpiredError", "Cart has expired", "cart_expired"); } @Test public void testCanProcessClientErrors() { Callback dispatch = mock(Callback.class); - CustomCheckoutListener processor = new CustomCheckoutListener(mockContext, mockReactContext, dispatch); + CustomCheckoutListener processor = new CustomCheckoutListener(dispatch); ClientException mockException = mock(ClientException.class); when(mockException.getErrorDescription()).thenReturn("Customer account required"); @@ -546,14 +589,13 @@ public void testCanProcessClientErrors() { verify(dispatch).invoke(args.capture()); assertThat((String) args.getValue()[0]) - .contains("\"type\":\"fail\"", "CheckoutClientError", "Customer account required", "customer_account_required", - "\"recoverable\":true"); + .contains("\"type\":\"fail\"", "CheckoutClientError", "Customer account required", "customer_account_required"); } @Test public void testCanProcessHttpErrors() { Callback dispatch = mock(Callback.class); - CustomCheckoutListener processor = new CustomCheckoutListener(mockContext, mockReactContext, dispatch); + CustomCheckoutListener processor = new CustomCheckoutListener(dispatch); HttpException mockException = mock(HttpException.class); when(mockException.getErrorDescription()).thenReturn("Not Found"); @@ -566,19 +608,17 @@ public void testCanProcessHttpErrors() { verify(dispatch).invoke(args.capture()); assertThat((String) args.getValue()[0]) - .contains("\"type\":\"fail\"", "CheckoutHTTPError", "Not Found", "http_error", "\"statusCode\":404", - "\"recoverable\":false"); + .contains("\"type\":\"fail\"", "CheckoutHTTPError", "Not Found", "http_error", "\"statusCode\":404"); } @Test public void testOnFailCallbackIsSingleShot() { Callback dispatch = mock(Callback.class); - CustomCheckoutListener processor = new CustomCheckoutListener(mockContext, mockReactContext, dispatch); + CustomCheckoutListener processor = new CustomCheckoutListener(dispatch); CheckoutExpiredException mockException = mock(CheckoutExpiredException.class); when(mockException.getErrorDescription()).thenReturn("Cart has expired"); when(mockException.getErrorCode()).thenReturn("cart_expired"); - when(mockException.isRecoverable()).thenReturn(false); processor.onCheckoutFailed(mockException); processor.onCheckoutFailed(mockException); @@ -588,7 +628,7 @@ public void testOnFailCallbackIsSingleShot() { @Test public void testCheckoutFailedWithNoDispatchCallbackDoesNotEmitFailEvent() { - CustomCheckoutListener processor = new CustomCheckoutListener(mockContext, mockReactContext, null); + CustomCheckoutListener processor = new CustomCheckoutListener(null); CheckoutExpiredException mockException = mock(CheckoutExpiredException.class); diff --git a/platforms/react-native/sample/ios/ReactNativeTests/CheckoutDidFailTests.swift b/platforms/react-native/sample/ios/ReactNativeTests/CheckoutDidFailTests.swift index 4d17eb68..b76a64c8 100644 --- a/platforms/react-native/sample/ios/ReactNativeTests/CheckoutDidFailTests.swift +++ b/platforms/react-native/sample/ios/ReactNativeTests/CheckoutDidFailTests.swift @@ -25,181 +25,6 @@ import Foundation @testable import RNShopifyCheckoutKit import XCTest -// TODO: re-enable when iOS CheckoutDelegate (or equivalent) lands upstream — -// parallels Android's DefaultCheckoutEventProcessor.onCheckoutCanceled / onCheckoutFailed. +// TODO: re-enable checkoutDidFail coverage when tests target the per-present +// dispatcher instead of the removed NativeEventEmitter API. class CheckoutDidFailTests: XCTestCase {} - -/* -class CheckoutDidFailTests: XCTestCase { - private var shopifyCheckoutKit: RCTShopifyCheckoutKit! - - override func setUp() { - super.setUp() - shopifyCheckoutKit = getShopifyCheckoutKit() - resetShopifyCheckoutKitDefaults() - } - - private func resetShopifyCheckoutKitDefaults() { - ShopifyCheckoutSheetKit.configuration.colorScheme = .automatic - } - - override func tearDown() { - shopifyCheckoutKit = nil - super.tearDown() - } - - private func getShopifyCheckoutKit() -> RCTShopifyCheckoutKit { - return RCTShopifyCheckoutKit() - } - - func testCheckoutDidFailEmitsCheckoutExpiredError() { - let mock = mockSendEvent(eventName: "error") - mock.startObserving() - - let error = CheckoutError.checkoutExpired( - message: "expired", - code: CheckoutErrorCode.cartExpired, - recoverable: false - ) - - mock.checkoutDidFail(error: error) - - XCTAssertTrue(mock.didSendEvent, "Event should have been sent when checkout fails") - - guard let eventBody = mock.eventBody as? [String: Any] else { - return XCTFail("Event body was not available or not in the correct format") - } - - if case .checkoutExpired = error { - XCTAssertEqual(eventBody["__typename"] as? String, "CheckoutExpiredError") - XCTAssertEqual(eventBody["message"] as? String, "expired") - XCTAssertEqual(eventBody["code"] as? String, CheckoutErrorCode.cartExpired.rawValue) - XCTAssertEqual(eventBody["recoverable"] as? Bool, false) - } else { - XCTFail("Expected checkoutExpiredError but found different error") - } - } - - func testCheckoutDidFailEmitsCheckoutClientError() { - let mock = mockSendEvent(eventName: "error") - mock.startObserving() - - let error = CheckoutError.checkoutUnavailable( - message: "expired", - code: .clientError(code: CheckoutErrorCode.cartExpired), - recoverable: false - ) - - mock.checkoutDidFail(error: error) - - XCTAssertTrue(mock.didSendEvent, "Event should have been sent when checkout fails") - - guard let eventBody = mock.eventBody as? [String: Any] else { - return XCTFail("Event body was not available or not in the correct format") - } - - if case .checkoutUnavailable = error { - XCTAssertEqual(eventBody["__typename"] as? String, "CheckoutClientError") - XCTAssertEqual(eventBody["message"] as? String, "expired") - XCTAssertEqual(eventBody["code"] as? String, CheckoutErrorCode.cartExpired.rawValue) - XCTAssertEqual(eventBody["recoverable"] as? Bool, false) - } else { - XCTFail("Expected checkoutClientError but found different error") - } - } - - func testCheckoutDidFailEmitsCheckoutHTTPError() { - let mock = mockSendEvent(eventName: "error") - mock.startObserving() - - let error = CheckoutError.checkoutUnavailable( - message: "internal server error", - code: .httpError(statusCode: 500), - recoverable: true - ) - - mock.checkoutDidFail(error: error) - - XCTAssertTrue(mock.didSendEvent, "Event should have been sent when checkout fails") - - guard let eventBody = mock.eventBody as? [String: Any] else { - return XCTFail("Event body was not available or not in the correct format") - } - - if case .checkoutUnavailable = error { - XCTAssertEqual(eventBody["__typename"] as? String, "CheckoutHTTPError") - XCTAssertEqual(eventBody["message"] as? String, "internal server error") - XCTAssertEqual(eventBody["statusCode"] as? Int, 500) - XCTAssertEqual(eventBody["recoverable"] as? Bool, true) - } else { - XCTFail("Expected checkoutClientError but found different error") - } - } - - func testCheckoutDidFailEmitsConfigurationError() { - let mock = mockSendEvent(eventName: "error") - mock.startObserving() - - let error = CheckoutError.configurationError( - message: "storefront password required", - code: CheckoutErrorCode.storefrontPasswordRequired, - recoverable: false - ) - - mock.checkoutDidFail(error: error) - - XCTAssertTrue(mock.didSendEvent, "Event should have been sent when checkout fails") - - guard let eventBody = mock.eventBody as? [String: Any] else { - return XCTFail("Event body was not available or not in the correct format") - } - - if case .configurationError = error { - XCTAssertEqual(eventBody["__typename"] as? String, "ConfigurationError") - XCTAssertEqual(eventBody["message"] as? String, "storefront password required") - XCTAssertEqual(eventBody["code"] as? String, CheckoutErrorCode.storefrontPasswordRequired.rawValue) - XCTAssertEqual(eventBody["recoverable"] as? Bool, false) - } else { - XCTFail("Expected CheckoutConfigurationError but found different error") - } - } - - func testCheckoutDidFailEmitsInternalError() { - let mock = mockSendEvent(eventName: "error") - mock.startObserving() - - let error = CheckoutError.sdkError( - underlying: NSError(domain: "com.shopify", code: 1001, userInfo: [NSLocalizedDescriptionKey: "failed"]), - recoverable: true - ) - - mock.checkoutDidFail(error: error) - - XCTAssertTrue(mock.didSendEvent, "Event should have been sent when checkout fails") - - guard let eventBody = mock.eventBody as? [String: Any] else { - return XCTFail("Event body was not available or not in the correct format") - } - - if case .sdkError = error { - XCTAssertEqual(eventBody["__typename"] as? String, "InternalError") - XCTAssertEqual(eventBody["message"] as? String, "failed") - XCTAssertEqual(eventBody["recoverable"] as? Bool, true) - } else { - XCTFail("Expected InternalError but found different error") - } - } - - private func mockSendEvent(eventName: String) -> RCTShopifyCheckoutKitMock { - let mock = RCTShopifyCheckoutKitMock() - mock.eventName = eventName - return mock - } - - private func mockAsyncSendEvent(eventName: String) -> AsyncRCTShopifyCheckoutKitMock { - let mock = AsyncRCTShopifyCheckoutKitMock() - mock.eventName = eventName - return mock - } -} -*/ diff --git a/platforms/react-native/sample/ios/ReactNativeTests/ShopifyCheckoutKitTests.swift b/platforms/react-native/sample/ios/ReactNativeTests/ShopifyCheckoutKitTests.swift index 4a287482..69204b95 100644 --- a/platforms/react-native/sample/ios/ReactNativeTests/ShopifyCheckoutKitTests.swift +++ b/platforms/react-native/sample/ios/ReactNativeTests/ShopifyCheckoutKitTests.swift @@ -303,6 +303,23 @@ class ShopifyCheckoutKitTests: XCTestCase { XCTAssertEqual(result?["logLevel"] as? String, "error") } + func testFailedPresentReleasesPendingDispatchCallback() { + let presentAttemptCompleted = expectation(description: "present attempt completed") + var dispatchCount = 0 + + shopifyCheckoutKit.present("", dispatch: { _ in + dispatchCount += 1 + }) + + DispatchQueue.main.async { + self.shopifyCheckoutKit.checkoutDidCancel() + XCTAssertEqual(dispatchCount, 0) + presentAttemptCompleted.fulfill() + } + + wait(for: [presentAttemptCompleted], timeout: 1) + } + // TODO: re-enable terminal-event tests (checkoutDidComplete, checkoutDidCancel, checkoutDidFail) // once the iOS CheckoutDelegate lands upstream — parallels Android's // DefaultCheckoutListener.onCheckoutCanceled / onCheckoutFailed.