Skip to content
Merged
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
106 changes: 75 additions & 31 deletions platforms/react-native/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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);

<AcceleratedCheckoutButtons
cartId={cartId}
onComplete={(event) => {
// 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)}
/>
```

Expand Down
9 changes: 8 additions & 1 deletion platforms/react-native/__mocks__/react-native.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
5 changes: 5 additions & 0 deletions platforms/react-native/jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,11 @@ module.exports = {
preset: 'react-native',
modulePathIgnorePatterns: ['modules/@shopify/checkout-kit-react-native/lib'],
modulePaths: ['<rootDir>/node_modules', '<rootDir>/sample/node_modules'],
// Resolve workspace imports without requiring generated lib files.
moduleNameMapper: {
'^@shopify/checkout-kit-react-native$':
'<rootDir>/modules/@shopify/checkout-kit-react-native/src',
},
setupFiles: ['<rootDir>/jest.setup.ts'],
collectCoverageFrom: [
'modules/@shopify/checkout-kit-react-native/src/**/*.{ts,tsx}',
Expand Down
Original file line number Diff line number Diff line change
@@ -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))
})
})
}
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -46,6 +53,7 @@ public void invokeGeolocationCallback(boolean allow) {

public void release() {
dispatch.release();
invokeGeolocationCallback(false);
geolocationCallback = null;
geolocationOrigin = null;
}
Expand Down Expand Up @@ -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<String, Object> 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 {
Expand All @@ -114,14 +125,39 @@ 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 {
release();
}
}

@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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> ALL = Collections.unmodifiableList(
Arrays.asList(CLOSE, FAIL, GEOLOCATION_REQUEST));
Arrays.asList(START, UPDATE, COMPLETE, DISMISS, FAIL, GEOLOCATION_REQUEST));

private DispatchEventTypes() {}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down

This file was deleted.

Loading
Loading