diff --git a/platforms/react-native/README.md b/platforms/react-native/README.md index cbee328e1..0edd8bf7d 100644 --- a/platforms/react-native/README.md +++ b/platforms/react-native/README.md @@ -779,31 +779,73 @@ 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}) => { + if (completed) clearCart(); + // 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). | - -`onClose` and `onFail` are mutually exclusive — exactly one of them fires -per `present(...)` call, after which both handles are released. +| 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 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. 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. + +### Migrating from protocol callbacks + +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 +1168,30 @@ 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}) => { + 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/__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/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}', 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..3239b6a85 --- /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,24 @@ +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, checkout: Checkout): String = + Json.encodeToString(buildJsonObject { + put("type", type) + 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..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,6 +20,13 @@ public class CustomCheckoutListener extends DefaultCheckoutListener { private final ObjectMapper mapper = new ObjectMapper(); private final DispatchHandle dispatch; + private Runnable onTerminal = () -> {}; + + public void setOnTerminal(Runnable onTerminal) { + this.onTerminal = onTerminal; + } + + public boolean isReleased() { return dispatch.isReleased(); } // Geolocation-specific variables @@ -46,6 +53,7 @@ public void invokeGeolocationCallback(boolean allow) { public void release() { dispatch.release(); + invokeGeolocationCallback(false); geolocationCallback = null; geolocationOrigin = null; } @@ -95,12 +103,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 +125,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,6 +134,30 @@ 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, checkout)); + } catch (Exception e) { + Log.e(TAG, "Error serializing checkout event"); + } + } + // Private private String buildEnvelope(String type, @Nullable Object payload) throws IOException { 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..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 @@ -4,13 +4,16 @@ 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; 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; @@ -30,8 +33,12 @@ public class ShopifyCheckoutKitModule extends NativeShopifyCheckoutKitSpec { public static Configuration checkoutConfig = new Configuration(); + private volatile boolean invalidated; + private CheckoutHandle checkoutSheet; + private WeakReference closingCheckoutSheet; + private CustomCheckoutListener checkoutListener; private CheckoutPreload checkoutPreload; @@ -47,8 +54,9 @@ public ShopifyCheckoutKitModule(ReactApplicationContext reactContext) { @Override public void invalidate() { - releaseCheckoutListener(); + invalidated = true; releaseCheckoutPreload(); + UiThreadUtil.runOnUiThread(this::releaseCheckoutListener); super.invalidate(); } @@ -73,42 +81,67 @@ public void removeListeners(double count) { } @ReactMethod - public void present(String checkoutURL, ReadableArray subscribedMethods) { - releaseCheckoutListener(); - + public void present(String checkoutURL, Callback onResult) { + if (invalidated) { + onResult.invoke(false); + return; + } 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(() -> { + // Ignore duplicate calls without replacing the active listener or policy. + if (invalidated || (checkoutListener != null && !checkoutListener.isReleased())) { + onResult.invoke(false); + return; + } + CustomCheckoutListener listener = new CustomCheckoutListener(this::emitDispatchEvent); + checkoutListener = listener; + listener.setOnTerminal(this::finishCheckoutPresentation); + 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; } - checkoutSheet = ShopifyCheckoutKit.present(checkoutURL, (ComponentActivity) currentActivity, - listener, client); + 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 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 @@ -298,9 +331,11 @@ public boolean isApplePayAvailable() { @ReactMethod public void respondToGeolocationRequest(boolean allow) { - if (checkoutListener != null) { - checkoutListener.invokeGeolocationCallback(allow); - } + UiThreadUtil.runOnUiThread(() -> { + if (checkoutListener != null) { + 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..d7577d2f7 --- /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,45 @@ +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", checkout)).jsonObject + 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.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") + } + +} 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..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 @@ -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,26 @@ 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) } + @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 ?: 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..7385ed54e --- /dev/null +++ b/platforms/react-native/modules/@shopify/checkout-kit-react-native/ios/CheckoutEventBridge.swift @@ -0,0 +1,78 @@ +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 payload: Payload +} + +struct CheckoutEventPayload: Encodable { + let checkout: Checkout +} + +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, 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 { + private var dispatch: ((String) -> Void)? + private let onTerminal: (CheckoutEventBridge) -> Void + + init(dispatch: @escaping (String) -> Void, onTerminal: @escaping (CheckoutEventBridge) -> Void) { + 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) else { return } + dispatch?(json) + } + + private func emit(_ type: DispatchEventType, payload: [String: Any] = [:]) { + 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) + } + + private func finish(_ type: DispatchEventType, payload: [String: Any] = [:]) { + guard dispatch != nil else { return } + onTerminal(self) + 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..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 @@ -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) + onResult:(RCTResponseSenderBlock)onResult) 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..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 @@ -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,8 @@ class RCTShopifyCheckoutKit: NSObject { private static let storefrontColorScheme = "storefront" 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? @@ -85,8 +90,20 @@ class RCTShopifyCheckoutKit: NSObject { @objc func dismiss() { DispatchQueue.main.async { - self.checkoutSheet?.dismiss(animated: true) + let sheet = self.checkoutSheet + let events = self.checkoutEvents + // Keep the closing session separate until UIKit completes dismissal. 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() + } } } @@ -97,34 +114,49 @@ class RCTShopifyCheckoutKit: NSObject { } } - @objc func present(_ checkoutURL: String, subscribedMethods: [String]) { + @objc func present(_ checkoutURL: String, onResult: @escaping RCTResponseSenderBlock) { 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 + // 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 + 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() + onResult([true]) + return + } + self.checkoutSheet = self.presentCheckout(url, from: viewController, delegate: events) + onResult([true]) } } + @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) { DispatchQueue.main.async { self.checkoutPreload?.onStateChange = nil @@ -321,54 +353,9 @@ 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 { - private func emitDispatchEvent(_ json: String) { - perform(NSSelectorFromString("emitOnDispatchFromSwift:"), with: json) - } - private func emitPreloadStateChange(requestId: String, state: PreloadState) { var event: [String: Any] = ["requestId": requestId] @@ -407,24 +394,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..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,8 +19,8 @@ }, "checkoutKit": { "nativeSdkVersions": { - "ios": "4.0.0-alpha.6", - "android": "4.0.0-alpha.6" + "ios": "4.0.0-alpha.8", + "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..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 @@ -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,12 @@ 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 active or closing are ignored, including across instances. + * Ignored attempts do not fire callbacks. */ - present( - checkoutURL: string, - callbacks?: PresentCallbacks, - protocol?: ProtocolHandlers, - ): void; + present(checkoutURL: string, callbacks?: PresentCallbacks): void; /** * Preload the checkout for faster presentation. * @@ -368,7 +341,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 09b319374..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 @@ -39,14 +39,6 @@ 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'; const defaultFeatures: Features = { @@ -54,10 +46,18 @@ const defaultFeatures: Features = { }; 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}; + private presentation?: { + callbacks?: PresentCallbacks; + cancellation: AbortController; + }; + private preloadSubscription?: CheckoutPreloadSubscription; private _acceleratedCheckoutsReady = false; @@ -101,7 +101,7 @@ class ShopifyCheckout implements ShopifyCheckoutKit { * Dismisses the currently displayed checkout sheet */ public dismiss(): void { - this.releaseDispatchSubscription(); + ShopifyCheckout.activePresentation?.cancelGeolocationRequest(); RNShopifyCheckoutKit.dismiss(); } @@ -125,50 +125,54 @@ 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 { - this.releaseDispatchSubscription(); - + /** Presents checkout with lifecycle callbacks. */ + public present(checkoutUrl: string, callbacks?: PresentCallbacks): void { + 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 {dispatcher, subscribedMethods} = createPresentDispatcher({ + const cancellation = new AbortController(); + const presentation = { callbacks, - protocol, + cancellation, + geolocationSignal: cancellation.signal, handleDefaultGeolocationRequests: this.featureEnabled( 'handleGeolocationRequests', ), - handleDefaultGeolocationRequest: () => - this.handleDefaultGeolocationRequest(), - respondToGeolocationRequest: allow => - this.respondToGeolocationRequest(allow), + handleDefaultGeolocationRequest: async () => { + const allowed = await this.requestGeolocation().catch(() => false); + if (!cancellation.signal.aborted) + this.respondToGeolocationRequest(allowed); + }, + respondToGeolocationRequest: (allow: boolean) => { + if (!cancellation.signal.aborted) + this.respondToGeolocationRequest(allow); + }, onTerminal: () => { if (subscription) this.releaseDispatchSubscription(subscription); }, + }; + this.presentation = presentation; + const {dispatcher} = createPresentDispatcher(presentation); + subscription = RNShopifyCheckoutKit.onDispatch(json => { + if (subscription && this.dispatchSubscription === subscription) + dispatcher(json); }); - - if (dispatcher) { - subscription = RNShopifyCheckoutKit.onDispatch(json => { - if (subscription && this.dispatchSubscription === subscription) - dispatcher(json); - }); - this.dispatchSubscription = subscription; - } - + this.dispatchSubscription = subscription; + ShopifyCheckout.activePresentation = this; try { - RNShopifyCheckoutKit.present(checkoutUrl, subscribedMethods); + 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) { - if (subscription) this.releaseDispatchSubscription(subscription); + this.releaseDispatchSubscription(subscription); throw error; } } @@ -199,7 +203,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; } @@ -352,9 +359,21 @@ 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 @@ -366,15 +385,6 @@ class ShopifyCheckout implements ShopifyCheckoutKit { } } - /** - * 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 +405,6 @@ class ShopifyCheckout implements ShopifyCheckoutKit { private permissionGranted(status: PermissionStatus): boolean { return status === 'granted'; } - } // API @@ -404,7 +413,6 @@ export { ApplePayContactField, ApplePayLabel, ApplePayStyle, - CheckoutProtocol, ColorScheme, DispatchEventParityError, LifecycleEventParseError, @@ -425,11 +433,7 @@ export type { AcceleratedCheckoutCustomer, AndroidAutomaticColors, AndroidColors, - Checkout, - CheckoutProtocolMethod, - CheckoutProtocolPayloads, Configuration, - ErrorResponse, Features, GeolocationRequestEvent, IosColors, @@ -438,7 +442,6 @@ export type { PreloadOptions, PreloadState, CheckoutPreloadSubscription, - ProtocolHandlers, RenderStateChangeEvent, }; @@ -447,3 +450,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..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 @@ -1,232 +1,118 @@ 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. */ + geolocationSignal?: AbortSignal; + 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; } - 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 (options.geolocationSignal?.aborted) return; + 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 +120,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..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,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, 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/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..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 @@ -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'; @@ -40,6 +32,9 @@ describe('ShopifyCheckoutProvider', () => { ); afterEach(() => { + for (const [dispatch] of NativeModules.ShopifyCheckoutKit.onDispatch.mock + .calls) + dispatch(JSON.stringify({type: 'dismiss'})); __resetPreloadForTests(); jest.clearAllMocks(); }); @@ -61,9 +56,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 +68,7 @@ describe('ShopifyCheckoutProvider', () => { , ); - expect( - NativeModules.ShopifyCheckoutKit.setConfig, - ).not.toHaveBeenCalled(); + expect(NativeModules.ShopifyCheckoutKit.setConfig).not.toHaveBeenCalled(); expect( NativeModules.ShopifyCheckoutKit.configureAcceleratedCheckouts, ).not.toHaveBeenCalled(); @@ -143,9 +136,9 @@ describe('ShopifyCheckoutProvider', () => { , ); - expect( - NativeModules.ShopifyCheckoutKit.setConfig.mock.calls, - ).toHaveLength(2); + expect(NativeModules.ShopifyCheckoutKit.setConfig.mock.calls).toHaveLength( + 2, + ); }); }); @@ -157,6 +150,9 @@ describe('useShopifyCheckout', () => { ); afterEach(() => { + for (const [dispatch] of NativeModules.ShopifyCheckoutKit.onDispatch.mock + .calls) + dispatch(JSON.stringify({type: 'dismiss'})); __resetPreloadForTests(); jest.clearAllMocks(); }); @@ -179,7 +175,7 @@ describe('useShopifyCheckout', () => { expect(NativeModules.ShopifyCheckoutKit.present).toHaveBeenCalledWith( checkoutUrl, - [], + expect.any(Function), ); }); @@ -195,12 +191,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,35 +204,8 @@ describe('useShopifyCheckout', () => { ); expect(NativeModules.ShopifyCheckoutKit.present).toHaveBeenCalledWith( checkoutUrl, - [], - ); - }); - - 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', () => { @@ -255,9 +224,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 +329,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 +371,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..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 @@ -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', + ], }); }); @@ -202,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(); @@ -392,68 +400,20 @@ 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, []); - }); - - 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('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()}); + const checkout = { + id: 'checkout-1', + currency: 'USD', + status: 'incomplete', + line_items: [], + links: [], + totals: [], + }; - expect(firstSubscription.remove).toHaveBeenCalledTimes(1); - expect(secondSubscription.remove).not.toHaveBeenCalled(); + it('presents checkout without a session ID', () => { + new ShopifyCheckout().present(checkoutUrl); + expect(NativeModule.present).toHaveBeenCalledWith(checkoutUrl, expect.any(Function)); }); - 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 +421,241 @@ 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); - }); - - 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}); - 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); - }); - - 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', - ); - 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); + lastDispatch()(JSON.stringify({type: 'dismiss'})); + expect(secondDismiss).toHaveBeenCalledTimes(1); }); - it('invokes `onClose` when the dispatcher receives a close envelope', () => { + 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 onClose = jest.fn(); - instance.present(checkoutUrl, {onClose}); - lastDispatch()(JSON.stringify({type: 'close'})); - expect(onClose).toHaveBeenCalledTimes(1); + 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('ignores a close envelope when no `onClose` handler was provided', () => { + it('does not release a newer session when an old result arrives late', () => { const instance = new ShopifyCheckout(); - instance.present(checkoutUrl, {onFail: jest.fn()}); - expect(() => - lastDispatch()(JSON.stringify({type: 'close'})), - ).not.toThrow(); + 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); }); - describe('onFail callback', () => { - const sdkError = { - message: 'Something went wrong', - code: CheckoutErrorCode.sdkError, + 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(); + 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([ - {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, + 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, + }, }, - 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(); - }); + 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); }); - describe('onGeolocationRequest callback', () => { - it('parses the geolocationRequest envelope payload and surfaces the typed event', () => { - const instance = new ShopifyCheckout(); - const onGeolocationRequest = jest.fn(); - instance.present(checkoutUrl, {onGeolocationRequest}); - lastDispatch()( - JSON.stringify({ - type: 'geolocationRequest', - payload: {origin: 'https://shopify.com'}, - }), - ); - expect(onGeolocationRequest).toHaveBeenCalledWith({ - origin: 'https://shopify.com', - respond: expect.any(Function), + 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(); + }, + ); - describe('protocol handlers', () => { - const wireStartPayload = { - id: 'chk_123', - currency: 'USD', - line_items: [], - links: [], - status: 'incomplete', - totals: [], - ucp: { - version: EmbeddedCheckoutProtocol.specVersion, - payment_handlers: { - loyalty_gold: [], - }, - }, - }; + it('ignores queued events from a finished presentation after reopening', () => { + const instance = new ShopifyCheckout(); + instance.present(checkoutUrl); + const oldDispatch = lastDispatch(); + oldDispatch(JSON.stringify({type: 'dismiss'})); + const onDismiss = jest.fn(); + instance.present(checkoutUrl, {onDismiss}); + oldDispatch(JSON.stringify({type: 'dismiss'})); + expect(onDismiss).not.toHaveBeenCalled(); + lastDispatch()(JSON.stringify({type: 'dismiss'})); + expect(onDismiss).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: [], - }, - }, - buyer: undefined, - context: undefined, - continueUrl: undefined, - expiresAt: undefined, - messages: undefined, - order: undefined, - payment: undefined, - signals: undefined, - }; + it('keeps session ownership after teardown until native dismissal', () => { + const first = new ShopifyCheckout(); + const second = new ShopifyCheckout(); + const onDismiss = jest.fn(); + const onUpdate = jest.fn(); + first.present(checkoutUrl, {onDismiss, onUpdate}); + first.teardown(); + second.present(checkoutUrl); + expect(NativeModule.present).toHaveBeenCalledTimes(1); + lastDispatch()(JSON.stringify({type: 'update', payload: {checkout}})); + lastDispatch()(JSON.stringify({type: 'dismiss'})); + expect(onDismiss).not.toHaveBeenCalled(); + expect(onUpdate).not.toHaveBeenCalled(); + second.present(checkoutUrl, {onDismiss}); + first.teardown(); + lastDispatch()(JSON.stringify({type: 'dismiss'})); + expect(onDismiss).toHaveBeenCalledTimes(1); + }); - it('routes envelope.type via the protocol handler map', () => { + it.each(['dismiss', 'fail'])( + 'keeps an accepted presentation from the %s callback subscribed', + type => { const instance = new ShopifyCheckout(); - const onStart = jest.fn(); - instance.present(checkoutUrl, undefined, { - [CheckoutProtocol.start]: onStart, - }); + 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: CheckoutProtocol.start, - payload: wireStartPayload, + type, + payload: {error: {code: 'sdk_error', message: 'Failed'}}, }), ); - 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), - ); - }); + expect(nextRemove).not.toHaveBeenCalled(); + lastDispatch()(JSON.stringify({type: 'dismiss'})); + expect(nextDismiss).toHaveBeenCalledTimes(1); + }, + ); - 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(); + 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'); + }, }); + 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('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('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('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.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('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 +748,18 @@ 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), ); }); - 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(Function)); }); it('handles geolocation permission grant correctly', async () => { @@ -1077,11 +843,87 @@ describe('ShopifyCheckoutKit', () => { ); }); + 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, }); - instance.present(checkoutUrl, {onClose: jest.fn()}); + instance.present(checkoutUrl, {onDismiss: jest.fn()}); lastDispatch()(geolocationEnvelope); await flush(); @@ -1101,15 +943,15 @@ 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(Function)); }); 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..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 @@ -1,10 +1,12 @@ package com.shopify.checkoutkit.reactnativedemo; +import android.os.Looper; import android.webkit.GeolocationPermissions; import androidx.activity.ComponentActivity; import com.facebook.react.bridge.Arguments; +import com.facebook.react.bridge.Callback; import com.facebook.react.bridge.JavaOnlyArray; import com.facebook.react.bridge.JavaOnlyMap; import com.facebook.react.bridge.Promise; @@ -13,6 +15,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 +28,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 +48,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.Mockito.*; +import static org.robolectric.Shadows.shadowOf; @@ -49,6 +58,8 @@ public class ShopifyCheckoutKitModuleTest { private ReactApplicationContext mockReactContext; @Mock private ComponentActivity mockComponentActivity; + @Mock + private Callback presentationResult; @Captor ArgumentCaptor runnableCaptor; @Captor @@ -81,10 +92,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 +158,189 @@ 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, presentationResult); 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 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", presentationResult); + nativeKit.verify(() -> ShopifyCheckoutKit.present(eq("https://example.test/first"), eq(mockComponentActivity), listeners.capture())); + shopifyCheckoutKitModule.present("https://example.test/second", presentationResult); + nativeKit.verifyNoMoreInteractions(); + listeners.getValue().onCheckoutDismissed(); + assertThat(shopifyCheckoutKitModule.dispatchEvents).hasSize(1); + } + } + + @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", presentationResult); + 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\""); + + shopifyCheckoutKitModule.dismiss(); + shadowOf(Looper.getMainLooper()).idle(); + when(mockReactContext.getCurrentActivity()).thenReturn(mockComponentActivity); + 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(); + + firstListener.onCheckoutDismissed(); + assertThat(shopifyCheckoutKitModule.dispatchEvents).hasSize(1); + verifyNoMoreInteractions(firstSheet); + verifyNoInteractions(secondSheet); + } + } + + @Test + public void testPresentFromDismissCallbackIsIgnoredWhileClosing() { + assertPresentWhileClosingIsIgnored("programmatic"); + } + + @Test + public void testPresentFromFailCallbackIsIgnoredWhileClosing() { + assertPresentWhileClosingIsIgnored("fail"); + } + + @Test + public void testPresentFromBuyerDismissCallbackIsIgnoredWhileClosing() { + assertPresentWhileClosingIsIgnored("buyer"); + } + + private void assertPresentWhileClosingIsIgnored(String terminal) { + 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); + Callback ignoredResult = mock(Callback.class); + nativeKit.when(() -> ShopifyCheckoutKit.present(anyString(), eq(mockComponentActivity), any())) + .thenReturn(firstSheet, firstSheet, firstSheet, secondSheet); + 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/ignored", ignoredResult); + }; + + 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(); + 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/after-close"), eq(mockComponentActivity), listeners.capture())); + assertThat(listeners.getValue().isReleased()).isFalse(); + listeners.getValue().onCheckoutDismissed(); + assertThat(shopifyCheckoutKitModule.dispatchEvents).hasSize(2); + verifyNoInteractions(secondSheet); + } + } + + @Test + public void testSynchronousInitializationFailureStillEmitsFailure() { + doAnswer(invocation -> { + ((Runnable) invocation.getArgument(0)).run(); + return null; + }).when(mockComponentActivity).runOnUiThread(any()); + try (MockedStatic nativeKit = Mockito.mockStatic(ShopifyCheckoutKit.class)) { + nativeKit.when(() -> ShopifyCheckoutKit.present(anyString(), eq(mockComponentActivity), any())) + .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/checkout", presentationResult); + nativeKit.verifyNoInteractions(); + verify(presentationResult, times(2)).invoke(false); + assertThat(shopifyCheckoutKitModule.dispatchEvents).isEmpty(); } } @@ -263,18 +453,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 +495,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 +509,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 +994,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 +1008,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 +1024,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 +1042,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 +1055,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..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.6) - - ShopifyCheckoutKit/AcceleratedCheckouts (= 4.0.0-alpha.6) + - 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.6): - - ShopifyCheckoutKit/Core (= 4.0.0-alpha.6) - - ShopifyCheckoutKit/AcceleratedCheckouts (4.0.0-alpha.6): + - 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.6) + - 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: 7424be393789079c7420a44c853fe52a543d6ad0 + RNShopifyCheckoutKit: 1e880bbeab4e0b8baf5929f90c0c89ba5ad3f7a3 RNVectorIcons: be4d047a76ad307ffe54732208fb0498fcb8477f - ShopifyCheckoutKit: c98a992e7371f1173b7e030c183fa5fe1a694459 + ShopifyCheckoutKit: 119e1a5a7eb5deefbb6d5fce95089c745d0794f6 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/__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 5a6c535f8..902dd0f0d 100644 --- a/platforms/react-native/sample/src/hooks/useCheckoutEventHandlers.ts +++ b/platforms/react-native/sample/src/hooks/useCheckoutEventHandlers.ts @@ -1,62 +1,44 @@ +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 { + onCompletedCheckout?: () => void, +): EventHandlers { const log = createDebugLogger(name ?? ''); + const completed = useRef(false); + const finishCheckout = () => { + if (completed.current) { + completed.current = false; + onCompletedCheckout?.(); + } + }; 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}) => { + log('onFail', error); + finishCheckout(); }, - onClickLink: async url => { - log('onClickLink', url); - - if (await Linking.canOpenURL(url)) { - await Linking.openURL(url); - } + onDismiss: () => { + log('onDismiss'); + finishCheckout(); }, + 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())) + let envelope = try parse(json) + XCTAssertEqual(envelope["type"] as? String, "start") + 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(dispatch: { events.append($0) }, onTerminal: { _ in 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(dispatch: { events.append($0) }, onTerminal: { _ in }) + 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) + } + + 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..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("", subscribedMethods: []) + shopifyCheckoutKit.present("", onResult: { _ in }) 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) @@ -525,3 +525,142 @@ private final class DismissTrackingViewController: UIViewController { completion?() } } + +extension ShopifyCheckoutKitTests { + @MainActor + func testPresentDuringProgrammaticDismissIsIgnored() async { + let module = PresentationTrackingModule() + module.attemptPresentation("https://example.test/first") + await flushPresentationQueue() + let oldSheet = module.sheets[0] + let oldEvents = module.delegates[0] + module.dismiss() + module.attemptPresentation("https://example.test/second") + await flushPresentationQueue() + XCTAssertEqual(module.urls.count, 1) + XCTAssertEqual(module.results, [true, false]) + XCTAssertTrue(module.events.isEmpty) + + oldSheet.finishDismissal() + 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 testPresentFromNativeDismissIsIgnoredWhileClosing() async { + await assertPresentFromTerminalIsIgnored(fail: false) + } + + @MainActor + func testPresentFromNativeFailureIsIgnoredWhileClosing() async { + await assertPresentFromTerminalIsIgnored(fail: true) + } + + @MainActor + private func assertPresentFromTerminalIsIgnored(fail: Bool) async { + let module = PresentationTrackingModule() + module.attemptPresentation("https://example.test/first") + await flushPresentationQueue() + let oldSheet = module.sheets[0] + module.onEvent = { + module.onEvent = nil + module.attemptPresentation("https://example.test/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) + XCTAssertEqual(module.results, [true, false]) + XCTAssertEqual(module.events.count, 1) + oldSheet.finishDismissal() + await flushPresentationQueue() + XCTAssertEqual(module.urls.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]) + } + + @MainActor + func testPresentWhileActivePreservesOriginalDelegate() async { + let module = PresentationTrackingModule() + module.attemptPresentation("https://example.test/first") + await flushPresentationQueue() + let originalDelegate = module.delegates[0] + 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) + } + + @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 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() + } + + override func presentCheckout(_ url: URL, from _: UIViewController, delegate: CheckoutEventBridge) -> UIViewController { + let sheet = DeferredDismissViewController() + urls.append(url) + delegates.append(delegate) + sheets.append(sheet) + 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?() + } +}