From 71b8a5d533f83f68fe4130d1ec947907df1e1007 Mon Sep 17 00:00:00 2001 From: Kieran Osgood Date: Tue, 8 Sep 2026 15:18:47 +0100 Subject: [PATCH 1/2] Rename React Native dismissal callbacks Assisted-By: devx/3f0e7f93-212d-45d8-b056-138b2da8389f --- platforms/react-native/README.md | 33 +++++--- .../api/checkout-kit-react-native.api.md | 2 +- .../ios/AcceleratedCheckoutButtons.swift | 4 +- .../ios/ShopifyCheckoutKit.mm | 4 +- .../components/AcceleratedCheckoutButtons.tsx | 14 ++-- .../checkout-kit-react-native/src/index.d.ts | 14 ++-- .../checkout-kit-react-native/src/index.ts | 6 +- .../src/present-dispatcher.ts | 2 +- ...celeratedCheckoutButtonsNativeComponent.ts | 2 +- .../tests/AcceleratedCheckoutButtons.test.tsx | 41 ++++++++-- .../tests/context.test.tsx | 4 +- .../tests/index.test.ts | 82 +++++++++++++------ platforms/react-native/sample/src/App.tsx | 2 +- .../src/hooks/useCheckoutEventHandlers.ts | 6 +- .../sample/src/screens/CartScreen.tsx | 6 +- .../AcceleratedCheckouts_SupportedTests.swift | 10 +++ 16 files changed, 162 insertions(+), 70 deletions(-) diff --git a/platforms/react-native/README.md b/platforms/react-native/README.md index cbee328e1..f728e2470 100644 --- a/platforms/react-native/README.md +++ b/platforms/react-native/README.md @@ -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) => { @@ -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 @@ -1126,20 +1130,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'; + { - // 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' @@ -1151,6 +1160,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 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..e540a7190 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 @@ -286,7 +286,7 @@ export type PreloadState = // @public export interface PresentCallbacks { - onClose?: () => void; + onDismiss?: () => void; onFail?: (error: CheckoutException) => void; onGeolocationRequest?: (event: GeolocationRequestEvent) => void; } 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..4880ec569 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 @@ -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? @@ -339,7 +339,7 @@ class RCTAcceleratedCheckoutButtonsView: UIView { } private func handleCheckoutDismissed() { - onCancel?([:]) + onDismiss?([:]) } private func handleRenderStateChange(_ state: RenderState) { 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..cde81310a 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 @@ -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". 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..956edd869 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 @@ -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 @@ -169,7 +169,7 @@ export const AcceleratedCheckoutButtons: React.FC< cornerRadius, wallets, onFail, - onCancel, + onDismiss, onRenderStateChange, onClickLink, events, @@ -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}) => { @@ -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} 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..0ac50b2e3 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 @@ -190,8 +190,8 @@ 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. @@ -199,10 +199,12 @@ export interface GeolocationRequestEvent { 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 @@ -335,9 +337,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( 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 7907a42a0..a05f0d384 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 @@ -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(); @@ -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 * 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 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 d76888560..fe66aaac3 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 @@ -190,7 +190,7 @@ function routeSdkLifecycleEvent( ): PresentDispatchResult { switch (type) { case 'close': - callbacks?.onClose?.(); + callbacks?.onDismiss?.(); return {terminal: true}; case 'fail': { const failPayload = validateFailPayload(payload); 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..3c35cfcb9 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 @@ -35,7 +35,7 @@ interface NativeProps extends ViewProps { applePayLabel?: string; applePayStyle?: string; onFail?: BubblingEventHandler; - onCancel?: BubblingEventHandler; + onDismiss?: DirectEventHandler; onRenderStateChange?: BubblingEventHandler; onClickLink?: BubblingEventHandler; onDispatch?: 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..ae0bf63ea 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 @@ -341,17 +341,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( , ); 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( + , + ); + 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', () => { @@ -449,7 +478,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(), }; 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..8e1898919 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 @@ -195,12 +195,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( 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 91e8756b1..a7591bc16 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 @@ -402,7 +402,7 @@ describe('ShopifyCheckoutKit', () => { it('calls `present` with a dispatcher when callbacks are provided', () => { const instance = new ShopifyCheckout(); - instance.present(checkoutUrl, {onClose: jest.fn()}); + instance.present(checkoutUrl, {onDismiss: jest.fn()}); expect(NativeModule.present).toHaveBeenCalledWith(checkoutUrl, []); expect(NativeModule.onDispatch).toHaveBeenCalledWith( expect.any(Function), @@ -417,8 +417,8 @@ describe('ShopifyCheckoutKit', () => { .mockReturnValueOnce(secondSubscription); const instance = new ShopifyCheckout(); - instance.present(checkoutUrl, {onClose: jest.fn()}); - instance.present(checkoutUrl, {onClose: jest.fn()}); + instance.present(checkoutUrl, {onDismiss: jest.fn()}); + instance.present(checkoutUrl, {onDismiss: jest.fn()}); expect(firstSubscription.remove).toHaveBeenCalledTimes(1); expect(secondSubscription.remove).not.toHaveBeenCalled(); @@ -429,21 +429,21 @@ describe('ShopifyCheckoutKit', () => { NativeModule.onDispatch.mockReturnValueOnce(subscription); const instance = new ShopifyCheckout(); - instance.present(checkoutUrl, {onClose: jest.fn()}); + instance.present(checkoutUrl, {onDismiss: jest.fn()}); lastDispatch()(JSON.stringify({type: 'close'})); expect(subscription.remove).toHaveBeenCalledTimes(1); }); - it('invokes `onClose` when the dispatcher receives a close envelope', () => { + it('invokes `onDismiss` when the dispatcher receives a close envelope', () => { const instance = new ShopifyCheckout(); - const onClose = jest.fn(); - instance.present(checkoutUrl, {onClose}); + const onDismiss = jest.fn(); + instance.present(checkoutUrl, {onDismiss}); lastDispatch()(JSON.stringify({type: 'close'})); - expect(onClose).toHaveBeenCalledTimes(1); + expect(onDismiss).toHaveBeenCalledTimes(1); }); - it('ignores a close envelope when no `onClose` handler was provided', () => { + it('ignores a close envelope when no `onDismiss` handler was provided', () => { const instance = new ShopifyCheckout(); instance.present(checkoutUrl, {onFail: jest.fn()}); expect(() => @@ -522,8 +522,8 @@ describe('ShopifyCheckoutKit', () => { it('ignores a fail envelope when no `onFail` handler was provided', () => { const instance = new ShopifyCheckout(); - const onClose = jest.fn(); - instance.present(checkoutUrl, {onClose}); + const onDismiss = jest.fn(); + instance.present(checkoutUrl, {onDismiss}); expect(() => lastDispatch()( JSON.stringify({type: 'fail', payload: sdkError}), @@ -609,6 +609,32 @@ describe('ShopifyCheckoutKit', () => { expect(onStart.mock.calls[0][0].id).toBe('chk_123'); }); + it('delivers completion before a later sheet dismissal as separate events', () => { + const instance = new ShopifyCheckout(); + const onComplete = jest.fn(); + const onDismiss = jest.fn(); + instance.present( + checkoutUrl, + {onDismiss}, + {[CheckoutProtocol.complete]: onComplete}, + ); + const dispatch = lastDispatch(); + + dispatch( + JSON.stringify({ + type: CheckoutProtocol.complete, + payload: {...wireStartPayload, status: 'completed'}, + }), + ); + + expect(onComplete).toHaveBeenCalledTimes(1); + expect(onDismiss).not.toHaveBeenCalled(); + + dispatch(JSON.stringify({type: 'close'})); + + expect(onDismiss).toHaveBeenCalledTimes(1); + }); + it('passes subscribedMethods to native present()', () => { const instance = new ShopifyCheckout(); instance.present(checkoutUrl, undefined, { @@ -625,13 +651,13 @@ describe('ShopifyCheckoutKit', () => { it('still routes existing close/fail/geolocationRequest cases alongside protocol handlers', () => { Platform.OS = 'ios'; const instance = new ShopifyCheckout(); - const onClose = jest.fn(); + const onDismiss = jest.fn(); const onFail = jest.fn(); const onGeolocationRequest = jest.fn(); const onStart = jest.fn(); instance.present( checkoutUrl, - {onClose, onFail, onGeolocationRequest}, + {onDismiss, onFail, onGeolocationRequest}, {[CheckoutProtocol.start]: onStart}, ); const dispatch = lastDispatch(); @@ -652,7 +678,7 @@ describe('ShopifyCheckoutKit', () => { payload: {origin: 'https://shopify.com'}, }), ); - expect(onClose).toHaveBeenCalledTimes(1); + expect(onDismiss).toHaveBeenCalledTimes(1); expect(onFail).toHaveBeenCalledTimes(1); expect(onFail.mock.calls[0][0]).toBeInstanceOf(CheckoutException); expect(onGeolocationRequest).toHaveBeenCalledWith({ @@ -666,10 +692,10 @@ describe('ShopifyCheckoutKit', () => { 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}); + const onDismiss = jest.fn(); + instance.present(checkoutUrl, {onDismiss}); lastDispatch()('not-json'); - expect(onClose).not.toHaveBeenCalled(); + expect(onDismiss).not.toHaveBeenCalled(); expect(console.error).toHaveBeenCalledWith( expect.any(LifecycleEventParseError), 'not-json', @@ -678,13 +704,13 @@ describe('ShopifyCheckoutKit', () => { it('warns via console.warn for envelopes with unknown `type` values', () => { const instance = new ShopifyCheckout(); - const onClose = jest.fn(); + const onDismiss = jest.fn(); const onFail = jest.fn(); - instance.present(checkoutUrl, {onClose, onFail}); + instance.present(checkoutUrl, {onDismiss, onFail}); expect(() => lastDispatch()(JSON.stringify({type: 'unknown', payload: {}})), ).not.toThrow(); - expect(onClose).not.toHaveBeenCalled(); + expect(onDismiss).not.toHaveBeenCalled(); expect(onFail).not.toHaveBeenCalled(); expect(console.warn).toHaveBeenCalledWith( expect.stringContaining('unknown type "unknown"'), @@ -693,7 +719,7 @@ describe('ShopifyCheckoutKit', () => { it('logs a LifecycleEventParseError when the envelope is missing a string `type`', () => { const instance = new ShopifyCheckout(); - instance.present(checkoutUrl, {onClose: jest.fn()}); + instance.present(checkoutUrl, {onDismiss: jest.fn()}); lastDispatch()(JSON.stringify({payload: {}})); expect(console.error).toHaveBeenCalledWith( expect.any(LifecycleEventParseError), @@ -791,6 +817,16 @@ describe('ShopifyCheckoutKit', () => { instance.dismiss(); expect(NativeModule.dismiss).toHaveBeenCalledTimes(1); }); + + it('does not invoke `onDismiss` for programmatic dismissal', () => { + const instance = new ShopifyCheckout(); + const onDismiss = jest.fn(); + instance.present(checkoutUrl, {onDismiss}); + + instance.dismiss(); + + expect(onDismiss).not.toHaveBeenCalled(); + }); }); describe('getConfig', () => { @@ -973,7 +1009,7 @@ describe('ShopifyCheckoutKit', () => { const instance = new ShopifyCheckout(undefined, { handleGeolocationRequests: false, }); - instance.present(checkoutUrl, {onClose: jest.fn()}); + instance.present(checkoutUrl, {onDismiss: jest.fn()}); lastDispatch()(geolocationEnvelope); await flush(); @@ -1001,7 +1037,7 @@ describe('ShopifyCheckoutKit', () => { 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/sample/src/App.tsx b/platforms/react-native/sample/src/App.tsx index c28eca034..61ab32796 100644 --- a/platforms/react-native/sample/src/App.tsx +++ b/platforms/react-native/sample/src/App.tsx @@ -453,7 +453,7 @@ function Routes() { // Checkout URLs case storefrontUrl.isCheckout() && !storefrontUrl.isThankYouPage(): shopify.present(url, { - onClose: () => eventHandlers.onCancel?.(), + onDismiss: () => eventHandlers.onDismiss?.(), onFail: error => eventHandlers.onFail?.(error), }); return; diff --git a/platforms/react-native/sample/src/hooks/useCheckoutEventHandlers.ts b/platforms/react-native/sample/src/hooks/useCheckoutEventHandlers.ts index 5a6c535f8..63cd8ea5a 100644 --- a/platforms/react-native/sample/src/hooks/useCheckoutEventHandlers.ts +++ b/platforms/react-native/sample/src/hooks/useCheckoutEventHandlers.ts @@ -10,7 +10,7 @@ import {Linking} from 'react-native'; interface EventHandlers { onFail?: (error: CheckoutException) => void; - onCancel?: () => void; + onDismiss?: () => void; onRenderStateChange?: (event: RenderStateChangeEvent) => void; onClickLink?: (url: string) => void; } @@ -45,8 +45,8 @@ export function useShopifyEventHandlers(name?: string): EventHandlers { onFail: error => { log('onFail', error); }, - onCancel: () => { - log('onCancel'); + onDismiss: () => { + log('onDismiss'); }, 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..274e6dd42 100644 --- a/platforms/react-native/sample/src/screens/CartScreen.tsx +++ b/platforms/react-native/sample/src/screens/CartScreen.tsx @@ -52,7 +52,7 @@ function CartScreen(): React.JSX.Element { 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 + // surface that emitted the event. Otherwise an `onDismiss` from the // `ShopifyCheckout.present()` sheet would log under the // `AcceleratedCheckoutButtons` namespace and confuse anyone debugging. const sheetEventHandlers = useShopifyEventHandlers('Cart - CheckoutSheet'); @@ -128,8 +128,8 @@ function CartScreen(): React.JSX.Element { present( checkoutURL, { - onClose: () => { - sheetEventHandlers.onCancel?.(); + onDismiss: () => { + sheetEventHandlers.onDismiss?.(); }, onFail: error => { sheetEventHandlers.onFail?.(error); diff --git a/platforms/react-native/test/rct-integration-app/RCTIntegrationAppTests/AcceleratedCheckouts_SupportedTests.swift b/platforms/react-native/test/rct-integration-app/RCTIntegrationAppTests/AcceleratedCheckouts_SupportedTests.swift index 65d3129c8..f5c3c82d7 100644 --- a/platforms/react-native/test/rct-integration-app/RCTIntegrationAppTests/AcceleratedCheckouts_SupportedTests.swift +++ b/platforms/react-native/test/rct-integration-app/RCTIntegrationAppTests/AcceleratedCheckouts_SupportedTests.swift @@ -1,5 +1,6 @@ import Foundation import PassKit +import React @testable import RNShopifyCheckoutKit @testable import ShopifyCheckoutKit import SwiftUI @@ -60,6 +61,15 @@ class AcceleratedCheckouts_SupportedTests: XCTestCase { ).boolValue } + func testDismissIsRegisteredAsDirectEvent() throws { + let viewConfig = RCTComponentData.viewConfig(forViewMangerClass: RCTAcceleratedCheckoutButtonsManager.self) + let directEvents = try XCTUnwrap(viewConfig["directEvents"] as? [String]) + let bubblingEvents = try XCTUnwrap(viewConfig["bubblingEvents"] as? [String]) + + XCTAssertTrue(directEvents.contains("topDismiss")) + XCTAssertFalse(bubblingEvents.contains("topDismiss")) + } + func testConfigureAcceleratedCheckoutsSetsSharedConfigsOnIOS16() { let notificationExpectation = expectation(forNotification: Notification.Name("AcceleratedCheckoutConfigurationUpdated"), object: nil, handler: nil) configureAcceleratedCheckouts(includeApplePay: true) From 5fea736d1eec015bfd4319fbe062c3b8398203e1 Mon Sep 17 00:00:00 2001 From: Kieran Osgood Date: Mon, 14 Sep 2026 16:05:19 +0100 Subject: [PATCH 2/2] Align React Native dismissal lifecycle Assisted-By: devx/3f0e7f93-212d-45d8-b056-138b2da8389f --- platforms/react-native/README.md | 34 ++++++---- .../checkoutkit/CustomCheckoutListener.java | 21 ++++-- .../checkoutkit/DispatchHandle.java | 5 +- .../checkoutkit/ShopifyCheckoutKitModule.java | 24 +++++-- .../checkoutkit/CustomCheckoutListenerTest.kt | 25 ++++++- .../ShopifyCheckoutKitModuleTest.kt | 25 +++++++ .../ios/ShopifyCheckoutKit.swift | 29 ++------ .../components/AcceleratedCheckoutButtons.tsx | 6 +- .../checkout-kit-react-native/src/index.d.ts | 20 +++--- .../checkout-kit-react-native/src/index.ts | 15 ++-- .../src/present-dispatcher.ts | 4 +- .../tests/AcceleratedCheckoutButtons.test.tsx | 19 ++++++ .../tests/index.test.ts | 68 ++++++++++++++++++- .../ShopifyCheckoutKitTests.swift | 15 ++-- 14 files changed, 229 insertions(+), 81 deletions(-) diff --git a/platforms/react-native/README.md b/platforms/react-native/README.md index f728e2470..21a2eab9a 100644 --- a/platforms/react-native/README.md +++ b/platforms/react-native/README.md @@ -780,34 +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, { onDismiss: () => { - // The sheet was dismissed without a terminal error + // 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 | | ---------------------- | ------------------------------------------ | ---------------------------------------------------------------------------------------------------------------- | -| `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. | +| `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). | -`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. +`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 @@ -1160,9 +1163,10 @@ import {CheckoutProtocol} from '@shopify/checkout-kit-react-native'; /> ``` -`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. +`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. --- 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..9491a2549 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 @@ -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 @@ -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 @@ -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; } @@ -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(); } } @@ -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); 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..b491bad6d 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 @@ -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; 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..d671fa635 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 @@ -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; @@ -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; @@ -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 methods = new ArrayList<>(); @@ -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; + } }); } } @@ -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(); 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..64898a076 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 @@ -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() + 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() 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 = diff --git a/platforms/react-native/modules/@shopify/checkout-kit-react-native/android/src/test/java/com/shopify/reactnative/checkoutkit/ShopifyCheckoutKitModuleTest.kt b/platforms/react-native/modules/@shopify/checkout-kit-react-native/android/src/test/java/com/shopify/reactnative/checkoutkit/ShopifyCheckoutKitModuleTest.kt index d9a2221f0..814e219b7 100644 --- a/platforms/react-native/modules/@shopify/checkout-kit-react-native/android/src/test/java/com/shopify/reactnative/checkoutkit/ShopifyCheckoutKitModuleTest.kt +++ b/platforms/react-native/modules/@shopify/checkout-kit-react-native/android/src/test/java/com/shopify/reactnative/checkoutkit/ShopifyCheckoutKitModuleTest.kt @@ -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) 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..afde2aa52 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 @@ -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 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 956edd869..71efeba77 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 @@ -80,12 +80,14 @@ interface CommonAcceleratedCheckoutButtonsProps { applePayStyle?: ApplePayStyle; /** - * Called when checkout fails + * Called when checkout cannot continue. If the failure closes the + * presentation, `onDismiss` follows after closure. */ onFail?: (error: CheckoutException) => void; /** - * Called when the buyer dismisses checkout, including after completion. + * Called after the accelerated checkout presentation closes, independently + * of checkout outcome. */ onDismiss?: () => void; 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 0ac50b2e3..22bb7ddcb 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 @@ -190,23 +190,25 @@ export interface GeolocationRequestEvent { /** * Per-call SDK callbacks for `present(url, callbacks, protocol)`. * - * At most one of `onDismiss` or `onFail` fires per `present(...)` invocation, - * after which the callbacks are released. Programmatic dismissal invokes neither. + * `onFail` reports a terminal checkout failure. If that failure closes the + * presentation, `onDismiss` follows after closure. The callbacks are released + * after dismissal. Programmatic dismissal invokes neither callback. * * `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. + * Fires after the checkout presentation closes, independently of checkout + * outcome. It can follow `onFail` when a terminal failure closes checkout and + * can follow `CheckoutProtocol.complete` when the presentation closes later. * Mirrors `CheckoutListener.onCheckoutDismissed` on Android * and `CheckoutDelegate.checkoutDidDismiss` on iOS. */ onDismiss?: () => void; /** - * Fires when the checkout sheet terminates with an error. + * Fires when checkout cannot continue. When the failure closes the native + * presentation, `onDismiss` follows after closure. * Mirrors `CheckoutListener.onCheckoutFailed` on Android * and `CheckoutDelegate.checkoutDidFail` on iOS. */ @@ -337,9 +339,9 @@ export interface ShopifyCheckoutKit { * Present the checkout. * * @param checkoutURL The URL of the checkout to display. - * @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 callbacks Optional per-call SDK callbacks. A terminal failure can + * invoke `onFail` followed by `onDismiss` after native presentation closure. + * Programmatic dismissal invokes neither callback. * @param protocol Optional per-call Checkout Protocol event handlers. */ present( 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 a05f0d384..8358fc150 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 @@ -129,9 +129,10 @@ class ShopifyCheckout implements ShopifyCheckoutKit { /** * Presents the checkout sheet for a given checkout URL. * - * At most one of `callbacks.onDismiss` or `callbacks.onFail` fires per - * call, after which the per-presentation dispatch subscription is released. - * Programmatic dismissal invokes neither callback. + * `callbacks.onFail` reports a terminal checkout failure. If that failure + * closes the presentation, `callbacks.onDismiss` follows after closure and + * releases the per-presentation dispatch subscription. Programmatic + * dismissal invokes neither callback. * * @param checkoutUrl The URL of the checkout to display * @param callbacks Optional per-call SDK callbacks @@ -156,14 +157,18 @@ class ShopifyCheckout implements ShopifyCheckoutKit { }); if (dispatcher) { - this.dispatchSubscription = RNShopifyCheckoutKit.onDispatch( + const dispatchSubscription = RNShopifyCheckoutKit.onDispatch( envelopeJson => { const result = dispatcher(envelopeJson); - if (result.terminal) { + if ( + result.terminal && + this.dispatchSubscription === dispatchSubscription + ) { this.releaseDispatchSubscription(); } }, ); + this.dispatchSubscription = dispatchSubscription; } RNShopifyCheckoutKit.present(checkoutUrl, subscribedMethods); 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 fe66aaac3..c0e300efa 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 @@ -196,10 +196,10 @@ function routeSdkLifecycleEvent( const failPayload = validateFailPayload(payload); if (failPayload == null) { logParseError('`fail` envelope payload is malformed', envelopeJson); - return {terminal: true}; + return {terminal: false}; } callbacks?.onFail?.(parseCheckoutError(failPayload)); - return {terminal: true}; + return {terminal: false}; } case 'geolocationRequest': { const geoPayload = validateGeolocationRequestPayload(payload); 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 ae0bf63ea..fa17cbc66 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 @@ -354,6 +354,25 @@ describe('AcceleratedCheckoutButtons', () => { expect(onDismiss).toHaveBeenCalledTimes(1); }); + it('delivers failure before a later dismissal as separate events', () => { + const lifecycleEvents: string[] = []; + const {getByTestId} = render( + lifecycleEvents.push('fail')} + onDismiss={() => lifecycleEvents.push('dismiss')} + />, + ); + const nativeComponent = getByTestId('accelerated-checkout-buttons'); + + nativeComponent.props.onFail({ + nativeEvent: {code: 'sdk_error', message: 'boom'}, + }); + nativeComponent.props.onDismiss(); + + expect(lifecycleEvents).toEqual(['fail', 'dismiss']); + }); + it('delivers completion before a later dismissal as separate events', () => { const onComplete = jest.fn(); const onDismiss = jest.fn(); 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 a7591bc16..92d30ff65 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 @@ -435,6 +435,56 @@ describe('ShopifyCheckoutKit', () => { expect(subscription.remove).toHaveBeenCalledTimes(1); }); + it('delivers failure followed by dismissal and releases after dismissal', () => { + const subscription = {remove: jest.fn()}; + NativeModule.onDispatch.mockReturnValueOnce(subscription); + const instance = new ShopifyCheckout(); + const lifecycleEvents: string[] = []; + instance.present(checkoutUrl, { + onFail: () => lifecycleEvents.push('fail'), + onDismiss: () => lifecycleEvents.push('dismiss'), + }); + const dispatch = lastDispatch(); + + dispatch( + JSON.stringify({ + type: 'fail', + payload: { + message: 'Something went wrong', + code: CheckoutErrorCode.sdkError, + }, + }), + ); + + expect(lifecycleEvents).toEqual(['fail']); + expect(subscription.remove).not.toHaveBeenCalled(); + + dispatch(JSON.stringify({type: 'close'})); + + expect(lifecycleEvents).toEqual(['fail', 'dismiss']); + expect(subscription.remove).toHaveBeenCalledTimes(1); + }); + + it('keeps a re-entrant presentation subscribed after dismissal', () => { + const firstSubscription = {remove: jest.fn()}; + const secondSubscription = {remove: jest.fn()}; + NativeModule.onDispatch + .mockReturnValueOnce(firstSubscription) + .mockReturnValueOnce(secondSubscription); + const instance = new ShopifyCheckout(); + instance.present(checkoutUrl, { + onDismiss: () => { + instance.present(`${checkoutUrl}/next`, {onDismiss: jest.fn()}); + }, + }); + const firstDispatch = lastDispatch(); + + firstDispatch(JSON.stringify({type: 'close'})); + + expect(firstSubscription.remove).toHaveBeenCalledTimes(1); + expect(secondSubscription.remove).not.toHaveBeenCalled(); + }); + it('invokes `onDismiss` when the dispatcher receives a close envelope', () => { const instance = new ShopifyCheckout(); const onDismiss = jest.fn(); @@ -727,18 +777,30 @@ describe('ShopifyCheckoutKit', () => { ); }); - it('logs a LifecycleEventParseError when a `fail` envelope payload is malformed', () => { + it('logs a malformed `fail` envelope without dropping the later dismissal', () => { + const subscription = {remove: jest.fn()}; + NativeModule.onDispatch.mockReturnValueOnce(subscription); const instance = new ShopifyCheckout(); + const onDismiss = jest.fn(); const onFail = jest.fn(); - instance.present(checkoutUrl, {onFail}); - lastDispatch()( + instance.present(checkoutUrl, {onDismiss, onFail}); + const dispatch = lastDispatch(); + + dispatch( JSON.stringify({type: 'fail', payload: {message: 'no code'}}), ); + expect(onFail).not.toHaveBeenCalled(); + expect(subscription.remove).not.toHaveBeenCalled(); expect(console.error).toHaveBeenCalledWith( expect.any(LifecycleEventParseError), expect.any(String), ); + + dispatch(JSON.stringify({type: 'close'})); + + expect(onDismiss).toHaveBeenCalledTimes(1); + expect(subscription.remove).toHaveBeenCalledTimes(1); }); it('logs a LifecycleEventParseError when a `geolocationRequest` envelope payload is malformed', () => { 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..a541a6b4c 100644 --- a/platforms/react-native/test/rct-integration-app/RCTIntegrationAppTests/ShopifyCheckoutKitTests.swift +++ b/platforms/react-native/test/rct-integration-app/RCTIntegrationAppTests/ShopifyCheckoutKitTests.swift @@ -497,21 +497,22 @@ class ShopifyCheckoutKitTests: XCTestCase { wait(for: [presentAttemptCompleted], timeout: 1) } - func testCheckoutDidDismissDismissesCheckoutSheetFromRCTWrapper() { - let dismissCompleted = expectation(description: "checkout sheet dismissed") + @MainActor + func testCheckoutDidDismissClearsCheckoutSheetWithoutDismissingItAgain() { + let callbackCompleted = expectation(description: "checkout dismissal handled") let checkoutSheet = DismissTrackingViewController() shopifyCheckoutKit.checkoutSheet = checkoutSheet shopifyCheckoutKit.checkoutDidDismiss() + XCTAssertNil(shopifyCheckoutKit.checkoutSheet) DispatchQueue.main.async { - XCTAssertTrue(checkoutSheet.dismissCalled) - XCTAssertTrue(checkoutSheet.dismissAnimated) - XCTAssertNil(self.shopifyCheckoutKit.checkoutSheet) - dismissCompleted.fulfill() + XCTAssertFalse(checkoutSheet.dismissCalled) + XCTAssertFalse(checkoutSheet.dismissAnimated) + callbackCompleted.fulfill() } - wait(for: [dismissCompleted], timeout: 1) + wait(for: [callbackCompleted], timeout: 1) } }