From 3fe052f301a10c988779af4254db6f84063d6182 Mon Sep 17 00:00:00 2001 From: Kieran Osgood Date: Thu, 14 May 2026 21:41:46 +0100 Subject: [PATCH] feat: remove addEventListener API --- platforms/react-native/README.md | 143 ++++++------- .../react-native/__mocks__/react-native.ts | 2 - .../CustomCheckoutEventProcessor.java | 43 ++-- .../checkoutkit/ShopifyCheckoutKitModule.java | 8 +- .../ios/ShopifyCheckoutKit-Bridging-Header.h | 1 - .../ios/ShopifyCheckoutKit.mm | 5 + .../ios/ShopifyCheckoutKit.swift | 67 ++---- .../checkout-kit-react-native/src/context.tsx | 35 +--- .../checkout-kit-react-native/src/index.d.ts | 82 ++++---- .../checkout-kit-react-native/src/index.ts | 152 ++++++-------- .../src/specs/NativeShopifyCheckoutKit.ts | 7 +- .../tests/context.test.tsx | 54 ++--- .../tests/index.test.ts | 190 +++++++++++------- .../ShopifyCheckoutKitModuleTest.java | 138 +++++++++++-- .../ShopifyCheckoutKitTests.swift | 126 +----------- platforms/react-native/sample/src/App.tsx | 30 +-- .../sample/src/screens/CartScreen.tsx | 5 +- 17 files changed, 512 insertions(+), 576 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 fc2e5da6..f76136d7 100644 --- a/platforms/react-native/__mocks__/react-native.ts +++ b/platforms/react-native/__mocks__/react-native.ts @@ -56,8 +56,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/CustomCheckoutEventProcessor.java b/platforms/react-native/modules/@shopify/checkout-kit-react-native/android/src/main/java/com/shopify/reactnative/checkoutkit/CustomCheckoutEventProcessor.java index 898cbe0b..fdbd976c 100644 --- a/platforms/react-native/modules/@shopify/checkout-kit-react-native/android/src/main/java/com/shopify/reactnative/checkoutkit/CustomCheckoutEventProcessor.java +++ b/platforms/react-native/modules/@shopify/checkout-kit-react-native/android/src/main/java/com/shopify/reactnative/checkoutkit/CustomCheckoutEventProcessor.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.shopify.checkoutkit.lifecycleevents.CheckoutCompletedEvent; import com.fasterxml.jackson.databind.ObjectMapper; @@ -44,13 +44,25 @@ public class CustomCheckoutEventProcessor extends DefaultCheckoutEventProcessor 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 CustomCheckoutEventProcessor(Context context, ReactApplicationContext reactContext) { + public CustomCheckoutEventProcessor(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 @@ -86,11 +98,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); } @@ -107,17 +123,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; } @Override @@ -162,12 +187,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 3dba43fc..9432bafc 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 @@ -27,6 +27,8 @@ of this software and associated documentation files (the "Software"), to deal import android.content.Context; import androidx.activity.ComponentActivity; import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import com.facebook.react.bridge.Callback; import com.facebook.react.bridge.Promise; import com.facebook.react.bridge.ReactApplicationContext; import com.facebook.react.bridge.ReactMethod; @@ -80,10 +82,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) { - checkoutEventProcessor = new CustomCheckoutEventProcessor(currentActivity, this.reactContext); + checkoutEventProcessor = new CustomCheckoutEventProcessor(currentActivity, this.reactContext, onClose, + onFail, onGeolocationRequest); currentActivity.runOnUiThread(() -> { checkoutSheet = ShopifyCheckoutKit.present(checkoutURL, (ComponentActivity) currentActivity, checkoutEventProcessor); 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 ba49f172..dbc04355 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 62060911..f792771e 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,48 +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 DefaultCheckoutEventProcessor.onCheckoutCanceled / onCheckoutFailed. - // Until then, the JS "error" and "close" events stay declared in supportedEvents() - // but native never emits them. - /* - - func shouldRecoverFromError(error: CheckoutError) -> Bool { - return error.isRecoverable - } - - 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 ] @@ -140,7 +106,12 @@ class RCTShopifyCheckoutKit: RCTEventEmitter { invalidate() } - @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 a75c1874..a6971d64 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,26 +23,17 @@ 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; preload: (checkoutUrl: string) => void; - present: (checkoutUrl: string) => void; + present: (checkoutUrl: string, callbacks?: PresentCallbacks) => void; dismiss: () => void; invalidate: () => void; version: Maybe; @@ -91,23 +82,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 preload = useCallback((checkoutUrl: string) => { if (checkoutUrl) { instance.current?.preload(checkoutUrl); @@ -133,21 +116,17 @@ export function ShopifyCheckoutProvider({ const context = useMemo((): Context => { return { acceleratedCheckoutsAvailable, - addEventListener, dismiss, setConfig, getConfig, preload, present, invalidate, - removeEventListeners, version: instance.current?.version, }; }, [ acceleratedCheckoutsAvailable, - addEventListener, dismiss, - removeEventListeners, getConfig, setConfig, preload, 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 67d7d460..8e901c70 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; @@ -169,22 +168,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 @@ -257,26 +278,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. @@ -293,8 +294,13 @@ export interface ShopifyCheckoutKit { invalidate(): void; /** * 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. */ @@ -303,14 +309,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 dd7a8f94..6c2dfd4a 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'; @@ -140,11 +135,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, + ); } /** @@ -168,47 +175,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 */ @@ -353,10 +319,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(); @@ -451,46 +419,51 @@ 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< + PresentCallbacks['onGeolocationRequest'] + >, + ): (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); } }; } @@ -536,12 +509,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 d217aa57..a3031a0b 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,7 +72,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; preload(checkoutUrl: string): void; dismiss(): void; invalidateCache(): 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 8de11f4f..107edf92 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), ); }); @@ -373,22 +369,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 ad9787f0..43e29882 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 @@ -137,55 +137,89 @@ describe('ShopifyCheckoutKit', () => { }); 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({ - preloading: true, - 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', @@ -229,7 +263,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, @@ -238,19 +272,13 @@ 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'); @@ -259,38 +287,60 @@ describe('ShopifyCheckoutKit', () => { }, ); - 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.addEventListener('close', () => {}); - instance.addEventListener('close', () => {}); - instance.removeEventListeners('close'); - expect(eventEmitter.removeAllListeners).toHaveBeenCalledWith('close'); + instance.dismiss(); + expect( + NativeModule.dismiss, + ).toHaveBeenCalledTimes(1); + }); + }); + + describe('getConfig', () => { + it('returns the parsed config from the Native Module', () => { + const instance = new ShopifyCheckout(); + expect(instance.getConfig()).toStrictEqual({ + preloading: true, + colorScheme: ColorScheme.automatic, + logLevel: LogLevel.error, + }); + expect( + NativeModule.getConfig, + ).toHaveBeenCalledTimes(1); }); }); 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 6f02bfa9..a316cd0c 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; @@ -123,7 +126,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(); @@ -134,6 +137,79 @@ public void testCanPresentCheckout() { } } + @Test + public void testPresentForwardsOnCloseCallback() { + Callback onClose = mock(Callback.class); + CustomCheckoutEventProcessor processor = new CustomCheckoutEventProcessor(mockContext, mockReactContext, + onClose, null, null); + + processor.onCheckoutCanceled(); + + verify(onClose).invoke(); + } + + @Test + public void testOnCloseCallbackIsSingleShot() { + Callback onClose = mock(Callback.class); + CustomCheckoutEventProcessor processor = new CustomCheckoutEventProcessor(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); + CustomCheckoutEventProcessor processor = new CustomCheckoutEventProcessor(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); + CustomCheckoutEventProcessor processor = new CustomCheckoutEventProcessor(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); + CustomCheckoutEventProcessor processor = new CustomCheckoutEventProcessor(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() { + CustomCheckoutEventProcessor processor = new CustomCheckoutEventProcessor(mockContext, mockReactContext, + null, null, null); + + processor.onCheckoutCanceled(); + + verify(mockEventEmitter, never()).emit(eq("close"), any()); + } + @Test public void testCanPreloadCheckout() { String checkoutUrl = "https://shopify.com"; @@ -470,7 +546,8 @@ public void testGetConfigReturnsDefaultLogLevel() { @Test public void testCanProcessCheckoutCompletedEvents() { - CustomCheckoutEventProcessor processor = new CustomCheckoutEventProcessor(mockContext, mockReactContext); + CustomCheckoutEventProcessor processor = new CustomCheckoutEventProcessor(mockContext, mockReactContext, + null, null, null); CartInfo cartInfo = new CartInfo(new ArrayList<>(), new Price(), "cart-token"); OrderDetails orderDetails = new OrderDetails( @@ -499,9 +576,10 @@ public void testCanProcessCheckoutCompletedEvents() { @Test public void testCanProcessCheckoutExpiredErrors() { - CustomCheckoutEventProcessor processor = new CustomCheckoutEventProcessor(mockContext, mockReactContext); + Callback onFail = mock(Callback.class); + CustomCheckoutEventProcessor processor = new CustomCheckoutEventProcessor(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"); @@ -509,15 +587,18 @@ public void testCanProcessCheckoutExpiredErrors() { processor.onCheckoutFailed(mockException); - verify(mockEventEmitter).emit(eq("error"), stringCaptor.capture()); + ArgumentCaptor args = ArgumentCaptor.forClass(Object[].class); + verify(onFail).invoke(args.capture()); - assertThat(stringCaptor.getValue()) + assertThat((String) args.getValue()[0]) .contains("CheckoutExpiredError", "Cart has expired", "cart_expired", "\"recoverable\":false"); } @Test public void testCanProcessClientErrors() { - CustomCheckoutEventProcessor processor = new CustomCheckoutEventProcessor(mockContext, mockReactContext); + Callback onFail = mock(Callback.class); + CustomCheckoutEventProcessor processor = new CustomCheckoutEventProcessor(mockContext, mockReactContext, + null, onFail, null); ClientException mockException = mock(ClientException.class); when(mockException.getErrorDescription()).thenReturn("Customer account required"); @@ -526,16 +607,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()) + assertThat((String) args.getValue()[0]) .contains("CheckoutClientError", "Customer account required", "customer_account_required", "\"recoverable\":true"); } @Test public void testCanProcessHttpErrors() { - CustomCheckoutEventProcessor processor = new CustomCheckoutEventProcessor(mockContext, mockReactContext); + Callback onFail = mock(Callback.class); + CustomCheckoutEventProcessor processor = new CustomCheckoutEventProcessor(mockContext, mockReactContext, + null, onFail, null); HttpException mockException = mock(HttpException.class); when(mockException.getErrorDescription()).thenReturn("Not Found"); @@ -545,12 +629,42 @@ 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(stringCaptor.getValue()) + assertThat((String) args.getValue()[0]) .contains("CheckoutHTTPError", "Not Found", "http_error", "\"statusCode\":404", "\"recoverable\":false"); } + @Test + public void testOnFailCallbackIsSingleShot() { + Callback onFail = mock(Callback.class); + CustomCheckoutEventProcessor processor = new CustomCheckoutEventProcessor(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() { + CustomCheckoutEventProcessor processor = new CustomCheckoutEventProcessor(mockContext, mockReactContext, + null, null, null); + + CheckoutExpiredException mockException = mock(CheckoutExpiredException.class); + + processor.onCheckoutFailed(mockException); + + verify(mockEventEmitter, never()).emit(eq("error"), any()); + } + /** * Integration */ diff --git a/platforms/react-native/sample/ios/ReactNativeTests/ShopifyCheckoutKitTests.swift b/platforms/react-native/sample/ios/ReactNativeTests/ShopifyCheckoutKitTests.swift index 603cef36..dcf74c9f 100644 --- a/platforms/react-native/sample/ios/ReactNativeTests/ShopifyCheckoutKitTests.swift +++ b/platforms/react-native/sample/ios/ReactNativeTests/ShopifyCheckoutKitTests.swift @@ -319,127 +319,7 @@ class ShopifyCheckoutKitTests: XCTestCase { XCTAssertEqual(result?["logLevel"] as? String, "error") } - // TODO: re-enable when iOS CheckoutDelegate (or equivalent) lands upstream — - // parallels Android's DefaultCheckoutEventProcessor.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 + // DefaultCheckoutEventProcessor.onCheckoutCanceled / onCheckoutFailed. } diff --git a/platforms/react-native/sample/src/App.tsx b/platforms/react-native/sample/src/App.tsx index 60349056..2cd0a1a8 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 { @@ -205,27 +204,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 ( @@ -459,6 +437,7 @@ function Routes() { const navigation = useNavigation>(); const {url: initialUrl} = useInitialURL(); const shopify = useShopifyCheckout(); + const eventHandlers = useShopifyEventHandlers('UniversalLink'); useEffect(() => { async function handleUniversalLink(url: string) { @@ -467,7 +446,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(): @@ -495,7 +477,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..b9ae5468 100644 --- a/platforms/react-native/sample/src/screens/CartScreen.tsx +++ b/platforms/react-native/sample/src/screens/CartScreen.tsx @@ -87,7 +87,10 @@ function CartScreen(): React.JSX.Element { const presentCheckout = async () => { if (checkoutURL) { - ShopifyCheckout.present(checkoutURL); + ShopifyCheckout.present(checkoutURL, { + onClose: () => eventHandlers.onCancel?.(), + onFail: error => eventHandlers.onFail?.(error), + }); } };