From b6a2080b80b363be78fd8cee9dfed5500ea1391a Mon Sep 17 00:00:00 2001 From: Mark Murray Date: Tue, 6 Oct 2026 14:29:01 +0100 Subject: [PATCH 1/7] Adopt native checkout lifecycle events in React Native --- platforms/react-native/README.md | 93 ++- .../react-native/__mocks__/react-native.ts | 9 +- .../checkoutkit/CheckoutEventSerialization.kt | 25 + .../checkoutkit/CustomCheckoutListener.java | 47 +- .../checkoutkit/DispatchEventTypes.java | 7 +- .../checkoutkit/DispatchHandle.java | 8 +- .../reactnative/checkoutkit/ProtocolRelay.kt | 90 --- .../checkoutkit/ShopifyCheckoutKitModule.java | 85 ++- .../CheckoutEventSerializationTest.kt | 58 ++ .../checkoutkit/CustomCheckoutListenerTest.kt | 17 +- .../checkoutkit/ProtocolRelayTest.kt | 253 ------- .../api/checkout-kit-react-native.api.md | 120 +++- .../ios/AcceleratedCheckoutButtons.swift | 34 +- .../ios/CheckoutEventBridge.swift | 81 +++ .../ios/ProtocolRelay.swift | 87 --- ...hopifyCheckoutKit+EventSerialization.swift | 4 +- .../ios/ShopifyCheckoutKit.mm | 22 +- .../ios/ShopifyCheckoutKit.swift | 120 +--- .../checkout-kit-react-native/package.json | 4 +- .../package.snapshot.json | 18 +- .../checkout-kit-react-native/src/checkout.ts | 67 ++ .../components/AcceleratedCheckoutButtons.tsx | 261 +------ .../checkout-kit-react-native/src/context.tsx | 15 +- .../src/dispatch-events.ts | 11 +- .../checkout-kit-react-native/src/index.d.ts | 56 +- .../checkout-kit-react-native/src/index.ts | 88 +-- .../src/present-dispatcher.ts | 311 +++------ .../checkout-kit-react-native/src/protocol.ts | 41 -- .../src/specs/NativeShopifyCheckoutKit.ts | 7 +- ...celeratedCheckoutButtonsNativeComponent.ts | 11 +- .../tests/AcceleratedCheckoutButtons.test.tsx | 220 ++---- .../tests/checkout.test.ts | 106 +++ .../tests/context.test.tsx | 73 +- .../tests/dispatch-events.test.ts | 8 +- .../tests/index.test.ts | 653 +++++------------- .../tests/protocol.test.ts | 415 ----------- .../ShopifyCheckoutKitModuleTest.java | 171 ++++- .../react-native/sample/ios/Podfile.lock | 16 +- platforms/react-native/sample/src/App.tsx | 2 +- .../src/hooks/useCheckoutEventHandlers.ts | 73 +- .../sample/src/screens/CartScreen.tsx | 41 +- .../test/rct-integration-app/Podfile.lock | 16 +- .../CheckoutEventBridgeTests.swift | 81 +++ .../EventSerializationTests.swift | 2 +- .../ProtocolRelayTests.swift | 192 ----- .../ShopifyCheckoutKitTests.swift | 4 +- 46 files changed, 1407 insertions(+), 2716 deletions(-) create mode 100644 platforms/react-native/modules/@shopify/checkout-kit-react-native/android/src/main/java/com/shopify/reactnative/checkoutkit/CheckoutEventSerialization.kt delete mode 100644 platforms/react-native/modules/@shopify/checkout-kit-react-native/android/src/main/java/com/shopify/reactnative/checkoutkit/ProtocolRelay.kt create mode 100644 platforms/react-native/modules/@shopify/checkout-kit-react-native/android/src/test/java/com/shopify/reactnative/checkoutkit/CheckoutEventSerializationTest.kt delete mode 100644 platforms/react-native/modules/@shopify/checkout-kit-react-native/android/src/test/java/com/shopify/reactnative/checkoutkit/ProtocolRelayTest.kt create mode 100644 platforms/react-native/modules/@shopify/checkout-kit-react-native/ios/CheckoutEventBridge.swift delete mode 100644 platforms/react-native/modules/@shopify/checkout-kit-react-native/ios/ProtocolRelay.swift create mode 100644 platforms/react-native/modules/@shopify/checkout-kit-react-native/src/checkout.ts delete mode 100644 platforms/react-native/modules/@shopify/checkout-kit-react-native/src/protocol.ts create mode 100644 platforms/react-native/modules/@shopify/checkout-kit-react-native/tests/checkout.test.ts delete mode 100644 platforms/react-native/modules/@shopify/checkout-kit-react-native/tests/protocol.test.ts create mode 100644 platforms/react-native/test/rct-integration-app/RCTIntegrationAppTests/CheckoutEventBridgeTests.swift delete mode 100644 platforms/react-native/test/rct-integration-app/RCTIntegrationAppTests/ProtocolRelayTests.swift diff --git a/platforms/react-native/README.md b/platforms/react-native/README.md index cbee328e1..9468ca1be 100644 --- a/platforms/react-native/README.md +++ b/platforms/react-native/README.md @@ -779,31 +779,65 @@ Should you wish to manually clear the preload cache, call `invalidate()` on your ## Checkout lifecycle -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. +Lifecycle callbacks are passed to `present()` or as props on +`AcceleratedCheckoutButtons`. Start, update, and complete events contain a +`Checkout` snapshot. Known fields use camelCase; extension fields keep their +original keys. Snapshots include checkout data such as line items, totals, +fulfillment, actions, and policies, without protocol metadata. ### SDK callbacks on `present()` ```tsx +let completed = false; shopify.present(checkoutUrl, { - onClose: () => { - // The sheet was dismissed without a terminal error + onStart: ({checkout}) => { + completed = false; }, - onFail: (error: CheckoutException) => { - // A terminal error occurred — inspect `error.code`, `error.message`, etc. + onUpdate: ({checkout}) => { + // Observe changes to checkout.lineItems, checkout.totals, etc. + }, + onComplete: ({checkout}) => { + completed = true; + // checkout.order contains the order confirmation when available. + }, + onDismiss: () => { + if (completed) clearCart(); + }, + onFail: ({error}) => { + // Inspect error.code, error.message, and optional error.statusCode. }, }); ``` -| 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). | +| Callback | Payload | When it fires | +| --- | --- | --- | +| `onStart` | `{checkout: Checkout}` | Checkout starts. Android does not replay a start received during preload. | +| `onUpdate` | `{checkout: Checkout}` | Checkout data changes. The native SDK suppresses duplicate snapshots. | +| `onComplete` | `{checkout: Checkout}` | Checkout completes. The confirmation UI can remain visible. | +| `onDismiss` | None | Checkout is dismissed, including after completion. | +| `onFail` | `{error: CheckoutException}` | Checkout cannot continue. | +| `onGeolocationRequest` | `GeolocationRequestEvent` | Android sheets only. See [geolocation handling](#opting-out-of-the-default-behavior). | + +Completion keeps callbacks active until dismissal or failure. Delay changes that +unmount checkout UI, such as clearing the cart that owns accelerated buttons, +until dismissal. Calling `dismiss()` also delivers `onDismiss`. + +Repeated `present()` calls while a sheet is visible replace its callbacks; the +existing checkout stays open. `teardown()` stops observations without dismissing +the sheet. + +### Migrating from protocol callbacks -`onClose` and `onFail` are mutually exclusive — exactly one of them fires -per `present(...)` call, after which both handles are released. +Replace the third `present()` argument and accelerated `events` prop with the +lifecycle callbacks above. `ec.start` becomes `onStart`, `ec.complete` becomes +`onComplete`, and checkout change notifications become `onUpdate`. Read checkout +data from `event.checkout`. Terminal protocol errors now arrive through `onFail`; +checkout messages remain available in snapshots. + +Rename sheet `onClose` and accelerated `onCancel` to `onDismiss`. Change +`onFail(error)` to `onFail({error})`. The accelerated `onClickLink` prop is removed; +native SDKs open checkout links by default. `CheckoutProtocol`, +`ProtocolHandlers`, and protocol payload exports have been removed. ## Identity & customer accounts @@ -1126,28 +1160,27 @@ The `cornerRadius` prop lets you match the buttons to other calls-to-action in y ### Handle loading, errors, and lifecycle events -Attach lifecycle handlers to respond when buyers finish, cancel, or encounter an error. +Accelerated buttons use the same lifecycle callbacks as sheets. +Use a ref to remember completion without unmounting the button's confirmation UI: ```tsx +const completed = useRef(false); + { - // Clear cart after successful checkout - clearCart(); - }} - onFail={(error) => { - console.error('Accelerated checkout failed:', error); - }} - onCancel={() => { - analytics.track('accelerated_checkout_cancelled'); - }} - onRenderStateChange={(event) => { - // event.state: 'loading' | 'rendered' | 'error' - setRenderState(event.state); + onStart={() => { completed.current = false; }} + onComplete={({checkout}) => { completed.current = true; }} + onDismiss={() => { + if (completed.current) { + completed.current = false; + clearCart(); + } }} - onClickLink={(url) => { - Linking.openURL(url); + onFail={({error}) => { + completed.current = false; + console.error('Accelerated checkout failed:', error.code); }} + onRenderStateChange={(event) => setRenderState(event.state)} /> ``` diff --git a/platforms/react-native/__mocks__/react-native.ts b/platforms/react-native/__mocks__/react-native.ts index f22bc7a47..80f3e61df 100644 --- a/platforms/react-native/__mocks__/react-native.ts +++ b/platforms/react-native/__mocks__/react-native.ts @@ -78,7 +78,14 @@ const ShopifyCheckoutKit = { version: '0.7.0', getConstants: jest.fn(() => ({ version: '0.7.0', - dispatchEventTypes: ['close', 'fail', 'geolocationRequest'], + dispatchEventTypes: [ + 'start', + 'update', + 'complete', + 'dismiss', + 'fail', + 'geolocationRequest', + ], })), onDispatch: jest.fn((callback: (envelopeJson: string) => void) => shopifyCheckoutKitEventEmitter.addListener('onDispatch', callback), diff --git a/platforms/react-native/modules/@shopify/checkout-kit-react-native/android/src/main/java/com/shopify/reactnative/checkoutkit/CheckoutEventSerialization.kt b/platforms/react-native/modules/@shopify/checkout-kit-react-native/android/src/main/java/com/shopify/reactnative/checkoutkit/CheckoutEventSerialization.kt new file mode 100644 index 000000000..9e0bc26cc --- /dev/null +++ b/platforms/react-native/modules/@shopify/checkout-kit-react-native/android/src/main/java/com/shopify/reactnative/checkoutkit/CheckoutEventSerialization.kt @@ -0,0 +1,25 @@ +package com.shopify.reactnative.checkoutkit + +import com.shopify.checkoutkit.Checkout +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.encodeToJsonElement +import kotlinx.serialization.json.put + +fun interface DispatchCallback { + fun invoke(json: String) +} + +/** Uses the native snapshot serializer to retain wire names and extension fields. */ +object CheckoutEventSerialization { + @JvmStatic + fun checkout(type: String, requestId: String, checkout: Checkout): String = + Json.encodeToString(buildJsonObject { + put("type", type) + put("requestId", requestId) + put("payload", buildJsonObject { + put("checkout", Json.encodeToJsonElement(checkout)) + }) + }) +} 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 d7668cbd4..bfba2bba6 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 @@ -20,6 +20,17 @@ public class CustomCheckoutListener extends DefaultCheckoutListener { private final ObjectMapper mapper = new ObjectMapper(); private final DispatchHandle dispatch; + private String requestId = ""; + private Runnable onTerminal = () -> {}; + + public void configure(String requestId, Runnable onTerminal) { + invokeGeolocationCallback(false); + this.requestId = requestId; + this.onTerminal = onTerminal; + } + + public boolean matchesRequest(String requestId) { return this.requestId.equals(requestId); } + public boolean isReleased() { return dispatch.isReleased(); } // Geolocation-specific variables @@ -46,6 +57,7 @@ public void invokeGeolocationCallback(boolean allow) { public void release() { dispatch.release(); + invokeGeolocationCallback(false); geolocationCallback = null; geolocationOrigin = null; } @@ -95,12 +107,15 @@ public void onGeolocationPermissionsHidePrompt() { } @Override - public void onCheckoutFailed(CheckoutException checkoutError) { + public void onCheckoutFailed(CheckoutFailureEvent event) { if (dispatch.isReleased()) { return; } try { - dispatch.invoke(buildEnvelope(DispatchEventTypes.FAIL, populateErrorDetails(checkoutError))); + onTerminal.run(); + Map payload = new HashMap<>(); + payload.put("error", populateErrorDetails(event.getError())); + dispatch.invoke(buildEnvelope(DispatchEventTypes.FAIL, payload)); } catch (IOException e) { Log.e(TAG, "Error processing checkout failed event", e); } finally { @@ -114,7 +129,8 @@ public void onCheckoutDismissed() { return; } try { - dispatch.invoke(buildEnvelope(DispatchEventTypes.CLOSE, null)); + onTerminal.run(); + dispatch.invoke(buildEnvelope(DispatchEventTypes.DISMISS, null)); } catch (IOException e) { Log.e(TAG, "Error processing checkout dismissed event", e); } finally { @@ -122,11 +138,36 @@ public void onCheckoutDismissed() { } } + @Override + public void onCheckoutStarted(CheckoutStartEvent event) { + emitCheckout(DispatchEventTypes.START, event.getCheckout()); + } + + @Override + public void onCheckoutUpdated(CheckoutUpdateEvent event) { + emitCheckout(DispatchEventTypes.UPDATE, event.getCheckout()); + } + + @Override + public void onCheckoutCompleted(CheckoutCompleteEvent event) { + emitCheckout(DispatchEventTypes.COMPLETE, event.getCheckout()); + } + + private void emitCheckout(String type, Checkout checkout) { + if (dispatch.isReleased()) return; + try { + dispatch.invoke(CheckoutEventSerialization.checkout(type, requestId, checkout)); + } catch (Exception e) { + Log.e(TAG, "Error serializing checkout event"); + } + } + // Private private String buildEnvelope(String type, @Nullable Object payload) throws IOException { ObjectNode envelope = mapper.createObjectNode(); envelope.put("type", type); + envelope.put("requestId", requestId); if (payload != null) { envelope.set("payload", mapper.valueToTree(payload)); } 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 index aaca43b21..66c0a5ea7 100644 --- 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 @@ -14,12 +14,15 @@ * two sides agree at construction time. */ public final class DispatchEventTypes { - public static final String CLOSE = "close"; + public static final String START = "start"; + public static final String UPDATE = "update"; + public static final String COMPLETE = "complete"; + public static final String DISMISS = "dismiss"; 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)); + Arrays.asList(START, UPDATE, COMPLETE, DISMISS, 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/DispatchHandle.java b/platforms/react-native/modules/@shopify/checkout-kit-react-native/android/src/main/java/com/shopify/reactnative/checkoutkit/DispatchHandle.java index 28315e76f..870a2029c 100644 --- a/platforms/react-native/modules/@shopify/checkout-kit-react-native/android/src/main/java/com/shopify/reactnative/checkoutkit/DispatchHandle.java +++ b/platforms/react-native/modules/@shopify/checkout-kit-react-native/android/src/main/java/com/shopify/reactnative/checkoutkit/DispatchHandle.java @@ -2,13 +2,7 @@ import androidx.annotation.NonNull; -/** - * Shared per-presentation dispatch handle. - * - * SDK lifecycle events and protocol events both invoke the same handle. Terminal - * lifecycle events release it so subsequent protocol emissions are dropped, - * matching the iOS pendingDispatchCallback lifecycle. - */ +/** Gates events after a checkout presentation ends. */ public class DispatchHandle implements DispatchCallback { private final DispatchCallback downstream; private boolean released = false; diff --git a/platforms/react-native/modules/@shopify/checkout-kit-react-native/android/src/main/java/com/shopify/reactnative/checkoutkit/ProtocolRelay.kt b/platforms/react-native/modules/@shopify/checkout-kit-react-native/android/src/main/java/com/shopify/reactnative/checkoutkit/ProtocolRelay.kt deleted file mode 100644 index bb5d679b6..000000000 --- a/platforms/react-native/modules/@shopify/checkout-kit-react-native/android/src/main/java/com/shopify/reactnative/checkoutkit/ProtocolRelay.kt +++ /dev/null @@ -1,90 +0,0 @@ -package com.shopify.reactnative.checkoutkit - -import android.util.Log -import com.shopify.checkoutkit.CheckoutProtocol -import kotlinx.serialization.Serializable -import kotlinx.serialization.encodeToString -import kotlinx.serialization.json.Json - -private const val TAG = "ShopifyCheckoutKit" - -fun interface DispatchCallback { - fun invoke(json: String) -} - -@Serializable -internal data class DispatchEnvelope

( - val type: String, - val payload: P, -) - -/** - * Bridges native CheckoutProtocol notifications to the React Native onDispatch - * event stream. Payloads are emitted in protocol wire casing; JS performs the - * schema-aware conversion to the public camelCase shape with QuickType. - */ -object ProtocolRelay { - - private val json: Json = Json { ignoreUnknownKeys = true } - - @JvmStatic - fun makeClient( - subscribedMethods: List, - dispatch: DispatchCallback, - ): CheckoutProtocol.Client { - var client = CheckoutProtocol.Client() - for (method in subscribedMethods) { - when (method) { - CheckoutProtocol.complete.method -> { - client = client.on(CheckoutProtocol.complete) { checkout -> - forwardEnvelope(method, checkout, dispatch) - } - } - CheckoutProtocol.error.method -> { - client = client.on(CheckoutProtocol.error) { error -> - forwardEnvelope(method, error, dispatch) - } - } - CheckoutProtocol.fulfillmentChange.method -> { - client = client.on(CheckoutProtocol.fulfillmentChange) { checkout -> - forwardEnvelope(method, checkout, dispatch) - } - } - CheckoutProtocol.lineItemsChange.method -> { - client = client.on(CheckoutProtocol.lineItemsChange) { checkout -> - forwardEnvelope(method, checkout, dispatch) - } - } - CheckoutProtocol.messagesChange.method -> { - client = client.on(CheckoutProtocol.messagesChange) { checkout -> - forwardEnvelope(method, checkout, dispatch) - } - } - CheckoutProtocol.start.method -> { - client = client.on(CheckoutProtocol.start) { checkout -> - forwardEnvelope(method, checkout, dispatch) - } - } - CheckoutProtocol.totalsChange.method -> { - client = client.on(CheckoutProtocol.totalsChange) { checkout -> - forwardEnvelope(method, checkout, dispatch) - } - } - } - } - return client - } - - private inline fun forwardEnvelope( - type: String, - payload: P, - dispatch: DispatchCallback, - ) { - try { - val envelopeJson = json.encodeToString(DispatchEnvelope(type, payload)) - dispatch.invoke(envelopeJson) - } catch (e: Exception) { - Log.e(TAG, "Error dispatching protocol event \"$type\"", e) - } - } -} 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 b42635b77..3753827bf 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 @@ -4,6 +4,7 @@ import androidx.activity.ComponentActivity; import com.facebook.react.bridge.ReactApplicationContext; import com.facebook.react.bridge.ReactMethod; +import com.facebook.react.bridge.UiThreadUtil; import com.facebook.react.bridge.Arguments; import com.facebook.react.bridge.ReadableArray; import com.facebook.react.bridge.ReadableMap; @@ -11,6 +12,7 @@ import com.shopify.checkoutkit.NativeShopifyCheckoutKitSpec; import com.shopify.checkoutkit.*; +import java.lang.ref.WeakReference; import java.util.ArrayList; import java.util.HashMap; import java.util.HashSet; @@ -32,6 +34,8 @@ public class ShopifyCheckoutKitModule extends NativeShopifyCheckoutKitSpec { private CheckoutHandle checkoutSheet; + private WeakReference closingCheckoutSheet; + private CustomCheckoutListener checkoutListener; private CheckoutPreload checkoutPreload; @@ -73,42 +77,61 @@ public void removeListeners(double count) { } @ReactMethod - public void present(String checkoutURL, ReadableArray subscribedMethods) { - releaseCheckoutListener(); - + public void present(String checkoutURL, String requestId) { Activity currentActivity = getReactApplicationContext().getCurrentActivity(); if (currentActivity instanceof ComponentActivity) { - DispatchHandle dispatch = new DispatchHandle(json -> emitOnDispatch(json)); - CustomCheckoutListener listener = new CustomCheckoutListener(dispatch); - checkoutListener = listener; - - List methods = new ArrayList<>(); - for (int i = 0; i < subscribedMethods.size(); i++) { - String method = subscribedMethods.getString(i); - if (method != null) { - methods.add(method); - } - } - CheckoutProtocol.Client client = ProtocolRelay.makeClient(methods, dispatch); - currentActivity.runOnUiThread(() -> { - if (checkoutListener != listener) { - return; + CustomCheckoutListener listener = checkoutListener; + if (checkoutSheet == null || listener == null || listener.isReleased()) { + releaseCheckoutListener(); + listener = new CustomCheckoutListener(this::emitDispatchEvent); + checkoutListener = listener; } - checkoutSheet = ShopifyCheckoutKit.present(checkoutURL, (ComponentActivity) currentActivity, - listener, client); + listener.configure(requestId, this::finishCheckoutPresentation); + presentCheckout(checkoutURL, (ComponentActivity) currentActivity, listener); }); + } else { + CustomCheckoutListener listener = new CustomCheckoutListener(this::emitDispatchEvent); + listener.configure(requestId, () -> {}); + listener.onCheckoutDismissed(); } } + private void presentCheckout(String checkoutURL, ComponentActivity activity, CustomCheckoutListener listener) { + if (checkoutListener != listener) return; + CheckoutHandle sheet = ShopifyCheckoutKit.present(checkoutURL, activity, listener); + if (checkoutListener != listener) return; + if (sheet != null && closingCheckoutSheet != null && sheet == closingCheckoutSheet.get()) { + // During the close animation the SDK returns the old handle without adopting + // this listener. Retry on the UI queue until a new presentation can start. + UiThreadUtil.runOnUiThread(() -> presentCheckout(checkoutURL, activity, listener), 16); + return; + } + closingCheckoutSheet = null; + checkoutSheet = sheet; + if (sheet == null) listener.onCheckoutDismissed(); + } + + private void finishCheckoutPresentation() { + if (checkoutSheet != null) closingCheckoutSheet = new WeakReference<>(checkoutSheet); + checkoutSheet = null; + checkoutListener = null; + } + + protected void emitDispatchEvent(String event) { + emitOnDispatch(event); + } + @ReactMethod public void dismiss() { - releaseCheckoutListener(); - - if (checkoutSheet != null) { - checkoutSheet.dismiss(); - checkoutSheet = null; - } + UiThreadUtil.runOnUiThread(() -> { + CheckoutHandle sheet = checkoutSheet; + CustomCheckoutListener listener = checkoutListener; + if (sheet != null) sheet.dismiss(); + // Native dismiss() does not notify the listener for programmatic dismissal. + if (listener != null) listener.onCheckoutDismissed(); + else checkoutSheet = null; + }); } @ReactMethod @@ -297,10 +320,12 @@ public boolean isApplePayAvailable() { } @ReactMethod - public void respondToGeolocationRequest(boolean allow) { - if (checkoutListener != null) { - checkoutListener.invokeGeolocationCallback(allow); - } + public void respondToGeolocationRequest(boolean allow, String requestId) { + UiThreadUtil.runOnUiThread(() -> { + if (checkoutListener != null && checkoutListener.matchesRequest(requestId)) { + checkoutListener.invokeGeolocationCallback(allow); + } + }); } // Private diff --git a/platforms/react-native/modules/@shopify/checkout-kit-react-native/android/src/test/java/com/shopify/reactnative/checkoutkit/CheckoutEventSerializationTest.kt b/platforms/react-native/modules/@shopify/checkout-kit-react-native/android/src/test/java/com/shopify/reactnative/checkoutkit/CheckoutEventSerializationTest.kt new file mode 100644 index 000000000..843eae1e5 --- /dev/null +++ b/platforms/react-native/modules/@shopify/checkout-kit-react-native/android/src/test/java/com/shopify/reactnative/checkoutkit/CheckoutEventSerializationTest.kt @@ -0,0 +1,58 @@ +package com.shopify.reactnative.checkoutkit + +import com.shopify.checkoutkit.* +import kotlinx.serialization.json.* +import org.assertj.core.api.Assertions.assertThat +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class CheckoutEventSerializationTest { + private val checkout = Json.decodeFromString(""" + {"id":"checkout-1","currency":"USD","status":"incomplete", + "line_items":[],"links":[],"totals":[], + "expires_at":"2026-09-25T12:00:00.123Z", + "actions":{"com.example.verify":[{"config":{"custom_key":true}}]}, + "policies":[{"id":"policy-1","type":"return","description":{"plain":"Returns accepted"},"applies_to":["$.line_items[0]"]}], + "buyer":{"first_name":"Test"},"custom_extension":{"nested_key":true}} + """.trimIndent()) + + @Test + fun `serializes checkout snapshots without protocol metadata`() { + val envelope = Json.parseToJsonElement(CheckoutEventSerialization.checkout("start", "request-1", checkout)).jsonObject + assertThat(envelope["requestId"]?.jsonPrimitive?.content).isEqualTo("request-1") + val snapshot = envelope["payload"]!!.jsonObject["checkout"]!!.jsonObject + assertThat(snapshot).doesNotContainKey("ucp") + assertThat(snapshot).containsKeys("line_items", "custom_extension", "policies", "actions") + assertThat(snapshot["expires_at"]?.jsonPrimitive?.content).isEqualTo("2026-09-25T12:00:00.123Z") + assertThat(snapshot["buyer"]?.jsonObject?.get("first_name")?.jsonPrimitive?.content).isEqualTo("Test") + } + + @Test + fun `completion retains callbacks until dismissal`() { + val events = mutableListOf() + val listener = CustomCheckoutListener(DispatchCallback { events.add(it) }) + listener.configure("request-1") {} + listener.onCheckoutStarted(CheckoutStartEvent(checkout)) + listener.onCheckoutUpdated(CheckoutUpdateEvent(checkout)) + listener.onCheckoutCompleted(CheckoutCompleteEvent(checkout)) + assertThat(listener.isReleased).isFalse() + listener.onCheckoutDismissed() + listener.onCheckoutUpdated(CheckoutUpdateEvent(checkout)) + assertThat(events.map { Json.parseToJsonElement(it).jsonObject["type"]?.jsonPrimitive?.content }) + .containsExactly("start", "update", "complete", "dismiss") + } + + @Test + fun `repeated presentation updates the existing listener session`() { + val events = mutableListOf() + val listener = CustomCheckoutListener(DispatchCallback { events.add(it) }) + listener.configure("old") {} + listener.configure("new") {} + listener.onCheckoutUpdated(CheckoutUpdateEvent(checkout)) + assertThat(Json.parseToJsonElement(events.single()).jsonObject["requestId"]?.jsonPrimitive?.content).isEqualTo("new") + assertThat(listener.matchesRequest("old")).isFalse() + } + +} diff --git a/platforms/react-native/modules/@shopify/checkout-kit-react-native/android/src/test/java/com/shopify/reactnative/checkoutkit/CustomCheckoutListenerTest.kt b/platforms/react-native/modules/@shopify/checkout-kit-react-native/android/src/test/java/com/shopify/reactnative/checkoutkit/CustomCheckoutListenerTest.kt index 1e5470a94..7fb2f07d0 100644 --- a/platforms/react-native/modules/@shopify/checkout-kit-react-native/android/src/test/java/com/shopify/reactnative/checkoutkit/CustomCheckoutListenerTest.kt +++ b/platforms/react-native/modules/@shopify/checkout-kit-react-native/android/src/test/java/com/shopify/reactnative/checkoutkit/CustomCheckoutListenerTest.kt @@ -1,6 +1,7 @@ package com.shopify.reactnative.checkoutkit import com.shopify.checkoutkit.CheckoutErrorCode +import com.shopify.checkoutkit.CheckoutFailureEvent import com.shopify.checkoutkit.CheckoutException import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonObject @@ -16,11 +17,11 @@ import org.robolectric.RobolectricTestRunner class CustomCheckoutListenerTest { @Test - fun `fail envelope carries the flattened error fields`() { + fun `fail envelope carries an error event`() { val captured = mutableListOf() val listener = CustomCheckoutListener(DispatchCallback { json -> captured.add(json) }) - listener.onCheckoutFailed(CheckoutException(CheckoutErrorCode.CART_EXPIRED, "expired")) + listener.onCheckoutFailed(CheckoutFailureEvent(CheckoutException(CheckoutErrorCode.CART_EXPIRED, "expired"))) val envelope = Json.parseToJsonElement(captured.single()).jsonObject assertThat(envelope["type"]?.jsonPrimitive?.content).isEqualTo("fail") @@ -38,7 +39,7 @@ class CustomCheckoutListenerTest { val listener = CustomCheckoutListener(DispatchCallback { json -> captured.add(json) }) listener.onCheckoutFailed( - CheckoutException(CheckoutErrorCode.HTTP_ERROR, "unprocessable entity", 422), + CheckoutFailureEvent(CheckoutException(CheckoutErrorCode.HTTP_ERROR, "unprocessable entity", 422)), ) val payload = payloadOf(Json.parseToJsonElement(captured.single()).jsonObject) @@ -52,7 +53,7 @@ class CustomCheckoutListenerTest { val captured = mutableListOf() val listener = CustomCheckoutListener(DispatchCallback { json -> captured.add(json) }) - listener.onCheckoutFailed(CheckoutException(code, "boom")) + listener.onCheckoutFailed(CheckoutFailureEvent(CheckoutException(code, "boom"))) val payload = payloadOf(Json.parseToJsonElement(captured.single()).jsonObject) assertThat(payload["code"]?.jsonPrimitive?.content) @@ -61,14 +62,14 @@ class CustomCheckoutListenerTest { } @Test - fun `dismiss emits a close envelope without a payload`() { + fun `dismiss emits a dismiss envelope without a payload`() { val captured = mutableListOf() val listener = CustomCheckoutListener(DispatchCallback { json -> captured.add(json) }) listener.onCheckoutDismissed() val envelope = Json.parseToJsonElement(captured.single()).jsonObject - assertThat(envelope["type"]?.jsonPrimitive?.content).isEqualTo("close") + assertThat(envelope["type"]?.jsonPrimitive?.content).isEqualTo("dismiss") assertThat(envelope).doesNotContainKey("payload") } @@ -78,11 +79,11 @@ class CustomCheckoutListenerTest { val listener = CustomCheckoutListener(DispatchCallback { json -> captured.add(json) }) listener.onCheckoutDismissed() - listener.onCheckoutFailed(CheckoutException(CheckoutErrorCode.SDK_ERROR, "late")) + listener.onCheckoutFailed(CheckoutFailureEvent(CheckoutException(CheckoutErrorCode.SDK_ERROR, "late"))) assertThat(captured).hasSize(1) } private fun payloadOf(envelope: JsonObject): JsonObject = - envelope["payload"]?.jsonObject ?: JsonObject(emptyMap()) + envelope["payload"]?.jsonObject?.get("error")?.jsonObject ?: JsonObject(emptyMap()) } diff --git a/platforms/react-native/modules/@shopify/checkout-kit-react-native/android/src/test/java/com/shopify/reactnative/checkoutkit/ProtocolRelayTest.kt b/platforms/react-native/modules/@shopify/checkout-kit-react-native/android/src/test/java/com/shopify/reactnative/checkoutkit/ProtocolRelayTest.kt deleted file mode 100644 index 15b4f67aa..000000000 --- a/platforms/react-native/modules/@shopify/checkout-kit-react-native/android/src/test/java/com/shopify/reactnative/checkoutkit/ProtocolRelayTest.kt +++ /dev/null @@ -1,253 +0,0 @@ -package com.shopify.reactnative.checkoutkit - -import android.os.Looper -import android.util.Log -import com.shopify.checkoutkit.CheckoutProtocol -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable -import kotlinx.serialization.encodeToString -import kotlinx.serialization.json.Json -import kotlinx.serialization.json.jsonArray -import kotlinx.serialization.json.jsonObject -import kotlinx.serialization.json.jsonPrimitive -import org.assertj.core.api.Assertions.assertThat -import org.junit.Test -import org.junit.runner.RunWith -import org.robolectric.RobolectricTestRunner -import org.robolectric.Shadows.shadowOf -import org.robolectric.shadows.ShadowLog - -@RunWith(RobolectricTestRunner::class) -class ProtocolRelayTest { - - @Test - fun `envelope encodes type and wire-case payload`() { - val payload = SnakePayload(continueUrl = "https://example.com", lineItems = emptyList()) - val envelope = DispatchEnvelope(type = "ec.start", payload = payload) - - val json = Json.encodeToString(envelope) - - val parsed = Json.parseToJsonElement(json).jsonObject - assertThat(parsed["type"]?.jsonPrimitive?.content).isEqualTo("ec.start") - - val payloadObj = parsed["payload"]!!.jsonObject - assertThat(payloadObj["continue_url"]?.jsonPrimitive?.content).isEqualTo("https://example.com") - assertThat(payloadObj).containsKey("line_items") - assertThat(payloadObj).doesNotContainKey("continueUrl") - assertThat(payloadObj).doesNotContainKey("lineItems") - } - - @Test - fun `relay dispatches envelope on ec start`() { - var captured: String? = null - val client = ProtocolRelay.makeClient( - listOf("ec.start"), - DispatchCallback { json -> captured = json }, - ) - - client.processForTest(ecStartNotificationFixture) - shadowOf(Looper.getMainLooper()).runToEndOfTasks() - - val json = captured - assertThat(json).isNotNull() - val parsed = Json.parseToJsonElement(json!!).jsonObject - assertThat(parsed["type"]?.jsonPrimitive?.content).isEqualTo("ec.start") - - val payload = parsed["payload"]!!.jsonObject - assertThat(payload["id"]?.jsonPrimitive?.content).isEqualTo("checkout-123") - assertThat(payload["currency"]?.jsonPrimitive?.content).isEqualTo("USD") - - val lineItems = payload["line_items"]!!.jsonArray - assertThat(lineItems).hasSize(1) - val firstItem = lineItems[0].jsonObject["item"]!!.jsonObject - assertThat(firstItem["image_url"]?.jsonPrimitive?.content).isEqualTo("https://example.com/image.png") - val paymentHandlers = payload["ucp"]!!.jsonObject["payment_handlers"]!!.jsonObject - assertThat(paymentHandlers).containsKey("com.example.loyalty_gold") - } - - @Test - fun `relay logs dispatch failures`() { - val failure = RuntimeException("boom") - ShadowLog.clear() - val client = ProtocolRelay.makeClient( - listOf("ec.start"), - DispatchCallback { throw failure }, - ) - - client.processForTest(ecStartNotificationFixture) - shadowOf(Looper.getMainLooper()).runToEndOfTasks() - - val logs = ShadowLog.getLogsForTag("ShopifyCheckoutKit") - .filter { it.msg == "Error dispatching protocol event \"ec.start\"" } - assertThat(logs).hasSize(1) - assertThat(logs.single().type).isEqualTo(Log.ERROR) - assertThat(logs.single().throwable).isSameAs(failure) - } - - @Test - fun `relay dispatches envelope for every public checkout state event`() { - val methods = listOf( - "ec.complete", - "ec.fulfillment.change", - "ec.line_items.change", - "ec.messages.change", - "ec.start", - "ec.totals.change", - ) - - for (method in methods) { - var captured: String? = null - val client = ProtocolRelay.makeClient( - listOf(method), - DispatchCallback { json -> captured = json }, - ) - - client.processForTest(checkoutNotificationFixture(method)) - shadowOf(Looper.getMainLooper()).runToEndOfTasks() - - val json = captured - assertThat(json).isNotNull() - val parsed = Json.parseToJsonElement(json!!).jsonObject - assertThat(parsed["type"]?.jsonPrimitive?.content).isEqualTo(method) - assertThat(parsed["payload"]!!.jsonObject["id"]?.jsonPrimitive?.content).isEqualTo("checkout-123") - } - } - - @Test - fun `relay dispatches envelope on ec error`() { - var captured: String? = null - val client = ProtocolRelay.makeClient( - listOf("ec.error"), - DispatchCallback { json -> captured = json }, - ) - - client.processForTest(ecErrorNotificationFixture) - shadowOf(Looper.getMainLooper()).runToEndOfTasks() - - val json = captured - assertThat(json).isNotNull() - val parsed = Json.parseToJsonElement(json!!).jsonObject - assertThat(parsed["type"]?.jsonPrimitive?.content).isEqualTo("ec.error") - - val payload = parsed["payload"]!!.jsonObject - assertThat(payload["messages"]!!.jsonArray[0].jsonObject["content"]?.jsonPrimitive?.content) - .isEqualTo("Something went wrong") - assertThat(payload["ucp"]!!.jsonObject["status"]?.jsonPrimitive?.content).isEqualTo("error") - } - - @Test - fun `relay ignores methods not in subscribed list`() { - var captured: String? = null - val client = ProtocolRelay.makeClient( - emptyList(), - DispatchCallback { json -> captured = json }, - ) - - client.processForTest(ecStartNotificationFixture) - shadowOf(Looper.getMainLooper()).runToEndOfTasks() - - assertThat(captured).isNull() - } - - @Test - fun `relay drops protocol envelopes after dispatch handle release`() { - var captured: String? = null - val dispatch = DispatchHandle(DispatchCallback { json -> captured = json }) - val client = ProtocolRelay.makeClient( - listOf("ec.start"), - dispatch, - ) - - dispatch.release() - client.processForTest(ecStartNotificationFixture) - shadowOf(Looper.getMainLooper()).runToEndOfTasks() - - assertThat(captured).isNull() - } -} - -private fun CheckoutProtocol.Client.processForTest(message: String): String? { - // `process` is intentionally internal; tests invoke it reflectively to simulate bridge delivery. - val process = javaClass.methods - .filter { it.parameterTypes.contentEquals(arrayOf(String::class.java)) } - .firstOrNull { it.name == "process" } - ?: javaClass.methods.single { - it.name.startsWith("process\$") && - it.parameterTypes.contentEquals(arrayOf(String::class.java)) - } - return process.invoke(this, message) as String? -} - -@Serializable -private data class SnakePayload( - @SerialName("continue_url") val continueUrl: String, - @SerialName("line_items") val lineItems: List, -) - -private fun checkoutNotificationFixture(method: String) = ecStartNotificationFixture.replace( - "\"method\": \"ec.start\"", - "\"method\": \"$method\"", -) - -private val ecStartNotificationFixture = """ -{ - "jsonrpc": "2.0", - "method": "ec.start", - "params": { - "checkout": { - "ucp": { - "version": "${CheckoutProtocol.SPEC_VERSION}", - "payment_handlers": { - "com.example.loyalty_gold": [] - } - }, - "id": "checkout-123", - "status": "incomplete", - "currency": "USD", - "line_items": [ - { - "id": "li-1", - "quantity": 1, - "item": { - "id": "product-1", - "title": "Test Product", - "price": 2999, - "image_url": "https://example.com/image.png" - }, - "totals": [ - {"type": "subtotal", "amount": 2999} - ] - } - ], - "totals": [ - {"type": "total", "amount": 2999} - ], - "links": [ - {"type": "privacy_policy", "url": "https://example.com/privacy"} - ] - } - } -} -""".trimIndent() - -private val ecErrorNotificationFixture = """ -{ - "jsonrpc": "2.0", - "method": "ec.error", - "params": { - "error": { - "ucp": { - "version": "${CheckoutProtocol.SPEC_VERSION}", - "status": "error" - }, - "messages": [ - { - "type": "error", - "content": "Something went wrong", - "severity": "recoverable" - } - ] - } - } -} -""".trimIndent() 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 97d4113a8..91be542a2 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,11 +4,19 @@ ```ts -import { Checkout } from '@shopify/checkout-kit-protocol'; -import { CheckoutProtocolCatalogPayloads } from '@shopify/checkout-kit-protocol'; -import { ErrorResponse } from '@shopify/checkout-kit-protocol'; +import type { Buyer } from '@shopify/checkout-kit-protocol'; +import type { CheckoutDiscounts } from '@shopify/checkout-kit-protocol'; +import type { CheckoutFulfillment } from '@shopify/checkout-kit-protocol'; +import type { CheckoutStatus } from '@shopify/checkout-kit-protocol'; +import type { CheckoutTotal } from '@shopify/checkout-kit-protocol'; +import type { Context } from '@shopify/checkout-kit-protocol'; +import type { LineItem } from '@shopify/checkout-kit-protocol'; +import type { Link } from '@shopify/checkout-kit-protocol'; +import type { Message } from '@shopify/checkout-kit-protocol'; +import type { OrderConfirmation } from '@shopify/checkout-kit-protocol'; +import type { Payment } from '@shopify/checkout-kit-protocol'; +import type { Policy } from '@shopify/checkout-kit-protocol'; import type { PropsWithChildren } from 'react'; -import { ProtocolHandlers as ProtocolHandlers_2 } from '@shopify/checkout-kit-protocol'; import { default as React_2 } from 'react'; // @public (undocumented) @@ -124,7 +132,54 @@ export enum ApplePayStyle { whiteOutline = "whiteOutline" } -export { Checkout } +// @public +export interface Checkout { + [key: string]: unknown; + // (undocumented) + actions?: Record[]>; + // (undocumented) + attribution?: Record; + // (undocumented) + buyer?: Buyer; + // (undocumented) + context?: Context; + // (undocumented) + continueUrl?: string; + // (undocumented) + currency: string; + // (undocumented) + discounts?: CheckoutDiscounts; + // (undocumented) + expiresAt?: string; + // (undocumented) + fulfillment?: CheckoutFulfillment; + // (undocumented) + id: string; + // (undocumented) + lineItems: LineItem[]; + // (undocumented) + links: Link[]; + // (undocumented) + messages?: Message[]; + // (undocumented) + order?: OrderConfirmation; + // (undocumented) + payment?: Payment; + // (undocumented) + policies?: Policy[]; + // (undocumented) + signals?: Record; + // (undocumented) + status: CheckoutStatus; + // (undocumented) + totals: CheckoutTotal[]; +} + +// @public (undocumented) +export interface CheckoutCompleteEvent { + // (undocumented) + checkout: Checkout; +} // @public export enum CheckoutErrorCode { @@ -141,6 +196,19 @@ export enum CheckoutErrorCode { webViewNotSupported = "web_view_not_supported" } +// @public +export interface CheckoutEventHandlers { + onComplete?: (event: CheckoutCompleteEvent) => void; + // (undocumented) + onDismiss?: () => void; + // (undocumented) + onFail?: (event: CheckoutFailureEvent) => void; + // (undocumented) + onStart?: (event: CheckoutStartEvent) => void; + // (undocumented) + onUpdate?: (event: CheckoutUpdateEvent) => void; +} + // @public export class CheckoutException { constructor(exception?: CheckoutNativeError); @@ -154,6 +222,12 @@ export class CheckoutException { statusCode?: number; } +// @public (undocumented) +export interface CheckoutFailureEvent { + // (undocumented) + error: CheckoutException; +} + // @public export type CheckoutNativeError = { code: CheckoutErrorCode; @@ -168,21 +242,16 @@ export interface CheckoutPreloadSubscription { } // @public (undocumented) -export const CheckoutProtocol: { - readonly complete: "ec.complete"; - readonly error: "ec.error"; - readonly fulfillmentChange: "ec.fulfillment.change"; - readonly lineItemsChange: "ec.line_items.change"; - readonly messagesChange: "ec.messages.change"; - readonly start: "ec.start"; - readonly totalsChange: "ec.totals.change"; -}; - -// @public (undocumented) -export type CheckoutProtocolMethod = (typeof CheckoutProtocol)[keyof typeof CheckoutProtocol]; +export interface CheckoutStartEvent { + // (undocumented) + checkout: Checkout; +} // @public (undocumented) -export type CheckoutProtocolPayloads = Pick; +export interface CheckoutUpdateEvent { + // (undocumented) + checkout: Checkout; +} // @public (undocumented) export enum ColorScheme { @@ -226,8 +295,6 @@ export class DispatchEventParityError extends Error { constructor(message: string); } -export { ErrorResponse } - // @public export interface Features { handleGeolocationRequests: boolean; @@ -285,15 +352,10 @@ export type PreloadState = }; // @public -export interface PresentCallbacks { - onClose?: () => void; - onFail?: (error: CheckoutException) => void; +export interface PresentCallbacks extends CheckoutEventHandlers { onGeolocationRequest?: (event: GeolocationRequestEvent) => void; } -// @public (undocumented) -export type ProtocolHandlers = ProtocolHandlers_2; - // @public (undocumented) export enum RenderState { // (undocumented) @@ -325,7 +387,7 @@ export class ShopifyCheckout implements ShopifyCheckoutKit { invalidate(): void; isAcceleratedCheckoutAvailable(): boolean; preload(checkoutUrl: string, options?: PreloadOptions): CheckoutPreloadSubscription; - present(checkoutUrl: string, callbacks?: PresentCallbacks, protocol?: ProtocolHandlers): void; + present(checkoutUrl: string, callbacks?: PresentCallbacks): void; setConfig(configuration: Configuration): void; teardown(): void; // (undocumented) @@ -337,10 +399,10 @@ export class ShopifyCheckout implements ShopifyCheckoutKit { // @public (undocumented) export function ShopifyCheckoutProvider(input: PropsWithChildren): React_2.JSX.Element; -// Warning: (ae-forgotten-export) The symbol "Context" needs to be exported by the entry point index.d.ts +// Warning: (ae-forgotten-export) The symbol "Context_2" needs to be exported by the entry point index.d.ts // // @public (undocumented) -export function useShopifyCheckout(): Context; +export function useShopifyCheckout(): Context_2; // (No @packageDocumentation comment for this package) diff --git a/platforms/react-native/modules/@shopify/checkout-kit-react-native/ios/AcceleratedCheckoutButtons.swift b/platforms/react-native/modules/@shopify/checkout-kit-react-native/ios/AcceleratedCheckoutButtons.swift index 184773f12..fff77eb3c 100644 --- a/platforms/react-native/modules/@shopify/checkout-kit-react-native/ios/AcceleratedCheckoutButtons.swift +++ b/platforms/react-native/modules/@shopify/checkout-kit-react-native/ios/AcceleratedCheckoutButtons.swift @@ -49,10 +49,6 @@ class RCTAcceleratedCheckoutButtonsManager: RCTViewManager { override static func requiresMainQueueSetup() -> Bool { return true } - - override func constantsToExport() -> [AnyHashable: Any]! { - return ["checkoutProtocolEventTypes": supportedProtocolRelayMethods] - } } @available(iOS 16.0, *) @@ -103,10 +99,8 @@ class RCTAcceleratedCheckoutButtonsView: UIView { } } - @objc var onFail: RCTBubblingEventBlock? - @objc var onCancel: RCTBubblingEventBlock? + @objc var onDismiss: RCTDirectEventBlock? @objc var onRenderStateChange: RCTBubblingEventBlock? - @objc var onClickLink: RCTBubblingEventBlock? @objc var onDispatch: RCTDirectEventBlock? // MARK: - Private @@ -229,6 +223,9 @@ class RCTAcceleratedCheckoutButtonsView: UIView { private func attachEventListeners(to buttons: AcceleratedCheckoutButtons) -> AcceleratedCheckoutButtons { return buttons + .onStart { [weak self] event in self?.dispatchCheckout(.start, checkout: event.checkout) } + .onUpdate { [weak self] event in self?.dispatchCheckout(.update, checkout: event.checkout) } + .onComplete { [weak self] event in self?.dispatchCheckout(.complete, checkout: event.checkout) } .onFail { [weak self] error in self?.handleCheckoutFailed(error) } @@ -274,14 +271,6 @@ class RCTAcceleratedCheckoutButtonsView: UIView { // Attach event handlers buttons = attachEventListeners(to: buttons) - let client = makeRelayClient( - subscribedMethods: supportedProtocolRelayMethods, - dispatch: { [weak self] json in - self?.onDispatch?(["value": json]) - } - ) - buttons = buttons.connect(client) - var view: AnyView let colorScheme: SwiftUI.ColorScheme = traitCollection.userInterfaceStyle == .dark ? .dark : .light @@ -335,19 +324,26 @@ class RCTAcceleratedCheckoutButtonsView: UIView { // MARK: - Event Handlers private func handleCheckoutFailed(_ error: CheckoutError) { - onFail?(ShopifyEventSerialization.serialize(checkoutError: error)) + dispatchEvent(.fail, payload: ["error": ShopifyEventSerialization.serialize(checkoutError: error)]) } private func handleCheckoutDismissed() { - onCancel?([:]) + onDismiss?([:]) } private func handleRenderStateChange(_ state: RenderState) { onRenderStateChange?(ShopifyEventSerialization.serialize(renderState: state)) } - private func handleClickLink(_ url: URL) { - onClickLink?(ShopifyEventSerialization.serialize(clickEvent: url)) + private func dispatchCheckout(_ type: DispatchEventType, checkout: Checkout) { + guard let json = checkoutEventJSON(type: type, checkout: checkout) else { return } + onDispatch?(["value": json]) + } + + private func dispatchEvent(_ type: DispatchEventType, payload: [String: Any]) { + guard let data = try? JSONSerialization.data(withJSONObject: ["type": type.rawValue, "payload": payload]), + let json = String(data: data, encoding: .utf8) else { return } + onDispatch?(["value": json]) } // MARK: - Helper Methods diff --git a/platforms/react-native/modules/@shopify/checkout-kit-react-native/ios/CheckoutEventBridge.swift b/platforms/react-native/modules/@shopify/checkout-kit-react-native/ios/CheckoutEventBridge.swift new file mode 100644 index 000000000..e61af3fa9 --- /dev/null +++ b/platforms/react-native/modules/@shopify/checkout-kit-react-native/ios/CheckoutEventBridge.swift @@ -0,0 +1,81 @@ +import Foundation +import ShopifyCheckoutKit + +/// Serializes native Kit snapshots in wire casing; JS decodes known fields using +/// the shared schema and preserves extension keys unchanged. +struct DispatchEnvelope: Encodable { + let type: String + let requestId: String? + let payload: Payload +} + +struct CheckoutEventPayload: Encodable { + let checkout: Checkout +} + +func checkoutEventJSON(type: DispatchEventType, checkout: Checkout, requestId: String? = nil) -> String? { + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .custom { date, encoder in + var container = encoder.singleValueContainer() + try container.encode(date.ISO8601Format(Date.ISO8601FormatStyle(includingFractionalSeconds: true))) + } + do { + let data = try encoder.encode(DispatchEnvelope(type: type.rawValue, requestId: requestId, payload: CheckoutEventPayload(checkout: checkout))) + return String(data: data, encoding: .utf8) + } catch { + NSLog("[ShopifyCheckoutKit] Failed to serialize checkout event") + return nil + } +} + +/// A presentation retains its own delegate so queued events cannot cross sessions. +@MainActor +final class CheckoutEventBridge: CheckoutDelegate { + var requestId: String + private var dispatch: ((String) -> Void)? + private let onTerminal: () -> Void + + init(requestId: String, dispatch: @escaping (String) -> Void, onTerminal: @escaping () -> Void) { + self.requestId = requestId + self.dispatch = dispatch + self.onTerminal = onTerminal + } + + func checkoutDidStart(_ event: CheckoutStartEvent) { + emit(.start, checkout: event.checkout) + } + + func checkoutDidUpdate(_ event: CheckoutUpdateEvent) { + emit(.update, checkout: event.checkout) + } + + func checkoutDidComplete(_ event: CheckoutCompleteEvent) { + emit(.complete, checkout: event.checkout) + } + + func checkoutDidFail(_ event: CheckoutFailureEvent) { + finish(.fail, payload: ["error": ShopifyEventSerialization.serialize(checkoutError: event.error)]) + } + + func checkoutDidDismiss() { + finish(.dismiss) + } + + private func emit(_ type: DispatchEventType, checkout: Checkout) { + guard let json = checkoutEventJSON(type: type, checkout: checkout, requestId: requestId) else { return } + dispatch?(json) + } + + private func emit(_ type: DispatchEventType, payload: [String: Any] = [:]) { + let envelope: [String: Any] = ["type": type.rawValue, "requestId": requestId, "payload": payload] + guard let data = try? JSONSerialization.data(withJSONObject: envelope), let json = String(data: data, encoding: .utf8) else { return } + dispatch?(json) + } + + private func finish(_ type: DispatchEventType, payload: [String: Any] = [:]) { + guard dispatch != nil else { return } + onTerminal() + emit(type, payload: payload) + dispatch = nil + } +} diff --git a/platforms/react-native/modules/@shopify/checkout-kit-react-native/ios/ProtocolRelay.swift b/platforms/react-native/modules/@shopify/checkout-kit-react-native/ios/ProtocolRelay.swift deleted file mode 100644 index 484152f1d..000000000 --- a/platforms/react-native/modules/@shopify/checkout-kit-react-native/ios/ProtocolRelay.swift +++ /dev/null @@ -1,87 +0,0 @@ -import Foundation -#if COCOAPODS - import ShopifyCheckoutKit - - extension CheckoutProtocol.Client: @retroactive CheckoutCommunicationProtocol {} -#else - import ShopifyCheckoutProtocol -#endif - -struct DispatchEnvelope: Encodable { - let type: String - let payload: Payload -} - -/// Bridges native CheckoutProtocol notifications to the React Native onDispatch -/// event stream. Payloads are emitted in protocol wire casing; JS performs the -/// schema-aware conversion to the public camelCase shape with QuickType. -let supportedProtocolRelayMethods = [ - CheckoutProtocol.complete.method, - CheckoutProtocol.error.method, - CheckoutProtocol.fulfillmentChange.method, - CheckoutProtocol.lineItemsChange.method, - CheckoutProtocol.messagesChange.method, - CheckoutProtocol.start.method, - CheckoutProtocol.totalsChange.method -] - -func makeRelayClient( - subscribedMethods: [String], - dispatch: @escaping @MainActor @Sendable (String) -> Void -) -> CheckoutProtocol.Client { - var client = CheckoutProtocol.Client() - - for method in subscribedMethods { - switch method { - case CheckoutProtocol.complete.method: - client = client.on(CheckoutProtocol.complete) { checkout in - forwardEnvelope(type: method, payload: checkout, dispatch: dispatch) - } - case CheckoutProtocol.error.method: - client = client.on(CheckoutProtocol.error) { error in - forwardEnvelope(type: method, payload: error, dispatch: dispatch) - } - case CheckoutProtocol.fulfillmentChange.method: - client = client.on(CheckoutProtocol.fulfillmentChange) { checkout in - forwardEnvelope(type: method, payload: checkout, dispatch: dispatch) - } - case CheckoutProtocol.lineItemsChange.method: - client = client.on(CheckoutProtocol.lineItemsChange) { checkout in - forwardEnvelope(type: method, payload: checkout, dispatch: dispatch) - } - case CheckoutProtocol.messagesChange.method: - client = client.on(CheckoutProtocol.messagesChange) { checkout in - forwardEnvelope(type: method, payload: checkout, dispatch: dispatch) - } - case CheckoutProtocol.start.method: - client = client.on(CheckoutProtocol.start) { checkout in - forwardEnvelope(type: method, payload: checkout, dispatch: dispatch) - } - case CheckoutProtocol.totalsChange.method: - client = client.on(CheckoutProtocol.totalsChange) { checkout in - forwardEnvelope(type: method, payload: checkout, dispatch: dispatch) - } - default: - continue - } - } - - return client -} - -@MainActor -private func forwardEnvelope( - type: String, - payload: some Encodable, - dispatch: @MainActor @Sendable (String) -> Void -) { - let encoder = JSONEncoder() - encoder.dateEncodingStrategy = .iso8601 - guard - let data = try? encoder.encode(DispatchEnvelope(type: type, payload: payload)), - let json = String(data: data, encoding: .utf8) - else { - return - } - dispatch(json) -} diff --git a/platforms/react-native/modules/@shopify/checkout-kit-react-native/ios/ShopifyCheckoutKit+EventSerialization.swift b/platforms/react-native/modules/@shopify/checkout-kit-react-native/ios/ShopifyCheckoutKit+EventSerialization.swift index 2b456d38c..6f0a2ba10 100644 --- a/platforms/react-native/modules/@shopify/checkout-kit-react-native/ios/ShopifyCheckoutKit+EventSerialization.swift +++ b/platforms/react-native/modules/@shopify/checkout-kit-react-native/ios/ShopifyCheckoutKit+EventSerialization.swift @@ -36,8 +36,8 @@ internal enum ShopifyEventSerialization { } } - static func serialize(clickEvent url: URL) -> [String: URL] { - return ["url": url] + static func serialize(clickEvent url: URL) -> [String: String] { + return ["url": url.absoluteString] } /** 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 758abb561..60ad5b4be 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 @@ -18,7 +18,7 @@ @interface RCT_EXTERN_MODULE (RCTShopifyCheckoutKit, NativeShopifyCheckoutKitSpe RCT_EXTERN_METHOD(setConfig:(NSDictionary *)configuration) RCT_EXTERN_METHOD(present:(NSString *)checkoutURL - subscribedMethods:(NSArray *)subscribedMethods) + requestId:(NSString *)requestId) RCT_EXTERN_METHOD(preload:(NSString *)checkoutURL requestId:(NSString *)requestId) @@ -126,29 +126,15 @@ @interface RCT_EXTERN_MODULE (RCTAcceleratedCheckoutButtonsManager, RCTViewManag */ RCT_EXPORT_VIEW_PROPERTY(applePayStyle, NSString*) -/** - * Emitted when checkout fails. Payload contains a CheckoutException-like shape. - */ -RCT_EXPORT_VIEW_PROPERTY(onFail, RCTBubblingEventBlock) - -/** - * Emitted when checkout is cancelled by the buyer. - */ -RCT_EXPORT_VIEW_PROPERTY(onCancel, RCTBubblingEventBlock) +// React Native reserves topDismiss as a direct event. +RCT_EXPORT_VIEW_PROPERTY(onDismiss, RCTDirectEventBlock) /** * Emitted when the native render state changes. Values: "loading", "rendered", "error". */ RCT_EXPORT_VIEW_PROPERTY(onRenderStateChange, RCTBubblingEventBlock) -/** - * Emitted when a link is clicked within the checkout experience. Payload contains the URL. - */ -RCT_EXPORT_VIEW_PROPERTY(onClickLink, RCTBubblingEventBlock) - -/** - * Emitted when a subscribed Checkout Protocol event fires. Payload contains { value } where value is a JSON envelope. - */ +/** Emitted for checkout lifecycle events as a JSON envelope. */ RCT_EXPORT_VIEW_PROPERTY(onDispatch, RCTDirectEventBlock) /** 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 c07d7cd8a..44286c1e7 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 @@ -13,7 +13,10 @@ import UIKit /// `constantsToExport()` so the JS layer can verify the two sides /// agree at construction time. enum DispatchEventType: String, CaseIterable { - case close + case start + case update + case complete + case dismiss case fail case geolocationRequest } @@ -24,6 +27,7 @@ class RCTShopifyCheckoutKit: NSObject { private static let storefrontColorScheme = "storefront" internal var checkoutSheet: UIViewController? + private var checkoutEvents: CheckoutEventBridge? private var checkoutPreload: CheckoutPreload? private var acceleratedCheckoutsConfiguration: Any? private var acceleratedCheckoutsApplePayConfiguration: Any? @@ -85,8 +89,12 @@ class RCTShopifyCheckoutKit: NSObject { @objc func dismiss() { DispatchQueue.main.async { - self.checkoutSheet?.dismiss(animated: true) - self.checkoutSheet = nil + let sheet = self.checkoutSheet + let events = self.checkoutEvents + sheet?.dismiss(animated: true) { [weak self] in + if self?.checkoutSheet === sheet { self?.checkoutSheet = nil } + events?.checkoutDidDismiss() + } } } @@ -97,31 +105,26 @@ class RCTShopifyCheckoutKit: NSObject { } } - @objc func present(_ checkoutURL: String, subscribedMethods: [String]) { + @objc func present(_ checkoutURL: String, requestId: String) { DispatchQueue.main.async { - guard let url = URL(string: checkoutURL), - let viewController = self.getCurrentViewController() else { return } - - // Protocol relay: forwards UCP messages from native to the JS - // dispatch event stream. - let client = makeRelayClient( - subscribedMethods: subscribedMethods, - dispatch: { [weak self] json in - self?.emitDispatchEvent(json) - } - ) - - // `delegate: self` wires the SDK lifecycle events (close/fail) - // into the same JS dispatcher; `client:` wires the UCP - // protocol event stream. They are independent inputs feeding - // the same outbound envelope channel. - let view = ShopifyCheckoutKit.present( - checkout: url, - from: viewController, - delegate: self, - client: client - ) - self.checkoutSheet = view + // Native SDKs only support one visible checkout. Keep its delegate alive + // when JS replaces the callbacks for that presentation. + if let events = self.checkoutEvents { + events.requestId = requestId + return + } + let events = CheckoutEventBridge(requestId: requestId, dispatch: { [weak self] json in + self?.emitDispatchEvent(json) + }, onTerminal: { [weak self] in + self?.checkoutEvents = nil + self?.checkoutSheet = nil + }) + self.checkoutEvents = events + guard let url = URL(string: checkoutURL), let viewController = self.getCurrentViewController() else { + events.checkoutDidDismiss() + return + } + self.checkoutSheet = ShopifyCheckoutKit.present(checkout: url, from: viewController, delegate: events) } } @@ -298,7 +301,7 @@ class RCTShopifyCheckoutKit: NSObject { return NSNumber(value: available) } - @objc func respondToGeolocationRequest(_: Bool) { + @objc func respondToGeolocationRequest(_: Bool, requestId _: String) { // No-op on iOS — geolocation permission is handled natively } @@ -321,47 +324,6 @@ class RCTShopifyCheckoutKit: NSObject { } } -// MARK: - CheckoutDelegate - -extension RCTShopifyCheckoutKit: CheckoutDelegate { - /// Fired by the iOS SDK when the buyer dismisses the checkout sheet - /// without a terminal error. Mirrors - /// `CustomCheckoutListener.onCheckoutDismissed()` on Android. - /// - /// The iOS SDK dismisses the presented checkout when the buyer taps - /// the close button; this wrapper also clears its local reference so - /// future presentations start from a clean state. - func checkoutDidDismiss() { - 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 (`message` / `code` / optional `statusCode`) so it can be - /// coerced into a `CheckoutException` 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: ShopifyEventSerialization.serialize(checkoutError: 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 extension RCTShopifyCheckoutKit { @@ -407,24 +369,4 @@ extension RCTShopifyCheckoutKit { return ["reason": "protocolError"] } } - - /// Builds a `{ "type": ..., "payload": ... }` envelope and forwards - /// it to the JS dispatch event stream. - private func emitDispatchEnvelope(type: DispatchEventType, payload: [String: Any]?) { - 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 - } - emitDispatchEvent(json) - } catch { - NSLog("[ShopifyCheckoutKit] Failed to serialize dispatch envelope for \(type.rawValue): \(error)") - } - } } diff --git a/platforms/react-native/modules/@shopify/checkout-kit-react-native/package.json b/platforms/react-native/modules/@shopify/checkout-kit-react-native/package.json index d8cdeeb72..ff0e09fb3 100644 --- a/platforms/react-native/modules/@shopify/checkout-kit-react-native/package.json +++ b/platforms/react-native/modules/@shopify/checkout-kit-react-native/package.json @@ -19,8 +19,8 @@ }, "checkoutKit": { "nativeSdkVersions": { - "ios": "4.0.0-alpha.6", - "android": "4.0.0-alpha.6" + "ios": "4.0.0-alpha.7", + "android": "4.0.0-alpha.8" } }, "scripts": { 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 32f3e2743..eeefa00ba 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 @@ -4,20 +4,22 @@ "android/proguard-rules.pro", "android/src/main/AndroidManifest.xml", "android/src/main/AndroidManifestNew.xml", + "android/src/main/java/com/shopify/reactnative/checkoutkit/CheckoutEventSerialization.kt", "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/DispatchHandle.java", - "android/src/main/java/com/shopify/reactnative/checkoutkit/ProtocolRelay.kt", "android/src/main/java/com/shopify/reactnative/checkoutkit/ShopifyCheckoutKitModule.java", "android/src/main/java/com/shopify/reactnative/checkoutkit/ShopifyCheckoutKitPackage.java", "ios/AcceleratedCheckoutButtons.swift", "ios/AcceleratedCheckoutButtons+Extensions.swift", - "ios/ProtocolRelay.swift", + "ios/CheckoutEventBridge.swift", "ios/ShopifyCheckoutKit-Bridging-Header.h", "ios/ShopifyCheckoutKit.mm", "ios/ShopifyCheckoutKit.swift", "ios/ShopifyCheckoutKit+EventSerialization.swift", "ios/ShopifyCheckoutKit+Extensions.swift", + "lib/commonjs/checkout.js", + "lib/commonjs/checkout.js.map", "lib/commonjs/components/AcceleratedCheckoutButtons.js", "lib/commonjs/components/AcceleratedCheckoutButtons.js.map", "lib/commonjs/configuration.js", @@ -38,12 +40,12 @@ "lib/commonjs/preload.js.map", "lib/commonjs/present-dispatcher.js", "lib/commonjs/present-dispatcher.js.map", - "lib/commonjs/protocol.js", - "lib/commonjs/protocol.js.map", "lib/commonjs/specs/NativeShopifyCheckoutKit.js", "lib/commonjs/specs/NativeShopifyCheckoutKit.js.map", "lib/commonjs/specs/RCTAcceleratedCheckoutButtonsNativeComponent.js", "lib/commonjs/specs/RCTAcceleratedCheckoutButtonsNativeComponent.js.map", + "lib/module/checkout.js", + "lib/module/checkout.js.map", "lib/module/components/AcceleratedCheckoutButtons.js", "lib/module/components/AcceleratedCheckoutButtons.js.map", "lib/module/configuration.js", @@ -64,12 +66,12 @@ "lib/module/preload.js.map", "lib/module/present-dispatcher.js", "lib/module/present-dispatcher.js.map", - "lib/module/protocol.js", - "lib/module/protocol.js.map", "lib/module/specs/NativeShopifyCheckoutKit.js", "lib/module/specs/NativeShopifyCheckoutKit.js.map", "lib/module/specs/RCTAcceleratedCheckoutButtonsNativeComponent.js", "lib/module/specs/RCTAcceleratedCheckoutButtonsNativeComponent.js.map", + "lib/typescript/src/checkout.d.ts", + "lib/typescript/src/checkout.d.ts.map", "lib/typescript/src/components/AcceleratedCheckoutButtons.d.ts", "lib/typescript/src/components/AcceleratedCheckoutButtons.d.ts.map", "lib/typescript/src/configuration.d.ts", @@ -88,8 +90,6 @@ "lib/typescript/src/preload.d.ts.map", "lib/typescript/src/present-dispatcher.d.ts", "lib/typescript/src/present-dispatcher.d.ts.map", - "lib/typescript/src/protocol.d.ts", - "lib/typescript/src/protocol.d.ts.map", "lib/typescript/src/specs/NativeShopifyCheckoutKit.d.ts", "lib/typescript/src/specs/NativeShopifyCheckoutKit.d.ts.map", "lib/typescript/src/specs/RCTAcceleratedCheckoutButtonsNativeComponent.d.ts", @@ -123,6 +123,7 @@ "node_modules/@shopify/checkout-kit-protocol/src/window_open.ts", "package.json", "RNShopifyCheckoutKit.podspec", + "src/checkout.ts", "src/components/AcceleratedCheckoutButtons.tsx", "src/configuration.ts", "src/context.tsx", @@ -133,7 +134,6 @@ "src/index.ts", "src/preload.ts", "src/present-dispatcher.ts", - "src/protocol.ts", "src/specs/NativeShopifyCheckoutKit.ts", "src/specs/RCTAcceleratedCheckoutButtonsNativeComponent.ts" ] diff --git a/platforms/react-native/modules/@shopify/checkout-kit-react-native/src/checkout.ts b/platforms/react-native/modules/@shopify/checkout-kit-react-native/src/checkout.ts new file mode 100644 index 000000000..e58c254b7 --- /dev/null +++ b/platforms/react-native/modules/@shopify/checkout-kit-react-native/src/checkout.ts @@ -0,0 +1,67 @@ +import {decodeCheckoutSnapshot} from '@shopify/checkout-kit-protocol'; +import type { + Buyer, + CheckoutDiscounts, + CheckoutFulfillment, + CheckoutStatus, + CheckoutTotal, + Context, + LineItem, + Link, + Message, + OrderConfirmation, + Payment, + Policy, +} from '@shopify/checkout-kit-protocol'; +import type {CheckoutException} from './errors'; + +/** A checkout snapshot containing domain data without protocol metadata. */ +export interface Checkout { + actions?: Record[]>; + attribution?: Record; + buyer?: Buyer; + context?: Context; + continueUrl?: string; + currency: string; + discounts?: CheckoutDiscounts; + expiresAt?: string; + fulfillment?: CheckoutFulfillment; + id: string; + lineItems: LineItem[]; + links: Link[]; + messages?: Message[]; + order?: OrderConfirmation; + payment?: Payment; + policies?: Policy[]; + signals?: Record; + status: CheckoutStatus; + totals: CheckoutTotal[]; + /** Extension fields retain their original names and values. */ + [key: string]: unknown; +} + +export interface CheckoutStartEvent { + checkout: Checkout; +} +export interface CheckoutUpdateEvent { + checkout: Checkout; +} +export interface CheckoutCompleteEvent { + checkout: Checkout; +} +export interface CheckoutFailureEvent { + error: CheckoutException; +} +/** Lifecycle callbacks shared by checkout sheets and accelerated buttons. */ +export interface CheckoutEventHandlers { + onStart?: (event: CheckoutStartEvent) => void; + onUpdate?: (event: CheckoutUpdateEvent) => void; + /** Completion leaves the confirmation UI visible until dismissal. */ + onComplete?: (event: CheckoutCompleteEvent) => void; + onFail?: (event: CheckoutFailureEvent) => void; + onDismiss?: () => void; +} + +export function decodeCheckout(value: unknown): Checkout { + return decodeCheckoutSnapshot(value) as unknown as Checkout; +} diff --git a/platforms/react-native/modules/@shopify/checkout-kit-react-native/src/components/AcceleratedCheckoutButtons.tsx b/platforms/react-native/modules/@shopify/checkout-kit-react-native/src/components/AcceleratedCheckoutButtons.tsx index f3e97a9c9..a82122fb0 100644 --- a/platforms/react-native/modules/@shopify/checkout-kit-react-native/src/components/AcceleratedCheckoutButtons.tsx +++ b/platforms/react-native/modules/@shopify/checkout-kit-react-native/src/components/AcceleratedCheckoutButtons.tsx @@ -1,13 +1,8 @@ import React, {useCallback, useMemo, useState} from 'react'; -import {Platform, UIManager} from 'react-native'; -import type {AcceleratedCheckoutWallet, CheckoutException} from '..'; -import {parseCheckoutError, type CheckoutNativeError} from '../errors'; -import { - CheckoutProtocol, - decodeProtocolPayload, - type CheckoutProtocolPayloads, - type ProtocolHandlers, -} from '../protocol'; +import {Platform} from 'react-native'; +import type {AcceleratedCheckoutWallet} from '../enums'; +import type {CheckoutEventHandlers} from '../checkout'; +import {createPresentDispatcher} from '../present-dispatcher'; import RCTAcceleratedCheckoutButtons from '../specs/RCTAcceleratedCheckoutButtonsNativeComponent'; export enum RenderState { @@ -56,7 +51,7 @@ type CheckoutIdentifier = quantity: number; }; -interface CommonAcceleratedCheckoutButtonsProps { +interface CommonAcceleratedCheckoutButtonsProps extends CheckoutEventHandlers { /** * Corner radius for the button (defaults to 8) */ @@ -79,34 +74,12 @@ interface CommonAcceleratedCheckoutButtonsProps { */ applePayStyle?: ApplePayStyle; - /** - * Called when checkout fails - */ - onFail?: (error: CheckoutException) => void; - - /** - * Called when checkout is cancelled - */ - onCancel?: () => void; - /** * Called when the render state changes * States from SDK: loading, rendered, error */ onRenderStateChange?: (event: RenderStateChangeEvent) => void; - /** - * Checkout Protocol event handlers scoped to this button instance. - * - * Supports all public Checkout Protocol notification events. - */ - events?: ProtocolHandlers; - - /** - * Called when a link is clicked within the checkout - */ - onClickLink?: (url: string) => void; - /** * Called when the size of the button changes */ @@ -142,7 +115,7 @@ export type AcceleratedCheckoutButtonsProps = (CartProps | VariantProps) & * @example Cart-based checkout * console.error('Checkout failed:', error.message)} + * onFail={({error}) => console.error('Checkout failed:', error.message)} * /> * * @example Product-based checkout @@ -153,14 +126,6 @@ export type AcceleratedCheckoutButtonsProps = (CartProps | VariantProps) & */ const defaultStyles = {flex: 1}; -const nativeComponentName = 'RCTAcceleratedCheckoutButtons'; -const protocolEventTypesConstant = 'checkoutProtocolEventTypes'; -const checkoutProtocolEventTypeValues = Object.values(CheckoutProtocol); -const checkoutProtocolEventTypes: ReadonlySet = new Set( - checkoutProtocolEventTypeValues, -); -let verifiedProtocolEventParitySignature: string | undefined; - export const AcceleratedCheckoutButtons: React.FC< AcceleratedCheckoutButtonsProps > = ({ @@ -169,10 +134,11 @@ export const AcceleratedCheckoutButtons: React.FC< cornerRadius, wallets, onFail, - onCancel, + onDismiss, + onStart, + onUpdate, + onComplete, onRenderStateChange, - onClickLink, - events, ...props }) => { const isCart = isCartProps(props); @@ -181,16 +147,7 @@ export const AcceleratedCheckoutButtons: React.FC< undefined, ); - const handleFail = useCallback( - (event: {nativeEvent: unknown}) => { - onFail?.(parseCheckoutError(event.nativeEvent as CheckoutNativeError)); - }, - [onFail], - ); - - const handleCancel = useCallback(() => { - onCancel?.(); - }, [onCancel]); + const handleDismiss = useCallback(() => onDismiss?.(), [onDismiss]); const handleRenderStateChange = useCallback( (event: {nativeEvent: unknown}) => { @@ -210,27 +167,19 @@ export const AcceleratedCheckoutButtons: React.FC< [onRenderStateChange], ); - const handleClickLink = useCallback( - (event: {nativeEvent: unknown}) => { - const nativeEvent = event.nativeEvent as {url?: string}; - if (nativeEvent?.url) { - onClickLink?.(nativeEvent.url); - } - }, - [onClickLink], + const {dispatcher} = useMemo( + () => + createPresentDispatcher({ + callbacks: {onStart, onUpdate, onComplete, onFail}, + }), + [onStart, onUpdate, onComplete, onFail], ); const handleDispatch = useCallback( - (event: {nativeEvent: unknown}) => { - const nativeEvent = event.nativeEvent as {value?: unknown}; - if (typeof nativeEvent?.value !== 'string') { - logDispatchError('dispatch event is missing a string `value`'); - return; - } - - routeProtocolDispatchEnvelope(nativeEvent.value, events); + (event: {nativeEvent: {value: string}}) => { + dispatcher(event.nativeEvent.value); }, - [events], + [dispatcher], ); const handleSizeChange = useCallback( @@ -280,8 +229,6 @@ export const AcceleratedCheckoutButtons: React.FC< } } - verifyProtocolEventParity(); - return ( @@ -331,167 +276,3 @@ function isVariantProps( ): props is VariantProps { return 'variantId' in props && 'quantity' in props && props.quantity > 0; } - -function verifyProtocolEventParity(): void { - const nativeTypes = getNativeProtocolEventTypes(); - const signature = buildProtocolEventParitySignature(nativeTypes); - if (verifiedProtocolEventParitySignature === signature) return; - - verifiedProtocolEventParitySignature = signature; - - if (!Array.isArray(nativeTypes)) { - logProtocolEventParityWarning( - `native view manager did not report a \`${protocolEventTypesConstant}\` array. ` + - 'The bundled native component is likely older than this JS package.', - ); - return; - } - - const jsSet = new Set(checkoutProtocolEventTypeValues); - 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) { - 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(', ')}`); - } - - logProtocolEventParityWarning(lines.join('\n ')); -} - -function buildProtocolEventParitySignature( - nativeTypes: readonly string[] | undefined | null, -): string { - return JSON.stringify({ - js: [...checkoutProtocolEventTypeValues].sort(), - native: Array.isArray(nativeTypes) ? [...nativeTypes].sort() : nativeTypes, - }); -} - -function getNativeProtocolEventTypes(): readonly string[] | undefined | null { - const viewManagerConfig = UIManager.getViewManagerConfig?.( - nativeComponentName, - ) as - | { - Constants?: Record; - } - | undefined; - - return viewManagerConfig?.Constants?.[protocolEventTypesConstant] as - | readonly string[] - | undefined - | null; -} - -function routeProtocolDispatchEnvelope( - envelopeJson: string, - events: ProtocolHandlers | undefined, -): void { - let envelope: unknown; - try { - envelope = JSON.parse(envelopeJson); - } catch { - logDispatchError('dispatch envelope is not valid JSON', envelopeJson); - return; - } - - if (!isPlainObject(envelope) || typeof envelope.type !== 'string') { - logDispatchError( - 'dispatch envelope is missing a string `type` discriminator', - envelopeJson, - ); - return; - } - - if (!checkoutProtocolEventTypes.has(envelope.type)) { - logUnknownDispatchType(envelope.type); - return; - } - - const handler = ( - events as - | Record void) | undefined> - | undefined - )?.[envelope.type]; - - if (handler == null) { - return; - } - - if (!isPlainObject(envelope.payload)) { - logDispatchError( - `protocol envelope "${envelope.type}" payload is not an object`, - envelopeJson, - ); - return; - } - - let decodedPayload: - | CheckoutProtocolPayloads[keyof CheckoutProtocolPayloads] - | undefined; - try { - decodedPayload = decodeProtocolPayload(envelope.type, envelope.payload); - } catch (error) { - logDispatchError( - `protocol envelope "${envelope.type}" payload failed schema conversion: ${String(error)}`, - envelopeJson, - ); - return; - } - - if (decodedPayload == null) { - logDispatchError( - `protocol envelope "${envelope.type}" has no registered decoder`, - envelopeJson, - ); - return; - } - - handler(decodedPayload); -} - -function isPlainObject(value: unknown): value is Record { - return typeof value === 'object' && value !== null && !Array.isArray(value); -} - -function logUnknownDispatchType(type: string): void { - // eslint-disable-next-line no-console - console.warn( - `[ShopifyAcceleratedCheckouts] Ignoring protocol dispatch envelope with unknown type "${type}". ` + - 'Native emitted a Checkout Protocol event this JS package does not know how to handle. ' + - 'Confirm native and JS package versions are compatible.', - ); -} - -function logProtocolEventParityWarning(detail: string): void { - // eslint-disable-next-line no-console - console.warn( - '[ShopifyAcceleratedCheckouts] Checkout Protocol event list out of sync between JS ' + - 'and native. Rebuild your host app so the bundled native component matches ' + - `this version of '@shopify/checkout-kit-react-native'.\n ${detail}`, - ); -} - -function logDispatchError(detail: string, raw?: string): void { - const message = `[ShopifyAcceleratedCheckouts] Failed to handle protocol dispatch: ${detail}`; - if (raw == null) { - // eslint-disable-next-line no-console - console.error(message); - return; - } - - // eslint-disable-next-line no-console - console.error(message, raw); -} 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 1868e795b..fd1a75446 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 @@ -8,7 +8,6 @@ import type { PreloadOptions, PresentCallbacks, } from './index.d'; -import type {ProtocolHandlers} from './protocol'; type Maybe = T | undefined; @@ -16,11 +15,7 @@ interface Context { acceleratedCheckoutsAvailable: boolean; getConfig: () => Configuration | undefined; setConfig: (config: Configuration) => void; - present: ( - checkoutUrl: string, - callbacks?: PresentCallbacks, - protocol?: ProtocolHandlers, - ) => void; + present: (checkoutUrl: string, callbacks?: PresentCallbacks) => void; preload: ( checkoutUrl: string, options?: PreloadOptions, @@ -63,13 +58,9 @@ export function ShopifyCheckoutProvider({ }, [checkout, configuration]); const present = useCallback( - ( - checkoutUrl: string, - callbacks?: PresentCallbacks, - protocol?: ProtocolHandlers, - ) => { + (checkoutUrl: string, callbacks?: PresentCallbacks) => { if (checkoutUrl) { - checkout.present(checkoutUrl, callbacks, protocol); + checkout.present(checkoutUrl, callbacks); } }, [checkout], 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 index bf2d97a69..ca0719c95 100644 --- 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 @@ -11,7 +11,10 @@ * `dispatchEventTypes` array reported by `RNShopifyCheckoutKit.getConstants()`. */ export const SDK_LIFECYCLE_EVENT_TYPES = [ - 'close', + 'start', + 'update', + 'complete', + 'dismiss', 'fail', 'geolocationRequest', ] as const; @@ -99,7 +102,7 @@ export function verifyDispatchEventParity( 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 " + + 'and native. Rebuild your host app so the bundled native module matches ' + "this version of '@shopify/checkout-kit-react-native'.\n " + detail ); @@ -113,7 +116,9 @@ function buildMessage(detail: string): string { 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'); + 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 61978206d..3a137583d 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 @@ -1,10 +1,5 @@ -import type {CheckoutException} from './errors'; -import type {ProtocolHandlers} from './protocol'; -import type { - ApplePayContactField, - ColorScheme, - LogLevel, -} from './enums'; +import type {CheckoutEventHandlers} from './checkout'; +import type {ApplePayContactField, ColorScheme, LogLevel} from './enums'; export { AcceleratedCheckoutWallet, ApplePayContactField, @@ -13,10 +8,12 @@ export { } from './enums'; export type { Checkout, - CheckoutProtocolPayloads, - ErrorResponse, - ProtocolHandlers, -} from './protocol'; + CheckoutStartEvent, + CheckoutUpdateEvent, + CheckoutCompleteEvent, + CheckoutFailureEvent, + CheckoutEventHandlers, +} from './checkout'; export type Maybe = T | undefined; @@ -187,28 +184,8 @@ export interface GeolocationRequestEvent { respond: (allow: boolean) => void; } -/** - * Per-call SDK callbacks for `present(url, callbacks, protocol)`. - * - * 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 `CheckoutListener.onCheckoutDismissed` on Android - * and `CheckoutDelegate.checkoutDidDismiss` on iOS. - */ - onClose?: () => void; - /** - * Fires when the checkout sheet terminates with an error. - * Mirrors `CheckoutListener.onCheckoutFailed` on Android - * and `CheckoutDelegate.checkoutDidFail` on iOS. - */ - onFail?: (error: CheckoutException) => void; +/** Lifecycle callbacks for a checkout presentation. */ +export interface PresentCallbacks extends CheckoutEventHandlers { /** * Fires when the checkout sheet requests geolocation permissions. * Only Android currently delivers this callback; on iOS the @@ -335,16 +312,11 @@ export interface ShopifyCheckoutKit { * 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. - * @param protocol Optional per-call Checkout Protocol event handlers. + * @param callbacks Lifecycle callbacks. Callbacks remain + * active until dismissal or failure, including after completion. Repeated calls + * while checkout is visible replace its callbacks without opening another sheet. */ - present( - checkoutURL: string, - callbacks?: PresentCallbacks, - protocol?: ProtocolHandlers, - ): void; + present(checkoutURL: string, callbacks?: PresentCallbacks): void; /** * Preload the checkout for faster presentation. * 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 09b319374..60c30056e 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 @@ -39,16 +39,10 @@ import type { AcceleratedCheckoutButtonsProps, RenderStateChangeEvent, } from './components/AcceleratedCheckoutButtons'; -import {CheckoutProtocol} from './protocol'; -import type { - Checkout, - CheckoutProtocolMethod, - CheckoutProtocolPayloads, - ErrorResponse, - ProtocolHandlers, -} from './protocol'; import {preload as preloadCheckout} from './preload'; +let presentationSequence = 0; + const defaultFeatures: Features = { handleGeolocationRequests: true, }; @@ -101,7 +95,6 @@ class ShopifyCheckout implements ShopifyCheckoutKit { * Dismisses the currently displayed checkout sheet */ public dismiss(): void { - this.releaseDispatchSubscription(); RNShopifyCheckoutKit.dismiss(); } @@ -125,50 +118,36 @@ class ShopifyCheckout implements ShopifyCheckoutKit { return subscription; } - /** - * Presents the checkout sheet for a given checkout URL. - * - * Exactly one of `callbacks.onClose` or `callbacks.onFail` fires per - * call, after which the per-presentation dispatch subscription is released. - * - * @param checkoutUrl The URL of the checkout to display - * @param callbacks Optional per-call SDK callbacks - */ - public present( - checkoutUrl: string, - callbacks?: PresentCallbacks, - protocol?: ProtocolHandlers, - ): void { + /** Presents checkout with lifecycle callbacks. */ + public present(checkoutUrl: string, callbacks?: PresentCallbacks): void { this.releaseDispatchSubscription(); - let subscription: {remove: () => void} | undefined; - const {dispatcher, subscribedMethods} = createPresentDispatcher({ + const requestId = `present-${++presentationSequence}`; + const {dispatcher} = createPresentDispatcher({ callbacks, - protocol, + requestId, handleDefaultGeolocationRequests: this.featureEnabled( 'handleGeolocationRequests', ), - handleDefaultGeolocationRequest: () => - this.handleDefaultGeolocationRequest(), + handleDefaultGeolocationRequest: async () => { + const allowed = await this.requestGeolocation(); + this.respondToGeolocationRequest(allowed, requestId); + }, respondToGeolocationRequest: allow => - this.respondToGeolocationRequest(allow), + this.respondToGeolocationRequest(allow, requestId), onTerminal: () => { if (subscription) this.releaseDispatchSubscription(subscription); }, }); - - if (dispatcher) { - subscription = RNShopifyCheckoutKit.onDispatch(json => { - if (subscription && this.dispatchSubscription === subscription) - dispatcher(json); - }); - this.dispatchSubscription = subscription; - } - + subscription = RNShopifyCheckoutKit.onDispatch(json => { + if (subscription && this.dispatchSubscription === subscription) + dispatcher(json); + }); + this.dispatchSubscription = subscription; try { - RNShopifyCheckoutKit.present(checkoutUrl, subscribedMethods); + RNShopifyCheckoutKit.present(checkoutUrl, requestId); } catch (error) { - if (subscription) this.releaseDispatchSubscription(subscription); + this.releaseDispatchSubscription(subscription); throw error; } } @@ -360,21 +339,12 @@ class ShopifyCheckout implements ShopifyCheckoutKit { * This does not request OS location permissions; callers should check * or request Android permissions before responding. */ - private respondToGeolocationRequest(allow: boolean): void { + private respondToGeolocationRequest(allow: boolean, requestId: string): void { if (Platform.OS === 'android') { - RNShopifyCheckoutKit.respondToGeolocationRequest?.(allow); + RNShopifyCheckoutKit.respondToGeolocationRequest?.(allow, requestId); } } - /** - * Default Android geolocation handler — requests platform permissions - * and forwards the resolved grant state back to the native SDK. - */ - private async handleDefaultGeolocationRequest() { - const allowed = await this.requestGeolocation(); - this.respondToGeolocationRequest(allowed); - } - /** * Requests geolocation permissions on Android * @returns Promise indicating if permission was granted @@ -395,7 +365,6 @@ class ShopifyCheckout implements ShopifyCheckoutKit { private permissionGranted(status: PermissionStatus): boolean { return status === 'granted'; } - } // API @@ -404,7 +373,6 @@ export { ApplePayContactField, ApplePayLabel, ApplePayStyle, - CheckoutProtocol, ColorScheme, DispatchEventParityError, LifecycleEventParseError, @@ -425,11 +393,7 @@ export type { AcceleratedCheckoutCustomer, AndroidAutomaticColors, AndroidColors, - Checkout, - CheckoutProtocolMethod, - CheckoutProtocolPayloads, Configuration, - ErrorResponse, Features, GeolocationRequestEvent, IosColors, @@ -438,7 +402,6 @@ export type { PreloadOptions, PreloadState, CheckoutPreloadSubscription, - ProtocolHandlers, RenderStateChangeEvent, }; @@ -447,3 +410,12 @@ export { AcceleratedCheckoutButtons, RenderState, } from './components/AcceleratedCheckoutButtons'; + +export type { + Checkout, + CheckoutStartEvent, + CheckoutUpdateEvent, + CheckoutCompleteEvent, + CheckoutFailureEvent, + CheckoutEventHandlers, +} from './checkout'; diff --git a/platforms/react-native/modules/@shopify/checkout-kit-react-native/src/present-dispatcher.ts b/platforms/react-native/modules/@shopify/checkout-kit-react-native/src/present-dispatcher.ts index 3d69daef4..960cf8b85 100644 --- a/platforms/react-native/modules/@shopify/checkout-kit-react-native/src/present-dispatcher.ts +++ b/platforms/react-native/modules/@shopify/checkout-kit-react-native/src/present-dispatcher.ts @@ -1,232 +1,122 @@ import {Platform} from 'react-native'; -import { - isSdkLifecycleEventType, - type SdkLifecycleEventType, -} from './dispatch-events'; +import {isSdkLifecycleEventType} from './dispatch-events'; import {parseCheckoutError} from './errors'; import type {CheckoutNativeError} from './errors'; -import {decodeProtocolPayload} from './protocol'; -import type {CheckoutProtocolPayloads, ProtocolHandlers} from './protocol'; -import type {GeolocationRequestEvent, PresentCallbacks} from './index.d'; +import {decodeCheckout} from './checkout'; +import type {PresentCallbacks} from './index.d'; export class LifecycleEventParseError extends Error { constructor(message?: string, options?: ErrorOptions) { super(message, options); this.name = 'LifecycleEventParseError'; - - if (Error.captureStackTrace) { + if (Error.captureStackTrace) Error.captureStackTrace(this, LifecycleEventParseError); - } } } -export interface PresentDispatchResult { - terminal: boolean; -} - -type PresentDispatcher = (envelopeJson: string) => PresentDispatchResult; - interface CreatePresentDispatcherOptions { callbacks?: PresentCallbacks; - protocol?: ProtocolHandlers; - handleDefaultGeolocationRequests: boolean; - handleDefaultGeolocationRequest: () => void | Promise; - respondToGeolocationRequest: (allow: boolean) => void; - /** Release observations before consumer callbacks can present again or throw. */ + requestId?: string; + handleDefaultGeolocationRequests?: boolean; + handleDefaultGeolocationRequest?: () => void | Promise; + respondToGeolocationRequest?: (allow: boolean) => void; + /** Release the subscription before invoking consumer code, which may present again. */ onTerminal?: () => void; } -interface PresentDispatcherHandle { - dispatcher: PresentDispatcher | null; - subscribedMethods: string[]; -} - -type GeolocationRequestPayload = Pick; - -export function createPresentDispatcher({ - callbacks, - protocol, - handleDefaultGeolocationRequests, - handleDefaultGeolocationRequest, - respondToGeolocationRequest, - onTerminal, -}: CreatePresentDispatcherOptions): PresentDispatcherHandle { - const subscribedMethods = getSubscribedProtocolMethods(protocol); - const needsDefaultGeolocation = - Platform.OS === 'android' && handleDefaultGeolocationRequests; - - if (!callbacks && !needsDefaultGeolocation && subscribedMethods.length === 0) { - return {dispatcher: null, subscribedMethods}; - } - - return { - subscribedMethods, - dispatcher: envelopeJson => - dispatchEnvelope(envelopeJson, { - callbacks, - protocol, - needsDefaultGeolocation, - handleDefaultGeolocationRequest, - respondToGeolocationRequest, - onTerminal, - }), - }; -} - -function getSubscribedProtocolMethods(protocol?: ProtocolHandlers): string[] { - return Object.entries(protocol ?? {}) - .filter(([, handler]) => typeof handler === 'function') - .map(([method]) => method); +export function createPresentDispatcher( + options: CreatePresentDispatcherOptions, +) { + return {dispatcher: (json: string): void => dispatchEnvelope(json, options)}; } function dispatchEnvelope( - envelopeJson: string, - options: Omit< - CreatePresentDispatcherOptions, - 'handleDefaultGeolocationRequests' - > & {needsDefaultGeolocation: boolean}, -): PresentDispatchResult { + json: string, + options: CreatePresentDispatcherOptions, +): void { let envelope: unknown; try { - envelope = JSON.parse(envelopeJson); + envelope = JSON.parse(json); } catch { - logParseError('envelope is not valid JSON', envelopeJson); - return {terminal: false}; + logParseError('envelope is not valid JSON'); + return; } - if (!isPlainObject(envelope) || typeof envelope.type !== 'string') { - logParseError( - 'envelope is missing a string `type` discriminator', - envelopeJson, - ); - return {terminal: false}; + logParseError('envelope is missing a string `type` discriminator'); + return; } - + if ( + options.requestId !== undefined && + envelope.requestId !== options.requestId + ) + return; const {type, payload} = envelope; - - if (isSdkLifecycleEventType(type)) { - return routeSdkLifecycleEvent(type, payload, envelopeJson, options); - } - - // Protocol method names (e.g. `ec.start`) live one layer down — owned - // by `@shopify/checkout-kit-protocol`. Accept any string that has - // a registered handler, but validate the payload shape minimally - // before forwarding. - const protocolHandler = - options.protocol == null - ? undefined - : ( - options.protocol as Record< - string, - ((payload: unknown) => void) | undefined - > - )[type]; - - if (protocolHandler) { - if (!isPlainObject(payload)) { - logParseError( - `protocol envelope "${type}" payload is not an object`, - envelopeJson, - ); - return {terminal: false}; - } - - let decodedPayload: - | CheckoutProtocolPayloads[keyof CheckoutProtocolPayloads] - | undefined; - try { - decodedPayload = decodeProtocolPayload(type, payload); - } catch (error) { - logParseError( - `protocol envelope "${type}" payload failed schema conversion: ${String(error)}`, - envelopeJson, - ); - return {terminal: false}; - } - - if (decodedPayload == null) { - logParseError( - `protocol envelope "${type}" has no registered decoder`, - envelopeJson, - ); - return {terminal: false}; - } - - protocolHandler(decodedPayload); - return {terminal: false}; + if (!isSdkLifecycleEventType(type)) { + // eslint-disable-next-line no-console + console.warn( + `[ShopifyCheckoutKit] Ignoring dispatch envelope with unknown type "${type}".`, + ); + return; } - - // Loud default. The parity check at construction time should have - // already caught an SDK-lifecycle mismatch — hitting this branch - // means either the native module emitted an event the JS layer - // does not know how to handle, or no protocol handler was - // registered for it. - // eslint-disable-next-line no-console - console.warn( - `[ShopifyCheckoutKit] Ignoring dispatch envelope with unknown type "${type}". ` + - 'Either the native module emitted an event the JS layer does not know how ' + - 'to handle, or no protocol handler was registered for it. Confirm both sides ' + - 'are on compatible versions.', - ); - - return {terminal: false}; -} - -/** - * 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. - */ -function routeSdkLifecycleEvent( - type: SdkLifecycleEventType, - payload: unknown, - envelopeJson: string, - { - callbacks, - needsDefaultGeolocation, - handleDefaultGeolocationRequest, - respondToGeolocationRequest, - onTerminal, - }: Omit & { - needsDefaultGeolocation: boolean; - }, -): PresentDispatchResult { + const {callbacks} = options; switch (type) { - case 'close': - onTerminal?.(); - callbacks?.onClose?.(); - return {terminal: true}; + case 'start': + case 'update': + case 'complete': { + let checkout; + try { + checkout = decodeCheckout( + isPlainObject(payload) ? payload.checkout : undefined, + ); + } catch { + logParseError(`\`${type}\` envelope checkout is malformed`); + return; + } + const handler = { + start: callbacks?.onStart, + update: callbacks?.onUpdate, + complete: callbacks?.onComplete, + }[type]; + handler?.({checkout}); + return; + } + case 'dismiss': + options.onTerminal?.(); + callbacks?.onDismiss?.(); + return; case 'fail': { - onTerminal?.(); - const failPayload = validateFailPayload(payload); - if (failPayload == null) { - logParseError('`fail` envelope payload is malformed', envelopeJson); - return {terminal: true}; + options.onTerminal?.(); + const error = isPlainObject(payload) ? payload.error : undefined; + if ( + !isPlainObject(error) || + typeof error.message !== 'string' || + typeof error.code !== 'string' || + ('statusCode' in error && typeof error.statusCode !== 'number') + ) { + logParseError('`fail` envelope payload is malformed'); + return; } - callbacks?.onFail?.(parseCheckoutError(failPayload)); - return {terminal: true}; + callbacks?.onFail?.({ + error: parseCheckoutError(error as unknown as CheckoutNativeError), + }); + return; } - case 'geolocationRequest': { - const geoPayload = validateGeolocationRequestPayload(payload); - if (geoPayload == null) { - logParseError( - '`geolocationRequest` envelope payload is malformed', - envelopeJson, - ); - return {terminal: false}; + case 'geolocationRequest': + if (!isPlainObject(payload) || typeof payload.origin !== 'string') { + logParseError('`geolocationRequest` envelope payload is malformed'); + return; } if (callbacks?.onGeolocationRequest) { callbacks.onGeolocationRequest({ - ...geoPayload, - respond: allow => respondToGeolocationRequest(allow), + origin: payload.origin, + respond: allow => options.respondToGeolocationRequest?.(allow), }); - } else if (needsDefaultGeolocation) { - handleDefaultGeolocationRequest(); + } else if ( + Platform.OS === 'android' && + options.handleDefaultGeolocationRequests + ) { + options.handleDefaultGeolocationRequest?.(); } - return {terminal: false}; - } } } @@ -234,35 +124,12 @@ 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.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}, - ); +function logParseError(detail: string): void { + // Do not log checkout payloads: they can contain buyer information. // eslint-disable-next-line no-console - console.error(err, raw); + console.error( + new LifecycleEventParseError( + `Failed to handle checkout dispatcher envelope: ${detail}`, + ), + ); } diff --git a/platforms/react-native/modules/@shopify/checkout-kit-react-native/src/protocol.ts b/platforms/react-native/modules/@shopify/checkout-kit-react-native/src/protocol.ts deleted file mode 100644 index 2f0fccabd..000000000 --- a/platforms/react-native/modules/@shopify/checkout-kit-react-native/src/protocol.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { - checkoutProtocolCatalog, - type CheckoutProtocolCatalogPayloads, - type ProtocolHandlers as PackageProtocolHandlers, -} from '@shopify/checkout-kit-protocol'; - -export {decodeProtocolPayload} from '@shopify/checkout-kit-protocol'; -export type {Checkout, ErrorResponse} from '@shopify/checkout-kit-protocol'; - -type PublicCheckoutProtocolKey = - | 'complete' - | 'error' - | 'fulfillmentChange' - | 'lineItemsChange' - | 'messagesChange' - | 'start' - | 'totalsChange'; - -export const CheckoutProtocol = { - complete: checkoutProtocolCatalog.complete, - error: checkoutProtocolCatalog.error, - fulfillmentChange: checkoutProtocolCatalog.fulfillmentChange, - lineItemsChange: checkoutProtocolCatalog.lineItemsChange, - messagesChange: checkoutProtocolCatalog.messagesChange, - start: checkoutProtocolCatalog.start, - totalsChange: checkoutProtocolCatalog.totalsChange, -} as const satisfies Pick< - typeof checkoutProtocolCatalog, - PublicCheckoutProtocolKey ->; - -export type CheckoutProtocolMethod = - (typeof CheckoutProtocol)[keyof typeof CheckoutProtocol]; - -export type CheckoutProtocolPayloads = Pick< - CheckoutProtocolCatalogPayloads, - CheckoutProtocolMethod ->; - -export type ProtocolHandlers = - PackageProtocolHandlers; 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 95e8b4b60..e9457605c 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 @@ -56,10 +56,7 @@ export interface Spec extends TurboModule { readonly onDispatch: CodegenTypes.EventEmitter; readonly onPreloadStateChange: CodegenTypes.EventEmitter; - present( - checkoutUrl: string, - subscribedMethods: string[], - ): void; + present(checkoutUrl: string, requestId: string): void; preload(checkoutUrl: string, requestId: string): void; dismiss(): void; invalidateCache(): void; @@ -77,7 +74,7 @@ export interface Spec extends TurboModule { ): boolean; isAcceleratedCheckoutAvailable(): boolean; isApplePayAvailable(): boolean; - respondToGeolocationRequest(allow: boolean): void; + respondToGeolocationRequest(allow: boolean, requestId: string): void; addListener(eventName: string): void; removeListeners(count: number): void; getConstants(): { diff --git a/platforms/react-native/modules/@shopify/checkout-kit-react-native/src/specs/RCTAcceleratedCheckoutButtonsNativeComponent.ts b/platforms/react-native/modules/@shopify/checkout-kit-react-native/src/specs/RCTAcceleratedCheckoutButtonsNativeComponent.ts index 1c8a63373..d8b83cdb8 100644 --- a/platforms/react-native/modules/@shopify/checkout-kit-react-native/src/specs/RCTAcceleratedCheckoutButtonsNativeComponent.ts +++ b/platforms/react-native/modules/@shopify/checkout-kit-react-native/src/specs/RCTAcceleratedCheckoutButtonsNativeComponent.ts @@ -7,18 +7,11 @@ import type { // eslint-disable-next-line @react-native/no-deep-imports -- codegen parser requires these type names to be imported directly (not via aliases) so it can match them statically during AST traversal } from 'react-native/Libraries/Types/CodegenTypes'; -type FailEvent = Readonly<{ - message: string; - code?: string; - statusCode?: Double; -}>; - type RenderStateChangeEvent = Readonly<{ state: string; reason?: string; }>; -type ClickLinkEvent = Readonly<{url: string}>; type DispatchEvent = Readonly<{value: string}>; type SizeChangeEvent = Readonly<{height: Double}>; @@ -34,10 +27,8 @@ interface NativeProps extends ViewProps { wallets?: ReadonlyArray; applePayLabel?: string; applePayStyle?: string; - onFail?: BubblingEventHandler; - onCancel?: BubblingEventHandler; + onDismiss?: DirectEventHandler; onRenderStateChange?: BubblingEventHandler; - onClickLink?: BubblingEventHandler; onDispatch?: DirectEventHandler; onSizeChange?: DirectEventHandler; } diff --git a/platforms/react-native/modules/@shopify/checkout-kit-react-native/tests/AcceleratedCheckoutButtons.test.tsx b/platforms/react-native/modules/@shopify/checkout-kit-react-native/tests/AcceleratedCheckoutButtons.test.tsx index 357c1f2a0..74b4dd176 100644 --- a/platforms/react-native/modules/@shopify/checkout-kit-react-native/tests/AcceleratedCheckoutButtons.test.tsx +++ b/platforms/react-native/modules/@shopify/checkout-kit-react-native/tests/AcceleratedCheckoutButtons.test.tsx @@ -1,14 +1,12 @@ import React from 'react'; -import {EmbeddedCheckoutProtocol} from '@shopify/checkout-kit-protocol'; import {render, act} from '@testing-library/react-native'; -import {Platform, UIManager} from 'react-native'; +import {Platform} from 'react-native'; import { AcceleratedCheckoutButtons, AcceleratedCheckoutWallet, ApplePayStyle, CheckoutErrorCode, CheckoutException, - CheckoutProtocol, RenderState, } from '../src'; @@ -38,12 +36,6 @@ const wireCheckout = { links: [], status: 'incomplete', totals: [], - ucp: { - version: EmbeddedCheckoutProtocol.specVersion, - payment_handlers: { - loyalty_gold: [], - }, - }, }; describe('AcceleratedCheckoutButtons', () => { @@ -133,128 +125,60 @@ describe('AcceleratedCheckoutButtons', () => { expect(nativeComponent.props.applePayStyle).toBe(ApplePayStyle.black); }); - it('routes native protocol dispatch envelopes to event handlers', () => { - const onStart = jest.fn(); + it('routes native snapshots to the matching lifecycle callback', () => { + const callbacks = { + onStart: jest.fn(), + onUpdate: jest.fn(), + onComplete: jest.fn(), + onDismiss: jest.fn(), + }; const {getByTestId} = render( - , - ); - - const nativeComponent = getByTestId('accelerated-checkout-buttons'); - nativeComponent.props.onDispatch({ - nativeEvent: { - value: JSON.stringify({ - type: CheckoutProtocol.start, - payload: wireCheckout, - }), - }, - }); - - expect(onStart).toHaveBeenCalledWith( - expect.objectContaining({ - id: 'checkout-id', - lineItems: [], - ucp: expect.objectContaining({ - paymentHandlers: { - loyalty_gold: [], - }, - }), - }), + , ); + const component = getByTestId('accelerated-checkout-buttons'); + for (const type of ['start', 'update', 'complete']) { + component.props.onDispatch({ + nativeEvent: { + value: JSON.stringify({type, payload: {checkout: wireCheckout}}), + }, + }); + } + for (const handler of [ + callbacks.onStart, + callbacks.onUpdate, + callbacks.onComplete, + ]) { + expect(handler).toHaveBeenCalledWith({ + checkout: { + id: 'checkout-id', + currency: 'USD', + status: 'incomplete', + lineItems: [], + links: [], + totals: [], + }, + }); + } + expect(callbacks.onDismiss).not.toHaveBeenCalled(); + component.props.onDismiss(); + expect(callbacks.onDismiss).toHaveBeenCalledTimes(1); }); - it('does not throw when native protocol dispatch is malformed', () => { + it('rejects malformed native snapshots without calling consumers', () => { const onStart = jest.fn(); const {getByTestId} = render( - , + , ); - - const nativeComponent = getByTestId('accelerated-checkout-buttons'); - expect(() => { - nativeComponent.props.onDispatch({nativeEvent: {value: 'not json'}}); - }).not.toThrow(); - expect(onStart).not.toHaveBeenCalled(); - }); - - it('warns when native reports an unknown protocol event', () => { - const warn = jest.spyOn(global.console, 'warn').mockImplementation(); - const getViewManagerConfig = UIManager.getViewManagerConfig as jest.Mock; - const defaultImplementation = getViewManagerConfig.getMockImplementation(); - getViewManagerConfig.mockImplementation((name: string) => { - if (name === 'RCTAcceleratedCheckoutButtons') { - return { - Constants: { - checkoutProtocolEventTypes: [ - CheckoutProtocol.start, - 'ec.future.event', - ], - }, - }; - } - return null; - }); - - render(); - - expect(warn).toHaveBeenCalledWith( - expect.stringContaining( - 'events missing from js: ec.future.event', - ), - ); - - getViewManagerConfig.mockImplementation(defaultImplementation); - warn.mockRestore(); - }); - - it('warns when native emits an unknown protocol event', () => { - const warn = jest.spyOn(global.console, 'warn').mockImplementation(); - const {getByTestId} = render( - , - ); - - const nativeComponent = getByTestId('accelerated-checkout-buttons'); - nativeComponent.props.onDispatch({ + const component = getByTestId('accelerated-checkout-buttons'); + component.props.onDispatch({nativeEvent: {value: '{bad json'}}); + component.props.onDispatch({ nativeEvent: { - value: JSON.stringify({ - type: 'ec.future.event', - payload: {}, - }), + value: JSON.stringify({type: 'start', payload: {checkout: {}}}), }, }); - - expect(warn).toHaveBeenCalledWith( - expect.stringContaining( - 'Ignoring protocol dispatch envelope with unknown type "ec.future.event"', - ), - ); - warn.mockRestore(); + expect(onStart).not.toHaveBeenCalled(); }); - it.each([0, -1, -2, Number.NaN])( - 'throws when invalid variant quantity %p', - quantity => { - expect(() => { - render( - , - ); - }).toThrow( - 'AcceleratedCheckoutButton: Either `cartId` or `variantId` and `quantity` must be provided', - ); - }, - ); - it('uses default values for cornerRadius', () => { const {getByTestId} = render( { ); const nativeComponent = getByTestId('accelerated-checkout-buttons'); - nativeComponent.props.onFail({ - nativeEvent: {code: 'http_error', message: 'boom', statusCode: 503}, + nativeComponent.props.onDispatch({ + nativeEvent: { + value: JSON.stringify({ + type: 'fail', + payload: { + error: {code: 'http_error', message: 'boom', statusCode: 503}, + }, + }), + }, }); - const error = onFail.mock.calls[0][0]; + const {error} = onFail.mock.calls[0][0]; expect(error).toBeInstanceOf(CheckoutException); expect(error.code).toBe(CheckoutErrorCode.httpError); expect(error.message).toBe('boom'); @@ -331,27 +262,34 @@ describe('AcceleratedCheckoutButtons', () => { ); const nativeComponent = getByTestId('accelerated-checkout-buttons'); - nativeComponent.props.onFail({ - nativeEvent: {code: 'a_code_from_a_newer_sdk', message: 'boom'}, + nativeComponent.props.onDispatch({ + nativeEvent: { + value: JSON.stringify({ + type: 'fail', + payload: { + error: {code: 'a_code_from_a_newer_sdk', message: 'boom'}, + }, + }), + }, }); - const error = onFail.mock.calls[0][0]; + const {error} = onFail.mock.calls[0][0]; expect(error).toBeInstanceOf(CheckoutException); expect(error.code).toBe(CheckoutErrorCode.unknown); expect(error.statusCode).toBeUndefined(); }); - it('calls onCancel when native cancel is invoked', () => { - const onCancel = jest.fn(); + it('calls onDismiss when native cancel is invoked', () => { + const onDismiss = jest.fn(); const {getByTestId} = render( , ); const nativeComponent = getByTestId('accelerated-checkout-buttons'); - nativeComponent.props.onCancel(); - expect(onCancel).toHaveBeenCalled(); + nativeComponent.props.onDismiss(); + expect(onDismiss).toHaveBeenCalled(); }); it('maps render state change to typed states including error reason', () => { @@ -393,25 +331,6 @@ describe('AcceleratedCheckoutButtons', () => { }); }); - it('handles onClickLink when URL is present and ignores when absent', () => { - const onClickLink = jest.fn(); - const {getByTestId} = render( - , - ); - const nativeComponent = getByTestId('accelerated-checkout-buttons'); - nativeComponent.props.onClickLink({ - nativeEvent: {url: 'https://checkout.shopify.com'}, - }); - expect(onClickLink).toHaveBeenCalledWith('https://checkout.shopify.com'); - - onClickLink.mockClear(); - nativeComponent.props.onClickLink({nativeEvent: {}}); - expect(onClickLink).not.toHaveBeenCalled(); - }); - it('applies dynamic height when onSizeChange is emitted', async () => { const {getByTestId} = render( , @@ -449,9 +368,8 @@ describe('AcceleratedCheckoutButtons', () => { it('handles callbacks without throwing', () => { const mockCallbacks = { onFail: jest.fn(), - onCancel: jest.fn(), + onDismiss: jest.fn(), onRenderStateChange: jest.fn(), - onClickLink: jest.fn(), }; expect(() => { diff --git a/platforms/react-native/modules/@shopify/checkout-kit-react-native/tests/checkout.test.ts b/platforms/react-native/modules/@shopify/checkout-kit-react-native/tests/checkout.test.ts new file mode 100644 index 000000000..7d0c1231c --- /dev/null +++ b/platforms/react-native/modules/@shopify/checkout-kit-react-native/tests/checkout.test.ts @@ -0,0 +1,106 @@ +import {decodeCheckout} from '../src/checkout'; +import type {Checkout} from '../src'; + +export const wireCheckout = { + id: 'checkout-123', + currency: 'USD', + status: 'incomplete', + line_items: [], + links: [], + totals: [], +}; + +describe('Checkout snapshots', () => { + it('decodes snapshots without requiring or exposing protocol metadata', () => { + const checkout: Checkout = decodeCheckout({ + ...wireCheckout, + ucp: {version: 'ignored'}, + }); + expect(checkout.id).toBe('checkout-123'); + expect(checkout.lineItems).toEqual([]); + expect(checkout).not.toHaveProperty('line_items'); + expect(checkout).not.toHaveProperty('ucp'); + }); + + it('treats undefined optional fields as absent but rejects undefined required fields', () => { + const checkout = { + ...wireCheckout, + order: undefined, + fulfillment: undefined, + }; + const decoded = decodeCheckout(checkout); + expect(decoded).not.toHaveProperty('order'); + expect(decoded).not.toHaveProperty('fulfillment'); + expect(() => decodeCheckout({...checkout, totals: undefined})).toThrow( + 'Invalid Checkout.totals', + ); + }); + + it('converts schema fields while preserving extension and dictionary keys', () => { + const checkout = decodeCheckout({ + ...wireCheckout, + expires_at: '2026-09-25T12:00:00.123Z', + buyer: {first_name: 'Test', merchant_field: {nested_key: true}}, + actions: {'com.example.verify': [{id: 'a1', config: {custom_key: true}}]}, + attribution: {source_name: 'sample'}, + signals: {buyer_signal: {custom_key: 1}}, + custom_extension: {line_items: ['unchanged']}, + policies: [{id: 'p1', type: 'return', + description: {plain: 'Returns accepted'}, applies_to: ['$.line_items[0]']}], + fulfillment: { + available_methods: [{type: 'custom_delivery', line_item_ids: ['li-1']}], + methods: [ + { + id: 'm1', + type: 'custom_delivery', + line_item_ids: ['li-1'], + groups: [ + { + id: 'g1', + options: [ + {id: 'o1', title: 'Delivery', description: 'Tomorrow'}, + ], + }, + ], + }, + ], + }, + messages: [{type: 'info', content: 'Hello', content_type: 'plain'}], + order: {id: 'order-1', permalink_url: 'https://example.test/orders/1'}, + }); + expect(checkout.expiresAt).toBe('2026-09-25T12:00:00.123Z'); + expect(checkout.buyer).toEqual({ + firstName: 'Test', + merchant_field: {nested_key: true}, + }); + expect(checkout.actions?.['com.example.verify']).toEqual([ + {id: 'a1', config: {custom_key: true}}, + ]); + expect(checkout.attribution).toEqual({source_name: 'sample'}); + expect(checkout.signals).toEqual({buyer_signal: {custom_key: 1}}); + expect(checkout.custom_extension).toEqual({line_items: ['unchanged']}); + expect(checkout.policies?.[0]?.appliesTo).toEqual(['$.line_items[0]']); + expect(checkout.fulfillment?.availableMethods?.[0]).toEqual({ + type: 'custom_delivery', + lineItemIds: ['li-1'], + }); + expect( + checkout.fulfillment?.methods?.[0]?.groups?.[0]?.options?.[0] + ?.description, + ).toEqual({plain: 'Tomorrow'}); + expect(checkout.messages?.[0]?.contentType).toBe('plain'); + expect(checkout.order?.permalinkUrl).toBe('https://example.test/orders/1'); + }); + + it.each([ + null, + [], + {}, + {...wireCheckout, id: 1}, + {...wireCheckout, line_items: null}, + {...wireCheckout, status: 2}, + {...wireCheckout, order: {id: 'o1'}}, + ])('rejects malformed snapshots', value => { + expect(() => decodeCheckout(value)).toThrow(TypeError); + }); +}); 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 995ecb490..52d2ebbd5 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 @@ -1,16 +1,8 @@ import React from 'react'; import {render, act} from '@testing-library/react-native'; import {NativeModules, Platform} from 'react-native'; -import { - ShopifyCheckoutProvider, - useShopifyCheckout, -} from '../src/context'; -import { - ApplePayContactField, - CheckoutProtocol, - ColorScheme, - type Configuration, -} from '../src'; +import {ShopifyCheckoutProvider, useShopifyCheckout} from '../src/context'; +import {ApplePayContactField, ColorScheme, type Configuration} from '../src'; import {__resetPreloadForTests} from '../src/preload'; const checkoutUrl = 'https://shopify.com/checkout'; @@ -61,9 +53,9 @@ describe('ShopifyCheckoutProvider', () => { , ); - expect( - NativeModules.ShopifyCheckoutKit.setConfig, - ).toHaveBeenCalledWith(config); + expect(NativeModules.ShopifyCheckoutKit.setConfig).toHaveBeenCalledWith( + config, + ); }); it('skips configuration when no configuration is provided', () => { @@ -73,9 +65,7 @@ describe('ShopifyCheckoutProvider', () => { , ); - expect( - NativeModules.ShopifyCheckoutKit.setConfig, - ).not.toHaveBeenCalled(); + expect(NativeModules.ShopifyCheckoutKit.setConfig).not.toHaveBeenCalled(); expect( NativeModules.ShopifyCheckoutKit.configureAcceleratedCheckouts, ).not.toHaveBeenCalled(); @@ -143,9 +133,9 @@ describe('ShopifyCheckoutProvider', () => { , ); - expect( - NativeModules.ShopifyCheckoutKit.setConfig.mock.calls, - ).toHaveLength(2); + expect(NativeModules.ShopifyCheckoutKit.setConfig.mock.calls).toHaveLength( + 2, + ); }); }); @@ -179,7 +169,7 @@ describe('useShopifyCheckout', () => { expect(NativeModules.ShopifyCheckoutKit.present).toHaveBeenCalledWith( checkoutUrl, - [], + expect.any(String), ); }); @@ -195,12 +185,12 @@ describe('useShopifyCheckout', () => { , ); - const onClose = jest.fn(); + const onDismiss = jest.fn(); const onFail = jest.fn(); const onGeolocationRequest = jest.fn(); act(() => { - hookValue.present(checkoutUrl, {onClose, onFail, onGeolocationRequest}); + hookValue.present(checkoutUrl, {onDismiss, onFail, onGeolocationRequest}); }); expect(NativeModules.ShopifyCheckoutKit.onDispatch).toHaveBeenCalledWith( @@ -208,36 +198,10 @@ describe('useShopifyCheckout', () => { ); expect(NativeModules.ShopifyCheckoutKit.present).toHaveBeenCalledWith( checkoutUrl, - [], + expect.any(String), ); }); - it('forwards protocol handlers through the provider present function', () => { - let hookValue: any; - const onHookValue = (value: any) => { - hookValue = value; - }; - - render( - - - , - ); - - act(() => { - hookValue.present(checkoutUrl, undefined, { - [CheckoutProtocol.start]: jest.fn(), - }); - }); - - expect(NativeModules.ShopifyCheckoutKit.onDispatch).toHaveBeenCalledWith( - expect.any(Function), - ); - expect(NativeModules.ShopifyCheckoutKit.present).toHaveBeenCalledWith( - checkoutUrl, - [CheckoutProtocol.start], - ); - }); it('does not call present with empty checkoutUrl', () => { let hookValue: any; @@ -255,9 +219,7 @@ describe('useShopifyCheckout', () => { hookValue.present(''); }); - expect( - NativeModules.ShopifyCheckoutKit.present, - ).not.toHaveBeenCalled(); + expect(NativeModules.ShopifyCheckoutKit.present).not.toHaveBeenCalled(); }); it('provides preload function and forwards observation options', () => { @@ -362,9 +324,9 @@ describe('useShopifyCheckout', () => { hookValue.setConfig(newConfig); }); - expect( - NativeModules.ShopifyCheckoutKit.setConfig, - ).toHaveBeenCalledWith(newConfig); + expect(NativeModules.ShopifyCheckoutKit.setConfig).toHaveBeenCalledWith( + newConfig, + ); }); it('provides getConfig function', async () => { @@ -404,7 +366,6 @@ describe('useShopifyCheckout', () => { expect(hookValue.version).toBe('0.7.0'); }); - }); describe('ShopifyCheckoutContext without provider', () => { diff --git a/platforms/react-native/modules/@shopify/checkout-kit-react-native/tests/dispatch-events.test.ts b/platforms/react-native/modules/@shopify/checkout-kit-react-native/tests/dispatch-events.test.ts index 55aaadc88..2c3b67dba 100644 --- a/platforms/react-native/modules/@shopify/checkout-kit-react-native/tests/dispatch-events.test.ts +++ b/platforms/react-native/modules/@shopify/checkout-kit-react-native/tests/dispatch-events.test.ts @@ -20,7 +20,7 @@ describe('SDK lifecycle event dispatch contract', () => { }); it('accepts native event types in a different order and verifies once', () => { - verifyDispatchEventParity(['geolocationRequest', 'close', 'fail']); + verifyDispatchEventParity([...SDK_LIFECYCLE_EVENT_TYPES].reverse()); expect(() => verifyDispatchEventParity(['close'])).not.toThrow(); }); @@ -35,11 +35,11 @@ describe('SDK lifecycle event dispatch contract', () => { }); it('reports event types missing from either side of the dispatch contract', () => { - expect(() => verifyDispatchEventParity(['close', 'nativeOnly'])).toThrow( + expect(() => verifyDispatchEventParity(['dismiss', 'nativeOnly'])).toThrow( 'events missing from js: nativeOnly', ); - expect(() => verifyDispatchEventParity(['close', 'nativeOnly'])).toThrow( - 'events missing from native: fail, geolocationRequest', + expect(() => verifyDispatchEventParity(['dismiss', 'nativeOnly'])).toThrow( + 'events missing from native: complete, fail, geolocationRequest, start, update', ); }); }); 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 77d54c8ea..3e8b08f35 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 @@ -11,7 +11,6 @@ import { RenderState, LogLevel, ColorScheme, - CheckoutProtocol, type Configuration, type AcceleratedCheckoutConfiguration, type AcceleratedCheckoutCustomer, @@ -20,7 +19,6 @@ import {__resetDispatchEventParityForTests} from '../src/dispatch-events'; import {__resetPreloadForTests} from '../src/preload'; import type {ApplePayContactField} from '../src/index.d'; import {TurboModuleRegistry, PermissionsAndroid, Platform} from 'react-native'; -import {EmbeddedCheckoutProtocol} from '@shopify/checkout-kit-protocol'; const NativeModule = TurboModuleRegistry.getEnforcing( 'ShopifyCheckoutKit', @@ -45,7 +43,14 @@ beforeEach(() => { __resetDispatchEventParityForTests(); NativeModule.getConstants.mockReturnValue({ version: '0.7.0', - dispatchEventTypes: ['close', 'fail', 'geolocationRequest'], + dispatchEventTypes: [ + 'start', + 'update', + 'complete', + 'dismiss', + 'fail', + 'geolocationRequest', + ], }); }); @@ -175,7 +180,16 @@ function lastDispatch(): Dispatch { 'Expected the last present() call to subscribe to dispatch events', ); } - return dispatch; + const requestId = NativeModule.present.mock.calls.at(-1)?.[1]; + return json => { + let envelope; + try { + envelope = JSON.parse(json); + } catch { + return dispatch(json); + } + return dispatch(JSON.stringify({requestId, ...envelope})); + }; } type PreloadDispatch = (eventJson: string) => void; @@ -187,7 +201,16 @@ function preloadDispatch(): PreloadDispatch { if (!dispatch) { throw new Error('Expected preload() to subscribe to preload state events'); } - return dispatch; + const requestId = NativeModule.present.mock.calls.at(-1)?.[1]; + return json => { + let envelope; + try { + envelope = JSON.parse(json); + } catch { + return dispatch(json); + } + return dispatch(JSON.stringify({requestId, ...envelope})); + }; } function preloadRequestId(call = 0): string { @@ -392,68 +415,23 @@ describe('ShopifyCheckoutKit', () => { }); describe('present', () => { - 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, []); - }); + const checkout = { + id: 'checkout-1', + currency: 'USD', + status: 'incomplete', + line_items: [], + links: [], + totals: [], + }; - it('calls `present` with a dispatcher when callbacks are provided', () => { - const instance = new ShopifyCheckout(); - instance.present(checkoutUrl, {onClose: jest.fn()}); - expect(NativeModule.present).toHaveBeenCalledWith(checkoutUrl, []); - expect(NativeModule.onDispatch).toHaveBeenCalledWith( - expect.any(Function), + it('passes a session ID to native', () => { + new ShopifyCheckout().present(checkoutUrl); + expect(NativeModule.present).toHaveBeenCalledWith( + checkoutUrl, + expect.any(String), ); }); - it('releases the prior dispatch subscription before a subsequent present call', () => { - const firstSubscription = {remove: jest.fn()}; - const secondSubscription = {remove: jest.fn()}; - NativeModule.onDispatch - .mockReturnValueOnce(firstSubscription) - .mockReturnValueOnce(secondSubscription); - const instance = new ShopifyCheckout(); - - instance.present(checkoutUrl, {onClose: jest.fn()}); - instance.present(checkoutUrl, {onClose: jest.fn()}); - - expect(firstSubscription.remove).toHaveBeenCalledTimes(1); - expect(secondSubscription.remove).not.toHaveBeenCalled(); - }); - - it.each(['close', 'fail'])( - 'keeps a presentation opened from %s subscribed', - type => { - const firstSubscription = {remove: jest.fn()}; - const secondSubscription = {remove: jest.fn()}; - NativeModule.onDispatch - .mockReturnValueOnce(firstSubscription) - .mockReturnValueOnce(secondSubscription); - const instance = new ShopifyCheckout(); - const secondClose = jest.fn(); - const presentAgain = () => { - expect(firstSubscription.remove).toHaveBeenCalledTimes(1); - instance.present(checkoutUrl, {onClose: secondClose}); - }; - instance.present(checkoutUrl, { - onClose: presentAgain, - onFail: presentAgain, - }); - const firstDispatch = lastDispatch(); - firstDispatch( - JSON.stringify({type, payload: {code: 'unknown', message: 'failed'}}), - ); - expect(secondSubscription.remove).not.toHaveBeenCalled(); - firstDispatch(JSON.stringify({type: 'close'})); - expect(secondClose).not.toHaveBeenCalled(); - lastDispatch()(JSON.stringify({type: 'close'})); - expect(secondClose).toHaveBeenCalledTimes(1); - }, - ); - it('keeps a reentrant presentation subscribed when the first present throws', () => { const firstSubscription = {remove: jest.fn()}; const secondSubscription = {remove: jest.fn()}; @@ -461,435 +439,172 @@ describe('ShopifyCheckoutKit', () => { .mockReturnValueOnce(firstSubscription) .mockReturnValueOnce(secondSubscription); NativeModule.present.mockImplementationOnce(() => { - lastDispatch()(JSON.stringify({type: 'close'})); + lastDispatch()(JSON.stringify({type: 'dismiss'})); }); const instance = new ShopifyCheckout(); - const secondClose = jest.fn(); + const secondDismiss = jest.fn(); expect(() => instance.present(checkoutUrl, { - onClose: () => { - instance.present(checkoutUrl, {onClose: secondClose}); + onDismiss: () => { + instance.present(checkoutUrl, {onDismiss: secondDismiss}); throw new Error('consumer error'); }, }), ).toThrow('consumer error'); expect(firstSubscription.remove).toHaveBeenCalledTimes(1); expect(secondSubscription.remove).not.toHaveBeenCalled(); - lastDispatch()(JSON.stringify({type: 'close'})); - expect(secondClose).toHaveBeenCalledTimes(1); + lastDispatch()(JSON.stringify({type: 'dismiss'})); + expect(secondDismiss).toHaveBeenCalledTimes(1); }); - it('releases the subscription even if the terminal callback throws', () => { - const subscription = {remove: jest.fn()}; - NativeModule.onDispatch.mockReturnValueOnce(subscription); - const onClose = jest.fn(() => { - throw new Error('consumer error'); - }); - const instance = new ShopifyCheckout(); - instance.present(checkoutUrl, {onClose}); + it('delivers snapshots and keeps completion separate from dismissal', () => { + const remove = jest.fn(); + NativeModule.onDispatch.mockReturnValueOnce({remove}); + const callbacks = { + onStart: jest.fn(), + onUpdate: jest.fn(), + onComplete: jest.fn(), + onDismiss: jest.fn(), + }; + new ShopifyCheckout().present(checkoutUrl, callbacks); const dispatch = lastDispatch(); - expect(() => dispatch(JSON.stringify({type: 'close'}))).toThrow( - 'consumer error', - ); - expect(subscription.remove).toHaveBeenCalledTimes(1); - dispatch(JSON.stringify({type: 'close'})); - expect(onClose).toHaveBeenCalledTimes(1); + for (const type of ['start', 'update', 'complete']) + dispatch(JSON.stringify({type, payload: {checkout}})); + for (const handler of [ + callbacks.onStart, + callbacks.onUpdate, + callbacks.onComplete, + ]) { + expect(handler).toHaveBeenCalledWith({ + checkout: {...checkout, lineItems: [], line_items: undefined}, + }); + } + expect(remove).not.toHaveBeenCalled(); + expect(callbacks.onDismiss).not.toHaveBeenCalled(); + dispatch(JSON.stringify({type: 'dismiss'})); + expect(callbacks.onDismiss).toHaveBeenCalledTimes(1); + expect(remove).toHaveBeenCalledTimes(1); + dispatch(JSON.stringify({type: 'dismiss'})); + expect(callbacks.onDismiss).toHaveBeenCalledTimes(1); }); - it.each(['replace', 'dismiss', 'teardown'])( - 'ignores queued events after %s', - action => { - const instance = new ShopifyCheckout(); - const onClose = jest.fn(); - instance.present(checkoutUrl, {onClose}); - const staleDispatch = lastDispatch(); - if (action === 'replace') - instance.present(checkoutUrl, {onClose: jest.fn()}); - else if (action === 'dismiss') instance.dismiss(); - else instance.teardown(); - staleDispatch(JSON.stringify({type: 'close'})); - expect(onClose).not.toHaveBeenCalled(); - }, - ); - - it('releases the subscription when native presentation throws', () => { - const subscription = {remove: jest.fn()}; - NativeModule.onDispatch.mockReturnValueOnce(subscription); - NativeModule.present.mockImplementationOnce(() => { - throw new Error('native error'); - }); - const instance = new ShopifyCheckout(); - const onClose = jest.fn(); - expect(() => instance.present(checkoutUrl, {onClose})).toThrow( - 'native error', + it('delivers failure as an error event and releases the session', () => { + const remove = jest.fn(); + NativeModule.onDispatch.mockReturnValueOnce({remove}); + const onFail = jest.fn(); + new ShopifyCheckout().present(checkoutUrl, {onFail}); + lastDispatch()( + JSON.stringify({ + type: 'fail', + payload: { + error: { + code: 'http_error', + message: 'Unavailable', + statusCode: 503, + }, + }, + }), ); - expect(subscription.remove).toHaveBeenCalledTimes(1); - lastDispatch()(JSON.stringify({type: 'close'})); - expect(onClose).not.toHaveBeenCalled(); - }); - - it('releases the dispatch subscription after a terminal close event', () => { - const subscription = {remove: jest.fn()}; - NativeModule.onDispatch.mockReturnValueOnce(subscription); - const instance = new ShopifyCheckout(); - - instance.present(checkoutUrl, {onClose: jest.fn()}); - lastDispatch()(JSON.stringify({type: 'close'})); - - expect(subscription.remove).toHaveBeenCalledTimes(1); + const {error} = onFail.mock.calls[0][0]; + expect(error).toBeInstanceOf(CheckoutException); + expect(error.code).toBe(CheckoutErrorCode.httpError); + expect(error.statusCode).toBe(503); + expect(remove).toHaveBeenCalledTimes(1); }); - it('invokes `onClose` when the dispatcher receives a close envelope', () => { + it('ignores queued events from earlier presentations', () => { const instance = new ShopifyCheckout(); - const onClose = jest.fn(); - instance.present(checkoutUrl, {onClose}); - lastDispatch()(JSON.stringify({type: 'close'})); - expect(onClose).toHaveBeenCalledTimes(1); + const first = jest.fn(); + const second = jest.fn(); + instance.present(checkoutUrl, {onDismiss: first}); + const oldDispatch = lastDispatch(); + const oldId = NativeModule.present.mock.calls.at(-1)[1]; + instance.present(checkoutUrl, {onDismiss: second}); + oldDispatch(JSON.stringify({type: 'dismiss'})); + lastDispatch()(JSON.stringify({requestId: oldId, type: 'dismiss'})); + expect(first).not.toHaveBeenCalled(); + expect(second).not.toHaveBeenCalled(); + lastDispatch()(JSON.stringify({type: 'dismiss'})); + expect(second).toHaveBeenCalledTimes(1); }); - it('ignores a close envelope when no `onClose` handler was provided', () => { - const instance = new ShopifyCheckout(); - instance.present(checkoutUrl, {onFail: jest.fn()}); - expect(() => - lastDispatch()(JSON.stringify({type: 'close'})), - ).not.toThrow(); - }); - - describe('onFail callback', () => { - const sdkError = { - message: 'Something went wrong', - code: CheckoutErrorCode.sdkError, - }; - - it.each([ - {name: 'an sdk failure', error: sdkError, statusCode: undefined}, - { - name: 'a storefront password requirement', - error: { - message: 'Storefront Password Required', - code: CheckoutErrorCode.storefrontPasswordRequired, - }, - statusCode: undefined, - }, - { - name: 'an http failure', - error: { - message: 'Checkout not found', - code: CheckoutErrorCode.httpError, - statusCode: 400, - }, - statusCode: 400, - }, - { - name: 'an expired cart', - error: {message: 'Cart expired', code: CheckoutErrorCode.cartExpired}, - statusCode: undefined, - }, - { - name: 'an android-only web view failure', - error: { - message: 'WebView not supported', - code: CheckoutErrorCode.webViewNotSupported, - }, - statusCode: undefined, - }, - ])( - 'parses the fail envelope payload for $name', - ({error, statusCode}: {error: any; statusCode: number | undefined}) => { - const instance = new ShopifyCheckout(); - 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(CheckoutException); - expect(calledWith).not.toHaveProperty('__typename'); - expect(calledWith.code).toBe(error.code); - expect(calledWith.message).toBe(error.message); - expect(calledWith.statusCode).toBe(statusCode); - }, - ); - - it('coerces an unrecognised code to unknown', () => { - const instance = new ShopifyCheckout(); - const onFail = jest.fn(); - instance.present(checkoutUrl, {onFail}); - const error = { - message: 'Something went wrong', - code: 'some_future_code', - }; - lastDispatch()(JSON.stringify({type: 'fail', payload: error})); - const calledWith = onFail.mock.calls[0][0]; - expect(calledWith).toBeInstanceOf(CheckoutException); - expect(calledWith.code).toBe(CheckoutErrorCode.unknown); - expect(calledWith.message).toBe('Something went wrong'); - }); - - 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: sdkError}), - ), - ).not.toThrow(); - }); - }); - - describe('onGeolocationRequest callback', () => { - it('parses the geolocationRequest envelope payload and surfaces the typed event', () => { + it.each(['dismiss', 'fail'])( + 'allows presenting again inside the %s callback', + type => { const instance = new ShopifyCheckout(); - const onGeolocationRequest = jest.fn(); - instance.present(checkoutUrl, {onGeolocationRequest}); + const nextRemove = jest.fn(); + NativeModule.onDispatch + .mockReturnValueOnce({remove: jest.fn()}) + .mockReturnValueOnce({remove: nextRemove}); + const nextDismiss = jest.fn(); + const again = () => + instance.present(checkoutUrl, {onDismiss: nextDismiss}); + instance.present(checkoutUrl, {onDismiss: again, onFail: again}); lastDispatch()( JSON.stringify({ - type: 'geolocationRequest', - payload: {origin: 'https://shopify.com'}, + type, + payload: {error: {code: 'sdk_error', message: 'Failed'}}, }), ); - expect(onGeolocationRequest).toHaveBeenCalledWith({ - origin: 'https://shopify.com', - respond: expect.any(Function), - }); - }); - }); - - describe('protocol handlers', () => { - const wireStartPayload = { - id: 'chk_123', - currency: 'USD', - line_items: [], - links: [], - status: 'incomplete', - totals: [], - ucp: { - version: EmbeddedCheckoutProtocol.specVersion, - payment_handlers: { - loyalty_gold: [], - }, - }, - }; + expect(nextRemove).not.toHaveBeenCalled(); + lastDispatch()(JSON.stringify({type: 'dismiss'})); + expect(nextDismiss).toHaveBeenCalledTimes(1); + }, + ); - const decodedStartPayload = { - id: 'chk_123', - currency: 'USD', - lineItems: [], - links: [], - status: 'incomplete', - totals: [], - ucp: { - version: EmbeddedCheckoutProtocol.specVersion, - status: undefined, - capabilities: undefined, - services: undefined, - paymentHandlers: { - loyalty_gold: [], - }, + it('releases callbacks even if consumer code throws', () => { + const remove = jest.fn(); + NativeModule.onDispatch.mockReturnValueOnce({remove}); + new ShopifyCheckout().present(checkoutUrl, { + onDismiss: () => { + throw new Error('consumer'); }, - buyer: undefined, - context: undefined, - continueUrl: undefined, - expiresAt: undefined, - messages: undefined, - order: undefined, - payment: undefined, - signals: undefined, - }; - - it('routes envelope.type via the protocol handler map', () => { - const instance = new ShopifyCheckout(); - const onStart = jest.fn(); - instance.present(checkoutUrl, undefined, { - [CheckoutProtocol.start]: onStart, - }); - lastDispatch()( - JSON.stringify({ - type: CheckoutProtocol.start, - payload: wireStartPayload, - }), - ); - expect(onStart).toHaveBeenCalledTimes(1); - expect(onStart).toHaveBeenCalledWith(decodedStartPayload); - expect(onStart.mock.calls[0][0].id).toBe('chk_123'); - }); - - it('passes subscribedMethods to native present()', () => { - const instance = new ShopifyCheckout(); - instance.present(checkoutUrl, undefined, { - [CheckoutProtocol.start]: jest.fn(), - }); - expect(NativeModule.present).toHaveBeenCalledWith(checkoutUrl, [ - CheckoutProtocol.start, - ]); - expect(NativeModule.onDispatch).toHaveBeenCalledWith( - expect.any(Function), - ); - }); - - it('still routes existing close/fail/geolocationRequest cases alongside protocol handlers', () => { - Platform.OS = 'ios'; - const instance = new ShopifyCheckout(); - const onClose = jest.fn(); - const onFail = jest.fn(); - const onGeolocationRequest = jest.fn(); - const onStart = jest.fn(); - instance.present( - checkoutUrl, - {onClose, onFail, onGeolocationRequest}, - {[CheckoutProtocol.start]: onStart}, - ); - let dispatch = lastDispatch(); - dispatch(JSON.stringify({type: 'close'})); - instance.present(checkoutUrl, {onClose, onFail, onGeolocationRequest}); - dispatch = lastDispatch(); - dispatch( - JSON.stringify({ - type: 'fail', - payload: { - message: 'boom', - code: CheckoutErrorCode.unknown, - recoverable: true, - }, - }), - ); - instance.present(checkoutUrl, {onClose, onFail, onGeolocationRequest}); - dispatch = lastDispatch(); - dispatch( - JSON.stringify({ - type: 'geolocationRequest', - payload: {origin: 'https://shopify.com'}, - }), - ); - expect(onClose).toHaveBeenCalledTimes(1); - expect(onFail).toHaveBeenCalledTimes(1); - expect(onFail.mock.calls[0][0]).toBeInstanceOf(CheckoutException); - expect(onGeolocationRequest).toHaveBeenCalledWith({ - origin: 'https://shopify.com', - respond: expect.any(Function), - }); - expect(onStart).not.toHaveBeenCalled(); }); + expect(() => lastDispatch()(JSON.stringify({type: 'dismiss'}))).toThrow( + 'consumer', + ); + expect(remove).toHaveBeenCalledTimes(1); }); - 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 code'}}), - ); - 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), - ); - }); + it('keeps callbacks until native programmatic dismissal finishes', () => { + const instance = new ShopifyCheckout(); + const onDismiss = jest.fn(); + instance.present(checkoutUrl, {onDismiss}); + instance.dismiss(); + expect(onDismiss).not.toHaveBeenCalled(); + lastDispatch()(JSON.stringify({type: 'dismiss'})); + expect(onDismiss).toHaveBeenCalledTimes(1); }); - 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('stops callbacks on teardown', () => { + const instance = new ShopifyCheckout(); + const onDismiss = jest.fn(); + instance.present(checkoutUrl, {onDismiss}); + const dispatch = lastDispatch(); + instance.teardown(); + dispatch(JSON.stringify({type: 'dismiss'})); + expect(onDismiss).not.toHaveBeenCalled(); + }); - 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.each([ + 'not-json', + '{}', + JSON.stringify({type: 'start', payload: {checkout: {}}}), + JSON.stringify({type: 'fail', payload: {}}), + ])('logs malformed events without exposing their contents', json => { + new ShopifyCheckout().present(checkoutUrl); + lastDispatch()(json); + expect(console.error).toHaveBeenCalledWith( + expect.any(LifecycleEventParseError), + ); + }); - 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, - ); + it('rejects incompatible native lifecycle event sets', () => { + NativeModule.getConstants.mockReturnValue({ + version: 'old', + dispatchEventTypes: ['close', 'fail'], }); + expect(() => new ShopifyCheckout()).toThrow(DispatchEventParityError); }); }); @@ -982,18 +697,24 @@ describe('ShopifyCheckoutKit', () => { it('subscribes to dispatch events when the default handler is enabled, even without callbacks', () => { const instance = new ShopifyCheckout(); instance.present(checkoutUrl); - expect(NativeModule.present).toHaveBeenCalledWith(checkoutUrl, []); + expect(NativeModule.present).toHaveBeenCalledWith( + checkoutUrl, + expect.any(String), + ); expect(NativeModule.onDispatch).toHaveBeenCalledWith( expect.any(Function), ); }); - it('does not subscribe to dispatch events when no callbacks and the default handler is disabled', () => { + it('can present when callbacks and default geolocation are disabled', () => { const instance = new ShopifyCheckout(undefined, { handleGeolocationRequests: false, }); instance.present(checkoutUrl); - expect(NativeModule.present).toHaveBeenCalledWith(checkoutUrl, []); + expect(NativeModule.present).toHaveBeenCalledWith( + checkoutUrl, + expect.any(String), + ); }); it('handles geolocation permission grant correctly', async () => { @@ -1019,6 +740,7 @@ describe('ShopifyCheckoutKit', () => { ]); expect(NativeModule.respondToGeolocationRequest).toHaveBeenCalledWith( true, + expect.any(String), ); }); @@ -1045,6 +767,7 @@ describe('ShopifyCheckoutKit', () => { ]); expect(NativeModule.respondToGeolocationRequest).toHaveBeenCalledWith( false, + expect.any(String), ); }); @@ -1074,6 +797,7 @@ describe('ShopifyCheckoutKit', () => { expect(NativeModule.respondToGeolocationRequest).toHaveBeenCalledWith( true, + expect.any(String), ); }); @@ -1081,7 +805,7 @@ describe('ShopifyCheckoutKit', () => { const instance = new ShopifyCheckout(undefined, { handleGeolocationRequests: false, }); - instance.present(checkoutUrl, {onClose: jest.fn()}); + instance.present(checkoutUrl, {onDismiss: jest.fn()}); lastDispatch()(geolocationEnvelope); await flush(); @@ -1101,15 +825,18 @@ describe('ShopifyCheckoutKit', () => { Platform.OS = originalPlatform; }); - it('passes a null dispatcher by default — no default geolocation handling on iOS', () => { + it('presents with the default link policy on iOS', () => { const instance = new ShopifyCheckout(); instance.present(checkoutUrl); - expect(NativeModule.present).toHaveBeenCalledWith(checkoutUrl, []); + expect(NativeModule.present).toHaveBeenCalledWith( + checkoutUrl, + expect.any(String), + ); }); 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()}); + instance.present(checkoutUrl, {onDismiss: jest.fn()}); lastDispatch()(geolocationEnvelope); await flush(); diff --git a/platforms/react-native/modules/@shopify/checkout-kit-react-native/tests/protocol.test.ts b/platforms/react-native/modules/@shopify/checkout-kit-react-native/tests/protocol.test.ts deleted file mode 100644 index 4ec6e5d0b..000000000 --- a/platforms/react-native/modules/@shopify/checkout-kit-react-native/tests/protocol.test.ts +++ /dev/null @@ -1,415 +0,0 @@ -import { - CheckoutProtocol, - type Checkout, - type ErrorResponse, - type ProtocolHandlers, -} from '../src'; -import { - Convert, - EmbeddedCheckoutProtocol, - type LineItemQuantity, -} from '@shopify/checkout-kit-protocol'; -import {decodeProtocolPayload} from '../src/protocol'; - -const checkoutPayloadMethods = [ - CheckoutProtocol.complete, - CheckoutProtocol.fulfillmentChange, - CheckoutProtocol.lineItemsChange, - CheckoutProtocol.messagesChange, - CheckoutProtocol.start, - CheckoutProtocol.totalsChange, -] as const; - -describe('CheckoutProtocol', () => { - describe('runtime values', () => { - it('exposes all public checkout protocol notification method strings', () => { - expect(CheckoutProtocol).toEqual({ - complete: 'ec.complete', - error: 'ec.error', - fulfillmentChange: 'ec.fulfillment.change', - lineItemsChange: 'ec.line_items.change', - messagesChange: 'ec.messages.change', - start: 'ec.start', - totalsChange: 'ec.totals.change', - }); - expect(CheckoutProtocol).not.toHaveProperty('buyerChange'); - expect(CheckoutProtocol).not.toHaveProperty('paymentChange'); - }); - }); - - describe('wire payload decoding', () => { - it('returns undefined for methods absent from the protocol catalog', () => { - expect(decodeProtocolPayload('ec.unknown', {})).toBeUndefined(); - }); - - it('decodes catalog notifications outside the public CheckoutProtocol subset', () => { - const checkoutEnvelope = { - id: 'checkout-123', - currency: 'USD', - status: 'incomplete', - line_items: [], - totals: [], - links: [], - ucp: { - version: EmbeddedCheckoutProtocol.specVersion, - payment_handlers: {}, - }, - }; - - expect(decodeProtocolPayload('ec.buyer.change', checkoutEnvelope)?.id).toBe( - 'checkout-123', - ); - expect( - decodeProtocolPayload('ec.payment.change', checkoutEnvelope)?.id, - ).toBe('checkout-123'); - }); - - it('treats undefined optional checkout fields as absent but rejects undefined required fields', () => { - const checkoutEnvelope = { - id: 'checkout-123', - currency: 'USD', - status: 'incomplete', - line_items: [], - totals: [], - links: [], - ucp: {version: '2026-04-08'}, - order: undefined, - fulfillment: undefined, - }; - - const decoded = decodeProtocolPayload(CheckoutProtocol.start, checkoutEnvelope); - expect(decoded).not.toHaveProperty('order'); - expect(decoded).not.toHaveProperty('fulfillment'); - expect(() => - decodeProtocolPayload(CheckoutProtocol.start, { - ...checkoutEnvelope, - totals: undefined, - }), - ).toThrow('Invalid Checkout.totals'); - }); - - it.each(checkoutPayloadMethods)( - 'converts %s checkout schema fields to camelCase while preserving dynamic map keys', - method => { - const decoded = decodeProtocolPayload(method, { - id: 'checkout-123', - currency: 'USD', - status: 'incomplete', - line_items: [], - totals: [], - links: [], - ucp: { - version: EmbeddedCheckoutProtocol.specVersion, - map_order: { - payment_handlers: ['loyalty_gold'], - }, - payment_handlers: { - loyalty_gold: [ - { - id: 'handler-1', - version: EmbeddedCheckoutProtocol.specVersion, - available_instruments: [ - { - type: 'card', - constraints: { - merchant_defined_key: true, - }, - }, - ], - }, - ], - 'com.example.loyalty_gold': [], - }, - }, - }); - - expect(decoded?.lineItems).toEqual([]); - expect(decoded?.ucp.mapOrder?.payment_handlers).toEqual(['loyalty_gold']); - expect(decoded?.ucp.paymentHandlers).toHaveProperty('loyalty_gold'); - expect( - Object.prototype.hasOwnProperty.call( - decoded?.ucp.paymentHandlers, - 'com.example.loyalty_gold', - ), - ).toBe(true); - const loyaltyHandlers = decoded?.ucp.paymentHandlers.loyalty_gold; - expect(loyaltyHandlers).toBeDefined(); - const loyaltyHandler = loyaltyHandlers?.[0]; - expect(loyaltyHandler?.availableInstruments?.[0]?.constraints).toEqual({ - merchant_defined_key: true, - }); - expect(loyaltyHandler).not.toHaveProperty('available_instruments'); - }, - ); - - it('converts error schema fields to camelCase while preserving dynamic map keys', () => { - const decoded = decodeProtocolPayload(CheckoutProtocol.error, { - continue_url: 'https://example.test/recover', - messages: [ - { - content: 'Something went wrong', - content_type: 'plain', - type: 'error', - }, - ], - ucp: { - version: EmbeddedCheckoutProtocol.specVersion, - status: 'error', - payment_handlers: { - 'com.example.loyalty_gold': [], - }, - }, - }); - - expect(decoded?.continueUrl).toBe('https://example.test/recover'); - expect(decoded?.messages[0]?.contentType).toBe('plain'); - expect( - Object.prototype.hasOwnProperty.call( - decoded?.ucp.paymentHandlers, - 'com.example.loyalty_gold', - ), - ).toBe(true); - }); - - it('decodes each message type through the generated runtime converter', () => { - const decoded = decodeProtocolPayload(CheckoutProtocol.error, { - messages: [ - { - content: 'Error message', - type: 'error', - }, - { - content: 'Warning message', - type: 'warning', - }, - { - content: 'Info message', - type: 'info', - }, - ], - ucp: { - version: EmbeddedCheckoutProtocol.specVersion, - status: 'error', - payment_handlers: {}, - }, - }); - - expect(decoded?.messages.map(message => message.type)).toEqual([ - 'error', - 'warning', - 'info', - ]); - }); - - it('decodes checkout extension fields through the generated runtime converter', () => { - const decoded = decodeProtocolPayload(CheckoutProtocol.start, { - id: 'checkout-123', - currency: 'USD', - discounts: { - codes: ['SUMMER20'], - applied: [ - { - amount: 500, - code: 'SUMMER20', - method: 'across', - title: 'Summer sale', - allocations: [ - { - amount: 500, - path: '$.line_items[0]', - }, - ], - }, - ], - }, - fulfillment: { - available_methods: [ - { - line_item_ids: ['li-1'], - type: 'shipping', - }, - ], - methods: [ - { - id: 'pickup-main', - line_item_ids: ['li-1'], - type: 'pickup', - }, - ], - }, - line_items: [], - links: [], - status: 'incomplete', - totals: [], - ucp: { - payment_handlers: {}, - version: EmbeddedCheckoutProtocol.specVersion, - }, - }); - - expect(decoded?.discounts?.codes).toEqual(['SUMMER20']); - expect(decoded?.discounts?.applied?.[0]?.method).toBe('across'); - expect(decoded?.discounts?.applied?.[0]?.allocations?.[0]?.path).toBe( - '$.line_items[0]', - ); - expect(decoded?.fulfillment?.availableMethods?.[0]?.type).toBe( - 'shipping', - ); - expect(decoded?.fulfillment?.methods?.[0]?.id).toBe('pickup-main'); - expect(decoded?.fulfillment?.methods?.[0]?.type).toBe('pickup'); - }); - - it('decodes order line item quantity through the generated runtime converter', () => { - const decoded = Convert.toOrder( - JSON.stringify({ - checkout_id: 'checkout-123', - currency: 'USD', - fulfillment: {}, - id: 'order-123', - line_items: [ - { - id: 'li-1', - item: { - id: 'sku-1', - price: 1000, - title: 'Socks', - }, - quantity: { - fulfilled: 1, - original: 2, - total: 2, - }, - status: 'partial', - totals: [], - }, - ], - permalink_url: 'https://example.test/orders/order-123', - totals: [], - ucp: { - version: EmbeddedCheckoutProtocol.specVersion, - }, - }), - ); - const quantity: LineItemQuantity = decoded.lineItems[0]!.quantity; - - expect(quantity).toEqual({ - fulfilled: 1, - original: 2, - total: 2, - }); - expect(decoded.lineItems[0]!.status).toBe('partial'); - }); - - it('decodes embedded color schemes through the generated runtime converter', () => { - const decoded = decodeProtocolPayload(CheckoutProtocol.start, { - id: 'checkout-123', - currency: 'USD', - status: 'incomplete', - line_items: [], - totals: [], - links: [], - ucp: { - version: EmbeddedCheckoutProtocol.specVersion, - payment_handlers: {}, - services: { - 'com.example.embedded': [ - { - config: { - color_scheme: ['light', 'dark'], - delegate: ['window.open'], - }, - transport: 'embedded', - version: EmbeddedCheckoutProtocol.specVersion, - }, - ], - }, - }, - }); - - expect( - decoded?.ucp.services?.['com.example.embedded']?.[0]?.config - ?.colorScheme, - ).toEqual(['light', 'dark']); - expect( - decoded?.ucp.services?.['com.example.embedded']?.[0]?.config?.delegate, - ).toEqual(['window.open']); - }); - }); - - describe('ProtocolHandlers typing', () => { - it('accepts handlers keyed by every public CheckoutProtocol event', () => { - const handlers: ProtocolHandlers = { - [CheckoutProtocol.complete]: checkout => { - expect(typeof checkout.id).toBe('string'); - }, - [CheckoutProtocol.error]: error => { - expect(error.messages).toBeDefined(); - }, - [CheckoutProtocol.fulfillmentChange]: checkout => { - expect(typeof checkout.id).toBe('string'); - }, - [CheckoutProtocol.lineItemsChange]: checkout => { - expect(typeof checkout.id).toBe('string'); - }, - [CheckoutProtocol.messagesChange]: checkout => { - expect(typeof checkout.id).toBe('string'); - }, - [CheckoutProtocol.start]: checkout => { - expect(typeof checkout.id).toBe('string'); - }, - [CheckoutProtocol.totalsChange]: checkout => { - expect(typeof checkout.id).toBe('string'); - }, - }; - - expect(typeof handlers[CheckoutProtocol.complete]).toBe('function'); - expect(typeof handlers[CheckoutProtocol.error]).toBe('function'); - expect(typeof handlers[CheckoutProtocol.fulfillmentChange]).toBe( - 'function', - ); - expect(typeof handlers[CheckoutProtocol.lineItemsChange]).toBe( - 'function', - ); - expect(typeof handlers[CheckoutProtocol.messagesChange]).toBe('function'); - expect(typeof handlers[CheckoutProtocol.start]).toBe('function'); - expect(typeof handlers[CheckoutProtocol.totalsChange]).toBe('function'); - }); - - it('infers Checkout as the payload type for checkout-state events', () => { - type HandlerMap = ProtocolHandlers; - type CheckoutPayloadMethod = (typeof checkoutPayloadMethods)[number]; - type CheckoutPayloadParam = Parameters< - NonNullable - >[0]; - - type AllCheckoutPayloads = { - [K in CheckoutPayloadMethod]: Checkout extends CheckoutPayloadParam - ? CheckoutPayloadParam extends Checkout - ? true - : false - : false; - }[CheckoutPayloadMethod]; - - const _typeCheck: AllCheckoutPayloads = true; - - expect(_typeCheck).toBe(true); - }); - - it('infers ErrorResponse as the error handler payload type', () => { - type ErrorHandler = NonNullable; - type ErrorParam = Parameters[0]; - - const _typeCheck: ErrorResponse extends ErrorParam ? true : false = true; - const _reverseCheck: ErrorParam extends ErrorResponse ? true : false = - true; - - expect(_typeCheck).toBe(true); - expect(_reverseCheck).toBe(true); - }); - - it('accepts an empty handlers map', () => { - const empty: ProtocolHandlers = {}; - expect(empty).toEqual({}); - }); - }); -}); diff --git a/platforms/react-native/sample/android/app/src/test/java/com/shopify/checkoutkit/reactnativedemo/ShopifyCheckoutKitModuleTest.java b/platforms/react-native/sample/android/app/src/test/java/com/shopify/checkoutkit/reactnativedemo/ShopifyCheckoutKitModuleTest.java index e67d77808..3857f3a86 100644 --- a/platforms/react-native/sample/android/app/src/test/java/com/shopify/checkoutkit/reactnativedemo/ShopifyCheckoutKitModuleTest.java +++ b/platforms/react-native/sample/android/app/src/test/java/com/shopify/checkoutkit/reactnativedemo/ShopifyCheckoutKitModuleTest.java @@ -1,5 +1,6 @@ package com.shopify.checkoutkit.reactnativedemo; +import android.os.Looper; import android.webkit.GeolocationPermissions; import androidx.activity.ComponentActivity; @@ -13,6 +14,8 @@ import com.shopify.checkoutkit.CheckoutAppearance; import com.shopify.checkoutkit.CheckoutErrorCode; import com.shopify.checkoutkit.CheckoutException; +import com.shopify.checkoutkit.CheckoutFailureEvent; +import com.shopify.checkoutkit.CheckoutHandle; import com.shopify.checkoutkit.CheckoutPreload; import com.shopify.checkoutkit.LogLevel; import com.shopify.checkoutkit.PreloadState; @@ -24,7 +27,11 @@ import com.shopify.reactnative.checkoutkit.CustomCheckoutListener; import com.shopify.reactnative.checkoutkit.DispatchCallback; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; import java.util.Locale; +import java.util.function.Consumer; import org.junit.After; import org.junit.Before; @@ -40,6 +47,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.Mockito.*; +import static org.robolectric.Shadows.shadowOf; @@ -81,10 +89,19 @@ public class ShopifyCheckoutKitModuleTest { private static final class TestShopifyCheckoutKitModule extends ShopifyCheckoutKitModule { private String preloadStateEvent; + private final List dispatchEvents = new ArrayList<>(); + private Consumer onDispatch; + TestShopifyCheckoutKitModule(ReactApplicationContext reactContext) { super(reactContext); } + @Override + protected void emitDispatchEvent(String event) { + dispatchEvents.add(event); + if (onDispatch != null) onDispatch.accept(event); + } + @Override protected void emitPreloadStateEvent(String event) { preloadStateEvent = event; @@ -138,19 +155,127 @@ public void testCanPresentCheckout() { try (MockedStatic mockedShopifyCheckoutKit = Mockito .mockStatic(ShopifyCheckoutKit.class)) { String checkoutUrl = "https://shopify.com"; - // An empty JavaOnlyArray stands in for "no UCP methods subscribed", - // matching the JS-side default of `protocol = {}`. - shopifyCheckoutKitModule.present(checkoutUrl, new JavaOnlyArray()); + mockedShopifyCheckoutKit.when(() -> ShopifyCheckoutKit.present( + eq(checkoutUrl), any(), any())).thenReturn(mock(CheckoutHandle.class)); + shopifyCheckoutKitModule.present(checkoutUrl, "present-request"); verify(mockComponentActivity).runOnUiThread(runnableCaptor.capture()); runnableCaptor.getValue().run(); - mockedShopifyCheckoutKit.verify(() -> { - // (url, activity, checkoutListener, protocolClient) — the protocol - // client is the new fourth arg from `ShopifyCheckoutKit.present` - // when UCP wiring is enabled. - ShopifyCheckoutKit.present(eq(checkoutUrl), any(), any(), any()); - }); + mockedShopifyCheckoutKit.verify(() -> + ShopifyCheckoutKit.present(eq(checkoutUrl), eq(mockComponentActivity), any())); + } + } + + @Test + public void testProgrammaticDismissReleasesCallbacksAndAllowsAnotherPresentation() { + assertCanPresentAfterProgrammaticDismiss(false); + } + + @Test + public void testProgrammaticDismissCleansUpWithoutCurrentActivity() { + assertCanPresentAfterProgrammaticDismiss(true); + } + + private void assertCanPresentAfterProgrammaticDismiss(boolean detachActivity) { + doAnswer(invocation -> { + ((Runnable) invocation.getArgument(0)).run(); + return null; + }).when(mockComponentActivity).runOnUiThread(any()); + + try (MockedStatic nativeKit = Mockito.mockStatic(ShopifyCheckoutKit.class)) { + // Published native handles dismiss silently; the bridge must finish its own lifecycle. + CheckoutHandle firstSheet = mock(CheckoutHandle.class); + CheckoutHandle secondSheet = mock(CheckoutHandle.class); + ArgumentCaptor listeners = ArgumentCaptor.forClass(CustomCheckoutListener.class); + nativeKit.when(() -> ShopifyCheckoutKit.present(anyString(), eq(mockComponentActivity), any())) + .thenReturn(firstSheet, secondSheet); + + shopifyCheckoutKitModule.present("https://example.com/first", "first-request"); + nativeKit.verify(() -> ShopifyCheckoutKit.present( + eq("https://example.com/first"), eq(mockComponentActivity), listeners.capture())); + CustomCheckoutListener firstListener = listeners.getValue(); + if (detachActivity) when(mockReactContext.getCurrentActivity()).thenReturn(null); + + shopifyCheckoutKitModule.dismiss(); + shadowOf(Looper.getMainLooper()).idle(); + + verify(firstSheet).dismiss(); + assertThat(firstListener.isReleased()).isTrue(); + assertThat(shopifyCheckoutKitModule.dispatchEvents).hasSize(1); + assertThat(shopifyCheckoutKitModule.dispatchEvents.get(0)) + .contains("\"type\":\"dismiss\"", "\"requestId\":\"first-request\""); + + shopifyCheckoutKitModule.dismiss(); + shadowOf(Looper.getMainLooper()).idle(); + when(mockReactContext.getCurrentActivity()).thenReturn(mockComponentActivity); + shopifyCheckoutKitModule.present("https://example.com/second", "second-request"); + nativeKit.verify(() -> ShopifyCheckoutKit.present( + eq("https://example.com/second"), eq(mockComponentActivity), listeners.capture())); + assertThat(listeners.getValue().matchesRequest("second-request")).isTrue(); + assertThat(listeners.getValue().isReleased()).isFalse(); + + firstListener.onCheckoutDismissed(); + assertThat(shopifyCheckoutKitModule.dispatchEvents).hasSize(1); + verifyNoMoreInteractions(firstSheet); + verifyNoInteractions(secondSheet); + } + } + + @Test + public void testCanPresentFromDismissCallbackWhilePreviousSheetIsClosing() { + assertCanPresentFromTerminalCallback(false, false); + } + + @Test + public void testCanPresentFromFailCallbackWhilePreviousSheetIsClosing() { + assertCanPresentFromTerminalCallback(true, false); + } + + @Test + public void testDismissCancelsPresentationWaitingForPreviousSheetToClose() { + assertCanPresentFromTerminalCallback(false, true); + } + + private void assertCanPresentFromTerminalCallback(boolean fail, boolean cancelPending) { + doAnswer(invocation -> { + ((Runnable) invocation.getArgument(0)).run(); + return null; + }).when(mockComponentActivity).runOnUiThread(any()); + + try (MockedStatic nativeKit = Mockito.mockStatic(ShopifyCheckoutKit.class)) { + CheckoutHandle firstSheet = mock(CheckoutHandle.class); + CheckoutHandle secondSheet = mock(CheckoutHandle.class); + ArgumentCaptor listeners = ArgumentCaptor.forClass(CustomCheckoutListener.class); + nativeKit.when(() -> ShopifyCheckoutKit.present(anyString(), eq(mockComponentActivity), any())) + .thenReturn(firstSheet, firstSheet, firstSheet, secondSheet); + shopifyCheckoutKitModule.present("https://example.com/first", "first-request"); + nativeKit.verify(() -> ShopifyCheckoutKit.present( + eq("https://example.com/first"), eq(mockComponentActivity), listeners.capture())); + shopifyCheckoutKitModule.onDispatch = event -> { + shopifyCheckoutKitModule.onDispatch = null; + shopifyCheckoutKitModule.present("https://example.com/second", "second-request"); + }; + + if (fail) listeners.getValue().onCheckoutFailed(new CheckoutFailureEvent(cartExpired())); + else shopifyCheckoutKitModule.dismiss(); + shadowOf(Looper.getMainLooper()).idle(); + if (cancelPending) shopifyCheckoutKitModule.dismiss(); + shadowOf(Looper.getMainLooper()).idleFor(Duration.ofMillis(100)); + + nativeKit.verify(() -> ShopifyCheckoutKit.present( + eq("https://example.com/second"), eq(mockComponentActivity), listeners.capture()), + times(cancelPending ? 1 : 3)); + CustomCheckoutListener secondListener = listeners.getValue(); + assertThat(secondListener.matchesRequest("second-request")).isTrue(); + assertThat(secondListener.isReleased()).isEqualTo(cancelPending); + if (!cancelPending) { + secondListener.onCheckoutDismissed(); + } + assertThat(shopifyCheckoutKitModule.dispatchEvents).hasSize(2); + assertThat(shopifyCheckoutKitModule.dispatchEvents.get(1)) + .contains("\"type\":\"dismiss\"", "\"requestId\":\"second-request\""); + verifyNoInteractions(secondSheet); } } @@ -263,18 +388,18 @@ public void testModuleInvalidationDetachesPreloadListener() { } @Test - public void testPresentForwardsOnCloseCallback() { + public void testPresentForwardsOnDismissCallback() { DispatchCallback dispatch = mock(DispatchCallback.class); CustomCheckoutListener processor = new CustomCheckoutListener(dispatch); processor.onCheckoutDismissed(); verify(dispatch).invoke(stringCaptor.capture()); - assertThat(stringCaptor.getValue()).contains("\"type\":\"close\""); + assertThat(stringCaptor.getValue()).contains("\"type\":\"dismiss\""); } @Test - public void testOnCloseCallbackIsSingleShot() { + public void testOnDismissCallbackIsSingleShot() { DispatchCallback dispatch = mock(DispatchCallback.class); CustomCheckoutListener processor = new CustomCheckoutListener(dispatch); @@ -305,7 +430,8 @@ public void testReleaseClearsPendingGeolocationCallback() { processor.release(); processor.invokeGeolocationCallback(true); - verify(permissionsCallback, never()).invoke(anyString(), anyBoolean(), anyBoolean()); + verify(permissionsCallback).invoke("https://shopify.com", false, false); + verifyNoMoreInteractions(permissionsCallback); } @Test @@ -318,7 +444,8 @@ public void testTerminalEventClearsPendingGeolocationCallback() { processor.onCheckoutDismissed(); processor.invokeGeolocationCallback(true); - verify(permissionsCallback, never()).invoke(anyString(), anyBoolean(), anyBoolean()); + verify(permissionsCallback).invoke("https://shopify.com", false, false); + verifyNoMoreInteractions(permissionsCallback); } @Test @@ -802,7 +929,7 @@ public void testCanProcessCheckoutExpiredErrors() { DispatchCallback dispatch = mock(DispatchCallback.class); CustomCheckoutListener processor = new CustomCheckoutListener(dispatch); - processor.onCheckoutFailed(cartExpired()); + processor.onCheckoutFailed(new CheckoutFailureEvent(cartExpired())); verify(dispatch).invoke(stringCaptor.capture()); @@ -816,8 +943,8 @@ public void testCanProcessClientErrors() { DispatchCallback dispatch = mock(DispatchCallback.class); CustomCheckoutListener processor = new CustomCheckoutListener(dispatch); - processor.onCheckoutFailed(new CheckoutException( - CheckoutErrorCode.CUSTOMER_ACCOUNT_REQUIRED, "Customer account required")); + processor.onCheckoutFailed(new CheckoutFailureEvent(new CheckoutException( + CheckoutErrorCode.CUSTOMER_ACCOUNT_REQUIRED, "Customer account required"))); verify(dispatch).invoke(stringCaptor.capture()); @@ -832,8 +959,8 @@ public void testCanProcessHttpErrors() { DispatchCallback dispatch = mock(DispatchCallback.class); CustomCheckoutListener processor = new CustomCheckoutListener(dispatch); - processor.onCheckoutFailed(new CheckoutException( - CheckoutErrorCode.HTTP_ERROR, "Not Found", 404)); + processor.onCheckoutFailed(new CheckoutFailureEvent(new CheckoutException( + CheckoutErrorCode.HTTP_ERROR, "Not Found", 404))); verify(dispatch).invoke(stringCaptor.capture()); @@ -850,7 +977,7 @@ public void testEveryErrorCodeSerialisesAsLowerSnakeCase() { CustomCheckoutListener processor = new CustomCheckoutListener(dispatch); ArgumentCaptor envelopeCaptor = ArgumentCaptor.forClass(String.class); - processor.onCheckoutFailed(new CheckoutException(code, "failed")); + processor.onCheckoutFailed(new CheckoutFailureEvent(new CheckoutException(code, "failed"))); verify(dispatch).invoke(envelopeCaptor.capture()); assertThat(envelopeCaptor.getValue()) @@ -863,8 +990,8 @@ public void testOnFailCallbackIsSingleShot() { DispatchCallback dispatch = mock(DispatchCallback.class); CustomCheckoutListener processor = new CustomCheckoutListener(dispatch); - processor.onCheckoutFailed(cartExpired()); - processor.onCheckoutFailed(cartExpired()); + processor.onCheckoutFailed(new CheckoutFailureEvent(cartExpired())); + processor.onCheckoutFailed(new CheckoutFailureEvent(cartExpired())); verify(dispatch, times(1)).invoke(anyString()); } diff --git a/platforms/react-native/sample/ios/Podfile.lock b/platforms/react-native/sample/ios/Podfile.lock index 16f20eb2a..099de143b 100644 --- a/platforms/react-native/sample/ios/Podfile.lock +++ b/platforms/react-native/sample/ios/Podfile.lock @@ -2456,8 +2456,8 @@ PODS: - ReactCodegen - ReactCommon/turbomodule/bridging - ReactCommon/turbomodule/core - - ShopifyCheckoutKit (= 4.0.0-alpha.6) - - ShopifyCheckoutKit/AcceleratedCheckouts (= 4.0.0-alpha.6) + - ShopifyCheckoutKit (= 4.0.0-alpha.7) + - ShopifyCheckoutKit/AcceleratedCheckouts (= 4.0.0-alpha.7) - SocketRocket - Yoga - RNVectorIcons (10.3.0): @@ -2489,11 +2489,11 @@ PODS: - ReactCommon/turbomodule/core - SocketRocket - Yoga - - ShopifyCheckoutKit (4.0.0-alpha.6): - - ShopifyCheckoutKit/Core (= 4.0.0-alpha.6) - - ShopifyCheckoutKit/AcceleratedCheckouts (4.0.0-alpha.6): + - ShopifyCheckoutKit (4.0.0-alpha.7): + - ShopifyCheckoutKit/Core (= 4.0.0-alpha.7) + - ShopifyCheckoutKit/AcceleratedCheckouts (4.0.0-alpha.7): - ShopifyCheckoutKit/Core - - ShopifyCheckoutKit/Core (4.0.0-alpha.6) + - ShopifyCheckoutKit/Core (4.0.0-alpha.7) - SocketRocket (0.7.1) - Yoga (0.0.0) @@ -2843,9 +2843,9 @@ SPEC CHECKSUMS: RNCMaskedView: 7e0ce15656772a939ff0d269100bca3a182163c8 RNGestureHandler: eeb622199ef1fb3a076243131095df1c797072f0 RNScreens: 3fc29af06302e1f1c18a7829fe57cbc2c0259912 - RNShopifyCheckoutKit: 7424be393789079c7420a44c853fe52a543d6ad0 + RNShopifyCheckoutKit: 5f66c1e0755d22ef7346926c04882aa27e87a9a8 RNVectorIcons: be4d047a76ad307ffe54732208fb0498fcb8477f - ShopifyCheckoutKit: c98a992e7371f1173b7e030c183fa5fe1a694459 + ShopifyCheckoutKit: 294d06324232d0cea16138ce57cdf7b2518a123b SocketRocket: d4aabe649be1e368d1318fdf28a022d714d65748 Yoga: a742cc68e8366fcfc681808162492bc0aa7a9498 diff --git a/platforms/react-native/sample/src/App.tsx b/platforms/react-native/sample/src/App.tsx index c28eca034..61ab32796 100644 --- a/platforms/react-native/sample/src/App.tsx +++ b/platforms/react-native/sample/src/App.tsx @@ -453,7 +453,7 @@ function Routes() { // Checkout URLs case storefrontUrl.isCheckout() && !storefrontUrl.isThankYouPage(): shopify.present(url, { - onClose: () => eventHandlers.onCancel?.(), + onDismiss: () => eventHandlers.onDismiss?.(), onFail: error => eventHandlers.onFail?.(error), }); return; diff --git a/platforms/react-native/sample/src/hooks/useCheckoutEventHandlers.ts b/platforms/react-native/sample/src/hooks/useCheckoutEventHandlers.ts index 5a6c535f8..283d55099 100644 --- a/platforms/react-native/sample/src/hooks/useCheckoutEventHandlers.ts +++ b/platforms/react-native/sample/src/hooks/useCheckoutEventHandlers.ts @@ -1,62 +1,41 @@ +import {useRef} from 'react'; import {createDebugLogger} from '../utils'; - -import { - CheckoutProtocol, - type CheckoutException, - type ProtocolHandlers, - type RenderStateChangeEvent, +import type { + CheckoutEventHandlers, + RenderStateChangeEvent, } from '@shopify/checkout-kit-react-native'; -import {Linking} from 'react-native'; -interface EventHandlers { - onFail?: (error: CheckoutException) => void; - onCancel?: () => void; +type EventHandlers = CheckoutEventHandlers & { onRenderStateChange?: (event: RenderStateChangeEvent) => void; - onClickLink?: (url: string) => void; -} +}; -export function useShopifyProtocolEventHandlers( +export function useShopifyEventHandlers( name?: string, - additionalHandlers: Partial = {}, -): ProtocolHandlers { - const log = createDebugLogger(name ?? ''); - - // Keep the sample subscribed to every public protocol event automatically. - // When CheckoutProtocol grows, Object.values(...) includes the new method and - // the sample starts logging it without needing a hand-written handler update. - return Object.values(CheckoutProtocol).reduce< - Record void> - >((handlers, method) => { - handlers[method] = payload => { - log(method, payload); - ( - additionalHandlers[method as keyof ProtocolHandlers] as - | ((payload: unknown) => void) - | undefined - )?.(payload); - }; - return handlers; - }, {}) as ProtocolHandlers; -} - -export function useShopifyEventHandlers(name?: string): EventHandlers { + onCompletedDismiss?: () => void, +): EventHandlers { const log = createDebugLogger(name ?? ''); + const completed = useRef(false); return { - onFail: error => { - log('onFail', error); + onStart: () => { + completed.current = false; + log('onStart'); }, - onCancel: () => { - log('onCancel'); + onUpdate: () => log('onUpdate'), + onComplete: () => { + completed.current = true; + log('onComplete'); }, - onRenderStateChange: event => { - log('onRenderStateChange', event); + onFail: ({error}) => { + completed.current = false; + log('onFail', error); }, - onClickLink: async url => { - log('onClickLink', url); - - if (await Linking.canOpenURL(url)) { - await Linking.openURL(url); + onDismiss: () => { + log('onDismiss'); + if (completed.current) { + completed.current = false; + onCompletedDismiss?.(); } }, + onRenderStateChange: event => log('onRenderStateChange', event), }; } diff --git a/platforms/react-native/sample/src/screens/CartScreen.tsx b/platforms/react-native/sample/src/screens/CartScreen.tsx index 05c75fdbf..658c3f1d5 100644 --- a/platforms/react-native/sample/src/screens/CartScreen.tsx +++ b/platforms/react-native/sample/src/screens/CartScreen.tsx @@ -13,7 +13,6 @@ import { import Icon from 'react-native-vector-icons/Entypo'; import { - CheckoutProtocol, useShopifyCheckout, AcceleratedCheckoutButtons, ApplePayLabel, @@ -28,10 +27,7 @@ import type {Colors} from '../context/Theme'; import {useTheme} from '../context/Theme'; import {useCart} from '../context/Cart'; import {currency} from '../utils'; -import { - useShopifyEventHandlers, - useShopifyProtocolEventHandlers, -} from '../hooks/useCheckoutEventHandlers'; +import {useShopifyEventHandlers} from '../hooks/useCheckoutEventHandlers'; import {AccessibilityIdentifiers} from '../accessibility/accessibilityIdentifiers'; function CartScreen(): React.JSX.Element { @@ -51,26 +47,15 @@ function CartScreen(): React.JSX.Element { } = useCart(); const {queries} = useShopify(); const {appConfig} = useConfig(); - // 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 sheetProtocolEventHandlers = useShopifyProtocolEventHandlers( - 'Cart - CheckoutSheet Protocol', - { - [CheckoutProtocol.complete]: () => { - clearCart(); - }, - }, + // Wait for dismissal before removing the cart and its accelerated buttons. + const sheetEventHandlers = useShopifyEventHandlers( + 'Cart - CheckoutSheet', + clearCart, ); const acceleratedCheckoutEventHandlers = useShopifyEventHandlers( 'Cart - AcceleratedCheckoutButtons', + clearCart, ); - const acceleratedCheckoutProtocolEventHandlers = - useShopifyProtocolEventHandlers( - 'Cart - AcceleratedCheckoutButtons Protocol', - ); const [fetchCart, {data, loading, error}] = queries.cart; @@ -125,18 +110,7 @@ function CartScreen(): React.JSX.Element { const presentCheckout = async () => { if (checkoutURL) { - present( - checkoutURL, - { - onClose: () => { - sheetEventHandlers.onCancel?.(); - }, - onFail: error => { - sheetEventHandlers.onFail?.(error); - }, - }, - sheetProtocolEventHandlers, - ); + present(checkoutURL, sheetEventHandlers); } }; @@ -229,7 +203,6 @@ function CartScreen(): React.JSX.Element { AcceleratedCheckoutWallet.shopPay, ]} cornerRadius={cornerRadius} - events={acceleratedCheckoutProtocolEventHandlers} /> Checkout { + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .custom { decoder in + let container = try decoder.singleValueContainer() + let value = try container.decode(String.self) + return try Date(value, strategy: Date.ISO8601FormatStyle(includingFractionalSeconds: true)) + } + return try decoder.decode(Checkout.self, from: Data(snapshot.utf8)) + } + + func testSnapshotPreservesWireFieldsAndExtensionsWithoutMetadata() throws { + let json = try XCTUnwrap(checkoutEventJSON(type: .start, checkout: checkout(), requestId: "request-1")) + let envelope = try parse(json) + XCTAssertEqual(envelope["type"] as? String, "start") + XCTAssertEqual(envelope["requestId"] as? String, "request-1") + let payload = try XCTUnwrap(envelope["payload"] as? [String: Any]) + let checkout = try XCTUnwrap(payload["checkout"] as? [String: Any]) + XCTAssertNil(checkout["ucp"]) + XCTAssertNotNil(checkout["line_items"]) + XCTAssertNotNil(checkout["actions"]) + XCTAssertNotNil(checkout["policies"]) + XCTAssertEqual(checkout["expires_at"] as? String, "2026-09-25T12:00:00.123Z") + XCTAssertEqual((checkout["custom_extension"] as? [String: Bool])?["nested_key"], true) + } + + func testCompletionDoesNotReleaseCallbacks() throws { + var events: [String] = [] + var terminalCount = 0 + let bridge = CheckoutEventBridge(requestId: "request-1", dispatch: { events.append($0) }, onTerminal: { terminalCount += 1 }) + let checkout = try checkout() + bridge.checkoutDidStart(CheckoutStartEvent(checkout: checkout)) + bridge.checkoutDidUpdate(CheckoutUpdateEvent(checkout: checkout)) + bridge.checkoutDidComplete(CheckoutCompleteEvent(checkout: checkout)) + XCTAssertEqual(terminalCount, 0) + bridge.checkoutDidDismiss() + bridge.checkoutDidUpdate(CheckoutUpdateEvent(checkout: checkout)) + bridge.checkoutDidDismiss() + XCTAssertEqual(terminalCount, 1) + XCTAssertEqual(try events.map { try parse($0)["type"] as? String }, ["start", "update", "complete", "dismiss"]) + } + + func testFailureUsesErrorEventAndReleasesCallbacks() throws { + var events: [String] = [] + let bridge = CheckoutEventBridge(requestId: "request-1", dispatch: { events.append($0) }, onTerminal: {}) + bridge.checkoutDidFail(CheckoutFailureEvent(error: CheckoutError(code: .sdkError, message: "Failed"))) + bridge.checkoutDidDismiss() + let envelope = try parse(XCTUnwrap(events.first)) + let payload = try XCTUnwrap(envelope["payload"] as? [String: Any]) + let error = try XCTUnwrap(payload["error"] as? [String: Any]) + XCTAssertEqual(error["code"] as? String, "sdk_error") + XCTAssertEqual(events.count, 1) + } + + func testReplacingCallbacksRetainsTheNativeSession() throws { + var events: [String] = [] + let bridge = CheckoutEventBridge(requestId: "old", dispatch: { events.append($0) }, onTerminal: {}) + bridge.requestId = "new" + try bridge.checkoutDidUpdate(CheckoutUpdateEvent(checkout: checkout())) + XCTAssertEqual(try parse(XCTUnwrap(events.first))["requestId"] as? String, "new") + } + + private func parse(_ json: String) throws -> [String: Any] { + try XCTUnwrap(JSONSerialization.jsonObject(with: Data(json.utf8)) as? [String: Any]) + } +} + +private let snapshot = #""" +{"id":"checkout-1","currency":"USD","status":"incomplete", + "line_items":[],"links":[],"totals":[], + "expires_at":"2026-09-25T12:00:00.123Z", + "actions":{"com.example.verify":[{"config":{"custom_key":true}}]}, + "policies":[{"id":"policy-1","type":"return","description":{"plain":"Returns accepted"},"applies_to":["$.line_items[0]"]}], + "custom_extension":{"nested_key":true}} +"""# diff --git a/platforms/react-native/test/rct-integration-app/RCTIntegrationAppTests/EventSerializationTests.swift b/platforms/react-native/test/rct-integration-app/RCTIntegrationAppTests/EventSerializationTests.swift index 6683f2b3d..86df0f4e9 100644 --- a/platforms/react-native/test/rct-integration-app/RCTIntegrationAppTests/EventSerializationTests.swift +++ b/platforms/react-native/test/rct-integration-app/RCTIntegrationAppTests/EventSerializationTests.swift @@ -34,7 +34,7 @@ class EventSerializationTests: XCTestCase { func testClickEventSerialization() throws { let url = try XCTUnwrap(URL(string: "https://shopify.dev/test")) let serialized = ShopifyEventSerialization.serialize(clickEvent: url) - XCTAssertEqual(serialized["url"], url) + XCTAssertEqual(serialized["url"], url.absoluteString) } // MARK: - Checkout error diff --git a/platforms/react-native/test/rct-integration-app/RCTIntegrationAppTests/ProtocolRelayTests.swift b/platforms/react-native/test/rct-integration-app/RCTIntegrationAppTests/ProtocolRelayTests.swift deleted file mode 100644 index 01f5462ab..000000000 --- a/platforms/react-native/test/rct-integration-app/RCTIntegrationAppTests/ProtocolRelayTests.swift +++ /dev/null @@ -1,192 +0,0 @@ -import Foundation -#if COCOAPODS - import ShopifyCheckoutKit -#else - import EmbeddedCheckoutProtocol -#endif -@testable import RNShopifyCheckoutKit -import XCTest - -final class ProtocolRelayTests: XCTestCase { - func testEnvelopeEncodesTypeAndWireCasePayload() throws { - let payload = SnakePayload(continueURL: "https://example.com", lineItems: []) - let envelope = DispatchEnvelope(type: "ec.start", payload: payload) - let data = try JSONEncoder().encode(envelope) - let json = try XCTUnwrap(String(data: data, encoding: .utf8)) - - let parsed = try XCTUnwrap(JSONSerialization.jsonObject(with: Data(json.utf8)) as? [String: Any]) - XCTAssertEqual(parsed["type"] as? String, "ec.start") - - let payloadDict = try XCTUnwrap(parsed["payload"] as? [String: Any]) - XCTAssertEqual(payloadDict["continue_url"] as? String, "https://example.com") - XCTAssertTrue(payloadDict["line_items"] is [Any]) - XCTAssertNil(payloadDict["continueUrl"]) - XCTAssertNil(payloadDict["lineItems"]) - } - - @MainActor - func testRelayDispatchesEnvelopeOnEcStart() async throws { - var captured: String? - let client = makeRelayClient( - subscribedMethods: ["ec.start"], - dispatch: { json in captured = json } - ) - - _ = await client.process(ecStartNotificationFixture) - - let json = try XCTUnwrap(captured) - let parsed = try XCTUnwrap(JSONSerialization.jsonObject(with: Data(json.utf8)) as? [String: Any]) - XCTAssertEqual(parsed["type"] as? String, "ec.start") - let payload = try XCTUnwrap(parsed["payload"] as? [String: Any]) - XCTAssertEqual(payload["id"] as? String, "checkout-123") - XCTAssertEqual(payload["currency"] as? String, "USD") - let lineItems = try XCTUnwrap(payload["line_items"] as? [[String: Any]]) - XCTAssertEqual(lineItems.count, 1) - let firstItem = try XCTUnwrap(lineItems.first?["item"] as? [String: Any]) - XCTAssertEqual(firstItem["image_url"] as? String, "https://example.com/image.png") - let ucp = try XCTUnwrap(payload["ucp"] as? [String: Any]) - let paymentHandlers = try XCTUnwrap(ucp["payment_handlers"] as? [String: Any]) - XCTAssertNotNil(paymentHandlers["com.example.loyalty_gold"]) - } - - @MainActor - func testRelayDispatchesEnvelopeForEveryPublicCheckoutStateEvent() async throws { - let methods = [ - "ec.complete", - "ec.fulfillment.change", - "ec.line_items.change", - "ec.messages.change", - "ec.start", - "ec.totals.change" - ] - - for method in methods { - var captured: String? - let client = makeRelayClient( - subscribedMethods: [method], - dispatch: { json in captured = json } - ) - - _ = await client.process(checkoutNotificationFixture(method: method)) - - let json = try XCTUnwrap(captured) - let parsed = try XCTUnwrap(JSONSerialization.jsonObject(with: Data(json.utf8)) as? [String: Any]) - XCTAssertEqual(parsed["type"] as? String, method) - let payload = try XCTUnwrap(parsed["payload"] as? [String: Any]) - XCTAssertEqual(payload["id"] as? String, "checkout-123") - } - } - - @MainActor - func testRelayDispatchesEnvelopeOnEcError() async throws { - var captured: String? - let client = makeRelayClient( - subscribedMethods: ["ec.error"], - dispatch: { json in captured = json } - ) - - _ = await client.process(ecErrorNotificationFixture) - - let json = try XCTUnwrap(captured) - let parsed = try XCTUnwrap(JSONSerialization.jsonObject(with: Data(json.utf8)) as? [String: Any]) - XCTAssertEqual(parsed["type"] as? String, "ec.error") - let payload = try XCTUnwrap(parsed["payload"] as? [String: Any]) - let messages = try XCTUnwrap(payload["messages"] as? [[String: Any]]) - XCTAssertEqual(messages.first?["content"] as? String, "Something went wrong") - let ucp = try XCTUnwrap(payload["ucp"] as? [String: Any]) - XCTAssertEqual(ucp["status"] as? String, "error") - } - - @MainActor - func testRelayIgnoresMethodsNotInSubscribedList() async { - var captured: String? - let client = makeRelayClient( - subscribedMethods: [], - dispatch: { json in captured = json } - ) - - _ = await client.process(ecStartNotificationFixture) - - XCTAssertNil(captured) - } -} - -private struct SnakePayload: Codable { - let continueURL: String - let lineItems: [String] - - enum CodingKeys: String, CodingKey { - case continueURL = "continue_url" - case lineItems = "line_items" - } -} - -private func checkoutNotificationFixture(method: String) -> String { - ecStartNotificationFixture.replacingOccurrences( - of: "\"method\": \"ec.start\"", - with: "\"method\": \"\(method)\"" - ) -} - -private let ecStartNotificationFixture = #""" -{ - "jsonrpc": "2.0", - "method": "ec.start", - "params": { - "checkout": { - "ucp": { - "version": "\#(EmbeddedCheckoutProtocol.specVersion)", - "payment_handlers": { - "com.example.loyalty_gold": [] - } - }, - "id": "checkout-123", - "status": "incomplete", - "currency": "USD", - "line_items": [ - { - "id": "li-1", - "quantity": 1, - "item": { - "id": "product-1", - "title": "Test Product", - "price": 2999, - "image_url": "https://example.com/image.png" - }, - "totals": [ - {"type": "subtotal", "amount": 2999} - ] - } - ], - "totals": [ - {"type": "total", "amount": 2999} - ], - "links": [ - {"type": "privacy_policy", "url": "https://example.com/privacy"} - ] - } - } -} -"""# - -private let ecErrorNotificationFixture = #""" -{ - "jsonrpc": "2.0", - "method": "ec.error", - "params": { - "error": { - "ucp": { - "version": "\#(EmbeddedCheckoutProtocol.specVersion)", - "status": "error" - }, - "messages": [ - { - "type": "error", - "content": "Something went wrong", - "severity": "recoverable" - } - ] - } - } -} -"""# diff --git a/platforms/react-native/test/rct-integration-app/RCTIntegrationAppTests/ShopifyCheckoutKitTests.swift b/platforms/react-native/test/rct-integration-app/RCTIntegrationAppTests/ShopifyCheckoutKitTests.swift index d1923db90..6f6988314 100644 --- a/platforms/react-native/test/rct-integration-app/RCTIntegrationAppTests/ShopifyCheckoutKitTests.swift +++ b/platforms/react-native/test/rct-integration-app/RCTIntegrationAppTests/ShopifyCheckoutKitTests.swift @@ -487,7 +487,7 @@ class ShopifyCheckoutKitTests: XCTestCase { func testFailedPresentDoesNotRetainCheckoutSheet() { let presentAttemptCompleted = expectation(description: "present attempt completed") - shopifyCheckoutKit.present("", subscribedMethods: []) + shopifyCheckoutKit.present("", requestId: "test") DispatchQueue.main.async { XCTAssertNil(self.shopifyCheckoutKit.checkoutSheet) @@ -502,7 +502,7 @@ class ShopifyCheckoutKitTests: XCTestCase { let checkoutSheet = DismissTrackingViewController() shopifyCheckoutKit.checkoutSheet = checkoutSheet - shopifyCheckoutKit.checkoutDidDismiss() + shopifyCheckoutKit.dismiss() DispatchQueue.main.async { XCTAssertTrue(checkoutSheet.dismissCalled) From b10d7998ca933afe0e639be528b740437d21a855 Mon Sep 17 00:00:00 2001 From: Mark Murray Date: Tue, 6 Oct 2026 17:08:50 +0100 Subject: [PATCH 2/7] Handle closing checkout sessions and presentation ownership safely --- platforms/react-native/README.md | 13 +- .../checkoutkit/ShopifyCheckoutKitModule.java | 27 +++- .../ios/CheckoutEventBridge.swift | 6 +- .../ios/ShopifyCheckoutKit.swift | 73 +++++++-- .../checkout-kit-react-native/src/index.ts | 6 +- .../tests/index.test.ts | 29 +++- .../ShopifyCheckoutKitModuleTest.java | 58 +++++++ .../useCheckoutEventHandlers.test.ts | 43 ++++++ .../src/hooks/useCheckoutEventHandlers.ts | 15 +- .../CheckoutEventBridgeTests.swift | 6 +- .../ShopifyCheckoutKitTests.swift | 146 ++++++++++++++++++ 11 files changed, 378 insertions(+), 44 deletions(-) create mode 100644 platforms/react-native/sample/src/hooks/__tests__/useCheckoutEventHandlers.test.ts diff --git a/platforms/react-native/README.md b/platforms/react-native/README.md index 9468ca1be..49decc378 100644 --- a/platforms/react-native/README.md +++ b/platforms/react-native/README.md @@ -804,6 +804,7 @@ shopify.present(checkoutUrl, { if (completed) clearCart(); }, onFail: ({error}) => { + if (completed) clearCart(); // Inspect error.code, error.message, and optional error.statusCode. }, }); @@ -820,11 +821,12 @@ shopify.present(checkoutUrl, { Completion keeps callbacks active until dismissal or failure. Delay changes that unmount checkout UI, such as clearing the cart that owns accelerated buttons, -until dismissal. Calling `dismiss()` also delivers `onDismiss`. +until dismissal or failure. Calling `dismiss()` also delivers `onDismiss`. Repeated `present()` calls while a sheet is visible replace its callbacks; the -existing checkout stays open. `teardown()` stops observations without dismissing -the sheet. +existing checkout stays open. This also applies across `ShopifyCheckout` instances: +the latest presenter owns the callbacks. `teardown()` stops observations owned by +that instance without dismissing the sheet. ### Migrating from protocol callbacks @@ -1177,7 +1179,10 @@ const completed = useRef(false); } }} onFail={({error}) => { - completed.current = false; + if (completed.current) { + completed.current = false; + clearCart(); + } console.error('Accelerated checkout failed:', error.code); }} onRenderStateChange={(event) => setRenderState(event.state)} 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 3753827bf..f9a91c7e0 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 @@ -1,6 +1,7 @@ package com.shopify.reactnative.checkoutkit; import android.app.Activity; +import android.os.SystemClock; import androidx.activity.ComponentActivity; import com.facebook.react.bridge.ReactApplicationContext; import com.facebook.react.bridge.ReactMethod; @@ -32,6 +33,10 @@ public class ShopifyCheckoutKitModule extends NativeShopifyCheckoutKitSpec { public static Configuration checkoutConfig = new Configuration(); + private static final long PRESENTATION_TIMEOUT_MS = 5000; + + private volatile boolean invalidated; + private CheckoutHandle checkoutSheet; private WeakReference closingCheckoutSheet; @@ -51,8 +56,9 @@ public ShopifyCheckoutKitModule(ReactApplicationContext reactContext) { @Override public void invalidate() { - releaseCheckoutListener(); + invalidated = true; releaseCheckoutPreload(); + UiThreadUtil.runOnUiThread(this::releaseCheckoutListener); super.invalidate(); } @@ -78,9 +84,11 @@ public void removeListeners(double count) { @ReactMethod public void present(String checkoutURL, String requestId) { + if (invalidated) return; Activity currentActivity = getReactApplicationContext().getCurrentActivity(); if (currentActivity instanceof ComponentActivity) { currentActivity.runOnUiThread(() -> { + if (invalidated) return; CustomCheckoutListener listener = checkoutListener; if (checkoutSheet == null || listener == null || listener.isReleased()) { releaseCheckoutListener(); @@ -88,7 +96,7 @@ public void present(String checkoutURL, String requestId) { checkoutListener = listener; } listener.configure(requestId, this::finishCheckoutPresentation); - presentCheckout(checkoutURL, (ComponentActivity) currentActivity, listener); + presentCheckout(checkoutURL, (ComponentActivity) currentActivity, listener, SystemClock.uptimeMillis() + PRESENTATION_TIMEOUT_MS); }); } else { CustomCheckoutListener listener = new CustomCheckoutListener(this::emitDispatchEvent); @@ -97,14 +105,19 @@ public void present(String checkoutURL, String requestId) { } } - private void presentCheckout(String checkoutURL, ComponentActivity activity, CustomCheckoutListener listener) { - if (checkoutListener != listener) return; + private void presentCheckout(String checkoutURL, ComponentActivity activity, CustomCheckoutListener listener, long deadline) { + if (invalidated || checkoutListener != listener) return; CheckoutHandle sheet = ShopifyCheckoutKit.present(checkoutURL, activity, listener); - if (checkoutListener != listener) return; + if (invalidated || checkoutListener != listener) return; if (sheet != null && closingCheckoutSheet != null && sheet == closingCheckoutSheet.get()) { // During the close animation the SDK returns the old handle without adopting - // this listener. Retry on the UI queue until a new presentation can start. - UiThreadUtil.runOnUiThread(() -> presentCheckout(checkoutURL, activity, listener), 16); + // this listener. Bound the wait so a stuck native handle cannot retain callbacks forever. + if (SystemClock.uptimeMillis() >= deadline) { + listener.onCheckoutFailed(new CheckoutFailureEvent(new CheckoutException( + CheckoutErrorCode.SDK_ERROR, "Timed out waiting for the previous checkout to close"))); + return; + } + UiThreadUtil.runOnUiThread(() -> presentCheckout(checkoutURL, activity, listener, deadline), 16); return; } closingCheckoutSheet = null; diff --git a/platforms/react-native/modules/@shopify/checkout-kit-react-native/ios/CheckoutEventBridge.swift b/platforms/react-native/modules/@shopify/checkout-kit-react-native/ios/CheckoutEventBridge.swift index e61af3fa9..48c6904a9 100644 --- a/platforms/react-native/modules/@shopify/checkout-kit-react-native/ios/CheckoutEventBridge.swift +++ b/platforms/react-native/modules/@shopify/checkout-kit-react-native/ios/CheckoutEventBridge.swift @@ -33,9 +33,9 @@ func checkoutEventJSON(type: DispatchEventType, checkout: Checkout, requestId: S final class CheckoutEventBridge: CheckoutDelegate { var requestId: String private var dispatch: ((String) -> Void)? - private let onTerminal: () -> Void + private let onTerminal: (CheckoutEventBridge) -> Void - init(requestId: String, dispatch: @escaping (String) -> Void, onTerminal: @escaping () -> Void) { + init(requestId: String, dispatch: @escaping (String) -> Void, onTerminal: @escaping (CheckoutEventBridge) -> Void) { self.requestId = requestId self.dispatch = dispatch self.onTerminal = onTerminal @@ -74,7 +74,7 @@ final class CheckoutEventBridge: CheckoutDelegate { private func finish(_ type: DispatchEventType, payload: [String: Any] = [:]) { guard dispatch != nil else { return } - onTerminal() + onTerminal(self) emit(type, payload: payload) dispatch = nil } 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 44286c1e7..fe1c24a0d 100644 --- a/platforms/react-native/modules/@shopify/checkout-kit-react-native/ios/ShopifyCheckoutKit.swift +++ b/platforms/react-native/modules/@shopify/checkout-kit-react-native/ios/ShopifyCheckoutKit.swift @@ -28,6 +28,7 @@ class RCTShopifyCheckoutKit: NSObject { internal var checkoutSheet: UIViewController? private var checkoutEvents: CheckoutEventBridge? + private weak var closingCheckoutSheet: UIViewController? private var checkoutPreload: CheckoutPreload? private var acceleratedCheckoutsConfiguration: Any? private var acceleratedCheckoutsApplePayConfiguration: Any? @@ -91,8 +92,16 @@ class RCTShopifyCheckoutKit: NSObject { DispatchQueue.main.async { let sheet = self.checkoutSheet let events = self.checkoutEvents - sheet?.dismiss(animated: true) { [weak self] in - if self?.checkoutSheet === sheet { self?.checkoutSheet = nil } + // Detach the closing session before a queued present can replace its ID. + self.checkoutSheet = nil + self.checkoutEvents = nil + guard let sheet else { + events?.checkoutDidDismiss() + return + } + self.closingCheckoutSheet = sheet + sheet.dismiss(animated: true) { [weak self] in + if self?.closingCheckoutSheet === sheet { self?.closingCheckoutSheet = nil } events?.checkoutDidDismiss() } } @@ -107,25 +116,59 @@ class RCTShopifyCheckoutKit: NSObject { @objc func present(_ checkoutURL: String, requestId: String) { DispatchQueue.main.async { - // Native SDKs only support one visible checkout. Keep its delegate alive - // when JS replaces the callbacks for that presentation. - if let events = self.checkoutEvents { - events.requestId = requestId - return + if let sheet = self.checkoutSheet { + // Only replace callbacks while the same sheet remains active. + if !sheet.isBeingDismissed, let events = self.checkoutEvents { + events.requestId = requestId + return + } + self.closingCheckoutSheet = sheet + self.checkoutSheet = nil } let events = CheckoutEventBridge(requestId: requestId, dispatch: { [weak self] json in self?.emitDispatchEvent(json) - }, onTerminal: { [weak self] in - self?.checkoutEvents = nil - self?.checkoutSheet = nil + }, onTerminal: { [weak self] ended in + guard let self, self.checkoutEvents === ended else { return } + // Native dismissal/failure can arrive before UIKit starts animating. + if let sheet = self.checkoutSheet { self.closingCheckoutSheet = sheet } + self.checkoutEvents = nil + self.checkoutSheet = nil }) self.checkoutEvents = events - guard let url = URL(string: checkoutURL), let viewController = self.getCurrentViewController() else { - events.checkoutDidDismiss() + self.presentWhenReady(checkoutURL, events: events, deadline: ProcessInfo.processInfo.systemUptime + 5) + } + } + + @MainActor + private func presentWhenReady(_ checkoutURL: String, events: CheckoutEventBridge, deadline: TimeInterval) { + guard checkoutEvents === events else { return } + if let closing = closingCheckoutSheet, + closing.presentingViewController != nil || closing.isBeingDismissed || closing.isBeingPresented + { + guard ProcessInfo.processInfo.systemUptime < deadline else { + events.checkoutDidFail(CheckoutFailureEvent(error: CheckoutError(code: .sdkError, message: "Timed out waiting for the previous checkout to close"))) return } - self.checkoutSheet = ShopifyCheckoutKit.present(checkout: url, from: viewController, delegate: events) + DispatchQueue.main.asyncAfter(deadline: .now() + 0.016) { [weak self] in + self?.presentWhenReady(checkoutURL, events: events, deadline: deadline) + } + return } + closingCheckoutSheet = nil + guard let url = URL(string: checkoutURL), let viewController = getCurrentViewController() else { + events.checkoutDidDismiss() + return + } + checkoutSheet = presentCheckout(url, from: viewController, delegate: events) + } + + @MainActor + func presentCheckout(_ url: URL, from viewController: UIViewController, delegate: CheckoutEventBridge) -> UIViewController { + ShopifyCheckoutKit.present(checkout: url, from: viewController, delegate: delegate) + } + + func emitDispatchEvent(_ json: String) { + perform(NSSelectorFromString("emitOnDispatchFromSwift:"), with: json) } @objc func preload(_ checkoutURL: String, requestId: String) { @@ -327,10 +370,6 @@ class RCTShopifyCheckoutKit: NSObject { // MARK: - Dispatch envelope helpers extension RCTShopifyCheckoutKit { - private func emitDispatchEvent(_ json: String) { - perform(NSSelectorFromString("emitOnDispatchFromSwift:"), with: json) - } - private func emitPreloadStateChange(requestId: String, state: PreloadState) { var event: [String: Any] = ["requestId": requestId] 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 60c30056e..c7ca9ada8 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 @@ -42,6 +42,8 @@ import type { import {preload as preloadCheckout} from './preload'; let presentationSequence = 0; +// The native SDK owns one sheet across all ShopifyCheckout instances. +let activePresentation: ShopifyCheckout | undefined; const defaultFeatures: Features = { handleGeolocationRequests: true, @@ -120,7 +122,7 @@ class ShopifyCheckout implements ShopifyCheckoutKit { /** Presents checkout with lifecycle callbacks. */ public present(checkoutUrl: string, callbacks?: PresentCallbacks): void { - this.releaseDispatchSubscription(); + activePresentation?.releaseDispatchSubscription(); let subscription: {remove: () => void} | undefined; const requestId = `present-${++presentationSequence}`; const {dispatcher} = createPresentDispatcher({ @@ -144,6 +146,7 @@ class ShopifyCheckout implements ShopifyCheckoutKit { dispatcher(json); }); this.dispatchSubscription = subscription; + activePresentation = this; try { RNShopifyCheckoutKit.present(checkoutUrl, requestId); } catch (error) { @@ -331,6 +334,7 @@ class ShopifyCheckout implements ShopifyCheckoutKit { if (only && this.dispatchSubscription !== only) return; const subscription = this.dispatchSubscription; this.dispatchSubscription = undefined; + if (activePresentation === this) activePresentation = undefined; subscription?.remove(); } 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 3e8b08f35..3d12b1ac4 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 @@ -528,6 +528,29 @@ describe('ShopifyCheckoutKit', () => { expect(second).toHaveBeenCalledTimes(1); }); + it('releases the previous instance without letting its teardown affect the new owner', () => { + const first = new ShopifyCheckout(); + const second = new ShopifyCheckout(); + const firstRemove = jest.fn(); + const secondRemove = jest.fn(); + const firstDismiss = jest.fn(); + const secondDismiss = jest.fn(); + NativeModule.onDispatch + .mockReturnValueOnce({remove: firstRemove}) + .mockReturnValueOnce({remove: secondRemove}); + first.present(checkoutUrl, {onDismiss: firstDismiss}); + const oldDispatch = lastDispatch(); + second.present(checkoutUrl, {onDismiss: secondDismiss}); + expect(firstRemove).toHaveBeenCalledTimes(1); + first.teardown(); + oldDispatch(JSON.stringify({type: 'dismiss'})); + expect(firstDismiss).not.toHaveBeenCalled(); + expect(secondRemove).not.toHaveBeenCalled(); + lastDispatch()(JSON.stringify({type: 'dismiss'})); + expect(secondDismiss).toHaveBeenCalledTimes(1); + expect(secondRemove).toHaveBeenCalledTimes(1); + }); + it.each(['dismiss', 'fail'])( 'allows presenting again inside the %s callback', type => { @@ -700,7 +723,7 @@ describe('ShopifyCheckoutKit', () => { expect(NativeModule.present).toHaveBeenCalledWith( checkoutUrl, expect.any(String), - ); + ); expect(NativeModule.onDispatch).toHaveBeenCalledWith( expect.any(Function), ); @@ -714,7 +737,7 @@ describe('ShopifyCheckoutKit', () => { expect(NativeModule.present).toHaveBeenCalledWith( checkoutUrl, expect.any(String), - ); + ); }); it('handles geolocation permission grant correctly', async () => { @@ -831,7 +854,7 @@ describe('ShopifyCheckoutKit', () => { expect(NativeModule.present).toHaveBeenCalledWith( checkoutUrl, expect.any(String), - ); + ); }); it('does not run the default geolocation handler on iOS even if dispatcher fires', async () => { diff --git a/platforms/react-native/sample/android/app/src/test/java/com/shopify/checkoutkit/reactnativedemo/ShopifyCheckoutKitModuleTest.java b/platforms/react-native/sample/android/app/src/test/java/com/shopify/checkoutkit/reactnativedemo/ShopifyCheckoutKitModuleTest.java index 3857f3a86..11c2b6ef6 100644 --- a/platforms/react-native/sample/android/app/src/test/java/com/shopify/checkoutkit/reactnativedemo/ShopifyCheckoutKitModuleTest.java +++ b/platforms/react-native/sample/android/app/src/test/java/com/shopify/checkoutkit/reactnativedemo/ShopifyCheckoutKitModuleTest.java @@ -279,6 +279,64 @@ private void assertCanPresentFromTerminalCallback(boolean fail, boolean cancelPe } } + @Test + public void testInvalidationCancelsQueuedPresentation() { + try (MockedStatic nativeKit = Mockito.mockStatic(ShopifyCheckoutKit.class)) { + shopifyCheckoutKitModule.present("https://example.com/checkout", "queued"); + verify(mockComponentActivity).runOnUiThread(runnableCaptor.capture()); + shopifyCheckoutKitModule.invalidate(); + runnableCaptor.getValue().run(); + shadowOf(Looper.getMainLooper()).idle(); + shopifyCheckoutKitModule.present("https://example.com/checkout", "after-invalidation"); + nativeKit.verifyNoInteractions(); + assertThat(shopifyCheckoutKitModule.dispatchEvents).isEmpty(); + } + } + + @Test + public void testStuckClosingHandleFailsOnceAndStopsRetrying() { + assertStopsWaitingForClosingHandle(false); + } + + @Test + public void testInvalidationCancelsPendingPresentationRetry() { + assertStopsWaitingForClosingHandle(true); + } + + private void assertStopsWaitingForClosingHandle(boolean invalidate) { + doAnswer(invocation -> { + ((Runnable) invocation.getArgument(0)).run(); + return null; + }).when(mockComponentActivity).runOnUiThread(any()); + try (MockedStatic nativeKit = Mockito.mockStatic(ShopifyCheckoutKit.class)) { + CheckoutHandle closing = mock(CheckoutHandle.class); + nativeKit.when(() -> ShopifyCheckoutKit.present(anyString(), eq(mockComponentActivity), any())) + .thenReturn(closing); + shopifyCheckoutKitModule.present("https://example.com/first", "first"); + shopifyCheckoutKitModule.dismiss(); + shadowOf(Looper.getMainLooper()).idle(); + shopifyCheckoutKitModule.present("https://example.com/second", "second"); + if (invalidate) shopifyCheckoutKitModule.invalidate(); + shadowOf(Looper.getMainLooper()).idleFor(Duration.ofSeconds(6)); + if (invalidate) { + assertThat(shopifyCheckoutKitModule.dispatchEvents).hasSize(1); + } else { + assertThat(shopifyCheckoutKitModule.dispatchEvents).hasSize(2); + assertThat(shopifyCheckoutKitModule.dispatchEvents.get(1)) + .contains("\"type\":\"fail\"", "\"requestId\":\"second\"", "sdk_error"); + } + nativeKit.clearInvocations(); + shadowOf(Looper.getMainLooper()).idleFor(Duration.ofSeconds(6)); + nativeKit.verifyNoInteractions(); + if (!invalidate) { + nativeKit.when(() -> ShopifyCheckoutKit.present(anyString(), eq(mockComponentActivity), any())) + .thenReturn(mock(CheckoutHandle.class)); + shopifyCheckoutKitModule.present("https://example.com/third", "third"); + nativeKit.verify(() -> ShopifyCheckoutKit.present(eq("https://example.com/third"), eq(mockComponentActivity), any())); + } + } + } + @Test public void testCanPreloadCheckout() { try (MockedStatic mockedShopifyCheckoutKit = Mockito diff --git a/platforms/react-native/sample/src/hooks/__tests__/useCheckoutEventHandlers.test.ts b/platforms/react-native/sample/src/hooks/__tests__/useCheckoutEventHandlers.test.ts new file mode 100644 index 000000000..a23390405 --- /dev/null +++ b/platforms/react-native/sample/src/hooks/__tests__/useCheckoutEventHandlers.test.ts @@ -0,0 +1,43 @@ +import {act, renderHook} from '@testing-library/react-native'; +import { + CheckoutErrorCode, + CheckoutException, +} from '@shopify/checkout-kit-react-native'; +import type {CheckoutCompleteEvent} from '@shopify/checkout-kit-react-native'; +import {useShopifyEventHandlers} from '../useCheckoutEventHandlers'; + +jest.mock('../../utils', () => ({createDebugLogger: () => jest.fn()})); + +describe('completed checkout cleanup', () => { + const failure = { + error: new CheckoutException({ + code: CheckoutErrorCode.sdkError, + message: 'Failed', + }), + }; + + it.each(['onDismiss', 'onFail'] as const)( + 'clears a completed cart once on %s', + terminal => { + const clearCart = jest.fn(); + const {result} = renderHook(() => + useShopifyEventHandlers('test', clearCart), + ); + act(() => result.current.onComplete?.({} as CheckoutCompleteEvent)); + expect(clearCart).not.toHaveBeenCalled(); + act(() => result.current[terminal]?.(failure)); + expect(clearCart).toHaveBeenCalledTimes(1); + act(() => result.current.onDismiss?.()); + expect(clearCart).toHaveBeenCalledTimes(1); + }, + ); + + it('keeps an incomplete cart after failure', () => { + const clearCart = jest.fn(); + const {result} = renderHook(() => + useShopifyEventHandlers('test', clearCart), + ); + act(() => result.current.onFail?.(failure)); + expect(clearCart).not.toHaveBeenCalled(); + }); +}); diff --git a/platforms/react-native/sample/src/hooks/useCheckoutEventHandlers.ts b/platforms/react-native/sample/src/hooks/useCheckoutEventHandlers.ts index 283d55099..902dd0f0d 100644 --- a/platforms/react-native/sample/src/hooks/useCheckoutEventHandlers.ts +++ b/platforms/react-native/sample/src/hooks/useCheckoutEventHandlers.ts @@ -11,10 +11,16 @@ type EventHandlers = CheckoutEventHandlers & { export function useShopifyEventHandlers( name?: string, - onCompletedDismiss?: () => void, + onCompletedCheckout?: () => void, ): EventHandlers { const log = createDebugLogger(name ?? ''); const completed = useRef(false); + const finishCheckout = () => { + if (completed.current) { + completed.current = false; + onCompletedCheckout?.(); + } + }; return { onStart: () => { completed.current = false; @@ -26,15 +32,12 @@ export function useShopifyEventHandlers( log('onComplete'); }, onFail: ({error}) => { - completed.current = false; log('onFail', error); + finishCheckout(); }, onDismiss: () => { log('onDismiss'); - if (completed.current) { - completed.current = false; - onCompletedDismiss?.(); - } + finishCheckout(); }, onRenderStateChange: event => log('onRenderStateChange', event), }; diff --git a/platforms/react-native/test/rct-integration-app/RCTIntegrationAppTests/CheckoutEventBridgeTests.swift b/platforms/react-native/test/rct-integration-app/RCTIntegrationAppTests/CheckoutEventBridgeTests.swift index 553e799a9..0a282d2c2 100644 --- a/platforms/react-native/test/rct-integration-app/RCTIntegrationAppTests/CheckoutEventBridgeTests.swift +++ b/platforms/react-native/test/rct-integration-app/RCTIntegrationAppTests/CheckoutEventBridgeTests.swift @@ -33,7 +33,7 @@ final class CheckoutEventBridgeTests: XCTestCase { func testCompletionDoesNotReleaseCallbacks() throws { var events: [String] = [] var terminalCount = 0 - let bridge = CheckoutEventBridge(requestId: "request-1", dispatch: { events.append($0) }, onTerminal: { terminalCount += 1 }) + let bridge = CheckoutEventBridge(requestId: "request-1", dispatch: { events.append($0) }, onTerminal: { _ in terminalCount += 1 }) let checkout = try checkout() bridge.checkoutDidStart(CheckoutStartEvent(checkout: checkout)) bridge.checkoutDidUpdate(CheckoutUpdateEvent(checkout: checkout)) @@ -48,7 +48,7 @@ final class CheckoutEventBridgeTests: XCTestCase { func testFailureUsesErrorEventAndReleasesCallbacks() throws { var events: [String] = [] - let bridge = CheckoutEventBridge(requestId: "request-1", dispatch: { events.append($0) }, onTerminal: {}) + let bridge = CheckoutEventBridge(requestId: "request-1", dispatch: { events.append($0) }, onTerminal: { _ in }) bridge.checkoutDidFail(CheckoutFailureEvent(error: CheckoutError(code: .sdkError, message: "Failed"))) bridge.checkoutDidDismiss() let envelope = try parse(XCTUnwrap(events.first)) @@ -60,7 +60,7 @@ final class CheckoutEventBridgeTests: XCTestCase { func testReplacingCallbacksRetainsTheNativeSession() throws { var events: [String] = [] - let bridge = CheckoutEventBridge(requestId: "old", dispatch: { events.append($0) }, onTerminal: {}) + let bridge = CheckoutEventBridge(requestId: "old", dispatch: { events.append($0) }, onTerminal: { _ in }) bridge.requestId = "new" try bridge.checkoutDidUpdate(CheckoutUpdateEvent(checkout: checkout())) XCTAssertEqual(try parse(XCTUnwrap(events.first))["requestId"] as? String, "new") diff --git a/platforms/react-native/test/rct-integration-app/RCTIntegrationAppTests/ShopifyCheckoutKitTests.swift b/platforms/react-native/test/rct-integration-app/RCTIntegrationAppTests/ShopifyCheckoutKitTests.swift index 6f6988314..a0316534c 100644 --- a/platforms/react-native/test/rct-integration-app/RCTIntegrationAppTests/ShopifyCheckoutKitTests.swift +++ b/platforms/react-native/test/rct-integration-app/RCTIntegrationAppTests/ShopifyCheckoutKitTests.swift @@ -525,3 +525,149 @@ private final class DismissTrackingViewController: UIViewController { completion?() } } + +extension ShopifyCheckoutKitTests { + @MainActor + func testPresentDuringProgrammaticDismissWaitsForTheOldSheet() async { + let module = PresentationTrackingModule() + module.present("https://example.test/first", requestId: "first") + await flushPresentationQueue() + let oldSheet = module.sheets[0] + let oldEvents = module.delegates[0] + module.dismiss() + module.present("https://example.test/second", requestId: "second") + await flushPresentationQueue() + XCTAssertEqual(module.urls.count, 1) + XCTAssertEqual(oldEvents.requestId, "first") + + let reopened = expectation(description: "new checkout presented after dismissal") + module.onPresent = { reopened.fulfill() } + oldSheet.finishDismissal() + await fulfillment(of: [reopened], timeout: 1) + XCTAssertEqual(module.urls.last?.absoluteString, "https://example.test/second") + XCTAssertTrue(module.events[0].contains("\"requestId\":\"first\"")) + oldEvents.checkoutDidDismiss() + XCTAssertEqual(module.events.count, 1) + XCTAssertEqual(module.delegates.last?.requestId, "second") + } + + @MainActor + func testPresentFromNativeDismissWaitsForTheOldSheet() async { + await assertPresentFromTerminalWaits(fail: false) + } + + @MainActor + func testPresentFromNativeFailureWaitsForTheOldSheet() async { + await assertPresentFromTerminalWaits(fail: true) + } + + @MainActor + private func assertPresentFromTerminalWaits(fail: Bool) async { + let module = PresentationTrackingModule() + module.present("https://example.test/first", requestId: "first") + await flushPresentationQueue() + let oldSheet = module.sheets[0] + module.onEvent = { + module.onEvent = nil + module.present("https://example.test/second", requestId: "second") + } + if fail { + module.delegates[0].checkoutDidFail(CheckoutFailureEvent(error: CheckoutError(code: .sdkError, message: "Failed"))) + } else { + module.delegates[0].checkoutDidDismiss() + } + await flushPresentationQueue() + XCTAssertEqual(module.urls.count, 1) + let reopened = expectation(description: "terminal callback reopens checkout") + module.onPresent = { reopened.fulfill() } + oldSheet.finishDismissal() + await fulfillment(of: [reopened], timeout: 1) + XCTAssertEqual(module.urls.last?.absoluteString, "https://example.test/second") + XCTAssertEqual(module.delegates.last?.requestId, "second") + } + + @MainActor + func testDismissCancelsAnIOSPresentationWaitingForTheOldSheet() async { + let module = PresentationTrackingModule() + module.present("https://example.test/first", requestId: "first") + await flushPresentationQueue() + module.dismiss() + module.present("https://example.test/second", requestId: "second") + await flushPresentationQueue() + module.dismiss() + await flushPresentationQueue() + let reopened = expectation(description: "cancelled checkout never opens") + reopened.isInverted = true + module.onPresent = { reopened.fulfill() } + module.sheets[0].finishDismissal() + await fulfillment(of: [reopened], timeout: 0.05) + XCTAssertEqual(module.events.count, 2) + XCTAssertTrue(module.events.contains { $0.contains("\"requestId\":\"second\"") }) + } + + @MainActor + func testPresentWhileActiveOnlyReplacesCallbacks() async { + let module = PresentationTrackingModule() + module.present("https://example.test/first", requestId: "first") + await flushPresentationQueue() + module.present("https://example.test/second", requestId: "second") + await flushPresentationQueue() + XCTAssertEqual(module.urls.count, 1) + XCTAssertEqual(module.delegates[0].requestId, "second") + } + + @MainActor + private func flushPresentationQueue() async { + await withCheckedContinuation { continuation in + DispatchQueue.main.async { continuation.resume() } + } + } +} + +private final class PresentationTrackingModule: RCTShopifyCheckoutKit { + var urls: [URL] = [] + var delegates: [CheckoutEventBridge] = [] + var sheets: [DeferredDismissViewController] = [] + var events: [String] = [] + var onPresent: (() -> Void)? + var onEvent: (() -> Void)? + + override func getCurrentViewController(_: UIViewController? = nil) -> UIViewController? { + UIViewController() + } + + override func presentCheckout(_ url: URL, from _: UIViewController, delegate: CheckoutEventBridge) -> UIViewController { + let sheet = DeferredDismissViewController() + urls.append(url) + delegates.append(delegate) + sheets.append(sheet) + onPresent?() + return sheet + } + + override func emitDispatchEvent(_ json: String) { + events.append(json) + onEvent?() + } +} + +private final class DeferredDismissViewController: UIViewController { + private let presenter = UIViewController() + private var attached = true + private var completion: (() -> Void)? + + override var presentingViewController: UIViewController? { + attached ? presenter : nil + } + + override func dismiss(animated _: Bool, completion: (() -> Void)? = nil) { + self.completion = completion + } + + func finishDismissal() { + attached = false + let callback = completion + completion = nil + callback?() + } +} From cf363e5a7f160e2292e083a37e966b43cc64d084 Mon Sep 17 00:00:00 2001 From: Mark Murray Date: Wed, 7 Oct 2026 13:18:14 +0100 Subject: [PATCH 3/7] Resolve React Native workspace imports from source in Jest --- platforms/react-native/jest.config.js | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/platforms/react-native/jest.config.js b/platforms/react-native/jest.config.js index f1fd75484..cc878706e 100644 --- a/platforms/react-native/jest.config.js +++ b/platforms/react-native/jest.config.js @@ -2,6 +2,11 @@ module.exports = { preset: 'react-native', modulePathIgnorePatterns: ['modules/@shopify/checkout-kit-react-native/lib'], modulePaths: ['/node_modules', '/sample/node_modules'], + // Resolve workspace imports without requiring generated lib files. + moduleNameMapper: { + '^@shopify/checkout-kit-react-native$': + '/modules/@shopify/checkout-kit-react-native/src', + }, setupFiles: ['/jest.setup.ts'], collectCoverageFrom: [ 'modules/@shopify/checkout-kit-react-native/src/**/*.{ts,tsx}', From 49396d3a720d7db252285e0d631f575b49909518 Mon Sep 17 00:00:00 2001 From: Mark Murray Date: Thu, 8 Oct 2026 11:44:50 +0100 Subject: [PATCH 4/7] Keep active presentation ownership inside the checkout wrapper --- .../@shopify/checkout-kit-react-native/src/index.ts | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) 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 c7ca9ada8..55a82f73d 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 @@ -42,14 +42,15 @@ import type { import {preload as preloadCheckout} from './preload'; let presentationSequence = 0; -// The native SDK owns one sheet across all ShopifyCheckout instances. -let activePresentation: ShopifyCheckout | undefined; const defaultFeatures: Features = { handleGeolocationRequests: true, }; class ShopifyCheckout implements ShopifyCheckoutKit { + // The native SDK owns one sheet across all ShopifyCheckout instances. + private static activePresentation?: ShopifyCheckout; + private features: Features; private dispatchSubscription?: {remove: () => void}; @@ -122,7 +123,7 @@ class ShopifyCheckout implements ShopifyCheckoutKit { /** Presents checkout with lifecycle callbacks. */ public present(checkoutUrl: string, callbacks?: PresentCallbacks): void { - activePresentation?.releaseDispatchSubscription(); + ShopifyCheckout.activePresentation?.releaseDispatchSubscription(); let subscription: {remove: () => void} | undefined; const requestId = `present-${++presentationSequence}`; const {dispatcher} = createPresentDispatcher({ @@ -146,7 +147,7 @@ class ShopifyCheckout implements ShopifyCheckoutKit { dispatcher(json); }); this.dispatchSubscription = subscription; - activePresentation = this; + ShopifyCheckout.activePresentation = this; try { RNShopifyCheckoutKit.present(checkoutUrl, requestId); } catch (error) { @@ -334,7 +335,8 @@ class ShopifyCheckout implements ShopifyCheckoutKit { if (only && this.dispatchSubscription !== only) return; const subscription = this.dispatchSubscription; this.dispatchSubscription = undefined; - if (activePresentation === this) activePresentation = undefined; + if (ShopifyCheckout.activePresentation === this) + ShopifyCheckout.activePresentation = undefined; subscription?.remove(); } From 32fa778f4900dd8c5188abb3236165200bc923b7 Mon Sep 17 00:00:00 2001 From: Mark Murray Date: Thu, 8 Oct 2026 12:17:21 +0100 Subject: [PATCH 5/7] Preserve active checkout sessions and cancel stale geolocation responses --- platforms/react-native/README.md | 9 +- .../checkoutkit/CheckoutEventSerialization.kt | 3 +- .../checkoutkit/CustomCheckoutListener.java | 9 +- .../checkoutkit/ShopifyCheckoutKitModule.java | 19 +- .../CheckoutEventSerializationTest.kt | 15 +- .../checkoutkit/CustomCheckoutListenerTest.kt | 15 ++ .../ios/CheckoutEventBridge.swift | 13 +- .../ios/ShopifyCheckoutKit.mm | 3 +- .../ios/ShopifyCheckoutKit.swift | 19 +- .../checkout-kit-react-native/src/index.d.ts | 5 +- .../checkout-kit-react-native/src/index.ts | 59 ++++-- .../src/present-dispatcher.ts | 8 +- .../src/specs/NativeShopifyCheckoutKit.ts | 4 +- .../tests/context.test.tsx | 9 +- .../tests/index.test.ts | 192 ++++++++++++------ .../ShopifyCheckoutKitModuleTest.java | 48 +++-- .../CheckoutEventBridgeTests.swift | 15 +- .../ShopifyCheckoutKitTests.swift | 30 ++- 18 files changed, 276 insertions(+), 199 deletions(-) diff --git a/platforms/react-native/README.md b/platforms/react-native/README.md index 49decc378..3adb7a252 100644 --- a/platforms/react-native/README.md +++ b/platforms/react-native/README.md @@ -823,10 +823,11 @@ Completion keeps callbacks active until dismissal or failure. Delay changes that unmount checkout UI, such as clearing the cart that owns accelerated buttons, until dismissal or failure. Calling `dismiss()` also delivers `onDismiss`. -Repeated `present()` calls while a sheet is visible replace its callbacks; the -existing checkout stays open. This also applies across `ShopifyCheckout` instances: -the latest presenter owns the callbacks. `teardown()` stops observations owned by -that instance without dismissing the sheet. +Repeated `present()` calls while a checkout session is active are ignored, +including calls from another `ShopifyCheckout` instance. The original checkout +and callbacks remain active. `teardown()` stops consumer callbacks and cancels +pending geolocation responses without dismissing the sheet; another checkout +can be presented once the native session ends. ### Migrating from protocol callbacks diff --git a/platforms/react-native/modules/@shopify/checkout-kit-react-native/android/src/main/java/com/shopify/reactnative/checkoutkit/CheckoutEventSerialization.kt b/platforms/react-native/modules/@shopify/checkout-kit-react-native/android/src/main/java/com/shopify/reactnative/checkoutkit/CheckoutEventSerialization.kt index 9e0bc26cc..3239b6a85 100644 --- a/platforms/react-native/modules/@shopify/checkout-kit-react-native/android/src/main/java/com/shopify/reactnative/checkoutkit/CheckoutEventSerialization.kt +++ b/platforms/react-native/modules/@shopify/checkout-kit-react-native/android/src/main/java/com/shopify/reactnative/checkoutkit/CheckoutEventSerialization.kt @@ -14,10 +14,9 @@ fun interface DispatchCallback { /** Uses the native snapshot serializer to retain wire names and extension fields. */ object CheckoutEventSerialization { @JvmStatic - fun checkout(type: String, requestId: String, checkout: Checkout): String = + fun checkout(type: String, checkout: Checkout): String = Json.encodeToString(buildJsonObject { put("type", type) - put("requestId", requestId) put("payload", buildJsonObject { put("checkout", Json.encodeToJsonElement(checkout)) }) 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 bfba2bba6..f3204f7db 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 @@ -20,16 +20,12 @@ public class CustomCheckoutListener extends DefaultCheckoutListener { private final ObjectMapper mapper = new ObjectMapper(); private final DispatchHandle dispatch; - private String requestId = ""; private Runnable onTerminal = () -> {}; - public void configure(String requestId, Runnable onTerminal) { - invokeGeolocationCallback(false); - this.requestId = requestId; + public void setOnTerminal(Runnable onTerminal) { this.onTerminal = onTerminal; } - public boolean matchesRequest(String requestId) { return this.requestId.equals(requestId); } public boolean isReleased() { return dispatch.isReleased(); } // Geolocation-specific variables @@ -156,7 +152,7 @@ public void onCheckoutCompleted(CheckoutCompleteEvent event) { private void emitCheckout(String type, Checkout checkout) { if (dispatch.isReleased()) return; try { - dispatch.invoke(CheckoutEventSerialization.checkout(type, requestId, checkout)); + dispatch.invoke(CheckoutEventSerialization.checkout(type, checkout)); } catch (Exception e) { Log.e(TAG, "Error serializing checkout event"); } @@ -167,7 +163,6 @@ private void emitCheckout(String type, Checkout checkout) { private String buildEnvelope(String type, @Nullable Object payload) throws IOException { ObjectNode envelope = mapper.createObjectNode(); envelope.put("type", type); - envelope.put("requestId", requestId); if (payload != null) { envelope.set("payload", mapper.valueToTree(payload)); } 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 f9a91c7e0..797f05ac5 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 @@ -83,24 +83,21 @@ public void removeListeners(double count) { } @ReactMethod - public void present(String checkoutURL, String requestId) { + public void present(String checkoutURL) { if (invalidated) return; Activity currentActivity = getReactApplicationContext().getCurrentActivity(); if (currentActivity instanceof ComponentActivity) { currentActivity.runOnUiThread(() -> { if (invalidated) return; - CustomCheckoutListener listener = checkoutListener; - if (checkoutSheet == null || listener == null || listener.isReleased()) { - releaseCheckoutListener(); - listener = new CustomCheckoutListener(this::emitDispatchEvent); - checkoutListener = listener; - } - listener.configure(requestId, this::finishCheckoutPresentation); + // Ignore duplicate calls without replacing the active listener or policy. + if (checkoutListener != null && !checkoutListener.isReleased()) return; + CustomCheckoutListener listener = new CustomCheckoutListener(this::emitDispatchEvent); + checkoutListener = listener; + listener.setOnTerminal(this::finishCheckoutPresentation); presentCheckout(checkoutURL, (ComponentActivity) currentActivity, listener, SystemClock.uptimeMillis() + PRESENTATION_TIMEOUT_MS); }); } else { CustomCheckoutListener listener = new CustomCheckoutListener(this::emitDispatchEvent); - listener.configure(requestId, () -> {}); listener.onCheckoutDismissed(); } } @@ -333,9 +330,9 @@ public boolean isApplePayAvailable() { } @ReactMethod - public void respondToGeolocationRequest(boolean allow, String requestId) { + public void respondToGeolocationRequest(boolean allow) { UiThreadUtil.runOnUiThread(() -> { - if (checkoutListener != null && checkoutListener.matchesRequest(requestId)) { + if (checkoutListener != null) { checkoutListener.invokeGeolocationCallback(allow); } }); diff --git a/platforms/react-native/modules/@shopify/checkout-kit-react-native/android/src/test/java/com/shopify/reactnative/checkoutkit/CheckoutEventSerializationTest.kt b/platforms/react-native/modules/@shopify/checkout-kit-react-native/android/src/test/java/com/shopify/reactnative/checkoutkit/CheckoutEventSerializationTest.kt index 843eae1e5..d7577d2f7 100644 --- a/platforms/react-native/modules/@shopify/checkout-kit-react-native/android/src/test/java/com/shopify/reactnative/checkoutkit/CheckoutEventSerializationTest.kt +++ b/platforms/react-native/modules/@shopify/checkout-kit-react-native/android/src/test/java/com/shopify/reactnative/checkoutkit/CheckoutEventSerializationTest.kt @@ -20,8 +20,7 @@ class CheckoutEventSerializationTest { @Test fun `serializes checkout snapshots without protocol metadata`() { - val envelope = Json.parseToJsonElement(CheckoutEventSerialization.checkout("start", "request-1", checkout)).jsonObject - assertThat(envelope["requestId"]?.jsonPrimitive?.content).isEqualTo("request-1") + val envelope = Json.parseToJsonElement(CheckoutEventSerialization.checkout("start", checkout)).jsonObject val snapshot = envelope["payload"]!!.jsonObject["checkout"]!!.jsonObject assertThat(snapshot).doesNotContainKey("ucp") assertThat(snapshot).containsKeys("line_items", "custom_extension", "policies", "actions") @@ -33,7 +32,6 @@ class CheckoutEventSerializationTest { fun `completion retains callbacks until dismissal`() { val events = mutableListOf() val listener = CustomCheckoutListener(DispatchCallback { events.add(it) }) - listener.configure("request-1") {} listener.onCheckoutStarted(CheckoutStartEvent(checkout)) listener.onCheckoutUpdated(CheckoutUpdateEvent(checkout)) listener.onCheckoutCompleted(CheckoutCompleteEvent(checkout)) @@ -44,15 +42,4 @@ class CheckoutEventSerializationTest { .containsExactly("start", "update", "complete", "dismiss") } - @Test - fun `repeated presentation updates the existing listener session`() { - val events = mutableListOf() - val listener = CustomCheckoutListener(DispatchCallback { events.add(it) }) - listener.configure("old") {} - listener.configure("new") {} - listener.onCheckoutUpdated(CheckoutUpdateEvent(checkout)) - assertThat(Json.parseToJsonElement(events.single()).jsonObject["requestId"]?.jsonPrimitive?.content).isEqualTo("new") - assertThat(listener.matchesRequest("old")).isFalse() - } - } diff --git a/platforms/react-native/modules/@shopify/checkout-kit-react-native/android/src/test/java/com/shopify/reactnative/checkoutkit/CustomCheckoutListenerTest.kt b/platforms/react-native/modules/@shopify/checkout-kit-react-native/android/src/test/java/com/shopify/reactnative/checkoutkit/CustomCheckoutListenerTest.kt index 7fb2f07d0..a349ae053 100644 --- a/platforms/react-native/modules/@shopify/checkout-kit-react-native/android/src/test/java/com/shopify/reactnative/checkoutkit/CustomCheckoutListenerTest.kt +++ b/platforms/react-native/modules/@shopify/checkout-kit-react-native/android/src/test/java/com/shopify/reactnative/checkoutkit/CustomCheckoutListenerTest.kt @@ -84,6 +84,21 @@ class CustomCheckoutListenerTest { assertThat(captured).hasSize(1) } + @Test + fun `terminal cleanup denies pending geolocation exactly once`() { + val listener = CustomCheckoutListener(DispatchCallback {}) + val decisions = mutableListOf>() + listener.onGeolocationPermissionsShowPrompt("https://example.test") { origin, allow, retain -> + decisions.add(Triple(origin, allow, retain)) + } + + listener.onCheckoutDismissed() + listener.invokeGeolocationCallback(true) + listener.release() + + assertThat(decisions).containsExactly(Triple("https://example.test", false, false)) + } + private fun payloadOf(envelope: JsonObject): JsonObject = envelope["payload"]?.jsonObject?.get("error")?.jsonObject ?: JsonObject(emptyMap()) } diff --git a/platforms/react-native/modules/@shopify/checkout-kit-react-native/ios/CheckoutEventBridge.swift b/platforms/react-native/modules/@shopify/checkout-kit-react-native/ios/CheckoutEventBridge.swift index 48c6904a9..7385ed54e 100644 --- a/platforms/react-native/modules/@shopify/checkout-kit-react-native/ios/CheckoutEventBridge.swift +++ b/platforms/react-native/modules/@shopify/checkout-kit-react-native/ios/CheckoutEventBridge.swift @@ -5,7 +5,6 @@ import ShopifyCheckoutKit /// the shared schema and preserves extension keys unchanged. struct DispatchEnvelope: Encodable { let type: String - let requestId: String? let payload: Payload } @@ -13,14 +12,14 @@ struct CheckoutEventPayload: Encodable { let checkout: Checkout } -func checkoutEventJSON(type: DispatchEventType, checkout: Checkout, requestId: String? = nil) -> String? { +func checkoutEventJSON(type: DispatchEventType, checkout: Checkout) -> String? { let encoder = JSONEncoder() encoder.dateEncodingStrategy = .custom { date, encoder in var container = encoder.singleValueContainer() try container.encode(date.ISO8601Format(Date.ISO8601FormatStyle(includingFractionalSeconds: true))) } do { - let data = try encoder.encode(DispatchEnvelope(type: type.rawValue, requestId: requestId, payload: CheckoutEventPayload(checkout: checkout))) + let data = try encoder.encode(DispatchEnvelope(type: type.rawValue, payload: CheckoutEventPayload(checkout: checkout))) return String(data: data, encoding: .utf8) } catch { NSLog("[ShopifyCheckoutKit] Failed to serialize checkout event") @@ -31,12 +30,10 @@ func checkoutEventJSON(type: DispatchEventType, checkout: Checkout, requestId: S /// A presentation retains its own delegate so queued events cannot cross sessions. @MainActor final class CheckoutEventBridge: CheckoutDelegate { - var requestId: String private var dispatch: ((String) -> Void)? private let onTerminal: (CheckoutEventBridge) -> Void - init(requestId: String, dispatch: @escaping (String) -> Void, onTerminal: @escaping (CheckoutEventBridge) -> Void) { - self.requestId = requestId + init(dispatch: @escaping (String) -> Void, onTerminal: @escaping (CheckoutEventBridge) -> Void) { self.dispatch = dispatch self.onTerminal = onTerminal } @@ -62,12 +59,12 @@ final class CheckoutEventBridge: CheckoutDelegate { } private func emit(_ type: DispatchEventType, checkout: Checkout) { - guard let json = checkoutEventJSON(type: type, checkout: checkout, requestId: requestId) else { return } + guard let json = checkoutEventJSON(type: type, checkout: checkout) else { return } dispatch?(json) } private func emit(_ type: DispatchEventType, payload: [String: Any] = [:]) { - let envelope: [String: Any] = ["type": type.rawValue, "requestId": requestId, "payload": payload] + let envelope: [String: Any] = ["type": type.rawValue, "payload": payload] guard let data = try? JSONSerialization.data(withJSONObject: envelope), let json = String(data: data, encoding: .utf8) else { return } dispatch?(json) } 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 60ad5b4be..fca2973c5 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 @@ -17,8 +17,7 @@ @interface RCT_EXTERN_MODULE (RCTShopifyCheckoutKit, NativeShopifyCheckoutKitSpe RCT_EXTERN_METHOD(setConfig:(NSDictionary *)configuration) -RCT_EXTERN_METHOD(present:(NSString *)checkoutURL - requestId:(NSString *)requestId) +RCT_EXTERN_METHOD(present:(NSString *)checkoutURL) RCT_EXTERN_METHOD(preload:(NSString *)checkoutURL requestId:(NSString *)requestId) 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 fe1c24a0d..81c6f606c 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 @@ -92,7 +92,7 @@ class RCTShopifyCheckoutKit: NSObject { DispatchQueue.main.async { let sheet = self.checkoutSheet let events = self.checkoutEvents - // Detach the closing session before a queued present can replace its ID. + // Keep the closing session separate until UIKit completes dismissal. self.checkoutSheet = nil self.checkoutEvents = nil guard let sheet else { @@ -114,18 +114,11 @@ class RCTShopifyCheckoutKit: NSObject { } } - @objc func present(_ checkoutURL: String, requestId: String) { + @objc func present(_ checkoutURL: String) { DispatchQueue.main.async { - if let sheet = self.checkoutSheet { - // Only replace callbacks while the same sheet remains active. - if !sheet.isBeingDismissed, let events = self.checkoutEvents { - events.requestId = requestId - return - } - self.closingCheckoutSheet = sheet - self.checkoutSheet = nil - } - let events = CheckoutEventBridge(requestId: requestId, dispatch: { [weak self] json in + // Preserve the active session, including a presentation waiting for dismissal. + guard self.checkoutEvents == nil else { return } + let events = CheckoutEventBridge(dispatch: { [weak self] json in self?.emitDispatchEvent(json) }, onTerminal: { [weak self] ended in guard let self, self.checkoutEvents === ended else { return } @@ -344,7 +337,7 @@ class RCTShopifyCheckoutKit: NSObject { return NSNumber(value: available) } - @objc func respondToGeolocationRequest(_: Bool, requestId _: String) { + @objc func respondToGeolocationRequest(_: Bool) { // No-op on iOS — geolocation permission is handled natively } 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 3a137583d..708d3b096 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 @@ -314,7 +314,7 @@ export interface ShopifyCheckoutKit { * @param checkoutURL The URL of the checkout to display. * @param callbacks Lifecycle callbacks. Callbacks remain * active until dismissal or failure, including after completion. Repeated calls - * while checkout is visible replace its callbacks without opening another sheet. + * while a checkout session is active are ignored, including across instances. */ present(checkoutURL: string, callbacks?: PresentCallbacks): void; /** @@ -340,7 +340,8 @@ export interface ShopifyCheckoutKit { */ getConfig(): Configuration; /** - * Cleans up any event callbacks to prevent memory leaks. + * Stops consumer callbacks and cancels pending geolocation responses. + * The native checkout remains open and tracked until its session ends. */ teardown(): void; diff --git a/platforms/react-native/modules/@shopify/checkout-kit-react-native/src/index.ts b/platforms/react-native/modules/@shopify/checkout-kit-react-native/src/index.ts index 55a82f73d..fe0bea784 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 @@ -41,8 +41,6 @@ import type { } from './components/AcceleratedCheckoutButtons'; import {preload as preloadCheckout} from './preload'; -let presentationSequence = 0; - const defaultFeatures: Features = { handleGeolocationRequests: true, }; @@ -55,6 +53,11 @@ class ShopifyCheckout implements ShopifyCheckoutKit { private dispatchSubscription?: {remove: () => void}; + private presentation?: { + callbacks?: PresentCallbacks; + cancellation: AbortController; + }; + private preloadSubscription?: CheckoutPreloadSubscription; private _acceleratedCheckoutsReady = false; @@ -98,6 +101,7 @@ class ShopifyCheckout implements ShopifyCheckoutKit { * Dismisses the currently displayed checkout sheet */ public dismiss(): void { + ShopifyCheckout.activePresentation?.cancelGeolocationRequest(); RNShopifyCheckoutKit.dismiss(); } @@ -123,25 +127,37 @@ class ShopifyCheckout implements ShopifyCheckoutKit { /** Presents checkout with lifecycle callbacks. */ public present(checkoutUrl: string, callbacks?: PresentCallbacks): void { - ShopifyCheckout.activePresentation?.releaseDispatchSubscription(); + if (ShopifyCheckout.activePresentation) { + // eslint-disable-next-line no-console + console.warn( + '[ShopifyCheckoutKit] Checkout is already presented; ignoring duplicate presentation.', + ); + return; + } let subscription: {remove: () => void} | undefined; - const requestId = `present-${++presentationSequence}`; - const {dispatcher} = createPresentDispatcher({ + const cancellation = new AbortController(); + const presentation = { callbacks, - requestId, + cancellation, + geolocationSignal: cancellation.signal, handleDefaultGeolocationRequests: this.featureEnabled( 'handleGeolocationRequests', ), handleDefaultGeolocationRequest: async () => { - const allowed = await this.requestGeolocation(); - this.respondToGeolocationRequest(allowed, requestId); + const allowed = await this.requestGeolocation().catch(() => false); + if (!cancellation.signal.aborted) + this.respondToGeolocationRequest(allowed); + }, + respondToGeolocationRequest: (allow: boolean) => { + if (!cancellation.signal.aborted) + this.respondToGeolocationRequest(allow); }, - respondToGeolocationRequest: allow => - this.respondToGeolocationRequest(allow, requestId), onTerminal: () => { if (subscription) this.releaseDispatchSubscription(subscription); }, - }); + }; + this.presentation = presentation; + const {dispatcher} = createPresentDispatcher(presentation); subscription = RNShopifyCheckoutKit.onDispatch(json => { if (subscription && this.dispatchSubscription === subscription) dispatcher(json); @@ -149,7 +165,7 @@ class ShopifyCheckout implements ShopifyCheckoutKit { this.dispatchSubscription = subscription; ShopifyCheckout.activePresentation = this; try { - RNShopifyCheckoutKit.present(checkoutUrl, requestId); + RNShopifyCheckoutKit.present(checkoutUrl); } catch (error) { this.releaseDispatchSubscription(subscription); throw error; @@ -182,7 +198,10 @@ class ShopifyCheckout implements ShopifyCheckoutKit { * Stops callbacks retained by this instance without invalidating preload. */ public teardown() { - this.releaseDispatchSubscription(); + this.cancelGeolocationRequest(); + if (this.presentation) this.presentation.callbacks = undefined; + // Keep the internal subscription until native checkout ends, so another + // instance cannot attach callbacks to a sheet that is still open. this.preloadSubscription?.remove(); this.preloadSubscription = undefined; } @@ -335,19 +354,29 @@ class ShopifyCheckout implements ShopifyCheckoutKit { if (only && this.dispatchSubscription !== only) return; const subscription = this.dispatchSubscription; this.dispatchSubscription = undefined; + this.presentation?.cancellation.abort(); + if (this.presentation) this.presentation.callbacks = undefined; + this.presentation = undefined; if (ShopifyCheckout.activePresentation === this) ShopifyCheckout.activePresentation = undefined; subscription?.remove(); } + private cancelGeolocationRequest(): void { + const presentation = this.presentation; + if (!presentation || presentation.cancellation.signal.aborted) return; + presentation.cancellation.abort(); + this.respondToGeolocationRequest(false); + } + /** * 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, requestId: string): void { + private respondToGeolocationRequest(allow: boolean): void { if (Platform.OS === 'android') { - RNShopifyCheckoutKit.respondToGeolocationRequest?.(allow, requestId); + RNShopifyCheckoutKit.respondToGeolocationRequest?.(allow); } } diff --git a/platforms/react-native/modules/@shopify/checkout-kit-react-native/src/present-dispatcher.ts b/platforms/react-native/modules/@shopify/checkout-kit-react-native/src/present-dispatcher.ts index 960cf8b85..ef7943977 100644 --- a/platforms/react-native/modules/@shopify/checkout-kit-react-native/src/present-dispatcher.ts +++ b/platforms/react-native/modules/@shopify/checkout-kit-react-native/src/present-dispatcher.ts @@ -16,7 +16,7 @@ export class LifecycleEventParseError extends Error { interface CreatePresentDispatcherOptions { callbacks?: PresentCallbacks; - requestId?: string; + geolocationSignal?: AbortSignal; handleDefaultGeolocationRequests?: boolean; handleDefaultGeolocationRequest?: () => void | Promise; respondToGeolocationRequest?: (allow: boolean) => void; @@ -45,11 +45,6 @@ function dispatchEnvelope( logParseError('envelope is missing a string `type` discriminator'); return; } - if ( - options.requestId !== undefined && - envelope.requestId !== options.requestId - ) - return; const {type, payload} = envelope; if (!isSdkLifecycleEventType(type)) { // eslint-disable-next-line no-console @@ -102,6 +97,7 @@ function dispatchEnvelope( return; } case 'geolocationRequest': + if (options.geolocationSignal?.aborted) return; if (!isPlainObject(payload) || typeof payload.origin !== 'string') { logParseError('`geolocationRequest` envelope payload is malformed'); return; 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 e9457605c..260573bab 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 @@ -56,7 +56,7 @@ export interface Spec extends TurboModule { readonly onDispatch: CodegenTypes.EventEmitter; readonly onPreloadStateChange: CodegenTypes.EventEmitter; - present(checkoutUrl: string, requestId: string): void; + present(checkoutUrl: string): void; preload(checkoutUrl: string, requestId: string): void; dismiss(): void; invalidateCache(): void; @@ -74,7 +74,7 @@ export interface Spec extends TurboModule { ): boolean; isAcceleratedCheckoutAvailable(): boolean; isApplePayAvailable(): boolean; - respondToGeolocationRequest(allow: boolean, requestId: string): void; + respondToGeolocationRequest(allow: boolean): void; addListener(eventName: string): void; removeListeners(count: number): void; getConstants(): { 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 52d2ebbd5..fd9ad78a7 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 @@ -32,6 +32,9 @@ describe('ShopifyCheckoutProvider', () => { ); afterEach(() => { + for (const [dispatch] of NativeModules.ShopifyCheckoutKit.onDispatch.mock + .calls) + dispatch(JSON.stringify({type: 'dismiss'})); __resetPreloadForTests(); jest.clearAllMocks(); }); @@ -147,6 +150,9 @@ describe('useShopifyCheckout', () => { ); afterEach(() => { + for (const [dispatch] of NativeModules.ShopifyCheckoutKit.onDispatch.mock + .calls) + dispatch(JSON.stringify({type: 'dismiss'})); __resetPreloadForTests(); jest.clearAllMocks(); }); @@ -169,7 +175,6 @@ describe('useShopifyCheckout', () => { expect(NativeModules.ShopifyCheckoutKit.present).toHaveBeenCalledWith( checkoutUrl, - expect.any(String), ); }); @@ -198,11 +203,9 @@ describe('useShopifyCheckout', () => { ); expect(NativeModules.ShopifyCheckoutKit.present).toHaveBeenCalledWith( checkoutUrl, - expect.any(String), ); }); - it('does not call present with empty checkoutUrl', () => { let hookValue: any; const onHookValue = (value: any) => { 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 3d12b1ac4..49ab1cb02 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 @@ -180,16 +180,7 @@ function lastDispatch(): Dispatch { 'Expected the last present() call to subscribe to dispatch events', ); } - const requestId = NativeModule.present.mock.calls.at(-1)?.[1]; - return json => { - let envelope; - try { - envelope = JSON.parse(json); - } catch { - return dispatch(json); - } - return dispatch(JSON.stringify({requestId, ...envelope})); - }; + return dispatch; } type PreloadDispatch = (eventJson: string) => void; @@ -201,16 +192,7 @@ function preloadDispatch(): PreloadDispatch { if (!dispatch) { throw new Error('Expected preload() to subscribe to preload state events'); } - const requestId = NativeModule.present.mock.calls.at(-1)?.[1]; - return json => { - let envelope; - try { - envelope = JSON.parse(json); - } catch { - return dispatch(json); - } - return dispatch(JSON.stringify({requestId, ...envelope})); - }; + return dispatch; } function preloadRequestId(call = 0): string { @@ -225,6 +207,9 @@ function preloadRequestId(call = 0): string { describe('ShopifyCheckoutKit', () => { afterEach(() => { + // Native dismissal completes any presentation left open by the test. + for (const [dispatch] of NativeModule.onDispatch.mock.calls) + dispatch(JSON.stringify({type: 'dismiss'})); __resetPreloadForTests(); NativeModule.setConfig.mockReset(); jest.clearAllMocks(); @@ -424,12 +409,9 @@ describe('ShopifyCheckoutKit', () => { totals: [], }; - it('passes a session ID to native', () => { + it('presents checkout without a session ID', () => { new ShopifyCheckout().present(checkoutUrl); - expect(NativeModule.present).toHaveBeenCalledWith( - checkoutUrl, - expect.any(String), - ); + expect(NativeModule.present).toHaveBeenCalledWith(checkoutUrl); }); it('keeps a reentrant presentation subscribed when the first present throws', () => { @@ -512,43 +494,55 @@ describe('ShopifyCheckoutKit', () => { expect(remove).toHaveBeenCalledTimes(1); }); - it('ignores queued events from earlier presentations', () => { + it.each([false, true])( + 'ignores duplicate presentations across instances: %s', + anotherInstance => { + const first = new ShopifyCheckout(); + const second = anotherInstance ? new ShopifyCheckout() : first; + const onDismiss = jest.fn(); + const replacementDismiss = jest.fn(); + first.present(checkoutUrl, {onDismiss}); + second.present('https://example.test/other', { + onDismiss: replacementDismiss, + }); + expect(NativeModule.present).toHaveBeenCalledTimes(1); + expect(NativeModule.onDispatch).toHaveBeenCalledTimes(1); + lastDispatch()(JSON.stringify({type: 'dismiss'})); + expect(onDismiss).toHaveBeenCalledTimes(1); + expect(replacementDismiss).not.toHaveBeenCalled(); + }, + ); + + it('ignores queued events from a finished presentation after reopening', () => { const instance = new ShopifyCheckout(); - const first = jest.fn(); - const second = jest.fn(); - instance.present(checkoutUrl, {onDismiss: first}); + instance.present(checkoutUrl); const oldDispatch = lastDispatch(); - const oldId = NativeModule.present.mock.calls.at(-1)[1]; - instance.present(checkoutUrl, {onDismiss: second}); oldDispatch(JSON.stringify({type: 'dismiss'})); - lastDispatch()(JSON.stringify({requestId: oldId, type: 'dismiss'})); - expect(first).not.toHaveBeenCalled(); - expect(second).not.toHaveBeenCalled(); + const onDismiss = jest.fn(); + instance.present(checkoutUrl, {onDismiss}); + oldDispatch(JSON.stringify({type: 'dismiss'})); + expect(onDismiss).not.toHaveBeenCalled(); lastDispatch()(JSON.stringify({type: 'dismiss'})); - expect(second).toHaveBeenCalledTimes(1); + expect(onDismiss).toHaveBeenCalledTimes(1); }); - it('releases the previous instance without letting its teardown affect the new owner', () => { + it('keeps session ownership after teardown until native dismissal', () => { const first = new ShopifyCheckout(); const second = new ShopifyCheckout(); - const firstRemove = jest.fn(); - const secondRemove = jest.fn(); - const firstDismiss = jest.fn(); - const secondDismiss = jest.fn(); - NativeModule.onDispatch - .mockReturnValueOnce({remove: firstRemove}) - .mockReturnValueOnce({remove: secondRemove}); - first.present(checkoutUrl, {onDismiss: firstDismiss}); - const oldDispatch = lastDispatch(); - second.present(checkoutUrl, {onDismiss: secondDismiss}); - expect(firstRemove).toHaveBeenCalledTimes(1); + const onDismiss = jest.fn(); + const onUpdate = jest.fn(); + first.present(checkoutUrl, {onDismiss, onUpdate}); first.teardown(); - oldDispatch(JSON.stringify({type: 'dismiss'})); - expect(firstDismiss).not.toHaveBeenCalled(); - expect(secondRemove).not.toHaveBeenCalled(); + second.present(checkoutUrl); + expect(NativeModule.present).toHaveBeenCalledTimes(1); + lastDispatch()(JSON.stringify({type: 'update', payload: {checkout}})); lastDispatch()(JSON.stringify({type: 'dismiss'})); - expect(secondDismiss).toHaveBeenCalledTimes(1); - expect(secondRemove).toHaveBeenCalledTimes(1); + expect(onDismiss).not.toHaveBeenCalled(); + expect(onUpdate).not.toHaveBeenCalled(); + second.present(checkoutUrl, {onDismiss}); + first.teardown(); + lastDispatch()(JSON.stringify({type: 'dismiss'})); + expect(onDismiss).toHaveBeenCalledTimes(1); }); it.each(['dismiss', 'fail'])( @@ -720,10 +714,7 @@ describe('ShopifyCheckoutKit', () => { it('subscribes to dispatch events when the default handler is enabled, even without callbacks', () => { const instance = new ShopifyCheckout(); instance.present(checkoutUrl); - expect(NativeModule.present).toHaveBeenCalledWith( - checkoutUrl, - expect.any(String), - ); + expect(NativeModule.present).toHaveBeenCalledWith(checkoutUrl); expect(NativeModule.onDispatch).toHaveBeenCalledWith( expect.any(Function), ); @@ -734,10 +725,7 @@ describe('ShopifyCheckoutKit', () => { handleGeolocationRequests: false, }); instance.present(checkoutUrl); - expect(NativeModule.present).toHaveBeenCalledWith( - checkoutUrl, - expect.any(String), - ); + expect(NativeModule.present).toHaveBeenCalledWith(checkoutUrl); }); it('handles geolocation permission grant correctly', async () => { @@ -763,7 +751,6 @@ describe('ShopifyCheckoutKit', () => { ]); expect(NativeModule.respondToGeolocationRequest).toHaveBeenCalledWith( true, - expect.any(String), ); }); @@ -790,7 +777,6 @@ describe('ShopifyCheckoutKit', () => { ]); expect(NativeModule.respondToGeolocationRequest).toHaveBeenCalledWith( false, - expect.any(String), ); }); @@ -820,10 +806,85 @@ describe('ShopifyCheckoutKit', () => { expect(NativeModule.respondToGeolocationRequest).toHaveBeenCalledWith( true, - expect.any(String), ); }); + it.each(['dismiss', 'fail', 'teardown', 'programmatic'])( + 'ignores a permission result after %s', + async close => { + let resolve!: (permissions: Record) => void; + (PermissionsAndroid.requestMultiple as jest.Mock).mockReturnValueOnce( + new Promise(result => { + resolve = result; + }), + ); + const instance = new ShopifyCheckout(); + instance.present(checkoutUrl); + lastDispatch()(geolocationEnvelope); + if (close === 'teardown') instance.teardown(); + else if (close === 'programmatic') instance.dismiss(); + else + lastDispatch()( + JSON.stringify({ + type: close, + payload: {error: {code: 'sdk_error', message: 'closed'}}, + }), + ); + if (close === 'dismiss' || close === 'fail') + instance.present(checkoutUrl); + NativeModule.respondToGeolocationRequest.mockClear(); + resolve({'android.permission.ACCESS_FINE_LOCATION': 'granted'}); + await flush(); + expect( + NativeModule.respondToGeolocationRequest, + ).not.toHaveBeenCalled(); + }, + ); + + it.each(['dismiss', 'fail', 'teardown', 'programmatic'])( + 'invalidates a saved response after %s', + close => { + const instance = new ShopifyCheckout(); + const onGeolocationRequest = jest.fn(); + instance.present(checkoutUrl, {onGeolocationRequest}); + lastDispatch()(geolocationEnvelope); + const {respond} = onGeolocationRequest.mock.calls[0][0]; + if (close === 'teardown') instance.teardown(); + else if (close === 'programmatic') instance.dismiss(); + else + lastDispatch()( + JSON.stringify({ + type: close, + payload: {error: {code: 'sdk_error', message: 'closed'}}, + }), + ); + if (close === 'dismiss' || close === 'fail') + instance.present(checkoutUrl); + NativeModule.respondToGeolocationRequest.mockClear(); + respond(true); + expect( + NativeModule.respondToGeolocationRequest, + ).not.toHaveBeenCalled(); + }, + ); + + it('ignores a permission rejection while programmatic dismissal is pending', async () => { + let reject!: (error: Error) => void; + (PermissionsAndroid.requestMultiple as jest.Mock).mockReturnValueOnce( + new Promise((_, fail) => { + reject = fail; + }), + ); + const instance = new ShopifyCheckout(); + instance.present(checkoutUrl); + lastDispatch()(geolocationEnvelope); + instance.dismiss(); + NativeModule.respondToGeolocationRequest.mockClear(); + reject(new Error('Permission activity closed')); + await flush(); + expect(NativeModule.respondToGeolocationRequest).not.toHaveBeenCalled(); + }); + it('does not run the default handler when the feature is disabled', async () => { const instance = new ShopifyCheckout(undefined, { handleGeolocationRequests: false, @@ -851,10 +912,7 @@ describe('ShopifyCheckoutKit', () => { it('presents with the default link policy on iOS', () => { const instance = new ShopifyCheckout(); instance.present(checkoutUrl); - expect(NativeModule.present).toHaveBeenCalledWith( - checkoutUrl, - expect.any(String), - ); + expect(NativeModule.present).toHaveBeenCalledWith(checkoutUrl); }); it('does not run the default geolocation handler on iOS even if dispatcher fires', async () => { diff --git a/platforms/react-native/sample/android/app/src/test/java/com/shopify/checkoutkit/reactnativedemo/ShopifyCheckoutKitModuleTest.java b/platforms/react-native/sample/android/app/src/test/java/com/shopify/checkoutkit/reactnativedemo/ShopifyCheckoutKitModuleTest.java index 11c2b6ef6..77800ff1b 100644 --- a/platforms/react-native/sample/android/app/src/test/java/com/shopify/checkoutkit/reactnativedemo/ShopifyCheckoutKitModuleTest.java +++ b/platforms/react-native/sample/android/app/src/test/java/com/shopify/checkoutkit/reactnativedemo/ShopifyCheckoutKitModuleTest.java @@ -157,7 +157,7 @@ public void testCanPresentCheckout() { String checkoutUrl = "https://shopify.com"; mockedShopifyCheckoutKit.when(() -> ShopifyCheckoutKit.present( eq(checkoutUrl), any(), any())).thenReturn(mock(CheckoutHandle.class)); - shopifyCheckoutKitModule.present(checkoutUrl, "present-request"); + shopifyCheckoutKitModule.present(checkoutUrl); verify(mockComponentActivity).runOnUiThread(runnableCaptor.capture()); runnableCaptor.getValue().run(); @@ -167,6 +167,26 @@ public void testCanPresentCheckout() { } } + @Test + public void testDuplicatePresentationPreservesOriginalListener() { + doAnswer(invocation -> { + ((Runnable) invocation.getArgument(0)).run(); + return null; + }).when(mockComponentActivity).runOnUiThread(any()); + try (MockedStatic nativeKit = Mockito.mockStatic(ShopifyCheckoutKit.class)) { + CheckoutHandle sheet = mock(CheckoutHandle.class); + ArgumentCaptor listeners = ArgumentCaptor.forClass(CustomCheckoutListener.class); + nativeKit.when(() -> ShopifyCheckoutKit.present(anyString(), eq(mockComponentActivity), any())) + .thenReturn(sheet); + shopifyCheckoutKitModule.present("https://example.test/first"); + nativeKit.verify(() -> ShopifyCheckoutKit.present(eq("https://example.test/first"), eq(mockComponentActivity), listeners.capture())); + shopifyCheckoutKitModule.present("https://example.test/second"); + nativeKit.verifyNoMoreInteractions(); + listeners.getValue().onCheckoutDismissed(); + assertThat(shopifyCheckoutKitModule.dispatchEvents).hasSize(1); + } + } + @Test public void testProgrammaticDismissReleasesCallbacksAndAllowsAnotherPresentation() { assertCanPresentAfterProgrammaticDismiss(false); @@ -191,7 +211,7 @@ private void assertCanPresentAfterProgrammaticDismiss(boolean detachActivity) { nativeKit.when(() -> ShopifyCheckoutKit.present(anyString(), eq(mockComponentActivity), any())) .thenReturn(firstSheet, secondSheet); - shopifyCheckoutKitModule.present("https://example.com/first", "first-request"); + shopifyCheckoutKitModule.present("https://example.com/first"); nativeKit.verify(() -> ShopifyCheckoutKit.present( eq("https://example.com/first"), eq(mockComponentActivity), listeners.capture())); CustomCheckoutListener firstListener = listeners.getValue(); @@ -204,15 +224,14 @@ private void assertCanPresentAfterProgrammaticDismiss(boolean detachActivity) { assertThat(firstListener.isReleased()).isTrue(); assertThat(shopifyCheckoutKitModule.dispatchEvents).hasSize(1); assertThat(shopifyCheckoutKitModule.dispatchEvents.get(0)) - .contains("\"type\":\"dismiss\"", "\"requestId\":\"first-request\""); + .contains("\"type\":\"dismiss\""); shopifyCheckoutKitModule.dismiss(); shadowOf(Looper.getMainLooper()).idle(); when(mockReactContext.getCurrentActivity()).thenReturn(mockComponentActivity); - shopifyCheckoutKitModule.present("https://example.com/second", "second-request"); + shopifyCheckoutKitModule.present("https://example.com/second"); nativeKit.verify(() -> ShopifyCheckoutKit.present( eq("https://example.com/second"), eq(mockComponentActivity), listeners.capture())); - assertThat(listeners.getValue().matchesRequest("second-request")).isTrue(); assertThat(listeners.getValue().isReleased()).isFalse(); firstListener.onCheckoutDismissed(); @@ -249,12 +268,12 @@ private void assertCanPresentFromTerminalCallback(boolean fail, boolean cancelPe ArgumentCaptor listeners = ArgumentCaptor.forClass(CustomCheckoutListener.class); nativeKit.when(() -> ShopifyCheckoutKit.present(anyString(), eq(mockComponentActivity), any())) .thenReturn(firstSheet, firstSheet, firstSheet, secondSheet); - shopifyCheckoutKitModule.present("https://example.com/first", "first-request"); + shopifyCheckoutKitModule.present("https://example.com/first"); nativeKit.verify(() -> ShopifyCheckoutKit.present( eq("https://example.com/first"), eq(mockComponentActivity), listeners.capture())); shopifyCheckoutKitModule.onDispatch = event -> { shopifyCheckoutKitModule.onDispatch = null; - shopifyCheckoutKitModule.present("https://example.com/second", "second-request"); + shopifyCheckoutKitModule.present("https://example.com/second"); }; if (fail) listeners.getValue().onCheckoutFailed(new CheckoutFailureEvent(cartExpired())); @@ -267,14 +286,13 @@ private void assertCanPresentFromTerminalCallback(boolean fail, boolean cancelPe eq("https://example.com/second"), eq(mockComponentActivity), listeners.capture()), times(cancelPending ? 1 : 3)); CustomCheckoutListener secondListener = listeners.getValue(); - assertThat(secondListener.matchesRequest("second-request")).isTrue(); assertThat(secondListener.isReleased()).isEqualTo(cancelPending); if (!cancelPending) { secondListener.onCheckoutDismissed(); } assertThat(shopifyCheckoutKitModule.dispatchEvents).hasSize(2); assertThat(shopifyCheckoutKitModule.dispatchEvents.get(1)) - .contains("\"type\":\"dismiss\"", "\"requestId\":\"second-request\""); + .contains("\"type\":\"dismiss\""); verifyNoInteractions(secondSheet); } } @@ -282,12 +300,12 @@ private void assertCanPresentFromTerminalCallback(boolean fail, boolean cancelPe @Test public void testInvalidationCancelsQueuedPresentation() { try (MockedStatic nativeKit = Mockito.mockStatic(ShopifyCheckoutKit.class)) { - shopifyCheckoutKitModule.present("https://example.com/checkout", "queued"); + shopifyCheckoutKitModule.present("https://example.com/checkout"); verify(mockComponentActivity).runOnUiThread(runnableCaptor.capture()); shopifyCheckoutKitModule.invalidate(); runnableCaptor.getValue().run(); shadowOf(Looper.getMainLooper()).idle(); - shopifyCheckoutKitModule.present("https://example.com/checkout", "after-invalidation"); + shopifyCheckoutKitModule.present("https://example.com/checkout"); nativeKit.verifyNoInteractions(); assertThat(shopifyCheckoutKitModule.dispatchEvents).isEmpty(); } @@ -312,10 +330,10 @@ private void assertStopsWaitingForClosingHandle(boolean invalidate) { CheckoutHandle closing = mock(CheckoutHandle.class); nativeKit.when(() -> ShopifyCheckoutKit.present(anyString(), eq(mockComponentActivity), any())) .thenReturn(closing); - shopifyCheckoutKitModule.present("https://example.com/first", "first"); + shopifyCheckoutKitModule.present("https://example.com/first"); shopifyCheckoutKitModule.dismiss(); shadowOf(Looper.getMainLooper()).idle(); - shopifyCheckoutKitModule.present("https://example.com/second", "second"); + shopifyCheckoutKitModule.present("https://example.com/second"); if (invalidate) shopifyCheckoutKitModule.invalidate(); shadowOf(Looper.getMainLooper()).idleFor(Duration.ofSeconds(6)); if (invalidate) { @@ -323,7 +341,7 @@ private void assertStopsWaitingForClosingHandle(boolean invalidate) { } else { assertThat(shopifyCheckoutKitModule.dispatchEvents).hasSize(2); assertThat(shopifyCheckoutKitModule.dispatchEvents.get(1)) - .contains("\"type\":\"fail\"", "\"requestId\":\"second\"", "sdk_error"); + .contains("\"type\":\"fail\"", "sdk_error"); } nativeKit.clearInvocations(); shadowOf(Looper.getMainLooper()).idleFor(Duration.ofSeconds(6)); @@ -331,7 +349,7 @@ private void assertStopsWaitingForClosingHandle(boolean invalidate) { if (!invalidate) { nativeKit.when(() -> ShopifyCheckoutKit.present(anyString(), eq(mockComponentActivity), any())) .thenReturn(mock(CheckoutHandle.class)); - shopifyCheckoutKitModule.present("https://example.com/third", "third"); + shopifyCheckoutKitModule.present("https://example.com/third"); nativeKit.verify(() -> ShopifyCheckoutKit.present(eq("https://example.com/third"), eq(mockComponentActivity), any())); } } diff --git a/platforms/react-native/test/rct-integration-app/RCTIntegrationAppTests/CheckoutEventBridgeTests.swift b/platforms/react-native/test/rct-integration-app/RCTIntegrationAppTests/CheckoutEventBridgeTests.swift index 0a282d2c2..d25648252 100644 --- a/platforms/react-native/test/rct-integration-app/RCTIntegrationAppTests/CheckoutEventBridgeTests.swift +++ b/platforms/react-native/test/rct-integration-app/RCTIntegrationAppTests/CheckoutEventBridgeTests.swift @@ -16,10 +16,9 @@ final class CheckoutEventBridgeTests: XCTestCase { } func testSnapshotPreservesWireFieldsAndExtensionsWithoutMetadata() throws { - let json = try XCTUnwrap(checkoutEventJSON(type: .start, checkout: checkout(), requestId: "request-1")) + let json = try XCTUnwrap(checkoutEventJSON(type: .start, checkout: checkout())) let envelope = try parse(json) XCTAssertEqual(envelope["type"] as? String, "start") - XCTAssertEqual(envelope["requestId"] as? String, "request-1") let payload = try XCTUnwrap(envelope["payload"] as? [String: Any]) let checkout = try XCTUnwrap(payload["checkout"] as? [String: Any]) XCTAssertNil(checkout["ucp"]) @@ -33,7 +32,7 @@ final class CheckoutEventBridgeTests: XCTestCase { func testCompletionDoesNotReleaseCallbacks() throws { var events: [String] = [] var terminalCount = 0 - let bridge = CheckoutEventBridge(requestId: "request-1", dispatch: { events.append($0) }, onTerminal: { _ in terminalCount += 1 }) + let bridge = CheckoutEventBridge(dispatch: { events.append($0) }, onTerminal: { _ in terminalCount += 1 }) let checkout = try checkout() bridge.checkoutDidStart(CheckoutStartEvent(checkout: checkout)) bridge.checkoutDidUpdate(CheckoutUpdateEvent(checkout: checkout)) @@ -48,7 +47,7 @@ final class CheckoutEventBridgeTests: XCTestCase { func testFailureUsesErrorEventAndReleasesCallbacks() throws { var events: [String] = [] - let bridge = CheckoutEventBridge(requestId: "request-1", dispatch: { events.append($0) }, onTerminal: { _ in }) + let bridge = CheckoutEventBridge(dispatch: { events.append($0) }, onTerminal: { _ in }) bridge.checkoutDidFail(CheckoutFailureEvent(error: CheckoutError(code: .sdkError, message: "Failed"))) bridge.checkoutDidDismiss() let envelope = try parse(XCTUnwrap(events.first)) @@ -58,14 +57,6 @@ final class CheckoutEventBridgeTests: XCTestCase { XCTAssertEqual(events.count, 1) } - func testReplacingCallbacksRetainsTheNativeSession() throws { - var events: [String] = [] - let bridge = CheckoutEventBridge(requestId: "old", dispatch: { events.append($0) }, onTerminal: { _ in }) - bridge.requestId = "new" - try bridge.checkoutDidUpdate(CheckoutUpdateEvent(checkout: checkout())) - XCTAssertEqual(try parse(XCTUnwrap(events.first))["requestId"] as? String, "new") - } - private func parse(_ json: String) throws -> [String: Any] { try XCTUnwrap(JSONSerialization.jsonObject(with: Data(json.utf8)) as? [String: Any]) } diff --git a/platforms/react-native/test/rct-integration-app/RCTIntegrationAppTests/ShopifyCheckoutKitTests.swift b/platforms/react-native/test/rct-integration-app/RCTIntegrationAppTests/ShopifyCheckoutKitTests.swift index a0316534c..d89c8a5b5 100644 --- a/platforms/react-native/test/rct-integration-app/RCTIntegrationAppTests/ShopifyCheckoutKitTests.swift +++ b/platforms/react-native/test/rct-integration-app/RCTIntegrationAppTests/ShopifyCheckoutKitTests.swift @@ -487,7 +487,7 @@ class ShopifyCheckoutKitTests: XCTestCase { func testFailedPresentDoesNotRetainCheckoutSheet() { let presentAttemptCompleted = expectation(description: "present attempt completed") - shopifyCheckoutKit.present("", requestId: "test") + shopifyCheckoutKit.present("") DispatchQueue.main.async { XCTAssertNil(self.shopifyCheckoutKit.checkoutSheet) @@ -530,25 +530,22 @@ extension ShopifyCheckoutKitTests { @MainActor func testPresentDuringProgrammaticDismissWaitsForTheOldSheet() async { let module = PresentationTrackingModule() - module.present("https://example.test/first", requestId: "first") + module.present("https://example.test/first") await flushPresentationQueue() let oldSheet = module.sheets[0] let oldEvents = module.delegates[0] module.dismiss() - module.present("https://example.test/second", requestId: "second") + module.present("https://example.test/second") await flushPresentationQueue() XCTAssertEqual(module.urls.count, 1) - XCTAssertEqual(oldEvents.requestId, "first") let reopened = expectation(description: "new checkout presented after dismissal") module.onPresent = { reopened.fulfill() } oldSheet.finishDismissal() await fulfillment(of: [reopened], timeout: 1) XCTAssertEqual(module.urls.last?.absoluteString, "https://example.test/second") - XCTAssertTrue(module.events[0].contains("\"requestId\":\"first\"")) oldEvents.checkoutDidDismiss() XCTAssertEqual(module.events.count, 1) - XCTAssertEqual(module.delegates.last?.requestId, "second") } @MainActor @@ -564,12 +561,12 @@ extension ShopifyCheckoutKitTests { @MainActor private func assertPresentFromTerminalWaits(fail: Bool) async { let module = PresentationTrackingModule() - module.present("https://example.test/first", requestId: "first") + module.present("https://example.test/first") await flushPresentationQueue() let oldSheet = module.sheets[0] module.onEvent = { module.onEvent = nil - module.present("https://example.test/second", requestId: "second") + module.present("https://example.test/second") } if fail { module.delegates[0].checkoutDidFail(CheckoutFailureEvent(error: CheckoutError(code: .sdkError, message: "Failed"))) @@ -583,16 +580,15 @@ extension ShopifyCheckoutKitTests { oldSheet.finishDismissal() await fulfillment(of: [reopened], timeout: 1) XCTAssertEqual(module.urls.last?.absoluteString, "https://example.test/second") - XCTAssertEqual(module.delegates.last?.requestId, "second") } @MainActor func testDismissCancelsAnIOSPresentationWaitingForTheOldSheet() async { let module = PresentationTrackingModule() - module.present("https://example.test/first", requestId: "first") + module.present("https://example.test/first") await flushPresentationQueue() module.dismiss() - module.present("https://example.test/second", requestId: "second") + module.present("https://example.test/second") await flushPresentationQueue() module.dismiss() await flushPresentationQueue() @@ -602,18 +598,20 @@ extension ShopifyCheckoutKitTests { module.sheets[0].finishDismissal() await fulfillment(of: [reopened], timeout: 0.05) XCTAssertEqual(module.events.count, 2) - XCTAssertTrue(module.events.contains { $0.contains("\"requestId\":\"second\"") }) } @MainActor - func testPresentWhileActiveOnlyReplacesCallbacks() async { + func testPresentWhileActivePreservesOriginalDelegate() async { let module = PresentationTrackingModule() - module.present("https://example.test/first", requestId: "first") + module.present("https://example.test/first") await flushPresentationQueue() - module.present("https://example.test/second", requestId: "second") + let originalDelegate = module.delegates[0] + module.present("https://example.test/second") await flushPresentationQueue() XCTAssertEqual(module.urls.count, 1) - XCTAssertEqual(module.delegates[0].requestId, "second") + XCTAssertTrue(module.delegates[0] === originalDelegate) + originalDelegate.checkoutDidDismiss() + XCTAssertEqual(module.events.count, 1) } @MainActor From 46c67c14b69f4295af99a3d3feef57f73af19882 Mon Sep 17 00:00:00 2001 From: Mark Murray Date: Thu, 8 Oct 2026 12:26:05 +0100 Subject: [PATCH 6/7] Ignore checkout presentation attempts while closing --- platforms/react-native/README.md | 7 +- .../checkoutkit/ShopifyCheckoutKitModule.java | 56 ++++---- .../ios/ShopifyCheckoutKit.mm | 3 +- .../ios/ShopifyCheckoutKit.swift | 43 +++--- .../checkout-kit-react-native/src/index.d.ts | 3 +- .../checkout-kit-react-native/src/index.ts | 7 +- .../src/specs/NativeShopifyCheckoutKit.ts | 2 +- .../tests/context.test.tsx | 2 + .../tests/index.test.ts | 44 +++++- .../ShopifyCheckoutKitModuleTest.java | 133 ++++++++---------- .../ShopifyCheckoutKitTests.swift | 73 +++++----- 11 files changed, 199 insertions(+), 174 deletions(-) diff --git a/platforms/react-native/README.md b/platforms/react-native/README.md index 3adb7a252..0edd8bf7d 100644 --- a/platforms/react-native/README.md +++ b/platforms/react-native/README.md @@ -825,7 +825,12 @@ until dismissal or failure. Calling `dismiss()` also delivers `onDismiss`. Repeated `present()` calls while a checkout session is active are ignored, including calls from another `ShopifyCheckout` instance. The original checkout -and callbacks remain active. `teardown()` stops consumer callbacks and cancels +and callbacks remain active. Calls made while the previous sheet is closing are +also ignored, without firing callbacks for the ignored attempt. `onDismiss` and +`onFail` can run before the closing animation finishes, so presenting from those +callbacks is not guaranteed to open another checkout. + +`teardown()` stops consumer callbacks and cancels pending geolocation responses without dismissing the sheet; another checkout can be presented once the native session ends. 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 797f05ac5..39dae4f8e 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 @@ -1,12 +1,12 @@ package com.shopify.reactnative.checkoutkit; import android.app.Activity; -import android.os.SystemClock; import androidx.activity.ComponentActivity; import com.facebook.react.bridge.ReactApplicationContext; import com.facebook.react.bridge.ReactMethod; import com.facebook.react.bridge.UiThreadUtil; import com.facebook.react.bridge.Arguments; +import com.facebook.react.bridge.Callback; import com.facebook.react.bridge.ReadableArray; import com.facebook.react.bridge.ReadableMap; import com.facebook.react.bridge.WritableMap; @@ -33,8 +33,6 @@ public class ShopifyCheckoutKitModule extends NativeShopifyCheckoutKitSpec { public static Configuration checkoutConfig = new Configuration(); - private static final long PRESENTATION_TIMEOUT_MS = 5000; - private volatile boolean invalidated; private CheckoutHandle checkoutSheet; @@ -83,45 +81,47 @@ public void removeListeners(double count) { } @ReactMethod - public void present(String checkoutURL) { - if (invalidated) return; + public void present(String checkoutURL, Callback onResult) { + if (invalidated) { + onResult.invoke(false); + return; + } Activity currentActivity = getReactApplicationContext().getCurrentActivity(); if (currentActivity instanceof ComponentActivity) { currentActivity.runOnUiThread(() -> { - if (invalidated) return; // Ignore duplicate calls without replacing the active listener or policy. - if (checkoutListener != null && !checkoutListener.isReleased()) return; + if (invalidated || (checkoutListener != null && !checkoutListener.isReleased())) { + onResult.invoke(false); + return; + } CustomCheckoutListener listener = new CustomCheckoutListener(this::emitDispatchEvent); checkoutListener = listener; listener.setOnTerminal(this::finishCheckoutPresentation); - presentCheckout(checkoutURL, (ComponentActivity) currentActivity, listener, SystemClock.uptimeMillis() + PRESENTATION_TIMEOUT_MS); + CheckoutHandle sheet = ShopifyCheckoutKit.present(checkoutURL, (ComponentActivity) currentActivity, listener); + // Initialization can fail synchronously and already emit a terminal event. + if (checkoutListener != listener) { + onResult.invoke(true); + return; + } + if (sheet != null && closingCheckoutSheet != null && sheet == closingCheckoutSheet.get()) { + // The SDK returns the closing handle without adopting this listener. + // Keep the old handle so another explicit attempt can check it again. + releaseCheckoutListener(); + onResult.invoke(false); + return; + } + closingCheckoutSheet = null; + checkoutSheet = sheet; + if (sheet == null) listener.onCheckoutDismissed(); + onResult.invoke(true); }); } else { CustomCheckoutListener listener = new CustomCheckoutListener(this::emitDispatchEvent); listener.onCheckoutDismissed(); + onResult.invoke(true); } } - private void presentCheckout(String checkoutURL, ComponentActivity activity, CustomCheckoutListener listener, long deadline) { - if (invalidated || checkoutListener != listener) return; - CheckoutHandle sheet = ShopifyCheckoutKit.present(checkoutURL, activity, listener); - if (invalidated || checkoutListener != listener) return; - if (sheet != null && closingCheckoutSheet != null && sheet == closingCheckoutSheet.get()) { - // During the close animation the SDK returns the old handle without adopting - // this listener. Bound the wait so a stuck native handle cannot retain callbacks forever. - if (SystemClock.uptimeMillis() >= deadline) { - listener.onCheckoutFailed(new CheckoutFailureEvent(new CheckoutException( - CheckoutErrorCode.SDK_ERROR, "Timed out waiting for the previous checkout to close"))); - return; - } - UiThreadUtil.runOnUiThread(() -> presentCheckout(checkoutURL, activity, listener, deadline), 16); - return; - } - closingCheckoutSheet = null; - checkoutSheet = sheet; - if (sheet == null) listener.onCheckoutDismissed(); - } - private void finishCheckoutPresentation() { if (checkoutSheet != null) closingCheckoutSheet = new WeakReference<>(checkoutSheet); checkoutSheet = null; 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 fca2973c5..e07cb613b 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 @@ -17,7 +17,8 @@ @interface RCT_EXTERN_MODULE (RCTShopifyCheckoutKit, NativeShopifyCheckoutKitSpe RCT_EXTERN_METHOD(setConfig:(NSDictionary *)configuration) -RCT_EXTERN_METHOD(present:(NSString *)checkoutURL) +RCT_EXTERN_METHOD(present:(NSString *)checkoutURL + onResult:(RCTResponseSenderBlock)onResult) RCT_EXTERN_METHOD(preload:(NSString *)checkoutURL requestId:(NSString *)requestId) 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 81c6f606c..8a70892bb 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 @@ -114,10 +114,20 @@ class RCTShopifyCheckoutKit: NSObject { } } - @objc func present(_ checkoutURL: String) { + @objc func present(_ checkoutURL: String, onResult: @escaping RCTResponseSenderBlock) { DispatchQueue.main.async { - // Preserve the active session, including a presentation waiting for dismissal. - guard self.checkoutEvents == nil else { return } + // Preserve the active session and ignore attempts during dismissal. + guard self.checkoutEvents == nil else { + onResult([false]) + return + } + if let closing = self.closingCheckoutSheet, + closing.presentingViewController != nil || closing.isBeingDismissed || closing.isBeingPresented + { + onResult([false]) + return + } + self.closingCheckoutSheet = nil let events = CheckoutEventBridge(dispatch: { [weak self] json in self?.emitDispatchEvent(json) }, onTerminal: { [weak self] ended in @@ -128,31 +138,14 @@ class RCTShopifyCheckoutKit: NSObject { self.checkoutSheet = nil }) self.checkoutEvents = events - self.presentWhenReady(checkoutURL, events: events, deadline: ProcessInfo.processInfo.systemUptime + 5) - } - } - - @MainActor - private func presentWhenReady(_ checkoutURL: String, events: CheckoutEventBridge, deadline: TimeInterval) { - guard checkoutEvents === events else { return } - if let closing = closingCheckoutSheet, - closing.presentingViewController != nil || closing.isBeingDismissed || closing.isBeingPresented - { - guard ProcessInfo.processInfo.systemUptime < deadline else { - events.checkoutDidFail(CheckoutFailureEvent(error: CheckoutError(code: .sdkError, message: "Timed out waiting for the previous checkout to close"))) + guard let url = URL(string: checkoutURL), let viewController = self.getCurrentViewController() else { + events.checkoutDidDismiss() + onResult([true]) return } - DispatchQueue.main.asyncAfter(deadline: .now() + 0.016) { [weak self] in - self?.presentWhenReady(checkoutURL, events: events, deadline: deadline) - } - return - } - closingCheckoutSheet = nil - guard let url = URL(string: checkoutURL), let viewController = getCurrentViewController() else { - events.checkoutDidDismiss() - return + self.checkoutSheet = self.presentCheckout(url, from: viewController, delegate: events) + onResult([true]) } - checkoutSheet = presentCheckout(url, from: viewController, delegate: events) } @MainActor 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 708d3b096..29beab552 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 @@ -314,7 +314,8 @@ export interface ShopifyCheckoutKit { * @param checkoutURL The URL of the checkout to display. * @param callbacks Lifecycle callbacks. Callbacks remain * active until dismissal or failure, including after completion. Repeated calls - * while a checkout session is active are ignored, including across instances. + * while checkout is active or closing are ignored, including across instances. + * Ignored attempts do not fire callbacks. */ present(checkoutURL: string, callbacks?: PresentCallbacks): void; /** diff --git a/platforms/react-native/modules/@shopify/checkout-kit-react-native/src/index.ts b/platforms/react-native/modules/@shopify/checkout-kit-react-native/src/index.ts index fe0bea784..7e887bacb 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 @@ -165,7 +165,12 @@ class ShopifyCheckout implements ShopifyCheckoutKit { this.dispatchSubscription = subscription; ShopifyCheckout.activePresentation = this; try { - RNShopifyCheckoutKit.present(checkoutUrl); + RNShopifyCheckoutKit.present(checkoutUrl, accepted => { + // Native can ignore the attempt while the previous sheet is closing. + // Release only this attempt, without reporting a checkout dismissal. + if (!accepted && subscription) + this.releaseDispatchSubscription(subscription); + }); } catch (error) { this.releaseDispatchSubscription(subscription); throw 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 260573bab..4582f4eff 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 @@ -56,7 +56,7 @@ export interface Spec extends TurboModule { readonly onDispatch: CodegenTypes.EventEmitter; readonly onPreloadStateChange: CodegenTypes.EventEmitter; - present(checkoutUrl: string): void; + present(checkoutUrl: string, onResult: (accepted: boolean) => void): void; preload(checkoutUrl: string, requestId: string): void; dismiss(): void; invalidateCache(): void; diff --git a/platforms/react-native/modules/@shopify/checkout-kit-react-native/tests/context.test.tsx b/platforms/react-native/modules/@shopify/checkout-kit-react-native/tests/context.test.tsx index fd9ad78a7..66cd7ecc3 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 @@ -175,6 +175,7 @@ describe('useShopifyCheckout', () => { expect(NativeModules.ShopifyCheckoutKit.present).toHaveBeenCalledWith( checkoutUrl, + expect.any(Function), ); }); @@ -203,6 +204,7 @@ describe('useShopifyCheckout', () => { ); expect(NativeModules.ShopifyCheckoutKit.present).toHaveBeenCalledWith( checkoutUrl, + 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 49ab1cb02..0c1cf843e 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 @@ -411,7 +411,7 @@ describe('ShopifyCheckoutKit', () => { it('presents checkout without a session ID', () => { new ShopifyCheckout().present(checkoutUrl); - expect(NativeModule.present).toHaveBeenCalledWith(checkoutUrl); + expect(NativeModule.present).toHaveBeenCalledWith(checkoutUrl, expect.any(Function)); }); it('keeps a reentrant presentation subscribed when the first present throws', () => { @@ -439,6 +439,40 @@ describe('ShopifyCheckoutKit', () => { expect(secondDismiss).toHaveBeenCalledTimes(1); }); + it('silently releases an attempt ignored by native and allows a later attempt', () => { + const remove = jest.fn(); + NativeModule.onDispatch.mockReturnValueOnce({remove}); + const instance = new ShopifyCheckout(); + const onDismiss = jest.fn(); + const onFail = jest.fn(); + instance.present(checkoutUrl, {onDismiss, onFail}); + const rejectedDispatch = lastDispatch(); + NativeModule.present.mock.calls[0][1](false); + expect(remove).toHaveBeenCalledTimes(1); + expect(onDismiss).not.toHaveBeenCalled(); + expect(onFail).not.toHaveBeenCalled(); + + instance.present(checkoutUrl, {onDismiss}); + expect(NativeModule.present).toHaveBeenCalledTimes(2); + NativeModule.present.mock.calls[1][1](true); + rejectedDispatch(JSON.stringify({type: 'dismiss'})); + expect(onDismiss).not.toHaveBeenCalled(); + lastDispatch()(JSON.stringify({type: 'dismiss'})); + expect(onDismiss).toHaveBeenCalledTimes(1); + }); + + it('does not release a newer session when an old result arrives late', () => { + const instance = new ShopifyCheckout(); + instance.present(checkoutUrl); + const firstResult = NativeModule.present.mock.calls[0][1]; + lastDispatch()(JSON.stringify({type: 'dismiss'})); + const onDismiss = jest.fn(); + instance.present(checkoutUrl, {onDismiss}); + firstResult(false); + lastDispatch()(JSON.stringify({type: 'dismiss'})); + expect(onDismiss).toHaveBeenCalledTimes(1); + }); + it('delivers snapshots and keeps completion separate from dismissal', () => { const remove = jest.fn(); NativeModule.onDispatch.mockReturnValueOnce({remove}); @@ -546,7 +580,7 @@ describe('ShopifyCheckoutKit', () => { }); it.each(['dismiss', 'fail'])( - 'allows presenting again inside the %s callback', + 'keeps an accepted presentation from the %s callback subscribed', type => { const instance = new ShopifyCheckout(); const nextRemove = jest.fn(); @@ -714,7 +748,7 @@ describe('ShopifyCheckoutKit', () => { it('subscribes to dispatch events when the default handler is enabled, even without callbacks', () => { const instance = new ShopifyCheckout(); instance.present(checkoutUrl); - expect(NativeModule.present).toHaveBeenCalledWith(checkoutUrl); + expect(NativeModule.present).toHaveBeenCalledWith(checkoutUrl, expect.any(Function)); expect(NativeModule.onDispatch).toHaveBeenCalledWith( expect.any(Function), ); @@ -725,7 +759,7 @@ describe('ShopifyCheckoutKit', () => { handleGeolocationRequests: false, }); instance.present(checkoutUrl); - expect(NativeModule.present).toHaveBeenCalledWith(checkoutUrl); + expect(NativeModule.present).toHaveBeenCalledWith(checkoutUrl, expect.any(Function)); }); it('handles geolocation permission grant correctly', async () => { @@ -912,7 +946,7 @@ describe('ShopifyCheckoutKit', () => { it('presents with the default link policy on iOS', () => { const instance = new ShopifyCheckout(); instance.present(checkoutUrl); - expect(NativeModule.present).toHaveBeenCalledWith(checkoutUrl); + expect(NativeModule.present).toHaveBeenCalledWith(checkoutUrl, expect.any(Function)); }); it('does not run the default geolocation handler on iOS even if dispatcher fires', async () => { diff --git a/platforms/react-native/sample/android/app/src/test/java/com/shopify/checkoutkit/reactnativedemo/ShopifyCheckoutKitModuleTest.java b/platforms/react-native/sample/android/app/src/test/java/com/shopify/checkoutkit/reactnativedemo/ShopifyCheckoutKitModuleTest.java index 77800ff1b..5da3d78ba 100644 --- a/platforms/react-native/sample/android/app/src/test/java/com/shopify/checkoutkit/reactnativedemo/ShopifyCheckoutKitModuleTest.java +++ b/platforms/react-native/sample/android/app/src/test/java/com/shopify/checkoutkit/reactnativedemo/ShopifyCheckoutKitModuleTest.java @@ -6,6 +6,7 @@ import androidx.activity.ComponentActivity; import com.facebook.react.bridge.Arguments; +import com.facebook.react.bridge.Callback; import com.facebook.react.bridge.JavaOnlyArray; import com.facebook.react.bridge.JavaOnlyMap; import com.facebook.react.bridge.Promise; @@ -57,6 +58,8 @@ public class ShopifyCheckoutKitModuleTest { private ReactApplicationContext mockReactContext; @Mock private ComponentActivity mockComponentActivity; + @Mock + private Callback presentationResult; @Captor ArgumentCaptor runnableCaptor; @Captor @@ -157,7 +160,7 @@ public void testCanPresentCheckout() { String checkoutUrl = "https://shopify.com"; mockedShopifyCheckoutKit.when(() -> ShopifyCheckoutKit.present( eq(checkoutUrl), any(), any())).thenReturn(mock(CheckoutHandle.class)); - shopifyCheckoutKitModule.present(checkoutUrl); + shopifyCheckoutKitModule.present(checkoutUrl, presentationResult); verify(mockComponentActivity).runOnUiThread(runnableCaptor.capture()); runnableCaptor.getValue().run(); @@ -178,9 +181,9 @@ public void testDuplicatePresentationPreservesOriginalListener() { ArgumentCaptor listeners = ArgumentCaptor.forClass(CustomCheckoutListener.class); nativeKit.when(() -> ShopifyCheckoutKit.present(anyString(), eq(mockComponentActivity), any())) .thenReturn(sheet); - shopifyCheckoutKitModule.present("https://example.test/first"); + shopifyCheckoutKitModule.present("https://example.test/first", presentationResult); nativeKit.verify(() -> ShopifyCheckoutKit.present(eq("https://example.test/first"), eq(mockComponentActivity), listeners.capture())); - shopifyCheckoutKitModule.present("https://example.test/second"); + shopifyCheckoutKitModule.present("https://example.test/second", presentationResult); nativeKit.verifyNoMoreInteractions(); listeners.getValue().onCheckoutDismissed(); assertThat(shopifyCheckoutKitModule.dispatchEvents).hasSize(1); @@ -211,7 +214,7 @@ private void assertCanPresentAfterProgrammaticDismiss(boolean detachActivity) { nativeKit.when(() -> ShopifyCheckoutKit.present(anyString(), eq(mockComponentActivity), any())) .thenReturn(firstSheet, secondSheet); - shopifyCheckoutKitModule.present("https://example.com/first"); + shopifyCheckoutKitModule.present("https://example.com/first", presentationResult); nativeKit.verify(() -> ShopifyCheckoutKit.present( eq("https://example.com/first"), eq(mockComponentActivity), listeners.capture())); CustomCheckoutListener firstListener = listeners.getValue(); @@ -229,7 +232,7 @@ private void assertCanPresentAfterProgrammaticDismiss(boolean detachActivity) { shopifyCheckoutKitModule.dismiss(); shadowOf(Looper.getMainLooper()).idle(); when(mockReactContext.getCurrentActivity()).thenReturn(mockComponentActivity); - shopifyCheckoutKitModule.present("https://example.com/second"); + shopifyCheckoutKitModule.present("https://example.com/second", presentationResult); nativeKit.verify(() -> ShopifyCheckoutKit.present( eq("https://example.com/second"), eq(mockComponentActivity), listeners.capture())); assertThat(listeners.getValue().isReleased()).isFalse(); @@ -242,21 +245,21 @@ private void assertCanPresentAfterProgrammaticDismiss(boolean detachActivity) { } @Test - public void testCanPresentFromDismissCallbackWhilePreviousSheetIsClosing() { - assertCanPresentFromTerminalCallback(false, false); + public void testPresentFromDismissCallbackIsIgnoredWhileClosing() { + assertPresentWhileClosingIsIgnored("programmatic"); } @Test - public void testCanPresentFromFailCallbackWhilePreviousSheetIsClosing() { - assertCanPresentFromTerminalCallback(true, false); + public void testPresentFromFailCallbackIsIgnoredWhileClosing() { + assertPresentWhileClosingIsIgnored("fail"); } @Test - public void testDismissCancelsPresentationWaitingForPreviousSheetToClose() { - assertCanPresentFromTerminalCallback(false, true); + public void testPresentFromBuyerDismissCallbackIsIgnoredWhileClosing() { + assertPresentWhileClosingIsIgnored("buyer"); } - private void assertCanPresentFromTerminalCallback(boolean fail, boolean cancelPending) { + private void assertPresentWhileClosingIsIgnored(String terminal) { doAnswer(invocation -> { ((Runnable) invocation.getArgument(0)).run(); return null; @@ -266,92 +269,78 @@ private void assertCanPresentFromTerminalCallback(boolean fail, boolean cancelPe CheckoutHandle firstSheet = mock(CheckoutHandle.class); CheckoutHandle secondSheet = mock(CheckoutHandle.class); ArgumentCaptor listeners = ArgumentCaptor.forClass(CustomCheckoutListener.class); + Callback ignoredResult = mock(Callback.class); nativeKit.when(() -> ShopifyCheckoutKit.present(anyString(), eq(mockComponentActivity), any())) .thenReturn(firstSheet, firstSheet, firstSheet, secondSheet); - shopifyCheckoutKitModule.present("https://example.com/first"); + shopifyCheckoutKitModule.present("https://example.com/first", presentationResult); + verify(presentationResult).invoke(true); nativeKit.verify(() -> ShopifyCheckoutKit.present( eq("https://example.com/first"), eq(mockComponentActivity), listeners.capture())); shopifyCheckoutKitModule.onDispatch = event -> { shopifyCheckoutKitModule.onDispatch = null; - shopifyCheckoutKitModule.present("https://example.com/second"); + shopifyCheckoutKitModule.present("https://example.com/ignored", ignoredResult); }; - if (fail) listeners.getValue().onCheckoutFailed(new CheckoutFailureEvent(cartExpired())); + if (terminal.equals("fail")) listeners.getValue().onCheckoutFailed(new CheckoutFailureEvent(cartExpired())); + else if (terminal.equals("buyer")) listeners.getValue().onCheckoutDismissed(); else shopifyCheckoutKitModule.dismiss(); shadowOf(Looper.getMainLooper()).idle(); - if (cancelPending) shopifyCheckoutKitModule.dismiss(); - shadowOf(Looper.getMainLooper()).idleFor(Duration.ofMillis(100)); + nativeKit.verify(() -> ShopifyCheckoutKit.present( + eq("https://example.com/ignored"), eq(mockComponentActivity), listeners.capture())); + assertThat(listeners.getValue().isReleased()).isTrue(); + verify(ignoredResult).invoke(false); + // Repeated explicit attempts still recognize the closing handle. + shopifyCheckoutKitModule.present("https://example.com/still-closing", ignoredResult); + verify(ignoredResult, times(2)).invoke(false); + nativeKit.clearInvocations(); + shadowOf(Looper.getMainLooper()).idleFor(Duration.ofSeconds(6)); + nativeKit.verifyNoInteractions(); + assertThat(shopifyCheckoutKitModule.dispatchEvents).hasSize(1); + + shopifyCheckoutKitModule.present("https://example.com/after-close", presentationResult); + verify(presentationResult, times(2)).invoke(true); nativeKit.verify(() -> ShopifyCheckoutKit.present( - eq("https://example.com/second"), eq(mockComponentActivity), listeners.capture()), - times(cancelPending ? 1 : 3)); - CustomCheckoutListener secondListener = listeners.getValue(); - assertThat(secondListener.isReleased()).isEqualTo(cancelPending); - if (!cancelPending) { - secondListener.onCheckoutDismissed(); - } + eq("https://example.com/after-close"), eq(mockComponentActivity), listeners.capture())); + assertThat(listeners.getValue().isReleased()).isFalse(); + listeners.getValue().onCheckoutDismissed(); assertThat(shopifyCheckoutKitModule.dispatchEvents).hasSize(2); - assertThat(shopifyCheckoutKitModule.dispatchEvents.get(1)) - .contains("\"type\":\"dismiss\""); verifyNoInteractions(secondSheet); } } @Test - public void testInvalidationCancelsQueuedPresentation() { - try (MockedStatic nativeKit = Mockito.mockStatic(ShopifyCheckoutKit.class)) { - shopifyCheckoutKitModule.present("https://example.com/checkout"); - verify(mockComponentActivity).runOnUiThread(runnableCaptor.capture()); - shopifyCheckoutKitModule.invalidate(); - runnableCaptor.getValue().run(); - shadowOf(Looper.getMainLooper()).idle(); - shopifyCheckoutKitModule.present("https://example.com/checkout"); - nativeKit.verifyNoInteractions(); - assertThat(shopifyCheckoutKitModule.dispatchEvents).isEmpty(); - } - } - - @Test - public void testStuckClosingHandleFailsOnceAndStopsRetrying() { - assertStopsWaitingForClosingHandle(false); - } - - @Test - public void testInvalidationCancelsPendingPresentationRetry() { - assertStopsWaitingForClosingHandle(true); - } - - private void assertStopsWaitingForClosingHandle(boolean invalidate) { + public void testSynchronousInitializationFailureStillEmitsFailure() { doAnswer(invocation -> { ((Runnable) invocation.getArgument(0)).run(); return null; }).when(mockComponentActivity).runOnUiThread(any()); try (MockedStatic nativeKit = Mockito.mockStatic(ShopifyCheckoutKit.class)) { - CheckoutHandle closing = mock(CheckoutHandle.class); nativeKit.when(() -> ShopifyCheckoutKit.present(anyString(), eq(mockComponentActivity), any())) - .thenReturn(closing); - shopifyCheckoutKitModule.present("https://example.com/first"); - shopifyCheckoutKitModule.dismiss(); + .thenAnswer(invocation -> { + CustomCheckoutListener listener = invocation.getArgument(2); + listener.onCheckoutFailed(new CheckoutFailureEvent(cartExpired())); + return null; + }); + shopifyCheckoutKitModule.present("https://example.com/failing", presentationResult); + verify(presentationResult).invoke(true); + assertThat(shopifyCheckoutKitModule.dispatchEvents).hasSize(1); + assertThat(shopifyCheckoutKitModule.dispatchEvents.get(0)).contains("\"type\":\"fail\""); + } + } + + @Test + public void testInvalidationCancelsQueuedPresentation() { + try (MockedStatic nativeKit = Mockito.mockStatic(ShopifyCheckoutKit.class)) { + shopifyCheckoutKitModule.present("https://example.com/checkout", presentationResult); + verify(mockComponentActivity).runOnUiThread(runnableCaptor.capture()); + shopifyCheckoutKitModule.invalidate(); + runnableCaptor.getValue().run(); shadowOf(Looper.getMainLooper()).idle(); - shopifyCheckoutKitModule.present("https://example.com/second"); - if (invalidate) shopifyCheckoutKitModule.invalidate(); - shadowOf(Looper.getMainLooper()).idleFor(Duration.ofSeconds(6)); - if (invalidate) { - assertThat(shopifyCheckoutKitModule.dispatchEvents).hasSize(1); - } else { - assertThat(shopifyCheckoutKitModule.dispatchEvents).hasSize(2); - assertThat(shopifyCheckoutKitModule.dispatchEvents.get(1)) - .contains("\"type\":\"fail\"", "sdk_error"); - } - nativeKit.clearInvocations(); - shadowOf(Looper.getMainLooper()).idleFor(Duration.ofSeconds(6)); + shopifyCheckoutKitModule.present("https://example.com/checkout", presentationResult); nativeKit.verifyNoInteractions(); - if (!invalidate) { - nativeKit.when(() -> ShopifyCheckoutKit.present(anyString(), eq(mockComponentActivity), any())) - .thenReturn(mock(CheckoutHandle.class)); - shopifyCheckoutKitModule.present("https://example.com/third"); - nativeKit.verify(() -> ShopifyCheckoutKit.present(eq("https://example.com/third"), eq(mockComponentActivity), any())); - } + verify(presentationResult, times(2)).invoke(false); + assertThat(shopifyCheckoutKitModule.dispatchEvents).isEmpty(); } } diff --git a/platforms/react-native/test/rct-integration-app/RCTIntegrationAppTests/ShopifyCheckoutKitTests.swift b/platforms/react-native/test/rct-integration-app/RCTIntegrationAppTests/ShopifyCheckoutKitTests.swift index d89c8a5b5..b9b7cfbae 100644 --- a/platforms/react-native/test/rct-integration-app/RCTIntegrationAppTests/ShopifyCheckoutKitTests.swift +++ b/platforms/react-native/test/rct-integration-app/RCTIntegrationAppTests/ShopifyCheckoutKitTests.swift @@ -487,7 +487,7 @@ class ShopifyCheckoutKitTests: XCTestCase { func testFailedPresentDoesNotRetainCheckoutSheet() { let presentAttemptCompleted = expectation(description: "present attempt completed") - shopifyCheckoutKit.present("") + shopifyCheckoutKit.present("", onResult: { _ in }) DispatchQueue.main.async { XCTAssertNil(self.shopifyCheckoutKit.checkoutSheet) @@ -528,45 +528,50 @@ private final class DismissTrackingViewController: UIViewController { extension ShopifyCheckoutKitTests { @MainActor - func testPresentDuringProgrammaticDismissWaitsForTheOldSheet() async { + func testPresentDuringProgrammaticDismissIsIgnored() async { let module = PresentationTrackingModule() - module.present("https://example.test/first") + module.attemptPresentation("https://example.test/first") await flushPresentationQueue() let oldSheet = module.sheets[0] let oldEvents = module.delegates[0] module.dismiss() - module.present("https://example.test/second") + module.attemptPresentation("https://example.test/second") await flushPresentationQueue() XCTAssertEqual(module.urls.count, 1) + XCTAssertEqual(module.results, [true, false]) + XCTAssertTrue(module.events.isEmpty) - let reopened = expectation(description: "new checkout presented after dismissal") - module.onPresent = { reopened.fulfill() } oldSheet.finishDismissal() - await fulfillment(of: [reopened], timeout: 1) - XCTAssertEqual(module.urls.last?.absoluteString, "https://example.test/second") + await flushPresentationQueue() + XCTAssertEqual(module.urls.count, 1) + XCTAssertEqual(module.events.count, 1) + module.attemptPresentation("https://example.test/third") + await flushPresentationQueue() + XCTAssertEqual(module.urls.last?.absoluteString, "https://example.test/third") + XCTAssertEqual(module.results, [true, false, true]) oldEvents.checkoutDidDismiss() XCTAssertEqual(module.events.count, 1) } @MainActor - func testPresentFromNativeDismissWaitsForTheOldSheet() async { - await assertPresentFromTerminalWaits(fail: false) + func testPresentFromNativeDismissIsIgnoredWhileClosing() async { + await assertPresentFromTerminalIsIgnored(fail: false) } @MainActor - func testPresentFromNativeFailureWaitsForTheOldSheet() async { - await assertPresentFromTerminalWaits(fail: true) + func testPresentFromNativeFailureIsIgnoredWhileClosing() async { + await assertPresentFromTerminalIsIgnored(fail: true) } @MainActor - private func assertPresentFromTerminalWaits(fail: Bool) async { + private func assertPresentFromTerminalIsIgnored(fail: Bool) async { let module = PresentationTrackingModule() - module.present("https://example.test/first") + module.attemptPresentation("https://example.test/first") await flushPresentationQueue() let oldSheet = module.sheets[0] module.onEvent = { module.onEvent = nil - module.present("https://example.test/second") + module.attemptPresentation("https://example.test/second") } if fail { module.delegates[0].checkoutDidFail(CheckoutFailureEvent(error: CheckoutError(code: .sdkError, message: "Failed"))) @@ -575,40 +580,27 @@ extension ShopifyCheckoutKitTests { } await flushPresentationQueue() XCTAssertEqual(module.urls.count, 1) - let reopened = expectation(description: "terminal callback reopens checkout") - module.onPresent = { reopened.fulfill() } + XCTAssertEqual(module.results, [true, false]) + XCTAssertEqual(module.events.count, 1) oldSheet.finishDismissal() - await fulfillment(of: [reopened], timeout: 1) - XCTAssertEqual(module.urls.last?.absoluteString, "https://example.test/second") - } - - @MainActor - func testDismissCancelsAnIOSPresentationWaitingForTheOldSheet() async { - let module = PresentationTrackingModule() - module.present("https://example.test/first") - await flushPresentationQueue() - module.dismiss() - module.present("https://example.test/second") await flushPresentationQueue() - module.dismiss() + XCTAssertEqual(module.urls.count, 1) + module.attemptPresentation("https://example.test/third") await flushPresentationQueue() - let reopened = expectation(description: "cancelled checkout never opens") - reopened.isInverted = true - module.onPresent = { reopened.fulfill() } - module.sheets[0].finishDismissal() - await fulfillment(of: [reopened], timeout: 0.05) - XCTAssertEqual(module.events.count, 2) + XCTAssertEqual(module.urls.last?.absoluteString, "https://example.test/third") + XCTAssertEqual(module.results, [true, false, true]) } @MainActor func testPresentWhileActivePreservesOriginalDelegate() async { let module = PresentationTrackingModule() - module.present("https://example.test/first") + module.attemptPresentation("https://example.test/first") await flushPresentationQueue() let originalDelegate = module.delegates[0] - module.present("https://example.test/second") + module.attemptPresentation("https://example.test/second") await flushPresentationQueue() XCTAssertEqual(module.urls.count, 1) + XCTAssertEqual(module.results, [true, false]) XCTAssertTrue(module.delegates[0] === originalDelegate) originalDelegate.checkoutDidDismiss() XCTAssertEqual(module.events.count, 1) @@ -627,9 +619,13 @@ private final class PresentationTrackingModule: RCTShopifyCheckoutKit { var delegates: [CheckoutEventBridge] = [] var sheets: [DeferredDismissViewController] = [] var events: [String] = [] - var onPresent: (() -> Void)? + var results: [Bool?] = [] var onEvent: (() -> Void)? + func attemptPresentation(_ url: String) { + present(url, onResult: { self.results.append($0?.first as? Bool) }) + } + override func getCurrentViewController(_: UIViewController? = nil) -> UIViewController? { UIViewController() } @@ -639,7 +635,6 @@ private final class PresentationTrackingModule: RCTShopifyCheckoutKit { urls.append(url) delegates.append(delegate) sheets.append(sheet) - onPresent?() return sheet } From 26f4f452fe5da2647afdbc0778672481d4a5f0c3 Mon Sep 17 00:00:00 2001 From: Mark Murray Date: Thu, 8 Oct 2026 18:12:34 +0100 Subject: [PATCH 7/7] Bump React Native Swift dependency to published alpha.8 --- .../checkout-kit-react-native/package.json | 2 +- platforms/react-native/sample/ios/Podfile.lock | 16 ++++++++-------- .../test/rct-integration-app/Podfile.lock | 16 ++++++++-------- 3 files changed, 17 insertions(+), 17 deletions(-) diff --git a/platforms/react-native/modules/@shopify/checkout-kit-react-native/package.json b/platforms/react-native/modules/@shopify/checkout-kit-react-native/package.json index ff0e09fb3..9ebac568b 100644 --- a/platforms/react-native/modules/@shopify/checkout-kit-react-native/package.json +++ b/platforms/react-native/modules/@shopify/checkout-kit-react-native/package.json @@ -19,7 +19,7 @@ }, "checkoutKit": { "nativeSdkVersions": { - "ios": "4.0.0-alpha.7", + "ios": "4.0.0-alpha.8", "android": "4.0.0-alpha.8" } }, diff --git a/platforms/react-native/sample/ios/Podfile.lock b/platforms/react-native/sample/ios/Podfile.lock index 099de143b..8c004dcab 100644 --- a/platforms/react-native/sample/ios/Podfile.lock +++ b/platforms/react-native/sample/ios/Podfile.lock @@ -2456,8 +2456,8 @@ PODS: - ReactCodegen - ReactCommon/turbomodule/bridging - ReactCommon/turbomodule/core - - ShopifyCheckoutKit (= 4.0.0-alpha.7) - - ShopifyCheckoutKit/AcceleratedCheckouts (= 4.0.0-alpha.7) + - ShopifyCheckoutKit (= 4.0.0-alpha.8) + - ShopifyCheckoutKit/AcceleratedCheckouts (= 4.0.0-alpha.8) - SocketRocket - Yoga - RNVectorIcons (10.3.0): @@ -2489,11 +2489,11 @@ PODS: - ReactCommon/turbomodule/core - SocketRocket - Yoga - - ShopifyCheckoutKit (4.0.0-alpha.7): - - ShopifyCheckoutKit/Core (= 4.0.0-alpha.7) - - ShopifyCheckoutKit/AcceleratedCheckouts (4.0.0-alpha.7): + - ShopifyCheckoutKit (4.0.0-alpha.8): + - ShopifyCheckoutKit/Core (= 4.0.0-alpha.8) + - ShopifyCheckoutKit/AcceleratedCheckouts (4.0.0-alpha.8): - ShopifyCheckoutKit/Core - - ShopifyCheckoutKit/Core (4.0.0-alpha.7) + - ShopifyCheckoutKit/Core (4.0.0-alpha.8) - SocketRocket (0.7.1) - Yoga (0.0.0) @@ -2843,9 +2843,9 @@ SPEC CHECKSUMS: RNCMaskedView: 7e0ce15656772a939ff0d269100bca3a182163c8 RNGestureHandler: eeb622199ef1fb3a076243131095df1c797072f0 RNScreens: 3fc29af06302e1f1c18a7829fe57cbc2c0259912 - RNShopifyCheckoutKit: 5f66c1e0755d22ef7346926c04882aa27e87a9a8 + RNShopifyCheckoutKit: 1e880bbeab4e0b8baf5929f90c0c89ba5ad3f7a3 RNVectorIcons: be4d047a76ad307ffe54732208fb0498fcb8477f - ShopifyCheckoutKit: 294d06324232d0cea16138ce57cdf7b2518a123b + ShopifyCheckoutKit: 119e1a5a7eb5deefbb6d5fce95089c745d0794f6 SocketRocket: d4aabe649be1e368d1318fdf28a022d714d65748 Yoga: a742cc68e8366fcfc681808162492bc0aa7a9498 diff --git a/platforms/react-native/test/rct-integration-app/Podfile.lock b/platforms/react-native/test/rct-integration-app/Podfile.lock index 59178e6f9..d6759b4b8 100644 --- a/platforms/react-native/test/rct-integration-app/Podfile.lock +++ b/platforms/react-native/test/rct-integration-app/Podfile.lock @@ -2149,15 +2149,15 @@ PODS: - ReactCodegen - ReactCommon/turbomodule/bridging - ReactCommon/turbomodule/core - - ShopifyCheckoutKit (= 4.0.0-alpha.7) - - ShopifyCheckoutKit/AcceleratedCheckouts (= 4.0.0-alpha.7) + - ShopifyCheckoutKit (= 4.0.0-alpha.8) + - ShopifyCheckoutKit/AcceleratedCheckouts (= 4.0.0-alpha.8) - SocketRocket - Yoga - - ShopifyCheckoutKit (4.0.0-alpha.7): - - ShopifyCheckoutKit/Core (= 4.0.0-alpha.7) - - ShopifyCheckoutKit/AcceleratedCheckouts (4.0.0-alpha.7): + - ShopifyCheckoutKit (4.0.0-alpha.8): + - ShopifyCheckoutKit/Core (= 4.0.0-alpha.8) + - ShopifyCheckoutKit/AcceleratedCheckouts (4.0.0-alpha.8): - ShopifyCheckoutKit/Core - - ShopifyCheckoutKit/Core (4.0.0-alpha.7) + - ShopifyCheckoutKit/Core (4.0.0-alpha.8) - SocketRocket (0.7.1) - Yoga (0.0.0) @@ -2464,8 +2464,8 @@ SPEC CHECKSUMS: ReactAppDependencyProvider: 8df342c127fd0c1e30e8b9f71ff814c22414a7c0 ReactCodegen: 3ba2a79bc32ff858814c17ade10931b33b09dcf4 ReactCommon: 592ef441605638b95e533653259254b4bd35ff4f - RNShopifyCheckoutKit: 5f66c1e0755d22ef7346926c04882aa27e87a9a8 - ShopifyCheckoutKit: 294d06324232d0cea16138ce57cdf7b2518a123b + RNShopifyCheckoutKit: 1e880bbeab4e0b8baf5929f90c0c89ba5ad3f7a3 + ShopifyCheckoutKit: 119e1a5a7eb5deefbb6d5fce95089c745d0794f6 SocketRocket: d4aabe649be1e368d1318fdf28a022d714d65748 Yoga: a742cc68e8366fcfc681808162492bc0aa7a9498