From f5efe2e02ad82bd5ac68a86871e81d058c2da4f5 Mon Sep 17 00:00:00 2001 From: Kieran Osgood Date: Thu, 14 May 2026 23:55:03 +0100 Subject: [PATCH] feat: explore dynamic dispatch for checkout delegate --- .../CustomCheckoutEventProcessor.java | 74 ++--- .../checkoutkit/ShopifyCheckoutKitModule.java | 6 +- .../ios/ShopifyCheckoutKit.mm | 4 +- .../ios/ShopifyCheckoutKit.swift | 19 +- .../checkout-kit-react-native/src/index.ts | 157 +++++------ .../src/specs/NativeShopifyCheckoutKit.ts | 4 +- .../tests/context.test.tsx | 8 +- .../tests/index.test.ts | 265 +++++++++--------- .../ShopifyCheckoutKitModuleTest.java | 96 +++---- 9 files changed, 297 insertions(+), 336 deletions(-) diff --git a/platforms/react-native/modules/@shopify/checkout-kit-react-native/android/src/main/java/com/shopify/reactnative/checkoutkit/CustomCheckoutEventProcessor.java b/platforms/react-native/modules/@shopify/checkout-kit-react-native/android/src/main/java/com/shopify/reactnative/checkoutkit/CustomCheckoutEventProcessor.java index fdbd976c..7a2f36a7 100644 --- a/platforms/react-native/modules/@shopify/checkout-kit-react-native/android/src/main/java/com/shopify/reactnative/checkoutkit/CustomCheckoutEventProcessor.java +++ b/platforms/react-native/modules/@shopify/checkout-kit-react-native/android/src/main/java/com/shopify/reactnative/checkoutkit/CustomCheckoutEventProcessor.java @@ -36,6 +36,7 @@ of this software and associated documentation files (the "Software"), to deal import com.facebook.react.bridge.ReactApplicationContext; import com.shopify.checkoutkit.lifecycleevents.CheckoutCompletedEvent; 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; @@ -45,11 +46,7 @@ public class CustomCheckoutEventProcessor extends DefaultCheckoutEventProcessor private final ObjectMapper mapper = new ObjectMapper(); @Nullable - private Callback onCloseCallback; - @Nullable - private Callback onFailCallback; - @Nullable - private Callback onGeolocationRequestCallback; + private Callback dispatchCallback; // Geolocation-specific variables @@ -57,12 +54,9 @@ public class CustomCheckoutEventProcessor extends DefaultCheckoutEventProcessor private GeolocationPermissions.Callback geolocationCallback; public CustomCheckoutEventProcessor(Context context, ReactApplicationContext reactContext, - @Nullable Callback onClose, @Nullable Callback onFail, - @Nullable Callback onGeolocationRequest) { + @Nullable Callback dispatch) { this.reactContext = reactContext; - this.onCloseCallback = onClose; - this.onFailCallback = onFail; - this.onGeolocationRequestCallback = onGeolocationRequest; + this.dispatchCallback = dispatch; } // Public methods @@ -78,35 +72,29 @@ public void invokeGeolocationCallback(boolean allow) { // Lifecycle events /** - * This method is called when the checkout sheet webpage requests geolocation - * permissions. - * - * Since the app needs to request permissions first before granting, we store - * the callback and origin in memory and emit a "geolocationRequest" event to - * the app. The app will then request the necessary geolocation permissions - * and invoke the native callback with the result. + * Called when the checkout sheet's webpage requests geolocation + * permissions. The platform callback is stored in memory; the dispatcher + * is invoked with a `geolocationRequest` envelope so JS can either route + * to a per-call handler or run the default permission flow. * - * @param origin - The origin of the request - * @param callback - The callback to invoke when the app requests permissions + * Multi-shot — the same checkout sheet may request geolocation multiple + * times during a single `present()` call, so the dispatcher is not + * nulled after invocation. */ @Override public void onGeolocationPermissionsShowPrompt(@NonNull String origin, @NonNull GeolocationPermissions.Callback callback) { - // Store the callback and origin in memory. The kit will wait for the app to - // request permissions first before granting. this.geolocationCallback = callback; this.geolocationOrigin = origin; + if (dispatchCallback == null) { + return; + } try { - Map event = new HashMap<>(); - event.put("origin", origin); - String payload = mapper.writeValueAsString(event); - if (onGeolocationRequestCallback != null) { - onGeolocationRequestCallback.invoke(payload); - } else { - sendEventWithStringData("geolocationRequest", payload); - } + Map payload = new HashMap<>(); + payload.put("origin", origin); + dispatchCallback.invoke(buildEnvelope("geolocationRequest", payload)); } catch (IOException e) { Log.e("ShopifyCheckoutKit", "Error emitting \"geolocationRequest\" event", e); } @@ -116,33 +104,36 @@ public void onGeolocationPermissionsShowPrompt(@NonNull String origin, public void onGeolocationPermissionsHidePrompt() { super.onGeolocationPermissionsHidePrompt(); - // Reset the geolocation callback and origin when the prompt is hidden. this.geolocationCallback = null; this.geolocationOrigin = null; } @Override public void onCheckoutFailed(CheckoutException checkoutError) { - if (onFailCallback == null) { + if (dispatchCallback == null) { return; } try { - String data = mapper.writeValueAsString(populateErrorDetails(checkoutError)); - onFailCallback.invoke(data); + dispatchCallback.invoke(buildEnvelope("fail", populateErrorDetails(checkoutError))); } catch (IOException e) { Log.e("ShopifyCheckoutKit", "Error processing checkout failed event", e); } finally { - onFailCallback = null; + dispatchCallback = null; } } @Override public void onCheckoutCanceled() { - if (onCloseCallback == null) { + if (dispatchCallback == null) { return; } - onCloseCallback.invoke(); - onCloseCallback = null; + try { + dispatchCallback.invoke(buildEnvelope("close", null)); + } catch (IOException e) { + Log.e("ShopifyCheckoutKit", "Error processing checkout canceled event", e); + } finally { + dispatchCallback = null; + } } @Override @@ -157,6 +148,15 @@ public void onCheckoutCompleted(@NonNull CheckoutCompletedEvent event) { // Private + private String buildEnvelope(String type, @Nullable Object payload) throws IOException { + ObjectNode envelope = mapper.createObjectNode(); + envelope.put("type", type); + if (payload != null) { + envelope.set("payload", mapper.valueToTree(payload)); + } + return mapper.writeValueAsString(envelope); + } + private Map populateErrorDetails(CheckoutException checkoutError) { Map errorMap = new HashMap(); errorMap.put("__typename", getErrorTypeName(checkoutError)); diff --git a/platforms/react-native/modules/@shopify/checkout-kit-react-native/android/src/main/java/com/shopify/reactnative/checkoutkit/ShopifyCheckoutKitModule.java b/platforms/react-native/modules/@shopify/checkout-kit-react-native/android/src/main/java/com/shopify/reactnative/checkoutkit/ShopifyCheckoutKitModule.java index 9432bafc..c5b95e1f 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 @@ -82,12 +82,10 @@ public void removeListeners(double count) { } @ReactMethod - public void present(String checkoutURL, @Nullable Callback onClose, @Nullable Callback onFail, - @Nullable Callback onGeolocationRequest) { + public void present(String checkoutURL, @Nullable Callback dispatch) { Activity currentActivity = getCurrentActivity(); if (currentActivity instanceof ComponentActivity) { - checkoutEventProcessor = new CustomCheckoutEventProcessor(currentActivity, this.reactContext, onClose, - onFail, onGeolocationRequest); + checkoutEventProcessor = new CustomCheckoutEventProcessor(currentActivity, this.reactContext, dispatch); currentActivity.runOnUiThread(() -> { checkoutSheet = ShopifyCheckoutKit.present(checkoutURL, (ComponentActivity) currentActivity, checkoutEventProcessor); 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 dbc04355..2f8f2f45 100644 --- a/platforms/react-native/modules/@shopify/checkout-kit-react-native/ios/ShopifyCheckoutKit.mm +++ b/platforms/react-native/modules/@shopify/checkout-kit-react-native/ios/ShopifyCheckoutKit.mm @@ -41,9 +41,7 @@ @interface RCT_EXTERN_MODULE (RCTShopifyCheckoutKit, NativeShopifyCheckoutKitSpe RCT_EXTERN_METHOD(setConfig:(NSDictionary *)configuration) RCT_EXTERN_METHOD(present:(NSString *)checkoutURL - onClose:(RCTResponseSenderBlock)onClose - onFail:(RCTResponseSenderBlock)onFail - onGeolocationRequest:(RCTResponseSenderBlock)onGeolocationRequest) + dispatch:(RCTResponseSenderBlock)dispatch) @end diff --git a/platforms/react-native/modules/@shopify/checkout-kit-react-native/ios/ShopifyCheckoutKit.swift b/platforms/react-native/modules/@shopify/checkout-kit-react-native/ios/ShopifyCheckoutKit.swift index f792771e..ee90efca 100644 --- a/platforms/react-native/modules/@shopify/checkout-kit-react-native/ios/ShopifyCheckoutKit.swift +++ b/platforms/react-native/modules/@shopify/checkout-kit-react-native/ios/ShopifyCheckoutKit.swift @@ -35,14 +35,10 @@ class RCTShopifyCheckoutKit: NSObject { private var acceleratedCheckoutsApplePayConfiguration: Any? private var defaultLogLevel: LogLevel = .error - // TODO: invoke these once the iOS CheckoutDelegate (or equivalent) lands upstream — until then, - // onClose/onFail callbacks are stored but never fire (Android is the only platform delivering them). - // `pendingGeolocationRequestCallback` is intentionally a no-op on iOS — geolocation permission - // is handled natively, so the callback is stored only to keep the bridge signature symmetric - // with Android. - private var pendingCloseCallback: RCTResponseSenderBlock? - private var pendingFailCallback: RCTResponseSenderBlock? - private var pendingGeolocationRequestCallback: RCTResponseSenderBlock? + // TODO: invoke once the iOS CheckoutDelegate (or equivalent) lands upstream — until then, + // the dispatcher is stored but never fired (Android is the only platform delivering events). + // When wired, dispatch envelope JSON strings of the shape `{"type":"close"|"fail","payload":...}`. + private var pendingDispatchCallback: RCTResponseSenderBlock? @objc var methodQueue: DispatchQueue { return DispatchQueue.main @@ -106,11 +102,8 @@ class RCTShopifyCheckoutKit: NSObject { invalidate() } - @objc func present(_ checkoutURL: String, onClose: RCTResponseSenderBlock?, onFail: RCTResponseSenderBlock?, - onGeolocationRequest: RCTResponseSenderBlock?) { - pendingCloseCallback = onClose - pendingFailCallback = onFail - pendingGeolocationRequestCallback = onGeolocationRequest + @objc func present(_ checkoutURL: String, dispatch: RCTResponseSenderBlock?) { + pendingDispatchCallback = dispatch DispatchQueue.main.async { if let url = URL(string: checkoutURL), let viewController = self.getCurrentViewController() { diff --git a/platforms/react-native/modules/@shopify/checkout-kit-react-native/src/index.ts b/platforms/react-native/modules/@shopify/checkout-kit-react-native/src/index.ts index 6c2dfd4a..ad79fa29 100644 --- a/platforms/react-native/modules/@shopify/checkout-kit-react-native/src/index.ts +++ b/platforms/react-native/modules/@shopify/checkout-kit-react-native/src/index.ts @@ -21,8 +21,8 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -import {NativeEventEmitter, PermissionsAndroid, Platform} from 'react-native'; -import type {EventSubscription, PermissionStatus} from 'react-native'; +import {PermissionsAndroid, Platform} from 'react-native'; +import type {PermissionStatus} from 'react-native'; import RNShopifyCheckoutKit from './specs/NativeShopifyCheckoutKit'; import {ShopifyCheckoutProvider, useShopifyCheckout} from './context'; import {ApplePayContactField, ColorScheme, LogLevel} from './index.d'; @@ -31,7 +31,6 @@ import type { Configuration, Features, GeolocationRequestEvent, - Maybe, PresentCallbacks, ShopifyCheckoutKit, } from './index.d'; @@ -67,12 +66,7 @@ const colorSchemeValues: ReadonlySet = new Set( const logLevelValues: ReadonlySet = new Set(Object.values(LogLevel)); class ShopifyCheckout implements ShopifyCheckoutKit { - private static eventEmitter: NativeEventEmitter = new NativeEventEmitter( - RNShopifyCheckoutKit, - ); - private features: Features; - private geolocationCallback: Maybe; private _acceleratedCheckoutsReady = false; @@ -103,13 +97,6 @@ class ShopifyCheckout implements ShopifyCheckoutKit { if (configuration != null) { this.setConfig(configuration); } - - if ( - Platform.OS === 'android' && - this.featureEnabled('handleGeolocationRequests') - ) { - this.subscribeToGeolocationRequestPrompts(); - } } /** @@ -144,14 +131,7 @@ class ShopifyCheckout implements ShopifyCheckoutKit { * @param callbacks Optional per-call SDK callbacks */ public present(checkoutUrl: string, callbacks?: PresentCallbacks): void { - RNShopifyCheckoutKit.present( - checkoutUrl, - callbacks?.onClose ?? null, - callbacks?.onFail ? this.wrapFailCallback(callbacks.onFail) : null, - callbacks?.onGeolocationRequest - ? this.wrapGeolocationCallback(callbacks.onGeolocationRequest) - : null, - ); + RNShopifyCheckoutKit.present(checkoutUrl, this.buildDispatcher(callbacks)); } /** @@ -176,11 +156,11 @@ class ShopifyCheckout implements ShopifyCheckoutKit { } /** - * Cleans up resources and event listeners used by the checkout sheet + * Cleans up resources and event listeners used by the checkout sheet. + * Currently a no-op — retained as part of the public API for forward + * compatibility with future protocol-client subscriptions. */ - public teardown() { - this.geolocationCallback?.remove(); - } + public teardown() {} /** * Configure AcceleratedCheckouts for Shop Pay and Apple Pay buttons @@ -319,19 +299,69 @@ class ShopifyCheckout implements ShopifyCheckoutKit { } /** - * Sets up geolocation request handling for Android devices. - * Uses the internal NativeEventEmitter directly because the public - * listener API has been removed. + * Builds the single per-call dispatcher passed to the native bridge. + * Returns null when there is nothing for the bridge to deliver back — + * no user callbacks and no default-handler responsibilities — so the + * native side can skip serializing envelopes. + */ + private buildDispatcher( + callbacks: PresentCallbacks | undefined, + ): ((envelopeJson: string) => void) | null { + const needsDefaultGeolocation = + Platform.OS === 'android' && + this.featureEnabled('handleGeolocationRequests'); + + if (!callbacks && !needsDefaultGeolocation) { + return null; + } + + return (envelopeJson: string) => { + let envelope: {type?: string; payload?: unknown}; + try { + envelope = JSON.parse(envelopeJson); + } catch { + const parseError = new LifecycleEventParseError( + 'Failed to parse present() dispatcher envelope: Invalid JSON', + {cause: 'Invalid JSON'}, + ); + // eslint-disable-next-line no-console + console.error(parseError, envelopeJson); + return; + } + + switch (envelope.type) { + case 'close': + callbacks?.onClose?.(); + return; + case 'fail': + if (callbacks?.onFail) { + callbacks.onFail( + this.parseCheckoutError(envelope.payload as CheckoutNativeError), + ); + } + return; + case 'geolocationRequest': + if (callbacks?.onGeolocationRequest) { + callbacks.onGeolocationRequest( + envelope.payload as GeolocationRequestEvent, + ); + } else if (needsDefaultGeolocation) { + this.handleDefaultGeolocationRequest(); + } + return; + default: + return; + } + }; + } + + /** + * Default Android geolocation handler — requests platform permissions + * and forwards the resolved grant state back to the native SDK. */ - private subscribeToGeolocationRequestPrompts() { - this.geolocationCallback = ShopifyCheckout.eventEmitter.addListener( - 'geolocationRequest', - async () => { - const coarseOrFineGrainAccessGranted = await this.requestGeolocation(); - - this.initiateGeolocationRequest(coarseOrFineGrainAccessGranted); - }, - ); + private async handleDefaultGeolocationRequest() { + const allowed = await this.requestGeolocation(); + this.initiateGeolocationRequest(allowed); } /** @@ -418,55 +448,6 @@ class ShopifyCheckout implements ShopifyCheckoutKit { } } - /** - * Wraps a consumer-provided `onFail` callback so the native bridge can - * hand it the raw JSON error payload it serializes today. Invalid JSON - * is reported via `LifecycleEventParseError`; the user callback only - * fires on a successful parse. - */ - private wrapFailCallback( - onFail: NonNullable, - ): (raw: string) => void { - return (raw: string) => { - try { - const parsed = JSON.parse(raw); - onFail(this.parseCheckoutError(parsed)); - } catch { - const parseError = new LifecycleEventParseError( - 'Failed to parse "onFail" callback payload: Invalid JSON', - {cause: 'Invalid JSON'}, - ); - // eslint-disable-next-line no-console - console.error(parseError, raw); - } - }; - } - - /** - * Wraps a consumer-provided `onGeolocationRequest` callback so the - * native bridge can hand it the raw JSON origin payload. Invalid JSON - * is reported via `LifecycleEventParseError`; the user callback only - * fires on a successful parse. - */ - private wrapGeolocationCallback( - onGeolocationRequest: NonNullable< - PresentCallbacks['onGeolocationRequest'] - >, - ): (raw: string) => void { - return (raw: string) => { - try { - const parsed = JSON.parse(raw); - onGeolocationRequest(parsed); - } catch { - const parseError = new LifecycleEventParseError( - 'Failed to parse "onGeolocationRequest" callback payload: Invalid JSON', - {cause: 'Invalid JSON'}, - ); - // eslint-disable-next-line no-console - console.error(parseError, raw); - } - }; - } } export class LifecycleEventParseError extends Error { diff --git a/platforms/react-native/modules/@shopify/checkout-kit-react-native/src/specs/NativeShopifyCheckoutKit.ts b/platforms/react-native/modules/@shopify/checkout-kit-react-native/src/specs/NativeShopifyCheckoutKit.ts index a3031a0b..914f912f 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 @@ -74,9 +74,7 @@ type ConfigurationResultSpec = { export interface Spec extends TurboModule { present( checkoutUrl: string, - onClose: (() => void) | null, - onFail: ((errorJson: string) => void) | null, - onGeolocationRequest: ((originJson: string) => void) | null, + dispatch: ((envelopeJson: string) => void) | null, ): void; preload(checkoutUrl: string): void; dismiss(): void; diff --git a/platforms/react-native/modules/@shopify/checkout-kit-react-native/tests/context.test.tsx b/platforms/react-native/modules/@shopify/checkout-kit-react-native/tests/context.test.tsx index 107edf92..3451a6e4 100644 --- a/platforms/react-native/modules/@shopify/checkout-kit-react-native/tests/context.test.tsx +++ b/platforms/react-native/modules/@shopify/checkout-kit-react-native/tests/context.test.tsx @@ -154,7 +154,7 @@ describe('useShopifyCheckout', () => { jest.clearAllMocks(); }); - it('provides present function and calls it with checkoutUrl and null callbacks when none are passed', () => { + it('provides present function and calls it with checkoutUrl and a null dispatcher when no callbacks are passed', () => { let hookValue: any; const onHookValue = (value: any) => { hookValue = value; @@ -173,12 +173,10 @@ describe('useShopifyCheckout', () => { expect(NativeModules.ShopifyCheckoutKit.present).toHaveBeenCalledWith( checkoutUrl, null, - null, - null, ); }); - it('forwards onClose, onFail, and onGeolocationRequest callbacks through present', () => { + it('forwards a dispatcher to native when callbacks are supplied', () => { let hookValue: any; const onHookValue = (value: any) => { hookValue = value; @@ -201,8 +199,6 @@ describe('useShopifyCheckout', () => { expect(NativeModules.ShopifyCheckoutKit.present).toHaveBeenCalledWith( checkoutUrl, expect.any(Function), - expect.any(Function), - expect.any(Function), ); }); diff --git a/platforms/react-native/modules/@shopify/checkout-kit-react-native/tests/index.test.ts b/platforms/react-native/modules/@shopify/checkout-kit-react-native/tests/index.test.ts index 43e29882..9d993b12 100644 --- a/platforms/react-native/modules/@shopify/checkout-kit-react-native/tests/index.test.ts +++ b/platforms/react-native/modules/@shopify/checkout-kit-react-native/tests/index.test.ts @@ -63,10 +63,21 @@ describe('Exports', () => { }); }); -describe('ShopifyCheckoutKit', () => { - // @ts-expect-error "eventEmitter is private" - const eventEmitter = ShopifyCheckout.eventEmitter; +type Dispatch = (envelopeJson: string) => void; + +function lastDispatch(): Dispatch { + const dispatch = NativeModule.present.mock.calls[ + NativeModule.present.mock.calls.length - 1 + ][1] as Dispatch | null; + if (!dispatch) { + throw new Error( + 'Expected the last present() call to receive a non-null dispatcher', + ); + } + return dispatch; +} +describe('ShopifyCheckoutKit', () => { afterEach(() => { NativeModule.setConfig.mockReset(); jest.clearAllMocks(); @@ -137,86 +148,35 @@ describe('ShopifyCheckoutKit', () => { }); describe('present', () => { - it('calls `present` with the checkout URL and null callbacks when none are provided', () => { + it('calls `present` with a null dispatcher when no callbacks are provided on iOS', () => { + Platform.OS = 'ios'; const instance = new ShopifyCheckout(); instance.present(checkoutUrl); expect(NativeModule.present).toHaveBeenCalledTimes(1); - expect(NativeModule.present).toHaveBeenCalledWith( - checkoutUrl, - null, - null, - null, - ); + expect(NativeModule.present).toHaveBeenCalledWith(checkoutUrl, null); }); - it('forwards the `onClose` callback to native and invokes the user handler when fired', () => { + it('calls `present` with a dispatcher when callbacks are provided', () => { const instance = new ShopifyCheckout(); - const onClose = jest.fn(); - instance.present(checkoutUrl, {onClose}); + instance.present(checkoutUrl, {onClose: jest.fn()}); expect(NativeModule.present).toHaveBeenCalledWith( checkoutUrl, expect.any(Function), - null, - null, ); - const nativeOnClose = NativeModule.present.mock - .calls[0][1] as () => void; - nativeOnClose(); - expect(onClose).toHaveBeenCalledTimes(1); }); - it('forwards an `onFail` JSON wrapper to native when `onFail` is provided', () => { + it('invokes `onClose` when the dispatcher receives a close envelope', () => { const instance = new ShopifyCheckout(); - const onFail = jest.fn(); - instance.present(checkoutUrl, {onFail}); - expect(NativeModule.present).toHaveBeenCalledWith( - checkoutUrl, - null, - expect.any(Function), - null, - ); + const onClose = jest.fn(); + instance.present(checkoutUrl, {onClose}); + lastDispatch()(JSON.stringify({type: 'close'})); + expect(onClose).toHaveBeenCalledTimes(1); }); - it('forwards an `onGeolocationRequest` JSON wrapper to native when `onGeolocationRequest` is provided', () => { + it('ignores a close envelope when no `onClose` handler was provided', () => { const instance = new ShopifyCheckout(); - const onGeolocationRequest = jest.fn(); - instance.present(checkoutUrl, {onGeolocationRequest}); - expect(NativeModule.present).toHaveBeenCalledWith( - checkoutUrl, - null, - null, - expect.any(Function), - ); - }); - - describe('onGeolocationRequest callback', () => { - it('parses the native JSON payload and surfaces the typed event to the consumer', () => { - const instance = new ShopifyCheckout(); - const onGeolocationRequest = jest.fn(); - instance.present(checkoutUrl, {onGeolocationRequest}); - const nativeOnGeolocationRequest = NativeModule.present.mock - .calls[0][3] as (raw: string) => void; - nativeOnGeolocationRequest( - JSON.stringify({origin: 'https://shopify.com'}), - ); - expect(onGeolocationRequest).toHaveBeenCalledWith({ - origin: 'https://shopify.com', - }); - }); - - it('logs a LifecycleEventParseError and does not invoke `onGeolocationRequest` when payload is invalid JSON', () => { - const instance = new ShopifyCheckout(); - const onGeolocationRequest = jest.fn(); - instance.present(checkoutUrl, {onGeolocationRequest}); - const nativeOnGeolocationRequest = NativeModule.present.mock - .calls[0][3] as (raw: string) => void; - nativeOnGeolocationRequest('not-json'); - expect(onGeolocationRequest).not.toHaveBeenCalled(); - expect(console.error).toHaveBeenCalledWith( - expect.any(LifecycleEventParseError), - 'not-json', - ); - }); + instance.present(checkoutUrl, {onFail: jest.fn()}); + expect(() => lastDispatch()(JSON.stringify({type: 'close'}))).not.toThrow(); }); describe('onFail callback', () => { @@ -263,7 +223,7 @@ describe('ShopifyCheckoutKit', () => { {error: networkError, constructor: CheckoutHTTPError}, {error: expiredError, constructor: CheckoutExpiredError}, ])( - `parses the native JSON payload into a typed CheckoutException ($error.__typename)`, + `parses the fail envelope payload into a typed CheckoutException ($error.__typename)`, ({ error, constructor, @@ -274,10 +234,7 @@ describe('ShopifyCheckoutKit', () => { const instance = new ShopifyCheckout(); const onFail = jest.fn(); instance.present(checkoutUrl, {onFail}); - const nativeOnFail = NativeModule.present.mock.calls[0][2] as ( - raw: string, - ) => void; - nativeOnFail(JSON.stringify(error)); + lastDispatch()(JSON.stringify({type: 'fail', payload: error})); const calledWith = onFail.mock.calls[0][0]; expect(calledWith).toBeInstanceOf(constructor); expect(calledWith).not.toHaveProperty('__typename'); @@ -295,28 +252,64 @@ describe('ShopifyCheckoutKit', () => { __typename: 'UnknownError', message: 'Something went wrong', }; - const nativeOnFail = NativeModule.present.mock.calls[0][2] as ( - raw: string, - ) => void; - nativeOnFail(JSON.stringify(error)); + lastDispatch()(JSON.stringify({type: 'fail', payload: error})); const calledWith = onFail.mock.calls[0][0]; expect(calledWith).toBeInstanceOf(GenericError); }); - it('logs a LifecycleEventParseError and does not invoke `onFail` when payload is invalid JSON', () => { + it('ignores a fail envelope when no `onFail` handler was provided', () => { const instance = new ShopifyCheckout(); - const onFail = jest.fn(); - instance.present(checkoutUrl, {onFail}); - const nativeOnFail = NativeModule.present.mock.calls[0][2] as ( - raw: string, - ) => void; - nativeOnFail('not-json'); - expect(onFail).not.toHaveBeenCalled(); + const onClose = jest.fn(); + instance.present(checkoutUrl, {onClose}); + expect(() => + lastDispatch()( + JSON.stringify({type: 'fail', payload: internalError}), + ), + ).not.toThrow(); + }); + }); + + describe('onGeolocationRequest callback', () => { + it('parses the geolocationRequest envelope payload and surfaces the typed event', () => { + const instance = new ShopifyCheckout(); + const onGeolocationRequest = jest.fn(); + instance.present(checkoutUrl, {onGeolocationRequest}); + lastDispatch()( + JSON.stringify({ + type: 'geolocationRequest', + payload: {origin: 'https://shopify.com'}, + }), + ); + expect(onGeolocationRequest).toHaveBeenCalledWith({ + origin: 'https://shopify.com', + }); + }); + }); + + describe('envelope parsing', () => { + it('logs a LifecycleEventParseError when the envelope is invalid JSON', () => { + const instance = new ShopifyCheckout(); + const onClose = jest.fn(); + instance.present(checkoutUrl, {onClose}); + lastDispatch()('not-json'); + expect(onClose).not.toHaveBeenCalled(); expect(console.error).toHaveBeenCalledWith( expect.any(LifecycleEventParseError), 'not-json', ); }); + + it('silently ignores envelopes with unknown `type` values', () => { + const instance = new ShopifyCheckout(); + const onClose = jest.fn(); + const onFail = jest.fn(); + instance.present(checkoutUrl, {onClose, onFail}); + expect(() => + lastDispatch()(JSON.stringify({type: 'unknown', payload: {}})), + ).not.toThrow(); + expect(onClose).not.toHaveBeenCalled(); + expect(onFail).not.toHaveBeenCalled(); + }); }); }); @@ -345,15 +338,13 @@ describe('ShopifyCheckoutKit', () => { }); describe('Geolocation', () => { - const defaultConfig = {}; + const geolocationEnvelope = JSON.stringify({ + type: 'geolocationRequest', + payload: {origin: 'https://shopify.com'}, + }); - async function emitGeolocationRequest() { - await new Promise(resolve => { - eventEmitter.emit('geolocationRequest', { - origin: 'https://shopify.com', - }); - setTimeout(resolve); - }); + async function flush() { + await new Promise(resolve => setTimeout(resolve)); } describe('Android', () => { @@ -367,24 +358,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 () => { @@ -399,9 +387,10 @@ describe('ShopifyCheckoutKit', () => { } ).mockResolvedValue(mockPermissions); - new ShopifyCheckout(); - - await emitGeolocationRequest(); + const instance = new ShopifyCheckout(); + instance.present(checkoutUrl); + lastDispatch()(geolocationEnvelope); + await flush(); expect(PermissionsAndroid.requestMultiple).toHaveBeenCalledWith([ 'android.permission.ACCESS_COARSE_LOCATION', @@ -424,9 +413,10 @@ describe('ShopifyCheckoutKit', () => { } ).mockResolvedValue(mockPermissions); - new ShopifyCheckout(); - - await emitGeolocationRequest(); + const instance = new ShopifyCheckout(); + instance.present(checkoutUrl); + lastDispatch()(geolocationEnvelope); + await flush(); expect(PermissionsAndroid.requestMultiple).toHaveBeenCalledWith([ 'android.permission.ACCESS_COARSE_LOCATION', @@ -437,18 +427,34 @@ describe('ShopifyCheckoutKit', () => { ).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', + }); + expect(PermissionsAndroid.requestMultiple).not.toHaveBeenCalled(); + expect( + NativeModule.initiateGeolocationRequest, + ).not.toHaveBeenCalled(); + }); - sheet.teardown(); + it('does not run the default handler when the feature is disabled', async () => { + const instance = new ShopifyCheckout(undefined, { + handleGeolocationRequests: false, + }); + instance.present(checkoutUrl, {onClose: jest.fn()}); + lastDispatch()(geolocationEnvelope); + await flush(); - expect(mockRemove).toHaveBeenCalled(); + expect(PermissionsAndroid.requestMultiple).not.toHaveBeenCalled(); + expect( + NativeModule.initiateGeolocationRequest, + ).not.toHaveBeenCalled(); }); }); @@ -463,20 +469,19 @@ 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(PermissionsAndroid.requestMultiple).not.toHaveBeenCalled(); expect( NativeModule.initiateGeolocationRequest, ).not.toHaveBeenCalled(); 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 a316cd0c..e6219ce9 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 @@ -126,7 +126,7 @@ public void testCanPresentCheckout() { try (MockedStatic mockedShopifyCheckoutKit = Mockito .mockStatic(ShopifyCheckoutKit.class)) { String checkoutUrl = "https://shopify.com"; - shopifyCheckoutKitModule.present(checkoutUrl, null, null, null); + shopifyCheckoutKitModule.present(checkoutUrl, null); verify(mockComponentActivity).runOnUiThread(runnableCaptor.capture()); runnableCaptor.getValue().run(); @@ -139,71 +139,67 @@ public void testCanPresentCheckout() { @Test public void testPresentForwardsOnCloseCallback() { - Callback onClose = mock(Callback.class); - CustomCheckoutEventProcessor processor = new CustomCheckoutEventProcessor(mockContext, mockReactContext, - onClose, null, null); + Callback dispatch = mock(Callback.class); + CustomCheckoutEventProcessor processor = new CustomCheckoutEventProcessor(mockContext, mockReactContext, dispatch); processor.onCheckoutCanceled(); - verify(onClose).invoke(); + ArgumentCaptor args = ArgumentCaptor.forClass(Object[].class); + verify(dispatch).invoke(args.capture()); + assertThat((String) args.getValue()[0]).contains("\"type\":\"close\""); } @Test public void testOnCloseCallbackIsSingleShot() { - Callback onClose = mock(Callback.class); - CustomCheckoutEventProcessor processor = new CustomCheckoutEventProcessor(mockContext, mockReactContext, - onClose, null, null); + Callback dispatch = mock(Callback.class); + CustomCheckoutEventProcessor processor = new CustomCheckoutEventProcessor(mockContext, mockReactContext, dispatch); processor.onCheckoutCanceled(); processor.onCheckoutCanceled(); - verify(onClose, times(1)).invoke(); + verify(dispatch, times(1)).invoke(any(Object[].class)); } @Test - public void testGeolocationCallbackReceivesOriginJsonWhenSet() { - Callback onGeolocationRequest = mock(Callback.class); + public void testGeolocationDispatchesEnvelopeWithOrigin() { + Callback dispatch = mock(Callback.class); GeolocationPermissions.Callback permissionsCallback = mock(GeolocationPermissions.Callback.class); - CustomCheckoutEventProcessor processor = new CustomCheckoutEventProcessor(mockContext, mockReactContext, - null, null, onGeolocationRequest); + CustomCheckoutEventProcessor processor = new CustomCheckoutEventProcessor(mockContext, mockReactContext, dispatch); processor.onGeolocationPermissionsShowPrompt("https://shopify.com", permissionsCallback); ArgumentCaptor args = ArgumentCaptor.forClass(Object[].class); - verify(onGeolocationRequest).invoke(args.capture()); - assertThat((String) args.getValue()[0]).contains("https://shopify.com", "origin"); + verify(dispatch).invoke(args.capture()); + assertThat((String) args.getValue()[0]) + .contains("\"type\":\"geolocationRequest\"", "\"origin\":\"https://shopify.com\""); verify(mockEventEmitter, never()).emit(eq("geolocationRequest"), any()); } @Test - public void testGeolocationCallbackMayFireMultipleTimes() { - Callback onGeolocationRequest = mock(Callback.class); + public void testGeolocationDispatchIsMultiShot() { + Callback dispatch = mock(Callback.class); GeolocationPermissions.Callback permissionsCallback = mock(GeolocationPermissions.Callback.class); - CustomCheckoutEventProcessor processor = new CustomCheckoutEventProcessor(mockContext, mockReactContext, - null, null, onGeolocationRequest); + CustomCheckoutEventProcessor processor = new CustomCheckoutEventProcessor(mockContext, mockReactContext, dispatch); processor.onGeolocationPermissionsShowPrompt("https://shopify.com", permissionsCallback); processor.onGeolocationPermissionsShowPrompt("https://shopify.com", permissionsCallback); - verify(onGeolocationRequest, times(2)).invoke(any(Object[].class)); + verify(dispatch, times(2)).invoke(any(Object[].class)); } @Test - public void testGeolocationFallsBackToEventEmitterWhenNoCallbackSet() { + public void testGeolocationWithNoDispatchCallbackDoesNotInvoke() { GeolocationPermissions.Callback permissionsCallback = mock(GeolocationPermissions.Callback.class); - CustomCheckoutEventProcessor processor = new CustomCheckoutEventProcessor(mockContext, mockReactContext, - null, null, null); + CustomCheckoutEventProcessor processor = new CustomCheckoutEventProcessor(mockContext, mockReactContext, null); processor.onGeolocationPermissionsShowPrompt("https://shopify.com", permissionsCallback); - verify(mockEventEmitter).emit(eq("geolocationRequest"), stringCaptor.capture()); - assertThat(stringCaptor.getValue()).contains("https://shopify.com", "origin"); + verify(mockEventEmitter, never()).emit(eq("geolocationRequest"), any()); } @Test - public void testCheckoutCanceledWithNoCloseCallbackDoesNotEmitCloseEvent() { - CustomCheckoutEventProcessor processor = new CustomCheckoutEventProcessor(mockContext, mockReactContext, - null, null, null); + public void testCheckoutCanceledWithNoDispatchCallbackDoesNotEmitCloseEvent() { + CustomCheckoutEventProcessor processor = new CustomCheckoutEventProcessor(mockContext, mockReactContext, null); processor.onCheckoutCanceled(); @@ -546,8 +542,7 @@ public void testGetConfigReturnsDefaultLogLevel() { @Test public void testCanProcessCheckoutCompletedEvents() { - CustomCheckoutEventProcessor processor = new CustomCheckoutEventProcessor(mockContext, mockReactContext, - null, null, null); + CustomCheckoutEventProcessor processor = new CustomCheckoutEventProcessor(mockContext, mockReactContext, null); CartInfo cartInfo = new CartInfo(new ArrayList<>(), new Price(), "cart-token"); OrderDetails orderDetails = new OrderDetails( @@ -576,9 +571,8 @@ public void testCanProcessCheckoutCompletedEvents() { @Test public void testCanProcessCheckoutExpiredErrors() { - Callback onFail = mock(Callback.class); - CustomCheckoutEventProcessor processor = new CustomCheckoutEventProcessor(mockContext, mockReactContext, - null, onFail, null); + Callback dispatch = mock(Callback.class); + CustomCheckoutEventProcessor processor = new CustomCheckoutEventProcessor(mockContext, mockReactContext, dispatch); CheckoutExpiredException mockException = mock(CheckoutExpiredException.class); when(mockException.getErrorDescription()).thenReturn("Cart has expired"); @@ -588,17 +582,17 @@ public void testCanProcessCheckoutExpiredErrors() { processor.onCheckoutFailed(mockException); ArgumentCaptor args = ArgumentCaptor.forClass(Object[].class); - verify(onFail).invoke(args.capture()); + verify(dispatch).invoke(args.capture()); assertThat((String) args.getValue()[0]) - .contains("CheckoutExpiredError", "Cart has expired", "cart_expired", "\"recoverable\":false"); + .contains("\"type\":\"fail\"", "CheckoutExpiredError", "Cart has expired", "cart_expired", + "\"recoverable\":false"); } @Test public void testCanProcessClientErrors() { - Callback onFail = mock(Callback.class); - CustomCheckoutEventProcessor processor = new CustomCheckoutEventProcessor(mockContext, mockReactContext, - null, onFail, null); + Callback dispatch = mock(Callback.class); + CustomCheckoutEventProcessor processor = new CustomCheckoutEventProcessor(mockContext, mockReactContext, dispatch); ClientException mockException = mock(ClientException.class); when(mockException.getErrorDescription()).thenReturn("Customer account required"); @@ -608,18 +602,17 @@ public void testCanProcessClientErrors() { processor.onCheckoutFailed(mockException); ArgumentCaptor args = ArgumentCaptor.forClass(Object[].class); - verify(onFail).invoke(args.capture()); + verify(dispatch).invoke(args.capture()); assertThat((String) args.getValue()[0]) - .contains("CheckoutClientError", "Customer account required", "customer_account_required", + .contains("\"type\":\"fail\"", "CheckoutClientError", "Customer account required", "customer_account_required", "\"recoverable\":true"); } @Test public void testCanProcessHttpErrors() { - Callback onFail = mock(Callback.class); - CustomCheckoutEventProcessor processor = new CustomCheckoutEventProcessor(mockContext, mockReactContext, - null, onFail, null); + Callback dispatch = mock(Callback.class); + CustomCheckoutEventProcessor processor = new CustomCheckoutEventProcessor(mockContext, mockReactContext, dispatch); HttpException mockException = mock(HttpException.class); when(mockException.getErrorDescription()).thenReturn("Not Found"); @@ -630,17 +623,17 @@ public void testCanProcessHttpErrors() { processor.onCheckoutFailed(mockException); ArgumentCaptor args = ArgumentCaptor.forClass(Object[].class); - verify(onFail).invoke(args.capture()); + verify(dispatch).invoke(args.capture()); assertThat((String) args.getValue()[0]) - .contains("CheckoutHTTPError", "Not Found", "http_error", "\"statusCode\":404", "\"recoverable\":false"); + .contains("\"type\":\"fail\"", "CheckoutHTTPError", "Not Found", "http_error", "\"statusCode\":404", + "\"recoverable\":false"); } @Test public void testOnFailCallbackIsSingleShot() { - Callback onFail = mock(Callback.class); - CustomCheckoutEventProcessor processor = new CustomCheckoutEventProcessor(mockContext, mockReactContext, - null, onFail, null); + Callback dispatch = mock(Callback.class); + CustomCheckoutEventProcessor processor = new CustomCheckoutEventProcessor(mockContext, mockReactContext, dispatch); CheckoutExpiredException mockException = mock(CheckoutExpiredException.class); when(mockException.getErrorDescription()).thenReturn("Cart has expired"); @@ -650,13 +643,12 @@ public void testOnFailCallbackIsSingleShot() { processor.onCheckoutFailed(mockException); processor.onCheckoutFailed(mockException); - verify(onFail, times(1)).invoke(any(Object[].class)); + verify(dispatch, times(1)).invoke(any(Object[].class)); } @Test - public void testCheckoutFailedWithNoFailCallbackDoesNotEmitFailEvent() { - CustomCheckoutEventProcessor processor = new CustomCheckoutEventProcessor(mockContext, mockReactContext, - null, null, null); + public void testCheckoutFailedWithNoDispatchCallbackDoesNotEmitFailEvent() { + CustomCheckoutEventProcessor processor = new CustomCheckoutEventProcessor(mockContext, mockReactContext, null); CheckoutExpiredException mockException = mock(CheckoutExpiredException.class);