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 c54943ac..c5da8b0b 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 @@ -74,7 +74,7 @@ class RCTAcceleratedCheckoutButtonsManager: RCTViewManager { } override func constantsToExport() -> [AnyHashable: Any]! { - return [:] + return ["checkoutProtocolEventTypes": supportedProtocolRelayMethods] } } @@ -130,6 +130,7 @@ class RCTAcceleratedCheckoutButtonsView: UIView { @objc var onCancel: RCTBubblingEventBlock? @objc var onRenderStateChange: RCTBubblingEventBlock? @objc var onClickLink: RCTBubblingEventBlock? + @objc var onDispatch: RCTDirectEventBlock? // MARK: - Private @@ -296,6 +297,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 68ac74f5..084cfe25 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 @@ -28,6 +28,10 @@ import Foundation import ShopifyCheckoutProtocol #endif +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 f43e1636..5daa4ae6 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 @@ -152,6 +152,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 f7bfafe1..5fb44caf 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 @@ -22,8 +22,9 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SO */ 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 { @@ -111,6 +112,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 */ @@ -162,6 +170,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 @@ -174,6 +189,7 @@ export const AcceleratedCheckoutButtons: React.FC< onCancel, onRenderStateChange, onClickLink, + events, ...props }) => { const isCart = isCartProps(props); @@ -221,6 +237,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); @@ -268,6 +297,8 @@ export const AcceleratedCheckoutButtons: React.FC< } } + verifyProtocolEventParity(); + return ( ); @@ -316,3 +348,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 87421fe1..93360725 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 @@ -42,6 +42,7 @@ type RenderStateChangeEvent = Readonly<{ }>; type ClickLinkEvent = Readonly<{url: string}>; +type DispatchEvent = Readonly<{value: string}>; type SizeChangeEvent = Readonly<{height: Double}>; type CheckoutIdentifierSpec = Readonly<{ @@ -60,6 +61,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 0910e90d..fcb00b60 100644 --- a/platforms/react-native/sample/src/screens/CartScreen.tsx +++ b/platforms/react-native/sample/src/screens/CartScreen.tsx @@ -105,7 +105,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); }, }, @@ -198,6 +197,14 @@ function CartScreen(): React.JSX.Element { AcceleratedCheckoutWallet.shopPay, ]} cornerRadius={cornerRadius} + events={{ + [CheckoutProtocol.start]: checkout => { + console.log( + '[Cart - AcceleratedCheckoutButtons Protocol.ec.start]', + checkout, + ); + }, + }} />