Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 32 additions & 15 deletions platforms/react-native/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -780,30 +780,37 @@ 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.
handles for the duration of that presentation and releases them after the
presentation closes; nothing needs to be subscribed or torn down explicitly.

### SDK callbacks on `present()`

```tsx
shopify.present(checkoutUrl, {
onClose: () => {
// The sheet was dismissed without a terminal error
onDismiss: () => {
// The checkout presentation has closed
},
onFail: (error: CheckoutException) => {
// A terminal error occurred — inspect `error.code`, `error.message`, etc.
// Checkout cannot continue — inspect `error.code`, `error.message`, etc.
},
});
```

| 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. |
| `onDismiss` | `() => void` | Once, after the checkout presentation closes, independently of checkout outcome. |
| `onFail` | `(error: CheckoutException) => void` | Once, when checkout cannot continue. When the failure closes checkout, `onDismiss` follows after closure. |
| `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.
`onDismiss` and `onFail` are not mutually exclusive. A terminal failure emits
`onFail`, closes the native presentation, and then emits `onDismiss`. The bridge
retains the per-presentation callbacks through failure and releases them after
dismissal. Calling `dismiss()` programmatically releases the callbacks without
invoking either one.

Completion and dismissal are also separate events: `CheckoutProtocol.complete`
fires when the order completes, while `onDismiss` fires after the presentation
later closes, including from the confirmation page.

## Identity & customer accounts

Expand Down Expand Up @@ -1126,20 +1133,25 @@ 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.
Attach lifecycle and protocol handlers to respond when buyers complete,
dismiss, or encounter an error.

```tsx
import {CheckoutProtocol} from '@shopify/checkout-kit-react-native';

