From 21d60eb574f76b7bf9f089c892c9f4b17dd11f3a Mon Sep 17 00:00:00 2001 From: Mark Murray Date: Fri, 19 Dec 2025 14:01:32 +0000 Subject: [PATCH 1/5] Upgrade cocoapod, update error codes --- ...yCheckoutSheetKit+EventSerialization.swift | 8 +++---- .../ios/ShopifyCheckoutSheetKit.swift | 4 ++-- .../checkout-sheet-kit/src/errors.d.ts | 4 ++++ .../@shopify/checkout-sheet-kit/src/index.ts | 10 ++++++-- sample/Gemfile.lock | 24 +++++++++---------- sample/ios/Podfile.lock | 18 +++++++------- sample/src/App.tsx | 7 ++++-- 7 files changed, 43 insertions(+), 32 deletions(-) diff --git a/modules/@shopify/checkout-sheet-kit/ios/ShopifyCheckoutSheetKit+EventSerialization.swift b/modules/@shopify/checkout-sheet-kit/ios/ShopifyCheckoutSheetKit+EventSerialization.swift index ba9cd6f9..77af6d3f 100644 --- a/modules/@shopify/checkout-sheet-kit/ios/ShopifyCheckoutSheetKit+EventSerialization.swift +++ b/modules/@shopify/checkout-sheet-kit/ios/ShopifyCheckoutSheetKit+EventSerialization.swift @@ -112,7 +112,7 @@ internal enum ShopifyEventSerialization { */ static func serialize(checkoutError error: CheckoutError) -> [String: Any] { switch error { - case let .checkoutExpired(message, code, recoverable): + case let .expired(message, code, recoverable): return [ "__typename": "CheckoutExpiredError", "message": message, @@ -120,7 +120,7 @@ internal enum ShopifyEventSerialization { "recoverable": recoverable ] - case let .checkoutUnavailable(message, code, recoverable): + case let .unavailable(message, code, recoverable): switch code { case let .clientError(clientErrorCode): return [ @@ -139,7 +139,7 @@ internal enum ShopifyEventSerialization { ] } - case let .configurationError(message, code, recoverable): + case let .misconfiguration(message, code, recoverable): return [ "__typename": "ConfigurationError", "message": message, @@ -147,7 +147,7 @@ internal enum ShopifyEventSerialization { "recoverable": recoverable ] - case let .sdkError(underlying, recoverable): + case let .internal(underlying, recoverable): return [ "__typename": "InternalError", "code": "unknown", diff --git a/modules/@shopify/checkout-sheet-kit/ios/ShopifyCheckoutSheetKit.swift b/modules/@shopify/checkout-sheet-kit/ios/ShopifyCheckoutSheetKit.swift index de0a3b05..d465cafd 100644 --- a/modules/@shopify/checkout-sheet-kit/ios/ShopifyCheckoutSheetKit.swift +++ b/modules/@shopify/checkout-sheet-kit/ios/ShopifyCheckoutSheetKit.swift @@ -53,7 +53,7 @@ class RCTShopifyCheckoutSheetKit: RCTEventEmitter, CheckoutDelegate { } override func supportedEvents() -> [String]! { - return ["close", "complete", "start", "error", "addressChangeStart", "submitStart"] + return ["close", "complete", "start", "error", "addressChangeStart", "submitStart", "paymentMethodChangeStart"] } override func startObserving() { @@ -110,7 +110,7 @@ class RCTShopifyCheckoutSheetKit: RCTEventEmitter, CheckoutDelegate { self.sendEvent(withName: "close", body: nil) } - self.checkoutSheet?.dismiss(animated: true) + // self.checkoutSheet?.dismiss(animated: true) } } diff --git a/modules/@shopify/checkout-sheet-kit/src/errors.d.ts b/modules/@shopify/checkout-sheet-kit/src/errors.d.ts index faff876b..6c19249b 100644 --- a/modules/@shopify/checkout-sheet-kit/src/errors.d.ts +++ b/modules/@shopify/checkout-sheet-kit/src/errors.d.ts @@ -43,6 +43,10 @@ export enum CheckoutNativeErrorType { UnknownError = 'UnknownError', } +/** + * @important TODO: Fix mapping here. + * Currently "unknown" is returned for every code + */ function getCheckoutErrorCode(code: string | undefined): CheckoutErrorCode { const codeKey = Object.keys(CheckoutErrorCode).find( key => CheckoutErrorCode[key as keyof typeof CheckoutErrorCode] === code, diff --git a/modules/@shopify/checkout-sheet-kit/src/index.ts b/modules/@shopify/checkout-sheet-kit/src/index.ts index dbdfb005..335142f4 100644 --- a/modules/@shopify/checkout-sheet-kit/src/index.ts +++ b/modules/@shopify/checkout-sheet-kit/src/index.ts @@ -186,7 +186,10 @@ class ShopifyCheckoutSheet implements ShopifyCheckoutSheetKit { eventCallback = this.interceptEventEmission('start', callback); break; case 'addressChangeStart': - eventCallback = this.interceptEventEmission('addressChangeStart', callback); + eventCallback = this.interceptEventEmission( + 'addressChangeStart', + callback, + ); break; case 'submitStart': eventCallback = this.interceptEventEmission('submitStart', callback); @@ -524,7 +527,10 @@ export type { AcceleratedCheckoutButtonsProps, RenderStateChangeEvent, } from './components/AcceleratedCheckoutButtons'; -export type {ShopifyCheckoutProps, ShopifyCheckoutRef} from './components/Checkout'; +export type { + ShopifyCheckoutProps, + ShopifyCheckoutRef, +} from './components/Checkout'; export type {ShopifyCheckoutEventProviderProps} from './ShopifyCheckoutEventProvider'; // Components diff --git a/sample/Gemfile.lock b/sample/Gemfile.lock index 13b9ab76..6f0dc4d5 100644 --- a/sample/Gemfile.lock +++ b/sample/Gemfile.lock @@ -1,11 +1,8 @@ GEM remote: https://rubygems.org/ specs: - CFPropertyList (3.0.7) - base64 - nkf - rexml - activesupport (7.2.2.2) + CFPropertyList (3.0.8) + activesupport (7.2.3) base64 benchmark (>= 0.3) bigdecimal @@ -17,15 +14,15 @@ GEM minitest (>= 5.1) securerandom (>= 0.3) tzinfo (~> 2.0, >= 2.0.5) - addressable (2.8.7) - public_suffix (>= 2.0.2, < 7.0) + addressable (2.8.8) + public_suffix (>= 2.0.2, < 8.0) algoliasearch (1.27.5) httpclient (~> 2.8, >= 2.8.3) json (>= 1.5.1) atomos (0.1.3) base64 (0.3.0) - benchmark (0.4.1) - bigdecimal (3.2.3) + benchmark (0.5.0) + bigdecimal (4.0.1) claide (1.1.0) cocoapods (1.15.2) addressable (~> 2.8) @@ -68,7 +65,7 @@ GEM cocoapods-try (1.2.0) colored2 (3.1.2) concurrent-ruby (1.3.3) - connection_pool (2.5.4) + connection_pool (3.0.2) drb (2.2.3) escape (0.0.4) ethon (0.15.0) @@ -81,15 +78,16 @@ GEM mutex_m i18n (1.14.7) concurrent-ruby (~> 1.0) - json (2.15.0) + json (2.18.0) logger (1.7.0) - minitest (5.25.5) + minitest (6.0.0) + prism (~> 1.5) molinillo (0.8.0) mutex_m (0.3.0) nanaimo (0.3.0) nap (1.1.0) netrc (0.11.0) - nkf (0.2.0) + prism (1.7.0) public_suffix (4.0.7) rexml (3.4.4) ruby-macho (2.5.1) diff --git a/sample/ios/Podfile.lock b/sample/ios/Podfile.lock index 93a8bfae..ebdf163b 100644 --- a/sample/ios/Podfile.lock +++ b/sample/ios/Podfile.lock @@ -2544,11 +2544,11 @@ PODS: - ReactCommon/turbomodule/core - SocketRocket - Yoga - - ShopifyCheckoutSheetKit (3.4.0-rc.8): - - ShopifyCheckoutSheetKit/Core (= 3.4.0-rc.8) - - ShopifyCheckoutSheetKit/AcceleratedCheckouts (3.4.0-rc.8): + - ShopifyCheckoutSheetKit (3.4.0-rc.9): + - ShopifyCheckoutSheetKit/Core (= 3.4.0-rc.9) + - ShopifyCheckoutSheetKit/AcceleratedCheckouts (3.4.0-rc.9): - ShopifyCheckoutSheetKit/Core - - ShopifyCheckoutSheetKit/Core (3.4.0-rc.8) + - ShopifyCheckoutSheetKit/Core (3.4.0-rc.9) - SocketRocket (0.7.1) - Yoga (0.0.0) @@ -2595,7 +2595,7 @@ DEPENDENCIES: - React-Mapbuffer (from `../../node_modules/react-native/ReactCommon`) - React-microtasksnativemodule (from `../../node_modules/react-native/ReactCommon/react/nativemodule/microtasks`) - react-native-config (from `../node_modules/react-native-config`) - - react-native-safe-area-context (from `../../node_modules/react-native-safe-area-context`) + - react-native-safe-area-context (from `../node_modules/react-native-safe-area-context`) - React-NativeModulesApple (from `../../node_modules/react-native/ReactCommon/react/nativemodule/core/platform/ios`) - React-oscompat (from `../../node_modules/react-native/ReactCommon/oscompat`) - React-perflogger (from `../../node_modules/react-native/ReactCommon/reactperflogger`) @@ -2630,7 +2630,7 @@ DEPENDENCIES: - "RNCMaskedView (from `../node_modules/@react-native-masked-view/masked-view`)" - RNGestureHandler (from `../../node_modules/react-native-gesture-handler`) - RNReanimated (from `../node_modules/react-native-reanimated`) - - RNScreens (from `../../node_modules/react-native-screens`) + - RNScreens (from `../node_modules/react-native-screens`) - "RNShopifyCheckoutSheetKit (from `../../modules/@shopify/checkout-sheet-kit`)" - RNVectorIcons (from `../node_modules/react-native-vector-icons`) - ShopifyCheckoutSheetKit (from `~/src/github.com/Shopify/checkout-sheet-kit-swift`) @@ -2727,7 +2727,7 @@ EXTERNAL SOURCES: react-native-config: :path: "../node_modules/react-native-config" react-native-safe-area-context: - :path: "../../node_modules/react-native-safe-area-context" + :path: "../node_modules/react-native-safe-area-context" React-NativeModulesApple: :path: "../../node_modules/react-native/ReactCommon/react/nativemodule/core/platform/ios" React-oscompat: @@ -2797,7 +2797,7 @@ EXTERNAL SOURCES: RNReanimated: :path: "../node_modules/react-native-reanimated" RNScreens: - :path: "../../node_modules/react-native-screens" + :path: "../node_modules/react-native-screens" RNShopifyCheckoutSheetKit: :path: "../../modules/@shopify/checkout-sheet-kit" RNVectorIcons: @@ -2887,7 +2887,7 @@ SPEC CHECKSUMS: RNScreens: 26bb60cdb2ef2ca06fd87feefc495072f25982a7 RNShopifyCheckoutSheetKit: 26063fe3dfaed4a2f8ba0c6a5c92b8dfef15c1fb RNVectorIcons: be4d047a76ad307ffe54732208fb0498fcb8477f - ShopifyCheckoutSheetKit: 8e1574805ee7b3043608c92f3cdbea3adf7079fa + ShopifyCheckoutSheetKit: 4941c60418f1da7637a73a3f1aa33f68304ce1d3 SocketRocket: d4aabe649be1e368d1318fdf28a022d714d65748 Yoga: a742cc68e8366fcfc681808162492bc0aa7a9498 diff --git a/sample/src/App.tsx b/sample/src/App.tsx index 916fca03..88012044 100644 --- a/sample/src/App.tsx +++ b/sample/src/App.tsx @@ -228,8 +228,11 @@ function AppWithContext({children}: PropsWithChildren) { const addressChangeStart = shopify.addEventListener( 'addressChangeStart', - (event) => { - console.log('[App] onAddressChangeStart event received from imperative API:', event); + event => { + console.log( + '[App] onAddressChangeStart event received from imperative API:', + event, + ); }, ); From 53708327eadabb8282d256c9cf7e607762107402 Mon Sep 17 00:00:00 2001 From: Daniel Kift Date: Mon, 22 Dec 2025 12:04:08 +0000 Subject: [PATCH 2/5] updates --- .../checkoutsheetkit/RCTCheckoutWebView.java | 10 +- .../ios/ShopifyCheckoutSheetKit.swift | 2 +- .../src/components/Checkout.tsx | 11 +- .../checkout-sheet-kit/src/errors.d.ts | 149 +++++- .../@shopify/checkout-sheet-kit/src/index.ts | 37 +- .../tests/CheckoutError.test.tsx | 472 ++++++++++++++++++ .../checkout-sheet-kit/tests/index.test.ts | 50 +- .../ShopifyCheckoutSheetKitModuleTest.java | 6 +- sample/ios/Podfile.lock | 8 +- .../CheckoutDidFailTests.swift | 36 +- 10 files changed, 705 insertions(+), 76 deletions(-) create mode 100644 modules/@shopify/checkout-sheet-kit/tests/CheckoutError.test.tsx diff --git a/modules/@shopify/checkout-sheet-kit/android/src/main/java/com/shopify/reactnative/checkoutsheetkit/RCTCheckoutWebView.java b/modules/@shopify/checkout-sheet-kit/android/src/main/java/com/shopify/reactnative/checkoutsheetkit/RCTCheckoutWebView.java index 0d154ed2..066da8f6 100644 --- a/modules/@shopify/checkout-sheet-kit/android/src/main/java/com/shopify/reactnative/checkoutsheetkit/RCTCheckoutWebView.java +++ b/modules/@shopify/checkout-sheet-kit/android/src/main/java/com/shopify/reactnative/checkoutsheetkit/RCTCheckoutWebView.java @@ -62,8 +62,6 @@ of this software and associated documentation files (the "Software"), to deal import java.util.Map; import java.util.Objects; -import kotlin.Unit; - public class RCTCheckoutWebView extends FrameLayout { private static final String TAG = "RCTCheckoutWebView"; private final ThemedReactContext context; @@ -195,13 +193,7 @@ private CheckoutWebViewEventProcessor getCheckoutWebViewEventProcessor() { Activity currentActivity = this.context.getCurrentActivity(); InlineCheckoutEventProcessor eventProcessor = new InlineCheckoutEventProcessor(currentActivity); - return new CheckoutWebViewEventProcessor( - eventProcessor, - (visible) -> Unit.INSTANCE, // toggleHeader - (error) -> Unit.INSTANCE, // closeCheckoutDialogWithError - (visibility) -> Unit.INSTANCE, // setProgressBarVisibility - (percentage) -> Unit.INSTANCE // updateProgressBarPercentage - ); + return new CheckoutWebViewEventProcessor(eventProcessor); } void removeCheckout() { diff --git a/modules/@shopify/checkout-sheet-kit/ios/ShopifyCheckoutSheetKit.swift b/modules/@shopify/checkout-sheet-kit/ios/ShopifyCheckoutSheetKit.swift index d465cafd..e61a25a2 100644 --- a/modules/@shopify/checkout-sheet-kit/ios/ShopifyCheckoutSheetKit.swift +++ b/modules/@shopify/checkout-sheet-kit/ios/ShopifyCheckoutSheetKit.swift @@ -110,7 +110,7 @@ class RCTShopifyCheckoutSheetKit: RCTEventEmitter, CheckoutDelegate { self.sendEvent(withName: "close", body: nil) } - // self.checkoutSheet?.dismiss(animated: true) + self.checkoutSheet?.dismiss(animated: true) } } diff --git a/modules/@shopify/checkout-sheet-kit/src/components/Checkout.tsx b/modules/@shopify/checkout-sheet-kit/src/components/Checkout.tsx index 1ef09994..4f75016a 100644 --- a/modules/@shopify/checkout-sheet-kit/src/components/Checkout.tsx +++ b/modules/@shopify/checkout-sheet-kit/src/components/Checkout.tsx @@ -38,7 +38,11 @@ import type { CheckoutStartEvent, CheckoutSubmitStartEvent, } from '../events'; -import type {CheckoutException} from '../errors'; +import { + parseCheckoutError, + type CheckoutException, + type CheckoutNativeError, +} from '../errors.d'; export interface ShopifyCheckoutProps { /** @@ -125,7 +129,7 @@ interface NativeShopifyCheckoutWebViewProps { style?: ViewStyle; testID?: string; onStart?: (event: {nativeEvent: CheckoutStartEvent}) => void; - onError?: (event: {nativeEvent: CheckoutException}) => void; + onError?: (event: {nativeEvent: CheckoutNativeError}) => void; onComplete?: (event: {nativeEvent: CheckoutCompleteEvent}) => void; onCancel?: () => void; onLinkClick?: (event: {nativeEvent: {url: string}}) => void; @@ -231,7 +235,8 @@ export const ShopifyCheckout = forwardRef< Required['onError'] >( event => { - onError?.(event.nativeEvent); + const transformedError = parseCheckoutError(event.nativeEvent); + onError?.(transformedError); }, [onError], ); diff --git a/modules/@shopify/checkout-sheet-kit/src/errors.d.ts b/modules/@shopify/checkout-sheet-kit/src/errors.d.ts index 6c19249b..6739c7d7 100644 --- a/modules/@shopify/checkout-sheet-kit/src/errors.d.ts +++ b/modules/@shopify/checkout-sheet-kit/src/errors.d.ts @@ -21,16 +21,118 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ +/** + * Error codes that can be returned from checkout errors. + */ export enum CheckoutErrorCode { + // ============================================================================ + // Configuration errors + // ============================================================================ + + /** + * The app authentication payload passed could not be decoded. + */ + invalidPayload = 'invalid_payload', + + /** + * The app authentication JWT signature or encrypted token signature was invalid. + */ + invalidSignature = 'invalid_signature', + + /** + * The app authentication access token was not valid for the shop. + */ + notAuthorized = 'not_authorized', + + /** + * The provided app authentication payload has expired. + */ + payloadExpired = 'payload_expired', + + /** + * The buyer must be logged in to a customer account to proceed with checkout. + */ + customerAccountRequired = 'customer_account_required', + + /** + * The storefront requires a password to access checkout. + */ storefrontPasswordRequired = 'storefront_password_required', - cartExpired = 'cart_expired', + + // ============================================================================ + // Cart errors + // ============================================================================ + + /** + * The cart associated with the checkout has already been completed. + */ cartCompleted = 'cart_completed', + + /** + * The cart is invalid or no longer exists. + */ invalidCart = 'invalid_cart', + + // ============================================================================ + // Client errors + // ============================================================================ + + /** + * Checkout preloading has been temporarily disabled via killswitch. + */ + killswitchEnabled = 'killswitch_enabled', + + /** + * An unrecoverable error occurred during checkout. + */ + unrecoverableFailure = 'unrecoverable_failure', + + /** + * A policy violation was detected during checkout. + */ + policyViolation = 'policy_violation', + + /** + * An error occurred processing a vaulted payment method. + */ + vaultedPaymentError = 'vaulted_payment_error', + + // ============================================================================ + // Internal errors + // ============================================================================ + + /** + * A client-side error occurred in the SDK. + */ clientError = 'client_error', + + /** + * An HTTP error occurred while communicating with the checkout. + */ httpError = 'http_error', + + /** + * Failed to send a message to the checkout bridge. + */ sendingBridgeEventError = 'error_sending_message', + + /** + * Failed to receive a message from the checkout bridge. + */ receivingBridgeEventError = 'error_receiving_message', + + /** + * The WebView render process has terminated unexpectedly (Android only). + */ renderProcessGone = 'render_process_gone', + + // ============================================================================ + // Fallback + // ============================================================================ + + /** + * An unknown or unrecognized error code was received. + */ unknown = 'unknown', } @@ -44,20 +146,27 @@ export enum CheckoutNativeErrorType { } /** - * @important TODO: Fix mapping here. - * Currently "unknown" is returned for every code + * Maps a native error code string to a CheckoutErrorCode enum value. */ function getCheckoutErrorCode(code: string | undefined): CheckoutErrorCode { + if (!code) { + return CheckoutErrorCode.unknown; + } + + const normalizedCode = code.toLowerCase(); + const codeKey = Object.keys(CheckoutErrorCode).find( - key => CheckoutErrorCode[key as keyof typeof CheckoutErrorCode] === code, + key => CheckoutErrorCode[key as keyof typeof CheckoutErrorCode] === normalizedCode, ); - return codeKey ? CheckoutErrorCode[codeKey] : CheckoutErrorCode.unknown; + return codeKey + ? CheckoutErrorCode[codeKey as keyof typeof CheckoutErrorCode] + : CheckoutErrorCode.unknown; } type BridgeError = { __typename: CheckoutNativeErrorType; - code: CheckoutErrorCode; + code: string; message: string; recoverable: boolean; }; @@ -119,11 +228,13 @@ export class InternalError { code: CheckoutErrorCode; message: string; recoverable: boolean; + name: string; constructor(exception: CheckoutNativeError) { this.code = getCheckoutErrorCode(exception.code); this.message = exception.message; this.recoverable = exception.recoverable; + this.name = this.constructor.name; } } @@ -134,3 +245,29 @@ export type CheckoutException = | ConfigurationError | GenericError | InternalError; + +/** + * Transforms a native error object into the appropriate CheckoutException class. + * Maps __typename to the correct error class and normalizes error codes. + * + * @param exception Raw error object from native bridge + * @returns Appropriate CheckoutException instance + */ +export function parseCheckoutError( + exception: CheckoutNativeError, +): CheckoutException { + switch (exception?.__typename) { + case CheckoutNativeErrorType.InternalError: + return new InternalError(exception); + case CheckoutNativeErrorType.ConfigurationError: + return new ConfigurationError(exception); + case CheckoutNativeErrorType.CheckoutClientError: + return new CheckoutClientError(exception); + case CheckoutNativeErrorType.CheckoutHTTPError: + return new CheckoutHTTPError(exception); + case CheckoutNativeErrorType.CheckoutExpiredError: + return new CheckoutExpiredError(exception); + default: + return new GenericError(exception); + } +} diff --git a/modules/@shopify/checkout-sheet-kit/src/index.ts b/modules/@shopify/checkout-sheet-kit/src/index.ts index 335142f4..0aec42e4 100644 --- a/modules/@shopify/checkout-sheet-kit/src/index.ts +++ b/modules/@shopify/checkout-sheet-kit/src/index.ts @@ -47,17 +47,18 @@ import type { ShopifyCheckoutSheetKit, } from './index.d'; import {AcceleratedCheckoutWallet} from './index.d'; -import type {CheckoutException, CheckoutNativeError} from './errors.d'; +import type {CheckoutException} from './errors.d'; import { - CheckoutExpiredError, CheckoutClientError, + CheckoutErrorCode, + CheckoutExpiredError, CheckoutHTTPError, - ConfigurationError, - InternalError, CheckoutNativeErrorType, + ConfigurationError, GenericError, + InternalError, + parseCheckoutError, } from './errors.d'; -import {CheckoutErrorCode} from './errors.d'; import {ApplePayLabel} from './components/AcceleratedCheckoutButtons'; const RNShopifyCheckoutSheetKit = NativeModules.ShopifyCheckoutSheetKit; @@ -198,7 +199,7 @@ class ShopifyCheckoutSheet implements ShopifyCheckoutSheetKit { eventCallback = this.interceptEventEmission( 'error', callback, - this.parseCheckoutError, + parseCheckoutError, ); break; case 'geolocationRequest': @@ -383,30 +384,6 @@ class ShopifyCheckoutSheet implements ShopifyCheckoutSheetKit { return status === 'granted'; } - /** - * Converts native checkout errors into appropriate error class instances - * @param exception The native error to parse - * @returns Appropriate CheckoutException instance - */ - private parseCheckoutError( - exception: CheckoutNativeError, - ): CheckoutException { - switch (exception?.__typename) { - case CheckoutNativeErrorType.InternalError: - return new InternalError(exception); - case CheckoutNativeErrorType.ConfigurationError: - return new ConfigurationError(exception); - case CheckoutNativeErrorType.CheckoutClientError: - return new CheckoutClientError(exception); - case CheckoutNativeErrorType.CheckoutHTTPError: - return new CheckoutHTTPError(exception); - case CheckoutNativeErrorType.CheckoutExpiredError: - return new CheckoutExpiredError(exception); - default: - return new GenericError(exception); - } - } - /** * Handles event emission parsing and transformation * @param event The type of event being intercepted diff --git a/modules/@shopify/checkout-sheet-kit/tests/CheckoutError.test.tsx b/modules/@shopify/checkout-sheet-kit/tests/CheckoutError.test.tsx new file mode 100644 index 00000000..aa7ef4e7 --- /dev/null +++ b/modules/@shopify/checkout-sheet-kit/tests/CheckoutError.test.tsx @@ -0,0 +1,472 @@ +// Mock the native view component BEFORE imports +jest.mock('react-native', () => { + const RN = jest.requireActual('react-native'); + const React = jest.requireActual('react'); + + RN.UIManager.getViewManagerConfig = jest.fn(() => ({ + Commands: {}, + })); + + // Create mock component + const MockRCTCheckoutWebView = (props: any) => { + return React.createElement('View', props); + }; + + return Object.setPrototypeOf( + { + requireNativeComponent: jest.fn(() => MockRCTCheckoutWebView), + }, + RN, + ); +}); + +import React from 'react'; +import {render, act} from '@testing-library/react-native'; +import {ShopifyCheckout} from '../src/components/Checkout'; +import { + CheckoutErrorCode, + ConfigurationError, + CheckoutClientError, + CheckoutExpiredError, + CheckoutHTTPError, + InternalError, + GenericError, +} from '../src/errors.d'; + +describe('Checkout Component - Error Events', () => { + const mockCheckoutUrl = 'https://example.myshopify.com/checkout'; + + describe('error transformation', () => { + it('transforms native error to ConfigurationError class', () => { + const onError = jest.fn(); + + const {getByTestId} = render( + , + ); + + const nativeComponent = getByTestId('checkout-webview'); + + act(() => { + nativeComponent.props.onError({ + nativeEvent: { + __typename: 'ConfigurationError', + code: 'STOREFRONT_PASSWORD_REQUIRED', + message: 'Storefront password required', + recoverable: false, + }, + }); + }); + + expect(onError).toHaveBeenCalledTimes(1); + + const receivedError = onError.mock.calls[0][0]; + expect(receivedError).toBeInstanceOf(ConfigurationError); + expect(receivedError.code).toBe( + CheckoutErrorCode.storefrontPasswordRequired, + ); + expect(receivedError.message).toBe('Storefront password required'); + expect(receivedError.recoverable).toBe(false); + // Should not expose __typename + expect(receivedError).not.toHaveProperty('__typename'); + }); + + it('transforms native error to CheckoutClientError class', () => { + const onError = jest.fn(); + + const {getByTestId} = render( + , + ); + + const nativeComponent = getByTestId('checkout-webview'); + + act(() => { + nativeComponent.props.onError({ + nativeEvent: { + __typename: 'CheckoutClientError', + code: 'KILLSWITCH_ENABLED', + message: 'Checkout unavailable', + recoverable: false, + }, + }); + }); + + expect(onError).toHaveBeenCalledTimes(1); + + const receivedError = onError.mock.calls[0][0]; + expect(receivedError).toBeInstanceOf(CheckoutClientError); + // KILLSWITCH_ENABLED is now in the enum + expect(receivedError.code).toBe(CheckoutErrorCode.killswitchEnabled); + expect(receivedError.message).toBe('Checkout unavailable'); + }); + + it('transforms native error to CheckoutExpiredError class', () => { + const onError = jest.fn(); + + const {getByTestId} = render( + , + ); + + const nativeComponent = getByTestId('checkout-webview'); + + act(() => { + nativeComponent.props.onError({ + nativeEvent: { + __typename: 'CheckoutExpiredError', + code: 'CART_COMPLETED', + message: 'Cart already completed', + recoverable: false, + }, + }); + }); + + expect(onError).toHaveBeenCalledTimes(1); + + const receivedError = onError.mock.calls[0][0]; + expect(receivedError).toBeInstanceOf(CheckoutExpiredError); + expect(receivedError.code).toBe(CheckoutErrorCode.cartCompleted); + expect(receivedError.message).toBe('Cart already completed'); + }); + + it.each([ + // Configuration errors + {nativeCode: 'INVALID_PAYLOAD', expectedCode: CheckoutErrorCode.invalidPayload}, + {nativeCode: 'INVALID_SIGNATURE', expectedCode: CheckoutErrorCode.invalidSignature}, + {nativeCode: 'NOT_AUTHORIZED', expectedCode: CheckoutErrorCode.notAuthorized}, + {nativeCode: 'PAYLOAD_EXPIRED', expectedCode: CheckoutErrorCode.payloadExpired}, + {nativeCode: 'CUSTOMER_ACCOUNT_REQUIRED', expectedCode: CheckoutErrorCode.customerAccountRequired}, + {nativeCode: 'STOREFRONT_PASSWORD_REQUIRED', expectedCode: CheckoutErrorCode.storefrontPasswordRequired}, + // Expired checkout errors + {nativeCode: 'CART_COMPLETED', expectedCode: CheckoutErrorCode.cartCompleted}, + {nativeCode: 'INVALID_CART', expectedCode: CheckoutErrorCode.invalidCart}, + // Client errors + {nativeCode: 'KILLSWITCH_ENABLED', expectedCode: CheckoutErrorCode.killswitchEnabled}, + {nativeCode: 'UNRECOVERABLE_FAILURE', expectedCode: CheckoutErrorCode.unrecoverableFailure}, + {nativeCode: 'POLICY_VIOLATION', expectedCode: CheckoutErrorCode.policyViolation}, + {nativeCode: 'VAULTED_PAYMENT_ERROR', expectedCode: CheckoutErrorCode.vaultedPaymentError}, + ])( + 'maps native code $nativeCode to CheckoutErrorCode enum', + ({nativeCode, expectedCode}) => { + const onError = jest.fn(); + + const {getByTestId} = render( + , + ); + + const nativeComponent = getByTestId('checkout-webview'); + + act(() => { + nativeComponent.props.onError({ + nativeEvent: { + __typename: 'ConfigurationError', + code: nativeCode, + message: `Test error for ${nativeCode}`, + recoverable: false, + }, + }); + }); + + const receivedError = onError.mock.calls[0][0]; + expect(receivedError.code).toBe(expectedCode); + }, + ); + }); + + it('does not crash when onError prop is not provided', () => { + const {getByTestId} = render( + , + ); + + const nativeComponent = getByTestId('checkout-webview'); + + expect(() => { + act(() => { + nativeComponent.props.onError({ + nativeEvent: { + __typename: 'ConfigurationError', + code: 'STOREFRONT_PASSWORD_REQUIRED', + message: 'Test error', + recoverable: false, + }, + }); + }); + }).not.toThrow(); + }); + + describe('error object structure', () => { + it('ConfigurationError has correct properties', () => { + const onError = jest.fn(); + + const {getByTestId} = render( + , + ); + + const nativeComponent = getByTestId('checkout-webview'); + + act(() => { + nativeComponent.props.onError({ + nativeEvent: { + __typename: 'ConfigurationError', + code: 'INVALID_PAYLOAD', + message: 'The authentication payload could not be decoded', + recoverable: false, + }, + }); + }); + + const error = onError.mock.calls[0][0]; + + expect(error).toBeInstanceOf(ConfigurationError); + expect(error).toMatchObject({ + code: CheckoutErrorCode.invalidPayload, + message: 'The authentication payload could not be decoded', + recoverable: false, + name: 'ConfigurationError', + }); + expect(error).not.toHaveProperty('__typename'); + expect(error).not.toHaveProperty('statusCode'); + }); + + it('CheckoutClientError has correct properties', () => { + const onError = jest.fn(); + + const {getByTestId} = render( + , + ); + + const nativeComponent = getByTestId('checkout-webview'); + + act(() => { + nativeComponent.props.onError({ + nativeEvent: { + __typename: 'CheckoutClientError', + code: 'UNRECOVERABLE_FAILURE', + message: 'An unrecoverable error occurred', + recoverable: false, + }, + }); + }); + + const error = onError.mock.calls[0][0]; + + expect(error).toBeInstanceOf(CheckoutClientError); + expect(error).toMatchObject({ + code: CheckoutErrorCode.unrecoverableFailure, + message: 'An unrecoverable error occurred', + recoverable: false, + name: 'CheckoutClientError', + }); + expect(error).not.toHaveProperty('__typename'); + expect(error).not.toHaveProperty('statusCode'); + }); + + it('CheckoutExpiredError has correct properties', () => { + const onError = jest.fn(); + + const {getByTestId} = render( + , + ); + + const nativeComponent = getByTestId('checkout-webview'); + + act(() => { + nativeComponent.props.onError({ + nativeEvent: { + __typename: 'CheckoutExpiredError', + code: 'INVALID_CART', + message: 'The cart is no longer valid', + recoverable: true, + }, + }); + }); + + const error = onError.mock.calls[0][0]; + + expect(error).toBeInstanceOf(CheckoutExpiredError); + expect(error).toMatchObject({ + code: CheckoutErrorCode.invalidCart, + message: 'The cart is no longer valid', + recoverable: true, + name: 'CheckoutExpiredError', + }); + expect(error).not.toHaveProperty('__typename'); + expect(error).not.toHaveProperty('statusCode'); + }); + + it('CheckoutHTTPError has correct properties including statusCode', () => { + const onError = jest.fn(); + + const {getByTestId} = render( + , + ); + + const nativeComponent = getByTestId('checkout-webview'); + + act(() => { + nativeComponent.props.onError({ + nativeEvent: { + __typename: 'CheckoutHTTPError', + code: 'http_error', + message: 'Service temporarily unavailable', + recoverable: true, + statusCode: 503, + }, + }); + }); + + const error = onError.mock.calls[0][0]; + + expect(error).toBeInstanceOf(CheckoutHTTPError); + expect(error).toMatchObject({ + code: CheckoutErrorCode.httpError, + message: 'Service temporarily unavailable', + recoverable: true, + statusCode: 503, + name: 'CheckoutHTTPError', + }); + expect(error).not.toHaveProperty('__typename'); + }); + + it('InternalError has correct properties', () => { + const onError = jest.fn(); + + const {getByTestId} = render( + , + ); + + const nativeComponent = getByTestId('checkout-webview'); + + act(() => { + nativeComponent.props.onError({ + nativeEvent: { + __typename: 'InternalError', + code: 'error_sending_message', + message: 'Failed to send bridge message', + recoverable: false, + }, + }); + }); + + const error = onError.mock.calls[0][0]; + + expect(error).toBeInstanceOf(InternalError); + expect(error).toMatchObject({ + code: CheckoutErrorCode.sendingBridgeEventError, + message: 'Failed to send bridge message', + recoverable: false, + name: 'InternalError', + }); + expect(error).not.toHaveProperty('__typename'); + }); + + it('GenericError is returned for unknown __typename', () => { + const onError = jest.fn(); + + const {getByTestId} = render( + , + ); + + const nativeComponent = getByTestId('checkout-webview'); + + act(() => { + nativeComponent.props.onError({ + nativeEvent: { + __typename: 'SomeUnknownErrorType', + code: 'some_unknown_code', + message: 'Something went wrong', + recoverable: false, + }, + }); + }); + + const error = onError.mock.calls[0][0]; + + expect(error).toBeInstanceOf(GenericError); + expect(error).toMatchObject({ + code: CheckoutErrorCode.unknown, + message: 'Something went wrong', + recoverable: false, + name: 'GenericError', + }); + expect(error).not.toHaveProperty('__typename'); + }); + + it('GenericError preserves statusCode when provided', () => { + const onError = jest.fn(); + + const {getByTestId} = render( + , + ); + + const nativeComponent = getByTestId('checkout-webview'); + + act(() => { + nativeComponent.props.onError({ + nativeEvent: { + __typename: 'UnknownError', + code: 'unknown', + message: 'Server error', + recoverable: false, + statusCode: 500, + }, + }); + }); + + const error = onError.mock.calls[0][0]; + + expect(error).toBeInstanceOf(GenericError); + expect(error).toMatchObject({ + code: CheckoutErrorCode.unknown, + message: 'Server error', + recoverable: false, + statusCode: 500, + name: 'GenericError', + }); + expect(error).not.toHaveProperty('__typename'); + }); + }); +}); + diff --git a/modules/@shopify/checkout-sheet-kit/tests/index.test.ts b/modules/@shopify/checkout-sheet-kit/tests/index.test.ts index f2024972..fd6aea2d 100644 --- a/modules/@shopify/checkout-sheet-kit/tests/index.test.ts +++ b/modules/@shopify/checkout-sheet-kit/tests/index.test.ts @@ -490,8 +490,8 @@ describe('ShopifyCheckoutSheetKit', () => { const expiredError = { __typename: CheckoutNativeErrorType.CheckoutExpiredError, - message: 'Customer Account Required', - code: CheckoutErrorCode.cartExpired, + message: 'Cart Completed', + code: CheckoutErrorCode.cartCompleted, recoverable: false, }; @@ -554,6 +554,52 @@ describe('ShopifyCheckoutSheetKit', () => { expect(calledWith).toBeInstanceOf(GenericError); expect(callback).toHaveBeenCalledWith(new GenericError(error as any)); }); + + describe('error code mapping from native UPPER_SNAKE_CASE codes', () => { + it.each([ + // New checkout.error event codes from native (UPPER_SNAKE_CASE) + { + nativeCode: 'STOREFRONT_PASSWORD_REQUIRED', + expectedCode: CheckoutErrorCode.storefrontPasswordRequired, + }, + { + nativeCode: 'CART_COMPLETED', + expectedCode: CheckoutErrorCode.cartCompleted, + }, + { + nativeCode: 'INVALID_CART', + expectedCode: CheckoutErrorCode.invalidCart, + }, + // Existing codes in lower_snake_case should still work + { + nativeCode: 'storefront_password_required', + expectedCode: CheckoutErrorCode.storefrontPasswordRequired, + }, + { + nativeCode: 'cart_completed', + expectedCode: CheckoutErrorCode.cartCompleted, + }, + ])( + 'maps native code "$nativeCode" to CheckoutErrorCode.$expectedCode', + ({nativeCode, expectedCode}) => { + const instance = new ShopifyCheckoutSheet(); + const callback = jest.fn(); + instance.addEventListener('error', callback); + + const error = { + __typename: CheckoutNativeErrorType.ConfigurationError, + message: 'Test error', + code: nativeCode, // Raw string from native, not enum + recoverable: false, + }; + + eventEmitter.emit('error', error); + + const calledWith = callback.mock.calls[0][0]; + expect(calledWith.code).toBe(expectedCode); + }, + ); + }); }); }); diff --git a/sample/android/app/src/test/java/com/shopify/checkoutkitreactnative/ShopifyCheckoutSheetKitModuleTest.java b/sample/android/app/src/test/java/com/shopify/checkoutkitreactnative/ShopifyCheckoutSheetKitModuleTest.java index b11184d5..2a0300fd 100644 --- a/sample/android/app/src/test/java/com/shopify/checkoutkitreactnative/ShopifyCheckoutSheetKitModuleTest.java +++ b/sample/android/app/src/test/java/com/shopify/checkoutkitreactnative/ShopifyCheckoutSheetKitModuleTest.java @@ -474,8 +474,8 @@ public void testCanProcessCheckoutExpiredErrors() { // Use minimal mocking - just enough to test the processing logic CheckoutExpiredException mockException = mock(CheckoutExpiredException.class); - when(mockException.getErrorDescription()).thenReturn("Cart has expired"); - when(mockException.getErrorCode()).thenReturn("cart_expired"); + when(mockException.getErrorDescription()).thenReturn("Cart has been completed"); + when(mockException.getErrorCode()).thenReturn("CART_COMPLETED"); when(mockException.isRecoverable()).thenReturn(false); processor.onFail(mockException); @@ -483,7 +483,7 @@ public void testCanProcessCheckoutExpiredErrors() { verify(mockEventEmitter).emit(eq("error"), stringCaptor.capture()); assertThat(stringCaptor.getValue()) - .contains("CheckoutExpiredError", "Cart has expired", "cart_expired", "\"recoverable\":false"); + .contains("CheckoutExpiredError", "Cart has been completed", "CART_COMPLETED", "\"recoverable\":false"); } @Test diff --git a/sample/ios/Podfile.lock b/sample/ios/Podfile.lock index ebdf163b..8d93b121 100644 --- a/sample/ios/Podfile.lock +++ b/sample/ios/Podfile.lock @@ -2595,7 +2595,7 @@ DEPENDENCIES: - React-Mapbuffer (from `../../node_modules/react-native/ReactCommon`) - React-microtasksnativemodule (from `../../node_modules/react-native/ReactCommon/react/nativemodule/microtasks`) - react-native-config (from `../node_modules/react-native-config`) - - react-native-safe-area-context (from `../node_modules/react-native-safe-area-context`) + - react-native-safe-area-context (from `../../node_modules/react-native-safe-area-context`) - React-NativeModulesApple (from `../../node_modules/react-native/ReactCommon/react/nativemodule/core/platform/ios`) - React-oscompat (from `../../node_modules/react-native/ReactCommon/oscompat`) - React-perflogger (from `../../node_modules/react-native/ReactCommon/reactperflogger`) @@ -2630,7 +2630,7 @@ DEPENDENCIES: - "RNCMaskedView (from `../node_modules/@react-native-masked-view/masked-view`)" - RNGestureHandler (from `../../node_modules/react-native-gesture-handler`) - RNReanimated (from `../node_modules/react-native-reanimated`) - - RNScreens (from `../node_modules/react-native-screens`) + - RNScreens (from `../../node_modules/react-native-screens`) - "RNShopifyCheckoutSheetKit (from `../../modules/@shopify/checkout-sheet-kit`)" - RNVectorIcons (from `../node_modules/react-native-vector-icons`) - ShopifyCheckoutSheetKit (from `~/src/github.com/Shopify/checkout-sheet-kit-swift`) @@ -2727,7 +2727,7 @@ EXTERNAL SOURCES: react-native-config: :path: "../node_modules/react-native-config" react-native-safe-area-context: - :path: "../node_modules/react-native-safe-area-context" + :path: "../../node_modules/react-native-safe-area-context" React-NativeModulesApple: :path: "../../node_modules/react-native/ReactCommon/react/nativemodule/core/platform/ios" React-oscompat: @@ -2797,7 +2797,7 @@ EXTERNAL SOURCES: RNReanimated: :path: "../node_modules/react-native-reanimated" RNScreens: - :path: "../node_modules/react-native-screens" + :path: "../../node_modules/react-native-screens" RNShopifyCheckoutSheetKit: :path: "../../modules/@shopify/checkout-sheet-kit" RNVectorIcons: diff --git a/sample/ios/ReactNativeTests/CheckoutDidFailTests.swift b/sample/ios/ReactNativeTests/CheckoutDidFailTests.swift index a8243e2d..f8011900 100644 --- a/sample/ios/ReactNativeTests/CheckoutDidFailTests.swift +++ b/sample/ios/ReactNativeTests/CheckoutDidFailTests.swift @@ -53,9 +53,9 @@ class CheckoutDidFailTests: XCTestCase { let mock = mockSendEvent(eventName: "error") mock.startObserving() - let error = CheckoutError.checkoutExpired( + let error = CheckoutError.expired( message: "expired", - code: CheckoutErrorCode.cartExpired, + code: CheckoutError.ErrorCode.cartCompleted, recoverable: false ) @@ -67,10 +67,10 @@ class CheckoutDidFailTests: XCTestCase { return XCTFail("Event body was not available or not in the correct format") } - if case .checkoutExpired = error { + if case .expired = error { XCTAssertEqual(eventBody["__typename"] as? String, "CheckoutExpiredError") XCTAssertEqual(eventBody["message"] as? String, "expired") - XCTAssertEqual(eventBody["code"] as? String, CheckoutErrorCode.cartExpired.rawValue) + XCTAssertEqual(eventBody["code"] as? String, CheckoutError.ErrorCode.cartCompleted.rawValue) XCTAssertEqual(eventBody["recoverable"] as? Bool, false) } else { XCTFail("Expected checkoutExpiredError but found different error") @@ -81,9 +81,9 @@ class CheckoutDidFailTests: XCTestCase { let mock = mockSendEvent(eventName: "error") mock.startObserving() - let error = CheckoutError.checkoutUnavailable( - message: "expired", - code: .clientError(code: CheckoutErrorCode.cartExpired), + let error = CheckoutError.unavailable( + message: "unavailable", + code: .clientError(code: CheckoutError.ErrorCode.unrecoverableFailure), recoverable: false ) @@ -95,10 +95,10 @@ class CheckoutDidFailTests: XCTestCase { return XCTFail("Event body was not available or not in the correct format") } - if case .checkoutUnavailable = error { + if case .unavailable = error { XCTAssertEqual(eventBody["__typename"] as? String, "CheckoutClientError") - XCTAssertEqual(eventBody["message"] as? String, "expired") - XCTAssertEqual(eventBody["code"] as? String, CheckoutErrorCode.cartExpired.rawValue) + XCTAssertEqual(eventBody["message"] as? String, "unavailable") + XCTAssertEqual(eventBody["code"] as? String, CheckoutError.ErrorCode.unrecoverableFailure.rawValue) XCTAssertEqual(eventBody["recoverable"] as? Bool, false) } else { XCTFail("Expected checkoutClientError but found different error") @@ -109,7 +109,7 @@ class CheckoutDidFailTests: XCTestCase { let mock = mockSendEvent(eventName: "error") mock.startObserving() - let error = CheckoutError.checkoutUnavailable( + let error = CheckoutError.unavailable( message: "internal server error", code: .httpError(statusCode: 500), recoverable: true @@ -123,7 +123,7 @@ class CheckoutDidFailTests: XCTestCase { return XCTFail("Event body was not available or not in the correct format") } - if case .checkoutUnavailable = error { + if case .unavailable = error { XCTAssertEqual(eventBody["__typename"] as? String, "CheckoutHTTPError") XCTAssertEqual(eventBody["message"] as? String, "internal server error") XCTAssertEqual(eventBody["statusCode"] as? Int, 500) @@ -137,9 +137,9 @@ class CheckoutDidFailTests: XCTestCase { let mock = mockSendEvent(eventName: "error") mock.startObserving() - let error = CheckoutError.configurationError( + let error = CheckoutError.misconfiguration( message: "storefront password required", - code: CheckoutErrorCode.storefrontPasswordRequired, + code: CheckoutError.ErrorCode.storefrontPasswordRequired, recoverable: false ) @@ -151,10 +151,10 @@ class CheckoutDidFailTests: XCTestCase { return XCTFail("Event body was not available or not in the correct format") } - if case .configurationError = error { + if case .misconfiguration = error { XCTAssertEqual(eventBody["__typename"] as? String, "ConfigurationError") XCTAssertEqual(eventBody["message"] as? String, "storefront password required") - XCTAssertEqual(eventBody["code"] as? String, CheckoutErrorCode.storefrontPasswordRequired.rawValue) + XCTAssertEqual(eventBody["code"] as? String, CheckoutError.ErrorCode.storefrontPasswordRequired.rawValue) XCTAssertEqual(eventBody["recoverable"] as? Bool, false) } else { XCTFail("Expected CheckoutConfigurationError but found different error") @@ -165,7 +165,7 @@ class CheckoutDidFailTests: XCTestCase { let mock = mockSendEvent(eventName: "error") mock.startObserving() - let error = CheckoutError.sdkError( + let error = CheckoutError.internal( underlying: NSError(domain: "com.shopify", code: 1001, userInfo: [NSLocalizedDescriptionKey: "failed"]), recoverable: true ) @@ -178,7 +178,7 @@ class CheckoutDidFailTests: XCTestCase { return XCTFail("Event body was not available or not in the correct format") } - if case .sdkError = error { + if case .internal = error { XCTAssertEqual(eventBody["__typename"] as? String, "InternalError") XCTAssertEqual(eventBody["message"] as? String, "failed") XCTAssertEqual(eventBody["recoverable"] as? Bool, true) From 0ab711806575d5e5c07f6eab10f1943c65744c37 Mon Sep 17 00:00:00 2001 From: Daniel Kift Date: Mon, 22 Dec 2025 12:31:07 +0000 Subject: [PATCH 3/5] undo unrelated Gemfile.lock changes --- sample/Gemfile.lock | 24 +++++++++++++----------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/sample/Gemfile.lock b/sample/Gemfile.lock index 6f0dc4d5..13b9ab76 100644 --- a/sample/Gemfile.lock +++ b/sample/Gemfile.lock @@ -1,8 +1,11 @@ GEM remote: https://rubygems.org/ specs: - CFPropertyList (3.0.8) - activesupport (7.2.3) + CFPropertyList (3.0.7) + base64 + nkf + rexml + activesupport (7.2.2.2) base64 benchmark (>= 0.3) bigdecimal @@ -14,15 +17,15 @@ GEM minitest (>= 5.1) securerandom (>= 0.3) tzinfo (~> 2.0, >= 2.0.5) - addressable (2.8.8) - public_suffix (>= 2.0.2, < 8.0) + addressable (2.8.7) + public_suffix (>= 2.0.2, < 7.0) algoliasearch (1.27.5) httpclient (~> 2.8, >= 2.8.3) json (>= 1.5.1) atomos (0.1.3) base64 (0.3.0) - benchmark (0.5.0) - bigdecimal (4.0.1) + benchmark (0.4.1) + bigdecimal (3.2.3) claide (1.1.0) cocoapods (1.15.2) addressable (~> 2.8) @@ -65,7 +68,7 @@ GEM cocoapods-try (1.2.0) colored2 (3.1.2) concurrent-ruby (1.3.3) - connection_pool (3.0.2) + connection_pool (2.5.4) drb (2.2.3) escape (0.0.4) ethon (0.15.0) @@ -78,16 +81,15 @@ GEM mutex_m i18n (1.14.7) concurrent-ruby (~> 1.0) - json (2.18.0) + json (2.15.0) logger (1.7.0) - minitest (6.0.0) - prism (~> 1.5) + minitest (5.25.5) molinillo (0.8.0) mutex_m (0.3.0) nanaimo (0.3.0) nap (1.1.0) netrc (0.11.0) - prism (1.7.0) + nkf (0.2.0) public_suffix (4.0.7) rexml (3.4.4) ruby-macho (2.5.1) From b98146d8b799e1127c105b104e1974b002d3a226 Mon Sep 17 00:00:00 2001 From: Daniel Kift Date: Mon, 22 Dec 2025 13:26:16 +0000 Subject: [PATCH 4/5] use latest android dev --- modules/@shopify/checkout-sheet-kit/android/gradle.properties | 2 +- sample/android/gradle.properties | 2 +- scripts/publish_android_snapshot | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/modules/@shopify/checkout-sheet-kit/android/gradle.properties b/modules/@shopify/checkout-sheet-kit/android/gradle.properties index ff3e0b6c..66799338 100644 --- a/modules/@shopify/checkout-sheet-kit/android/gradle.properties +++ b/modules/@shopify/checkout-sheet-kit/android/gradle.properties @@ -5,4 +5,4 @@ ndkVersion=23.1.7779620 buildToolsVersion = "35.0.0" # Version of Shopify Checkout SDK to use with React Native -SHOPIFY_CHECKOUT_SDK_VERSION=4.0.0-SNAPSHOT +SHOPIFY_CHECKOUT_SDK_VERSION=4.0.1-SNAPSHOT diff --git a/sample/android/gradle.properties b/sample/android/gradle.properties index 195ae5b0..019d9d95 100644 --- a/sample/android/gradle.properties +++ b/sample/android/gradle.properties @@ -41,4 +41,4 @@ newArchEnabled=true hermesEnabled=true # Note: only used here for testing -SHOPIFY_CHECKOUT_SDK_VERSION=4.0.0-SNAPSHOT +SHOPIFY_CHECKOUT_SDK_VERSION=4.0.1-SNAPSHOT diff --git a/scripts/publish_android_snapshot b/scripts/publish_android_snapshot index 1c968b7d..e88640b4 100755 --- a/scripts/publish_android_snapshot +++ b/scripts/publish_android_snapshot @@ -2,7 +2,7 @@ set -e SDK_PATH="${1:-$HOME/src/github.com/Shopify/checkout-sheet-kit-android}" -VERSION="${2:-4.0.0-SNAPSHOT}" +VERSION="${2:-4.0.1-SNAPSHOT}" if [ ! -d "$SDK_PATH" ]; then echo "Error: checkout-sheet-kit-android not found at $SDK_PATH" From 0239dda00f8ff98256d8b297af7c0e9d71d26a28 Mon Sep 17 00:00:00 2001 From: Daniel Kift Date: Mon, 22 Dec 2025 14:07:30 +0000 Subject: [PATCH 5/5] update readme --- README.md | 39 +++++++++++++++++++++++++++++++++++---- 1 file changed, 35 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index f9a8b6e9..c7e58714 100644 --- a/README.md +++ b/README.md @@ -47,6 +47,9 @@ experiences. - [Checkout lifecycle](#checkout-lifecycle) - [`addEventListener(eventName, callback)`](#addeventlistenereventname-callback) - [`removeEventListeners(eventName)`](#removeeventlistenerseventname) + - [Error handling](#error-handling) + - [Error types](#error-types) + - [Error codes](#error-codes) - [Identity \& customer accounts](#identity--customer-accounts) - [Cart: buyer bag, identity, and preferences](#cart-buyer-bag-identity-and-preferences) - [Multipass](#multipass) @@ -581,7 +584,7 @@ methods - available on both the context provider as well as the class instance. | `close` | `() => void` | Fired when the checkout has been closed. | | `complete` | `(event: CheckoutCompleteEvent) => void` | Fired when the checkout has been successfully completed. | | `start` | `(event: CheckoutStartEvent) => void` | Fired when the checkout has been started. | -| `error` | `(error: {message: string}) => void` | Fired when a checkout exception has been raised. | +| `error` | `(error: CheckoutException) => void` | Fired when a checkout exception has been raised. See [Error handling](#error-handling) below. | ### `addEventListener(eventName, callback)` @@ -608,9 +611,9 @@ useEffect(() => { const error = shopifyCheckout.addEventListener( 'error', - (error: CheckoutError) => { - // Do something on checkout error - // console.log(error.message) + (error: CheckoutException) => { + // Handle checkout error - see "Error handling" section for details + console.log(error.message, error.code, error.recoverable); }, ); @@ -628,6 +631,34 @@ useEffect(() => { On the rare occasion that you want to remove all event listeners for a given `eventName`, you can use the `removeEventListeners(eventName)` method. +### Error handling + +The `error` event provides a `CheckoutException` object with detailed information about what went wrong. Each error includes: + +| Property | Type | Description | +| ------------- | ------------------- | -------------------------------------------------------------- | +| `message` | `string` | A human-readable error message. | +| `code` | `CheckoutErrorCode` | A machine-readable error code (see table below). | +| `recoverable` | `boolean` | Whether the error is recoverable (e.g., retry may succeed). | +| `name` | `string` | The error class name (e.g., `ConfigurationError`). | +| `statusCode` | `number` (optional) | HTTP status code (only present for `CheckoutHTTPError`). | + +#### Error types + +Errors are returned as instances of specific error classes: + +| Error Class | Description | +| --------------------- | --------------------------------------------------------------------------- | +| `ConfigurationError` | The checkout configuration is invalid (e.g., invalid credentials). | +| `CheckoutClientError` | A client-side error occurred (e.g., checkout unavailable). | +| `CheckoutExpiredError`| The checkout session has expired or the cart is no longer valid. | +| `CheckoutHTTPError` | An HTTP error occurred. Includes `statusCode` property. | +| `InternalError` | An internal SDK error occurred. | + +#### Error codes + +The `code` property uses the `CheckoutErrorCode` enum. See [`errors.d.ts`](./modules/@shopify/checkout-sheet-kit/src/errors.d.ts) for the full list of error codes and their descriptions. + ## Identity & customer accounts Buyer-aware checkout experience reduces friction and increases conversion.