diff --git a/platforms/react-native/README.md b/platforms/react-native/README.md index 7deabe0f..11b4e0c5 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.message`, 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,16 +731,17 @@ 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. -4. The result is passed back to checkout, which then proceeds to show relevant pickup points if permission was granted. +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 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. @@ -775,16 +751,44 @@ 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 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 `event.respond(allow)`: -If you're using the sheet programmatically, you can do so by specifying a `features` object as the second argument: +```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'; + + event.respond(granted); + }, +}); +``` + +`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}); ``` -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 +796,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..a0faa309 100644 --- a/platforms/react-native/__mocks__/react-native.ts +++ b/platforms/react-native/__mocks__/react-native.ts @@ -52,16 +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(), - addEventListener: jest.fn(), - removeEventListeners: 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 c1ef0a64..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; @@ -31,25 +30,28 @@ of this software and associated documentation files (the "Software"), to deal import androidx.annotation.Nullable; import com.shopify.checkoutkit.*; -import com.facebook.react.modules.core.DeviceEventManagerModule; -import com.facebook.react.bridge.WritableNativeMap; -import com.facebook.react.bridge.ReactApplicationContext; +import com.facebook.react.bridge.Callback; 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; public class CustomCheckoutListener extends DefaultCheckoutListener { - private final ReactApplicationContext reactContext; + private static final String TAG = "ShopifyCheckoutKit"; + private final ObjectMapper mapper = new ObjectMapper(); + @Nullable + private Callback dispatchCallback; + // Geolocation-specific variables private String geolocationOrigin; private GeolocationPermissions.Callback geolocationCallback; - public CustomCheckoutListener(Context context, ReactApplicationContext reactContext) { - this.reactContext = reactContext; + public CustomCheckoutListener(@Nullable Callback dispatch) { + this.dispatchCallback = dispatch; } // Public methods @@ -62,36 +64,44 @@ public void invokeGeolocationCallback(boolean allow) { } } + public void release() { + dispatchCallback = null; + geolocationCallback = null; + geolocationOrigin = null; + } + // 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; - // Emit a "geolocationRequest" event to the app. + 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 event = new HashMap<>(); - event.put("origin", origin); - sendEventWithStringData("geolocationRequest", mapper.writeValueAsString(event)); + Map payload = new HashMap<>(); + payload.put("origin", origin); + 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); } } @@ -99,28 +109,53 @@ 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) { + Callback dispatch = dispatchCallback; + if (dispatch == null) { + release(); + return; + } try { - String data = mapper.writeValueAsString(populateErrorDetails(checkoutError)); - sendEventWithStringData("error", data); + 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 { + release(); } } @Override public void onCheckoutCanceled() { - sendEvent("close", null); + Callback dispatch = dispatchCallback; + if (dispatch == null) { + release(); + return; + } + try { + dispatch.invoke(buildEnvelope(DispatchEventTypes.CLOSE, null)); + } catch (IOException e) { + Log.e(TAG, "Error processing checkout canceled event", e); + } finally { + release(); + } } // 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)); @@ -150,15 +185,4 @@ 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) - .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 fce22fd1..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 @@ -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; @@ -63,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; } @@ -77,25 +82,37 @@ public void removeListeners(double count) { } @ReactMethod - public void present(String checkoutURL) { + public void present(String checkoutURL, @Nullable Callback dispatch) { + releaseCheckoutListener(); + Activity currentActivity = getCurrentActivity(); if (currentActivity instanceof ComponentActivity) { - checkoutListener = new CustomCheckoutListener(currentActivity, this.reactContext); + 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(); @@ -168,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-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..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 @@ -40,6 +40,9 @@ @interface RCT_EXTERN_MODULE (RCTShopifyCheckoutKit, NativeShopifyCheckoutKitSpe RCT_EXTERN_METHOD(setConfig:(NSDictionary *)configuration) +RCT_EXTERN_METHOD(present:(NSString *)checkoutURL + dispatch:(RCTResponseSenderBlock)dispatch) + @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..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,20 +28,38 @@ import ShopifyCheckoutKit import SwiftUI import UIKit -@objc(RCTShopifyCheckoutKit) -class RCTShopifyCheckoutKit: RCTEventEmitter { - private var hasListeners = false +/// 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? private var acceleratedCheckoutsConfiguration: Any? private var acceleratedCheckoutsApplePayConfiguration: Any? private var defaultLogLevel: LogLevel = .error - override var methodQueue: DispatchQueue! { + /// 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 { return DispatchQueue.main } - @objc override static func requiresMainQueueSetup() -> Bool { + @objc static func requiresMainQueueSetup() -> Bool { return true } @@ -53,46 +71,12 @@ 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 + "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 } ] } @@ -128,7 +112,9 @@ class RCTShopifyCheckoutKit: RCTEventEmitter { @objc func dismiss() { DispatchQueue.main.async { + self.pendingDispatchCallback = nil self.checkoutSheet?.dismiss(animated: true) + self.checkoutSheet = nil } } @@ -136,13 +122,18 @@ class RCTShopifyCheckoutKit: RCTEventEmitter { // Retained for compatibility with the generated native module interface. } - @objc func present(_ checkoutURL: String) { + @objc func present(_ checkoutURL: String, dispatch: RCTResponseSenderBlock?) { 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 } } @@ -274,7 +265,7 @@ class RCTShopifyCheckoutKit: RCTEventEmitter { return NSNumber(value: available) } - @objc func initiateGeolocationRequest(_ allow: Bool) { + @objc func respondToGeolocationRequest(_ allow: Bool) { // No-op on iOS — geolocation permission is handled natively } @@ -303,3 +294,122 @@ class RCTShopifyCheckoutKit: RCTEventEmitter { } } } + +// 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/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/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 5b855549..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 @@ -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; @@ -31,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,22 +164,54 @@ export type Configuration = CommonConfiguration & { } ); -export type CheckoutEvent = 'close' | 'error' | 'geolocationRequest'; - 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; } -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 resolving Android permissions and calling `event.respond(allow)`. + */ + onGeolocationRequest?: (event: GeolocationRequestEvent) => void; +} /** * Available wallet types for accelerated checkout @@ -253,26 +284,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 +291,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 +306,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..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 @@ -21,23 +21,23 @@ 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 { - EmitterSubscription, - 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'; +import { + DispatchEventParityError, + isSdkLifecycleEventType, + verifyDispatchEventParity, + type SdkLifecycleEventType, +} from './dispatch-events'; import type { AcceleratedCheckoutConfiguration, - CheckoutEvent, - CheckoutEventCallback, Configuration, Features, GeolocationRequestEvent, - Maybe, + PresentCallbacks, ShopifyCheckoutKit, } from './index.d'; import {AcceleratedCheckoutWallet} from './index.d'; @@ -52,7 +52,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, @@ -72,18 +75,14 @@ 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; // 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 { @@ -103,18 +102,16 @@ 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) { this.setConfig(configuration); } - - if ( - Platform.OS === 'android' && - this.featureEnabled('handleGeolocationRequests') - ) { - this.subscribeToGeolocationRequestPrompts(); - } } /** @@ -125,11 +122,16 @@ 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, this.buildDispatcher(callbacks)); } /** @@ -154,52 +156,11 @@ class ShopifyCheckout implements ShopifyCheckoutKit { } /** - * 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 + * 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 removeEventListeners(event: CheckoutEvent) { - ShopifyCheckout.eventEmitter.removeAllListeners(event); - } - - /** - * Cleans up resources and event listeners used by the checkout sheet - */ - public teardown() { - this.geolocationCallback?.remove(); - } + public teardown() {} /** * Configure AcceleratedCheckouts for Shop Pay and Apple Pay buttons @@ -247,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 /** @@ -338,17 +289,132 @@ class ShopifyCheckout implements ShopifyCheckoutKit { } /** - * Sets up geolocation request handling for Android devices + * 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 — + * no user callbacks and no default-handler responsibilities — so the + * native side can skip serializing envelopes. + */ + 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: unknown; + try { + envelope = JSON.parse(envelopeJson); + } catch { + logParseError('envelope is not valid JSON', envelopeJson); + return; + } + + if (!isPlainObject(envelope) || typeof envelope.type !== 'string') { + logParseError( + 'envelope is missing a string `type` discriminator', + envelopeJson, + ); + return; + } + + 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; + } + 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; + } + } + } + + /** + * Default Android geolocation handler — requests platform permissions + * and forwards the resolved grant state back to the native SDK. */ - private subscribeToGeolocationRequestPrompts() { - this.geolocationCallback = this.addEventListener( - 'geolocationRequest', - async () => { - const coarseOrFineGrainAccessGranted = await this.requestGeolocation(); - - this.initiateGeolocationRequest(coarseOrFineGrainAccessGranted); - }, - ); + private async handleDefaultGeolocationRequest() { + const allowed = await this.requestGeolocation(); + this.respondToGeolocationRequest(allowed); } /** @@ -434,51 +500,6 @@ class ShopifyCheckout implements ShopifyCheckoutKit { return new GenericError(exception); } } - - /** - * 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 - */ - private interceptEventEmission( - event: CheckoutEvent, - callback: CheckoutEventCallback, - transformData?: (data: any) => any, - ): (eventData: string | typeof callback) => void { - return (eventData: string | typeof callback): void => { - 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 parseError = new LifecycleEventParseError( - `Failed to parse "${event}" event data`, - { - cause: 'Unknown', - }, - ); - // eslint-disable-next-line no-console - console.error(parseError); - } - }; - } } export class LifecycleEventParseError extends Error { @@ -492,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, @@ -499,6 +562,7 @@ export { ApplePayLabel, ApplePayStyle, ColorScheme, + DispatchEventParityError, LogLevel, ShopifyCheckout, ShopifyCheckoutProvider, @@ -521,12 +585,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..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 @@ -70,7 +70,10 @@ type ConfigurationResultSpec = { }; export interface Spec extends TurboModule { - present(checkoutUrl: string): void; + present( + checkoutUrl: string, + dispatch: ((envelopeJson: string) => void) | null, + ): void; dismiss(): void; setConfig(configuration: ConfigurationSpec): void; getConfig(): ConfigurationResultSpec; @@ -86,12 +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', -); +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..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,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 a null dispatcher when no callbacks are passed', () => { let hookValue: any; const onHookValue = (value: any) => { hookValue = value; @@ -183,13 +167,16 @@ describe('useShopifyCheckout', () => { ); act(() => { - hookValue.removeEventListeners('close'); + hookValue.present(checkoutUrl); }); - expect(hookValue.removeEventListeners).toBeDefined(); + expect(NativeModules.ShopifyCheckoutKit.present).toHaveBeenCalledWith( + checkoutUrl, + null, + ); }); - it('provides present function and calls it with checkoutUrl', () => { + it('forwards a dispatcher to native when callbacks are supplied', () => { let hookValue: any; const onHookValue = (value: any) => { hookValue = value; @@ -201,12 +188,17 @@ 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), ); }); @@ -309,22 +301,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..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', () => { @@ -63,10 +75,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(); @@ -75,16 +98,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 +111,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,61 +122,45 @@ 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 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); + expect(NativeModule.present).toHaveBeenCalledTimes(1); + expect(NativeModule.present).toHaveBeenCalledWith(checkoutUrl, null); }); - }); - describe('dismiss', () => { - it('calls `dismiss`', () => { + it('calls `present` with a dispatcher when callbacks are provided', () => { const instance = new ShopifyCheckout(); - instance.dismiss(); - expect( - NativeModule.dismiss, - ).toHaveBeenCalledTimes(1); + instance.present(checkoutUrl, {onClose: jest.fn()}); + expect(NativeModule.present).toHaveBeenCalledWith( + checkoutUrl, + expect.any(Function), + ); }); - }); - describe('getConfig', () => { - it('returns the parsed config from the Native Module', () => { + it('invokes `onClose` when the dispatcher receives a close envelope', () => { const instance = new ShopifyCheckout(); - expect(instance.getConfig()).toStrictEqual({ - colorScheme: ColorScheme.automatic, - logLevel: LogLevel.error, - }); - expect( - NativeModule.getConfig, - ).toHaveBeenCalledTimes(1); + const onClose = jest.fn(); + instance.present(checkoutUrl, {onClose}); + lastDispatch()(JSON.stringify({type: 'close'})); + expect(onClose).toHaveBeenCalledTimes(1); }); - }); - describe('addEventListener', () => { - it('creates a new event listener for a specific event', () => { + it('ignores a close envelope when no `onClose` handler was provided', () => { const instance = new ShopifyCheckout(); - const eventName = 'close'; - const callback = jest.fn(); - instance.addEventListener(eventName, callback); - expect(eventEmitter.addListener).toHaveBeenCalledWith( - eventName, - callback, - ); + instance.present(checkoutUrl, {onFail: jest.fn()}); + expect(() => + lastDispatch()(JSON.stringify({type: 'close'})), + ).not.toThrow(); }); - describe('Error Event', () => { + describe('onFail callback', () => { const internalError = { __typename: CheckoutNativeErrorType.InternalError, message: 'Something went wrong', @@ -200,7 +199,7 @@ describe('ShopifyCheckoutKit', () => { {error: networkError, constructor: CheckoutHTTPError}, {error: expiredError, constructor: CheckoutExpiredError}, ])( - `correctly parses error $error`, + `parses the fail envelope payload into a typed CheckoutException ($error.__typename)`, ({ error, constructor, @@ -209,19 +208,10 @@ 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}); + lastDispatch()(JSON.stringify({type: 'fail', payload: error})); + const calledWith = onFail.mock.calls[0][0]; expect(calledWith).toBeInstanceOf(constructor); expect(calledWith).not.toHaveProperty('__typename'); expect(calledWith).toHaveProperty('code'); @@ -229,51 +219,201 @@ describe('ShopifyCheckoutKit', () => { }, ); - it('returns an unknown generic error if the error cannot be parsed', () => { + it('falls back to GenericError when the payload has an unrecognised __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}); + // 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, }; - expect(eventEmitter.addListener).toHaveBeenCalledWith( - 'error', - expect.any(Function), - ); - eventEmitter.emit('error', error); - const calledWith = callback.mock.calls[0][0]; + lastDispatch()(JSON.stringify({type: 'fail', payload: error})); + const calledWith = onFail.mock.calls[0][0]; expect(calledWith).toBeInstanceOf(GenericError); - expect(callback).toHaveBeenCalledWith(new GenericError(error as any)); + }); + + it('ignores a fail envelope when no `onFail` handler was provided', () => { + const instance = new ShopifyCheckout(); + 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', + respond: expect.any(Function), + }); + }); + }); + + 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('warns via console.warn for 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(); + 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, + ); }); }); }); - 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({ + colorScheme: ColorScheme.automatic, + logLevel: LogLevel.error, + }); + expect(NativeModule.getConfig).toHaveBeenCalledTimes(1); }); }); 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', () => { @@ -287,24 +427,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 () => { @@ -319,17 +456,18 @@ 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', 'android.permission.ACCESS_FINE_LOCATION', ]); - expect( - NativeModule.initiateGeolocationRequest, - ).toHaveBeenCalledWith(true); + expect(NativeModule.respondToGeolocationRequest).toHaveBeenCalledWith( + true, + ); }); it('handles geolocation permission denial correctly', async () => { @@ -344,31 +482,59 @@ 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', 'android.permission.ACCESS_FINE_LOCATION', ]); - expect( - NativeModule.initiateGeolocationRequest, - ).toHaveBeenCalledWith(false); + expect(NativeModule.respondToGeolocationRequest).toHaveBeenCalledWith( + false, + ); }); - 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', + respond: expect.any(Function), + }); + expect(PermissionsAndroid.requestMultiple).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); - sheet.teardown(); + expect(NativeModule.respondToGeolocationRequest).toHaveBeenCalledWith( + true, + ); + }); + + 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.respondToGeolocationRequest).not.toHaveBeenCalled(); }); }); @@ -383,23 +549,20 @@ 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(); - expect( - NativeModule.initiateGeolocationRequest, - ).not.toHaveBeenCalled(); + expect(PermissionsAndroid.requestMultiple).not.toHaveBeenCalled(); + expect(NativeModule.respondToGeolocationRequest).not.toHaveBeenCalled(); }); it('tears down gracefully', () => { @@ -510,9 +673,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 +695,7 @@ describe('ShopifyCheckoutKit', () => { instance.configureAcceleratedCheckouts(minimalConfig); - expect( - NativeModule.configureAcceleratedCheckouts, - ).toHaveBeenCalledWith( + expect(NativeModule.configureAcceleratedCheckouts).toHaveBeenCalledWith( 'test-shop.myshopify.com', 'shpat_test_token', null, @@ -569,9 +728,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 +746,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 +771,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 +857,7 @@ describe('ShopifyCheckoutKit', () => { }, }); - expect( - NativeModule.configureAcceleratedCheckouts, - ).toHaveBeenCalledWith( + expect(NativeModule.configureAcceleratedCheckouts).toHaveBeenCalledWith( 'test-shop.myshopify.com', 'shpat_test_token', 'test@example.com', @@ -726,9 +883,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/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 eee9eb01..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 @@ -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; @@ -29,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; @@ -45,8 +48,6 @@ public class ShopifyCheckoutKitModuleTest { private ComponentActivity mockComponentActivity; @Mock private DeviceEventManagerModule.RCTDeviceEventEmitter mockEventEmitter; - @Mock - private Context mockContext; @Captor ArgumentCaptor runnableCaptor; @@ -54,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; @@ -76,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 @@ -90,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 -> { @@ -113,7 +123,7 @@ public void testCanPresentCheckout() { try (MockedStatic mockedShopifyCheckoutKit = Mockito .mockStatic(ShopifyCheckoutKit.class)) { String checkoutUrl = "https://shopify.com"; - shopifyCheckoutKitModule.present(checkoutUrl); + shopifyCheckoutKitModule.present(checkoutUrl, null); verify(mockComponentActivity).runOnUiThread(runnableCaptor.capture()); runnableCaptor.getValue().run(); @@ -124,6 +134,112 @@ public void testCanPresentCheckout() { } } + @Test + public void testPresentForwardsOnCloseCallback() { + Callback dispatch = mock(Callback.class); + CustomCheckoutListener processor = new CustomCheckoutListener(dispatch); + + processor.onCheckoutCanceled(); + + ArgumentCaptor args = ArgumentCaptor.forClass(Object[].class); + verify(dispatch).invoke(args.capture()); + assertThat((String) args.getValue()[0]).contains("\"type\":\"close\""); + } + + @Test + public void testOnCloseCallbackIsSingleShot() { + Callback dispatch = mock(Callback.class); + CustomCheckoutListener processor = new CustomCheckoutListener(dispatch); + + processor.onCheckoutCanceled(); + processor.onCheckoutCanceled(); + + 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(dispatch); + + processor.onGeolocationPermissionsShowPrompt("https://shopify.com", permissionsCallback); + + ArgumentCaptor args = ArgumentCaptor.forClass(Object[].class); + 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 testGeolocationDispatchIsMultiShot() { + Callback dispatch = mock(Callback.class); + GeolocationPermissions.Callback permissionsCallback = mock(GeolocationPermissions.Callback.class); + CustomCheckoutListener processor = new CustomCheckoutListener(dispatch); + + processor.onGeolocationPermissionsShowPrompt("https://shopify.com", permissionsCallback); + processor.onGeolocationPermissionsShowPrompt("https://shopify.com", permissionsCallback); + + verify(dispatch, times(2)).invoke(any(Object[].class)); + } + + @Test + public void testGeolocationWithNoDispatchCallbackDoesNotInvoke() { + GeolocationPermissions.Callback permissionsCallback = mock(GeolocationPermissions.Callback.class); + CustomCheckoutListener processor = new CustomCheckoutListener(null); + + processor.onGeolocationPermissionsShowPrompt("https://shopify.com", permissionsCallback); + + verify(mockEventEmitter, never()).emit(eq("geolocationRequest"), any()); + } + + @Test + public void testCheckoutCanceledWithNoDispatchCallbackDoesNotEmitCloseEvent() { + CustomCheckoutListener processor = new CustomCheckoutListener(null); + + processor.onCheckoutCanceled(); + + verify(mockEventEmitter, never()).emit(eq("close"), any()); + } + /** * Module name and version */ @@ -442,24 +558,26 @@ public void testGetConfigReturnsDefaultLogLevel() { @Test public void testCanProcessCheckoutExpiredErrors() { - CustomCheckoutListener processor = new CustomCheckoutListener(mockContext, mockReactContext); + Callback dispatch = mock(Callback.class); + CustomCheckoutListener processor = new CustomCheckoutListener(dispatch); - // 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(dispatch).invoke(args.capture()); - assertThat(stringCaptor.getValue()) - .contains("CheckoutExpiredError", "Cart has expired", "cart_expired"); + assertThat((String) args.getValue()[0]) + .contains("\"type\":\"fail\"", "CheckoutExpiredError", "Cart has expired", "cart_expired"); } @Test public void testCanProcessClientErrors() { - CustomCheckoutListener processor = new CustomCheckoutListener(mockContext, mockReactContext); + Callback dispatch = mock(Callback.class); + CustomCheckoutListener processor = new CustomCheckoutListener(dispatch); ClientException mockException = mock(ClientException.class); when(mockException.getErrorDescription()).thenReturn("Customer account required"); @@ -467,15 +585,17 @@ public void testCanProcessClientErrors() { processor.onCheckoutFailed(mockException); - verify(mockEventEmitter).emit(eq("error"), stringCaptor.capture()); + ArgumentCaptor args = ArgumentCaptor.forClass(Object[].class); + verify(dispatch).invoke(args.capture()); - assertThat(stringCaptor.getValue()) - .contains("CheckoutClientError", "Customer account required", "customer_account_required"); + assertThat((String) args.getValue()[0]) + .contains("\"type\":\"fail\"", "CheckoutClientError", "Customer account required", "customer_account_required"); } @Test public void testCanProcessHttpErrors() { - CustomCheckoutListener processor = new CustomCheckoutListener(mockContext, mockReactContext); + Callback dispatch = mock(Callback.class); + CustomCheckoutListener processor = new CustomCheckoutListener(dispatch); HttpException mockException = mock(HttpException.class); when(mockException.getErrorDescription()).thenReturn("Not Found"); @@ -484,10 +604,37 @@ public void testCanProcessHttpErrors() { processor.onCheckoutFailed(mockException); - verify(mockEventEmitter).emit(eq("error"), stringCaptor.capture()); + ArgumentCaptor args = ArgumentCaptor.forClass(Object[].class); + verify(dispatch).invoke(args.capture()); + + assertThat((String) args.getValue()[0]) + .contains("\"type\":\"fail\"", "CheckoutHTTPError", "Not Found", "http_error", "\"statusCode\":404"); + } + + @Test + public void testOnFailCallbackIsSingleShot() { + Callback dispatch = mock(Callback.class); + CustomCheckoutListener processor = new CustomCheckoutListener(dispatch); + + CheckoutExpiredException mockException = mock(CheckoutExpiredException.class); + when(mockException.getErrorDescription()).thenReturn("Cart has expired"); + when(mockException.getErrorCode()).thenReturn("cart_expired"); + + processor.onCheckoutFailed(mockException); + processor.onCheckoutFailed(mockException); + + verify(dispatch, times(1)).invoke(any(Object[].class)); + } + + @Test + public void testCheckoutFailedWithNoDispatchCallbackDoesNotEmitFailEvent() { + CustomCheckoutListener processor = new CustomCheckoutListener(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/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 805213ce..69204b95 100644 --- a/platforms/react-native/sample/ios/ReactNativeTests/ShopifyCheckoutKitTests.swift +++ b/platforms/react-native/sample/ios/ReactNativeTests/ShopifyCheckoutKitTests.swift @@ -303,127 +303,24 @@ 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") - } + 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() } - /// 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 + wait(for: [presentAttemptCompleted], timeout: 1) + } - 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 {