<AcceleratedCheckoutButtons
cartId={cartId}
onComplete={(event) => {
// Clear cart after successful checkout
clearCart();
events={{
[CheckoutProtocol.complete]: () => {
// Clear cart after successful checkout
clearCart();
},
}}
onFail={(error) => {
console.error('Accelerated checkout failed:', error);
}}
onCancel={() => {
analytics.track('accelerated_checkout_cancelled');
onDismiss={() => {
analytics.track('accelerated_checkout_dismissed');
}}
onRenderStateChange={(event) => {
// event.state: 'loading' | 'rendered' | 'error'
Expand All @@ -1151,6 +1163,11 @@ Attach lifecycle handlers to respond when buyers finish, cancel, or encounter an
/>
```

`onDismiss` runs after the accelerated checkout presentation closes,
independently of checkout outcome. A terminal failure invokes `onFail` first and
`onDismiss` after closure. Completion is also separate: use
`CheckoutProtocol.complete` to observe when the order completes.

---

## Contributing
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,17 @@
import java.util.Map;

public class CustomCheckoutListener extends DefaultCheckoutListener {
@FunctionalInterface
interface CheckoutDismissedCallback {
void invoke(@NonNull CustomCheckoutListener listener);
}

private static final String TAG = "ShopifyCheckoutKit";

private final ObjectMapper mapper = new ObjectMapper();

private final DispatchHandle dispatch;
private final CheckoutDismissedCallback checkoutDismissedCallback;

// Geolocation-specific variables

Expand All @@ -31,7 +37,13 @@ public CustomCheckoutListener(@NonNull DispatchCallback dispatch) {
}

public CustomCheckoutListener(@NonNull DispatchHandle dispatch) {
this(dispatch, listener -> { });
}

CustomCheckoutListener(@NonNull DispatchHandle dispatch,
@NonNull CheckoutDismissedCallback checkoutDismissedCallback) {
this.dispatch = dispatch;
this.checkoutDismissedCallback = checkoutDismissedCallback;
}

// Public methods
Expand Down Expand Up @@ -67,9 +79,9 @@ public void onGeolocationPermissionsShowPrompt(@NonNull String origin,
@NonNull GeolocationPermissions.Callback callback) {

if (dispatch.isReleased()) {
// Multi-shot geolocation requests can in principle arrive after a
// terminal event or explicit dismiss has released the dispatcher. Log
// so the silence is observable rather than mystifying.
// Multi-shot geolocation requests can in principle arrive after
// presentation teardown or explicit dismiss has released the dispatcher.
// Log so the silence is observable rather than mystifying.
Log.w(TAG, "Dropping geolocationRequest — dispatcher already released.");
return;
}
Expand Down Expand Up @@ -103,8 +115,6 @@ public void onCheckoutFailed(CheckoutException checkoutError) {
dispatch.invoke(buildEnvelope(DispatchEventTypes.FAIL, populateErrorDetails(checkoutError)));
} catch (IOException e) {
Log.e(TAG, "Error processing checkout failed event", e);
} finally {
release();
}
}

Expand All @@ -114,6 +124,7 @@ public void onCheckoutDismissed() {
return;
}
try {
checkoutDismissedCallback.invoke(this);
dispatch.invoke(buildEnvelope(DispatchEventTypes.CLOSE, null));
} catch (IOException e) {
Log.e(TAG, "Error processing checkout dismissed event", e);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,8 @@
/**
* 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.
* SDK lifecycle events and protocol events both invoke the same handle. Presentation
* dismissal or explicit teardown releases it so subsequent emissions are dropped.
*/
public class DispatchHandle implements DispatchCallback {
private final DispatchCallback downstream;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import android.app.Activity;
import androidx.activity.ComponentActivity;
import androidx.annotation.VisibleForTesting;
import com.facebook.react.bridge.ReactApplicationContext;
import com.facebook.react.bridge.ReactMethod;
import com.facebook.react.bridge.Arguments;
Expand Down Expand Up @@ -30,9 +31,11 @@ public class ShopifyCheckoutKitModule extends NativeShopifyCheckoutKitSpec {

public static Configuration checkoutConfig = new Configuration();

private CheckoutHandle checkoutSheet;
@VisibleForTesting
CheckoutHandle checkoutSheet;

private CustomCheckoutListener checkoutListener;
@VisibleForTesting
CustomCheckoutListener checkoutListener;

private CheckoutPreload checkoutPreload;

Expand Down Expand Up @@ -79,7 +82,7 @@ public void present(String checkoutURL, ReadableArray subscribedMethods) {
Activity currentActivity = getReactApplicationContext().getCurrentActivity();
if (currentActivity instanceof ComponentActivity) {
DispatchHandle dispatch = new DispatchHandle(json -> emitOnDispatch(json));
CustomCheckoutListener listener = new CustomCheckoutListener(dispatch);
CustomCheckoutListener listener = new CustomCheckoutListener(dispatch, this::clearCheckoutPresentation);
checkoutListener = listener;

List<String> methods = new ArrayList<>();
Expand All @@ -95,8 +98,11 @@ public void present(String checkoutURL, ReadableArray subscribedMethods) {
if (checkoutListener != listener) {
return;
}
checkoutSheet = ShopifyCheckoutKit.present(checkoutURL, (ComponentActivity) currentActivity,
listener, client);
CheckoutHandle presentedCheckout = ShopifyCheckoutKit.present(checkoutURL,
(ComponentActivity) currentActivity, listener, client);
if (checkoutListener == listener) {
checkoutSheet = presentedCheckout;
}
});
}
}
Expand Down Expand Up @@ -180,6 +186,14 @@ protected void emitPreloadStateEvent(String event) {
emitOnPreloadStateChange(event);
}

@VisibleForTesting
void clearCheckoutPresentation(CustomCheckoutListener dismissedListener) {
if (checkoutListener == dismissedListener) {
checkoutListener = null;
checkoutSheet = null;
}
}

private void releaseCheckoutListener() {
if (checkoutListener != null) {
checkoutListener.release();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -73,14 +73,35 @@ class CustomCheckoutListenerTest {
}

@Test
fun `a terminal event releases the dispatcher`() {
fun `dismissal clears native presentation before emitting close`() {
val lifecycleEvents = mutableListOf<String>()
lateinit var listener: CustomCheckoutListener
listener = CustomCheckoutListener(
DispatchHandle(DispatchCallback { json ->
lifecycleEvents += Json.parseToJsonElement(json).jsonObject["type"]?.jsonPrimitive?.content.orEmpty()
}),
CustomCheckoutListener.CheckoutDismissedCallback { dismissedListener ->
assertThat(dismissedListener === listener).isTrue()
lifecycleEvents += "clear"
},
)

listener.onCheckoutDismissed()

assertThat(lifecycleEvents).containsExactly("clear", "close")
}

@Test
fun `failure remains active until dismissal emits both lifecycle envelopes`() {
val captured = mutableListOf<String>()
val listener = CustomCheckoutListener(DispatchCallback { json -> captured.add(json) })

listener.onCheckoutFailed(CheckoutException(CheckoutErrorCode.SDK_ERROR, "failed"))
listener.onCheckoutDismissed()
listener.onCheckoutFailed(CheckoutException(CheckoutErrorCode.SDK_ERROR, "late"))

assertThat(captured).hasSize(1)
assertThat(captured.map { Json.parseToJsonElement(it).jsonObject["type"]?.jsonPrimitive?.content })
.containsExactly("fail", "close")
}

private fun payloadOf(envelope: JsonObject): JsonObject =
Expand Down
Original file line number Diff line number Diff line change
@@ -1,16 +1,41 @@
package com.shopify.reactnative.checkoutkit

import com.facebook.react.bridge.BridgeReactContext
import com.shopify.checkoutkit.CheckoutAppearance
import com.shopify.checkoutkit.CheckoutHandle
import com.shopify.checkoutkit.ColorScheme
import com.shopify.checkoutkit.LogLevel
import org.assertj.core.api.Assertions.assertThat
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment

@RunWith(RobolectricTestRunner::class)
class ShopifyCheckoutKitModuleTest {

@Test
fun `dismissal clears only the matching checkout presentation`() {
val module = ShopifyCheckoutKitModule(
BridgeReactContext(RuntimeEnvironment.getApplication()),
)
val activeListener = CustomCheckoutListener(DispatchCallback { })
val staleListener = CustomCheckoutListener(DispatchCallback { })
val activeHandle = CheckoutHandle { }
module.checkoutListener = activeListener
module.checkoutSheet = activeHandle

module.clearCheckoutPresentation(staleListener)

assertThat(module.checkoutListener === activeListener).isTrue()
assertThat(module.checkoutSheet === activeHandle).isTrue()

module.clearCheckoutPresentation(activeListener)

assertThat(module.checkoutListener).isNull()
assertThat(module.checkoutSheet).isNull()
}

@Test
fun `appearanceFor maps an app color scheme to an App appearance`() {
val appearance = ShopifyCheckoutKitModule.appearanceFor("dark", null)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -286,7 +286,7 @@ export type PreloadState =

// @public
export interface PresentCallbacks {
onClose?: () => void;
onDismiss?: () => void;
onFail?: (error: CheckoutException) => void;
onGeolocationRequest?: (event: GeolocationRequestEvent) => void;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@ 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?
Expand Down Expand Up @@ -339,7 +339,7 @@ class RCTAcceleratedCheckoutButtonsView: UIView {
}

private func handleCheckoutDismissed() {
onCancel?([:])
onDismiss?([:])
}

private func handleRenderStateChange(_ state: RenderState) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -132,9 +132,9 @@ @interface RCT_EXTERN_MODULE (RCTAcceleratedCheckoutButtonsManager, RCTViewManag
RCT_EXPORT_VIEW_PROPERTY(onFail, RCTBubblingEventBlock)

/**
* Emitted when checkout is cancelled by the buyer.
* Emitted when checkout is dismissed by the buyer.
*/
RCT_EXPORT_VIEW_PROPERTY(onCancel, RCTBubblingEventBlock)
RCT_EXPORT_VIEW_PROPERTY(onDismiss, RCTDirectEventBlock)

/**
* Emitted when the native render state changes. Values: "loading", "rendered", "error".
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -324,42 +324,25 @@ 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
/// Fired after the iOS SDK closes the checkout presentation. 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.
/// The SDK has already completed presentation teardown, so the wrapper
/// clears its retained reference without dismissing the controller again.
func checkoutDidDismiss() {
checkoutSheet = nil
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.
/// coerced into a `CheckoutException` on the JS side. When the failure
/// closes checkout, the SDK sends `checkoutDidDismiss()` after teardown.
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
Expand Down
Loading
Loading