From c4b84bfb70e976c9aa30742aa19f405165b873d2 Mon Sep 17 00:00:00 2001 From: Kieran Osgood Date: Thu, 21 May 2026 12:52:27 +0100 Subject: [PATCH] feat: add accelerated checkout protocol events --- .../react-native/__mocks__/react-native.ts | 14 ++ .../ios/AcceleratedCheckoutButtons.swift | 11 +- .../ios/ProtocolRelay.swift | 4 + .../ios/ShopifyCheckoutKit.mm | 5 + .../components/AcceleratedCheckoutButtons.tsx | 176 +++++++++++++++++- ...celeratedCheckoutButtonsNativeComponent.ts | 2 + .../tests/AcceleratedCheckoutButtons.test.tsx | 100 +++++++++- .../sample/src/screens/CartScreen.tsx | 9 +- 8 files changed, 317 insertions(+), 4 deletions(-) diff --git a/platforms/react-native/__mocks__/react-native.ts b/platforms/react-native/__mocks__/react-native.ts index af433253..594df9f4 100644 --- a/platforms/react-native/__mocks__/react-native.ts +++ b/platforms/react-native/__mocks__/react-native.ts @@ -51,6 +51,19 @@ const exampleConfig = { }; const shopifyCheckoutKitEventEmitter = createMockEmitter(); +const UIManager = { + getViewManagerConfig: jest.fn((name: string) => { + if (name === 'RCTAcceleratedCheckoutButtons') { + return { + Constants: { + checkoutProtocolEventTypes: ['ec.start'], + }, + }; + } + return null; + }), +}; + const ShopifyCheckoutKit = { version: '0.7.0', getConstants: jest.fn(() => ({ @@ -81,6 +94,7 @@ module.exports = { requestMultiple: jest.fn(async () => ({})), }, NativeEventEmitter: jest.fn(() => shopifyCheckoutKitEventEmitter), + UIManager, requireNativeComponent, codegenNativeComponent, TurboModuleRegistry: { 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 3a66b6d3..c1c9f5f5 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 @@ -51,7 +51,7 @@ class RCTAcceleratedCheckoutButtonsManager: RCTViewManager { } override func constantsToExport() -> [AnyHashable: Any]! { - return [:] + return ["checkoutProtocolEventTypes": supportedProtocolRelayMethods] } } @@ -107,6 +107,7 @@ class RCTAcceleratedCheckoutButtonsView: UIView { @objc var onCancel: RCTBubblingEventBlock? @objc var onRenderStateChange: RCTBubblingEventBlock? @objc var onClickLink: RCTBubblingEventBlock? + @objc var onDispatch: RCTDirectEventBlock? // MARK: - Private @@ -273,6 +274,14 @@ class RCTAcceleratedCheckoutButtonsView: UIView { // Attach event handlers buttons = attachEventListeners(to: buttons) + let client = makeRelayClient( + subscribedMethods: supportedProtocolRelayMethods, + dispatch: { [weak self] json in + self?.onDispatch?(["value": json]) + } + ) + buttons = buttons.connect(client) + var view: AnyView let colorScheme: SwiftUI.ColorScheme = traitCollection.userInterfaceStyle == .dark ? .dark : .light diff --git a/platforms/react-native/modules/@shopify/checkout-kit-react-native/ios/ProtocolRelay.swift b/platforms/react-native/modules/@shopify/checkout-kit-react-native/ios/ProtocolRelay.swift index a41d11bf..fb903b16 100644 --- a/platforms/react-native/modules/@shopify/checkout-kit-react-native/ios/ProtocolRelay.swift +++ b/platforms/react-native/modules/@shopify/checkout-kit-react-native/ios/ProtocolRelay.swift @@ -15,6 +15,10 @@ struct DispatchEnvelope: Encodable { // Bridges native CheckoutProtocol notifications to the React Native onDispatch // event stream. Payloads are emitted in protocol wire casing; JS performs the // schema-aware conversion to the public camelCase shape with QuickType. +let supportedProtocolRelayMethods = [ + CheckoutProtocol.start.method +] + func makeRelayClient( subscribedMethods: [String], dispatch: @escaping @MainActor @Sendable (String) -> Void 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 15dc1aef..0514037d 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 @@ -128,6 +128,11 @@ @interface RCT_EXTERN_MODULE (RCTAcceleratedCheckoutButtonsManager, RCTViewManag */ RCT_EXPORT_VIEW_PROPERTY(onClickLink, RCTBubblingEventBlock) +/** + * Emitted when a subscribed Checkout Protocol event fires. Payload contains { value } where value is a JSON envelope. + */ +RCT_EXPORT_VIEW_PROPERTY(onDispatch, RCTDirectEventBlock) + /** * Emitted when the intrinsic height of the native view changes. Payload contains { height }. */ 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 44080ba5..0b5737e9 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 @@ -1,6 +1,7 @@ import React, {useCallback, useMemo, useState} from 'react'; -import {Platform} from 'react-native'; +import {Platform, UIManager} from 'react-native'; import type {AcceleratedCheckoutWallet, CheckoutException} from '..'; +import {CheckoutProtocol, type ProtocolHandlers} from '../protocol'; import RCTAcceleratedCheckoutButtons from '../specs/RCTAcceleratedCheckoutButtonsNativeComponent'; export enum RenderState { @@ -88,6 +89,13 @@ interface CommonAcceleratedCheckoutButtonsProps { */ onRenderStateChange?: (event: RenderStateChangeEvent) => void; + /** + * Checkout Protocol event handlers scoped to this button instance. + * + * Currently supports CheckoutProtocol.start. + */ + events?: ProtocolHandlers; + /** * Called when a link is clicked within the checkout */ @@ -139,6 +147,13 @@ export type AcceleratedCheckoutButtonsProps = (CartProps | VariantProps) & */ const defaultStyles = {flex: 1}; +const nativeComponentName = 'RCTAcceleratedCheckoutButtons'; +const protocolEventTypesConstant = 'checkoutProtocolEventTypes'; +const checkoutProtocolEventTypeValues = Object.values(CheckoutProtocol); +const checkoutProtocolEventTypes: ReadonlySet = new Set( + checkoutProtocolEventTypeValues, +); +let verifiedProtocolEventParitySignature: string | undefined; export const AcceleratedCheckoutButtons: React.FC< AcceleratedCheckoutButtonsProps @@ -151,6 +166,7 @@ export const AcceleratedCheckoutButtons: React.FC< onCancel, onRenderStateChange, onClickLink, + events, ...props }) => { const isCart = isCartProps(props); @@ -198,6 +214,19 @@ export const AcceleratedCheckoutButtons: React.FC< [onClickLink], ); + const handleDispatch = useCallback( + (event: {nativeEvent: unknown}) => { + const nativeEvent = event.nativeEvent as {value?: unknown}; + if (typeof nativeEvent?.value !== 'string') { + logDispatchError('dispatch event is missing a string `value`'); + return; + } + + routeProtocolDispatchEnvelope(nativeEvent.value, events); + }, + [events], + ); + const handleSizeChange = useCallback( (event: {nativeEvent: {height: number}}) => { setDynamicHeight(event.nativeEvent.height); @@ -245,6 +274,8 @@ export const AcceleratedCheckoutButtons: React.FC< } } + verifyProtocolEventParity(); + return ( ); @@ -293,3 +325,145 @@ function isVariantProps( ): props is VariantProps { return 'variantId' in props && 'quantity' in props && props.quantity > 0; } + +function verifyProtocolEventParity(): void { + const nativeTypes = getNativeProtocolEventTypes(); + const signature = buildProtocolEventParitySignature(nativeTypes); + if (verifiedProtocolEventParitySignature === signature) return; + + verifiedProtocolEventParitySignature = signature; + + if (!Array.isArray(nativeTypes)) { + logProtocolEventParityWarning( + `native view manager did not report a \`${protocolEventTypesConstant}\` array. ` + + 'The bundled native component is likely older than this JS package.', + ); + return; + } + + const jsSet = new Set(checkoutProtocolEventTypeValues); + const nativeSet = new Set(nativeTypes); + + const missingFromJs = [...nativeSet].filter(t => !jsSet.has(t)).sort(); + const missingFromNative = [...jsSet].filter(t => !nativeSet.has(t)).sort(); + + if (missingFromJs.length === 0 && missingFromNative.length === 0) { + return; + } + + const lines = [ + `js = [${[...jsSet].sort().join(', ')}]`, + `native = [${[...nativeSet].sort().join(', ')}]`, + ]; + if (missingFromJs.length > 0) { + lines.push(`events missing from js: ${missingFromJs.join(', ')}`); + } + if (missingFromNative.length > 0) { + lines.push(`events missing from native: ${missingFromNative.join(', ')}`); + } + + logProtocolEventParityWarning(lines.join('\n ')); +} + +function buildProtocolEventParitySignature( + nativeTypes: readonly string[] | undefined | null, +): string { + return JSON.stringify({ + js: [...checkoutProtocolEventTypeValues].sort(), + native: Array.isArray(nativeTypes) ? [...nativeTypes].sort() : nativeTypes, + }); +} + +function getNativeProtocolEventTypes(): readonly string[] | undefined | null { + const viewManagerConfig = UIManager.getViewManagerConfig?.( + nativeComponentName, + ) as + | { + Constants?: Record; + } + | undefined; + + return viewManagerConfig?.Constants?.[protocolEventTypesConstant] as + | readonly string[] + | undefined + | null; +} + +function routeProtocolDispatchEnvelope( + envelopeJson: string, + events: ProtocolHandlers | undefined, +): void { + let envelope: unknown; + try { + envelope = JSON.parse(envelopeJson); + } catch { + logDispatchError('dispatch envelope is not valid JSON', envelopeJson); + return; + } + + if (!isPlainObject(envelope) || typeof envelope.type !== 'string') { + logDispatchError( + 'dispatch envelope is missing a string `type` discriminator', + envelopeJson, + ); + return; + } + + if (!checkoutProtocolEventTypes.has(envelope.type)) { + logUnknownDispatchType(envelope.type); + return; + } + + const handler = (events as Record< + string, + ((payload: unknown) => void) | undefined + > | undefined)?.[envelope.type]; + + if (handler == null) { + return; + } + + if (!isPlainObject(envelope.payload)) { + logDispatchError( + `protocol envelope "${envelope.type}" payload is not an object`, + envelopeJson, + ); + return; + } + + handler(envelope.payload); +} + +function isPlainObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function logUnknownDispatchType(type: string): void { + // eslint-disable-next-line no-console + console.warn( + `[ShopifyAcceleratedCheckouts] Ignoring protocol dispatch envelope with unknown type "${type}". ` + + 'Native emitted a Checkout Protocol event this JS package does not know how to handle. ' + + 'Confirm native and JS package versions are compatible.', + ); +} + +function logProtocolEventParityWarning(detail: string): void { + // eslint-disable-next-line no-console + console.warn( + '[ShopifyAcceleratedCheckouts] Checkout Protocol event list out of sync between JS ' + + 'and native. Rebuild your host app so the bundled native component matches ' + + `this version of '@shopify/checkout-kit-react-native'.\n ${detail}`, + ); +} + +function logDispatchError(detail: string, raw?: string): void { + const message = `[ShopifyAcceleratedCheckouts] Failed to handle protocol dispatch: ${detail}`; + if (raw == null) { + // eslint-disable-next-line no-console + console.error(message); + return; + } + + // eslint-disable-next-line no-console + console.error(message, raw); +} 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 ca23e35f..6c66f4c1 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 @@ -19,6 +19,7 @@ type RenderStateChangeEvent = Readonly<{ }>; type ClickLinkEvent = Readonly<{url: string}>; +type DispatchEvent = Readonly<{value: string}>; type SizeChangeEvent = Readonly<{height: Double}>; type CheckoutIdentifierSpec = Readonly<{ @@ -37,6 +38,7 @@ interface NativeProps extends ViewProps { onCancel?: BubblingEventHandler; onRenderStateChange?: BubblingEventHandler; onClickLink?: BubblingEventHandler; + onDispatch?: DirectEventHandler; onSizeChange?: 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 d27ebdeb..dbb51e64 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 @@ -1,10 +1,11 @@ import React from 'react'; import {render, act} from '@testing-library/react-native'; -import {Platform} from 'react-native'; +import {Platform, UIManager} from 'react-native'; import { AcceleratedCheckoutButtons, AcceleratedCheckoutWallet, ApplePayStyle, + CheckoutProtocol, RenderState, } from '../src'; @@ -114,6 +115,103 @@ describe('AcceleratedCheckoutButtons', () => { expect(nativeComponent.props.applePayStyle).toBe(ApplePayStyle.black); }); + it('routes native protocol dispatch envelopes to event handlers', () => { + const onStart = jest.fn(); + const {getByTestId} = render( + , + ); + + const nativeComponent = getByTestId('accelerated-checkout-buttons'); + const checkout = {id: 'checkout-id'}; + nativeComponent.props.onDispatch({ + nativeEvent: { + value: JSON.stringify({ + type: CheckoutProtocol.start, + payload: checkout, + }), + }, + }); + + expect(onStart).toHaveBeenCalledWith(checkout); + }); + + it('does not throw when native protocol dispatch is malformed', () => { + const onStart = jest.fn(); + const {getByTestId} = render( + , + ); + + const nativeComponent = getByTestId('accelerated-checkout-buttons'); + expect(() => { + nativeComponent.props.onDispatch({nativeEvent: {value: 'not json'}}); + }).not.toThrow(); + expect(onStart).not.toHaveBeenCalled(); + }); + + it('warns when native reports an unknown protocol event', () => { + const warn = jest.spyOn(global.console, 'warn').mockImplementation(); + const getViewManagerConfig = UIManager.getViewManagerConfig as jest.Mock; + const defaultImplementation = getViewManagerConfig.getMockImplementation(); + getViewManagerConfig.mockImplementation((name: string) => { + if (name === 'RCTAcceleratedCheckoutButtons') { + return { + Constants: { + checkoutProtocolEventTypes: [ + CheckoutProtocol.start, + 'ec.future.event', + ], + }, + }; + } + return null; + }); + + render(); + + expect(warn).toHaveBeenCalledWith( + expect.stringContaining( + 'events missing from js: ec.future.event', + ), + ); + + getViewManagerConfig.mockImplementation(defaultImplementation); + warn.mockRestore(); + }); + + it('warns when native emits an unknown protocol event', () => { + const warn = jest.spyOn(global.console, 'warn').mockImplementation(); + const {getByTestId} = render( + , + ); + + const nativeComponent = getByTestId('accelerated-checkout-buttons'); + nativeComponent.props.onDispatch({ + nativeEvent: { + value: JSON.stringify({ + type: 'ec.future.event', + payload: {}, + }), + }, + }); + + expect(warn).toHaveBeenCalledWith( + expect.stringContaining( + 'Ignoring protocol dispatch envelope with unknown type "ec.future.event"', + ), + ); + warn.mockRestore(); + }); + it.each([0, -1, -2, Number.NaN])( 'throws when invalid variant quantity %p', quantity => { diff --git a/platforms/react-native/sample/src/screens/CartScreen.tsx b/platforms/react-native/sample/src/screens/CartScreen.tsx index 7f95ee1f..6ed8be34 100644 --- a/platforms/react-native/sample/src/screens/CartScreen.tsx +++ b/platforms/react-native/sample/src/screens/CartScreen.tsx @@ -82,7 +82,6 @@ function CartScreen(): React.JSX.Element { // protocol events through `useShopifyEventHandlers` (or an // equivalent) just like the SDK lifecycle ones above. [CheckoutProtocol.start]: checkout => { - // eslint-disable-next-line no-console console.log('[Cart - Protocol.ec.start]', checkout); }, }, @@ -175,6 +174,14 @@ function CartScreen(): React.JSX.Element { AcceleratedCheckoutWallet.shopPay, ]} cornerRadius={cornerRadius} + events={{ + [CheckoutProtocol.start]: checkout => { + console.log( + '[Cart - AcceleratedCheckoutButtons Protocol.ec.start]', + checkout, + ); + }, + }} />