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
33 changes: 23 additions & 10 deletions platforms/react-native/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -787,7 +787,7 @@ terminal events; nothing needs to be subscribed or torn down explicitly.

```tsx
shopify.present(checkoutUrl, {
onClose: () => {
onDismiss: () => {
// The sheet was dismissed without a terminal error
},
onFail: (error: CheckoutException) => {
Expand All @@ -798,12 +798,16 @@ shopify.present(checkoutUrl, {

| Name | Callback | Fires |
| ---------------------- | ------------------------------------------ | ---------------------------------------------------------------------------------------------------------------- |
| `onClose` | `() => void` | Once, when the buyer dismisses the sheet without a terminal error. |
| `onDismiss` | `() => 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.
`onDismiss` and `onFail` are mutually exclusive. At most one fires per
`present(...)` call, after which both handles are released. Calling `dismiss()`
programmatically releases the handles without invoking either callback.
Completion and dismissal are separate events: `CheckoutProtocol.complete`
fires when the order completes, while `onDismiss` fires when the buyer later
dismisses the checkout sheet, including from the confirmation page.

## Identity & customer accounts

Expand Down Expand Up @@ -1127,20 +1131,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 @@ -1152,6 +1161,10 @@ Attach lifecycle handlers to respond when buyers finish, cancel, or encounter an
/>
```

`onDismiss` reports the checkout presentation lifecycle, including dismissal
from the confirmation page after a successful payment. It does not indicate
whether checkout completed; use `CheckoutProtocol.complete` for that outcome.

---

## Contributing
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 @@ -85,9 +85,9 @@ interface CommonAcceleratedCheckoutButtonsProps {
onFail?: (error: CheckoutException) => void;

/**
* Called when checkout is cancelled
* Called when the buyer dismisses checkout, including after completion.
*/
onCancel?: () => void;
onDismiss?: () => void;

/**
* Called when the render state changes
Expand Down Expand Up @@ -169,7 +169,7 @@ export const AcceleratedCheckoutButtons: React.FC<
cornerRadius,
wallets,
onFail,
onCancel,
onDismiss,
onRenderStateChange,
onClickLink,
events,
Expand All @@ -188,9 +188,9 @@ export const AcceleratedCheckoutButtons: React.FC<
[onFail],
);

const handleCancel = useCallback(() => {
onCancel?.();
}, [onCancel]);
const handleDismiss = useCallback(() => {
onDismiss?.();
}, [onDismiss]);

const handleRenderStateChange = useCallback(
(event: {nativeEvent: unknown}) => {
Expand Down Expand Up @@ -292,7 +292,7 @@ export const AcceleratedCheckoutButtons: React.FC<
cornerRadius={cornerRadius}
wallets={wallets}
onFail={handleFail}
onCancel={handleCancel}
onDismiss={handleDismiss}
onRenderStateChange={handleRenderStateChange}
onClickLink={handleClickLink}
onDispatch={handleDispatch}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -189,19 +189,21 @@ export interface GeolocationRequestEvent {
/**
* 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.
* At most one of `onDismiss` or `onFail` fires per `present(...)` invocation,
* after which the callbacks are released. Programmatic dismissal invokes neither.
*
* `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.
* This presentation lifecycle event is independent of checkout completion,
* so it also fires when the buyer dismisses the sheet after completing payment.
* Mirrors `CheckoutListener.onCheckoutDismissed` on Android
* and `CheckoutDelegate.checkoutDidDismiss` on iOS.
*/
onClose?: () => void;
onDismiss?: () => void;
/**
* Fires when the checkout sheet terminates with an error.
* Mirrors `CheckoutListener.onCheckoutFailed` on Android
Expand Down Expand Up @@ -334,9 +336,9 @@ 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 callbacks Optional per-call SDK callbacks. At most one of
* `onDismiss` or `onFail` fires per call, after which the callbacks are
* released. Programmatic dismissal invokes neither.
* @param protocol Optional per-call Checkout Protocol event handlers.
*/
present(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,8 @@ class ShopifyCheckout implements ShopifyCheckoutKit {
}

/**
* Dismisses the currently displayed checkout sheet
* Dismisses the currently displayed checkout sheet without invoking
* the per-presentation `onDismiss` callback.
*/
public dismiss(): void {
this.releaseDispatchSubscription();
Expand Down Expand Up @@ -128,8 +129,9 @@ class ShopifyCheckout implements ShopifyCheckoutKit {
/**
* Presents the checkout sheet for a given checkout URL.
*
* Exactly one of `callbacks.onClose` or `callbacks.onFail` fires per
* At most one of `callbacks.onDismiss` or `callbacks.onFail` fires per

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Before this PR is merged, update checkoutKit.nativeSdkVersions.ios and .android in package.json to 4.0.0-alpha.7 after those native artifacts are published. This lifecycle contract depends on the alpha.7 native dismissal behavior.

* call, after which the per-presentation dispatch subscription is released.
* Programmatic dismissal invokes neither callback.
*
* @param checkoutUrl The URL of the checkout to display
* @param callbacks Optional per-call SDK callbacks
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -190,7 +190,7 @@ function routeSdkLifecycleEvent(
): PresentDispatchResult {
switch (type) {
case 'close':
callbacks?.onClose?.();
callbacks?.onDismiss?.();
return {terminal: true};
case 'fail': {
const failPayload = validateFailPayload(payload);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ interface NativeProps extends ViewProps {
applePayLabel?: string;
applePayStyle?: string;
onFail?: BubblingEventHandler<FailEvent>;
onCancel?: BubblingEventHandler<null>;
onDismiss?: DirectEventHandler<null>;
onRenderStateChange?: BubblingEventHandler<RenderStateChangeEvent>;
onClickLink?: BubblingEventHandler<ClickLinkEvent>;
onDispatch?: DirectEventHandler<DispatchEvent>;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -340,17 +340,46 @@ describe('AcceleratedCheckoutButtons', () => {
expect(error.statusCode).toBeUndefined();
});

it('calls onCancel when native cancel is invoked', () => {
const onCancel = jest.fn();
it('calls onDismiss when native dismissal is invoked', () => {
const onDismiss = jest.fn();
const {getByTestId} = render(
<AcceleratedCheckoutButtons
cartId="gid://shopify/Cart/123"
onCancel={onCancel}
onDismiss={onDismiss}
/>,
);
const nativeComponent = getByTestId('accelerated-checkout-buttons');
nativeComponent.props.onCancel();
expect(onCancel).toHaveBeenCalled();
nativeComponent.props.onDismiss();
expect(onDismiss).toHaveBeenCalledTimes(1);
});

it('delivers completion before a later dismissal as separate events', () => {
const onComplete = jest.fn();
const onDismiss = jest.fn();
const {getByTestId} = render(
<AcceleratedCheckoutButtons
cartId="gid://shopify/Cart/123"
events={{[CheckoutProtocol.complete]: onComplete}}
onDismiss={onDismiss}
/>,
);
const nativeComponent = getByTestId('accelerated-checkout-buttons');

nativeComponent.props.onDispatch({
nativeEvent: {
value: JSON.stringify({
type: CheckoutProtocol.complete,
payload: {...wireCheckout, status: 'completed'},
}),
},
});

expect(onComplete).toHaveBeenCalledTimes(1);
expect(onDismiss).not.toHaveBeenCalled();

nativeComponent.props.onDismiss();

expect(onDismiss).toHaveBeenCalledTimes(1);
});

it('maps render state change to typed states including error reason', () => {
Expand Down Expand Up @@ -448,7 +477,7 @@ describe('AcceleratedCheckoutButtons', () => {
it('handles callbacks without throwing', () => {
const mockCallbacks = {
onFail: jest.fn(),
onCancel: jest.fn(),
onDismiss: jest.fn(),
onRenderStateChange: jest.fn(),
onClickLink: jest.fn(),
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -195,12 +195,12 @@ describe('useShopifyCheckout', () => {
</Wrapper>,
);

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(
Expand Down
Loading
Loading