Skip to content

Commit b845a72

Browse files
feat(RN): Wire up accelerated checkouts with protocol events (#181)
## Stack Context This stack is adding Checkout Protocol event support to the React Native wrapper. The parent PR introduces the first protocol event (`ec.start`) for the imperative `present()` path; this PR wires the same start event into instance-based accelerated checkout buttons. ## What? - Adds an `events?: ProtocolHandlers` prop to `AcceleratedCheckoutButtons` using computed `CheckoutProtocol` keys. - Routes native component dispatch envelopes to JS handlers for `[CheckoutProtocol.start]`. - Connects iOS accelerated checkout buttons to the existing protocol relay. - Exposes native supported protocol event constants through `constantsToExport` and warns if JS/native event lists drift. - Adds tests and sample usage in the RN sample cart screen. ## Why? Consumers need to observe checkout protocol events from declarative component instances, not only from `ShopifyCheckout.present()`. This keeps the public TypeScript interface consistent across both surfaces while intentionally keeping protocol support scoped to `CheckoutProtocol.start` for this PR. ## How to test - `pnpm --dir platforms/react-native/modules/@shopify/checkout-kit-react-native typecheck` - `pnpm --dir platforms/react-native/sample typecheck` - `pnpm --dir platforms/react-native test -- AcceleratedCheckoutButtons.test.tsx --runInBand` - `pnpm --dir platforms/react-native exec jest --runInBand` - `swift test --package-path platforms/react-native/modules/@shopify/checkout-kit-react-native/ios` - Manual iOS simulator validation with `agent-device`: tapped the sample cart `Shop Pay` accelerated checkout button and confirmed the `[CheckoutProtocol.start]` handler ran by observing the checkout start event state during validation. --- ### Before you merge > [!IMPORTANT] > > - [x] I've added tests to support my implementation > - [x] I have read and agree with the [Contribution Guidelines](./CONTRIBUTING.md) > - [x] I have read and agree with the [Code of Conduct](./CODE_OF_CONDUCT.md) > - [ ] I've updated the relevant platform README (`platforms/swift/README.md` and/or `platforms/android/README.md`) --- <details> <summary>Releasing a new Swift version?</summary> - [ ] I have bumped the version in `ShopifyCheckoutKit.podspec` - [ ] I have bumped the version in `platforms/swift/Sources/ShopifyCheckoutKit/ShopifyCheckoutKit.swift` - [ ] I have updated `platforms/swift/CHANGELOG.md` - [ ] I have updated the SwiftPM/CocoaPods version snippets in `platforms/swift/README.md` (major version only) </details> <details> <summary>Releasing a new Android version?</summary> - [ ] I have bumped the `versionName` in `platforms/android/lib/build.gradle` - [ ] I have updated `platforms/android/CHANGELOG.md` - [ ] I have updated the Gradle/Maven version snippets in `platforms/android/README.md` </details>
1 parent c50854f commit b845a72

8 files changed

Lines changed: 317 additions & 4 deletions

File tree

platforms/react-native/__mocks__/react-native.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,19 @@ const exampleConfig = {
5151
};
5252
const shopifyCheckoutKitEventEmitter = createMockEmitter();
5353

54+
const UIManager = {
55+
getViewManagerConfig: jest.fn((name: string) => {
56+
if (name === 'RCTAcceleratedCheckoutButtons') {
57+
return {
58+
Constants: {
59+
checkoutProtocolEventTypes: ['ec.start'],
60+
},
61+
};
62+
}
63+
return null;
64+
}),
65+
};
66+
5467
const ShopifyCheckoutKit = {
5568
version: '0.7.0',
5669
getConstants: jest.fn(() => ({
@@ -81,6 +94,7 @@ module.exports = {
8194
requestMultiple: jest.fn(async () => ({})),
8295
},
8396
NativeEventEmitter: jest.fn(() => shopifyCheckoutKitEventEmitter),
97+
UIManager,
8498
requireNativeComponent,
8599
codegenNativeComponent,
86100
TurboModuleRegistry: {

platforms/react-native/modules/@shopify/checkout-kit-react-native/ios/AcceleratedCheckoutButtons.swift

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ class RCTAcceleratedCheckoutButtonsManager: RCTViewManager {
5151
}
5252

5353
override func constantsToExport() -> [AnyHashable: Any]! {
54-
return [:]
54+
return ["checkoutProtocolEventTypes": supportedProtocolRelayMethods]
5555
}
5656
}
5757

@@ -107,6 +107,7 @@ class RCTAcceleratedCheckoutButtonsView: UIView {
107107
@objc var onCancel: RCTBubblingEventBlock?
108108
@objc var onRenderStateChange: RCTBubblingEventBlock?
109109
@objc var onClickLink: RCTBubblingEventBlock?
110+
@objc var onDispatch: RCTDirectEventBlock?
110111

111112
// MARK: - Private
112113

@@ -273,6 +274,14 @@ class RCTAcceleratedCheckoutButtonsView: UIView {
273274
// Attach event handlers
274275
buttons = attachEventListeners(to: buttons)
275276

277+
let client = makeRelayClient(
278+
subscribedMethods: supportedProtocolRelayMethods,
279+
dispatch: { [weak self] json in
280+
self?.onDispatch?(["value": json])
281+
}
282+
)
283+
buttons = buttons.connect(client)
284+
276285
var view: AnyView
277286

278287
let colorScheme: SwiftUI.ColorScheme = traitCollection.userInterfaceStyle == .dark ? .dark : .light

platforms/react-native/modules/@shopify/checkout-kit-react-native/ios/ProtocolRelay.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,10 @@ struct DispatchEnvelope<Payload: Encodable>: Encodable {
1515
// Bridges native CheckoutProtocol notifications to the React Native onDispatch
1616
// event stream. Payloads are emitted in protocol wire casing; JS performs the
1717
// schema-aware conversion to the public camelCase shape with QuickType.
18+
let supportedProtocolRelayMethods = [
19+
CheckoutProtocol.start.method
20+
]
21+
1822
func makeRelayClient(
1923
subscribedMethods: [String],
2024
dispatch: @escaping @MainActor @Sendable (String) -> Void

platforms/react-native/modules/@shopify/checkout-kit-react-native/ios/ShopifyCheckoutKit.mm

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,11 @@ @interface RCT_EXTERN_MODULE (RCTAcceleratedCheckoutButtonsManager, RCTViewManag
128128
*/
129129
RCT_EXPORT_VIEW_PROPERTY(onClickLink, RCTBubblingEventBlock)
130130

131+
/**
132+
* Emitted when a subscribed Checkout Protocol event fires. Payload contains { value } where value is a JSON envelope.
133+
*/
134+
RCT_EXPORT_VIEW_PROPERTY(onDispatch, RCTDirectEventBlock)
135+
131136
/**
132137
* Emitted when the intrinsic height of the native view changes. Payload contains { height }.
133138
*/

platforms/react-native/modules/@shopify/checkout-kit-react-native/src/components/AcceleratedCheckoutButtons.tsx

Lines changed: 175 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import React, {useCallback, useMemo, useState} from 'react';
2-
import {Platform} from 'react-native';
2+
import {Platform, UIManager} from 'react-native';
33
import type {AcceleratedCheckoutWallet, CheckoutException} from '..';
4+
import {CheckoutProtocol, type ProtocolHandlers} from '../protocol';
45
import RCTAcceleratedCheckoutButtons from '../specs/RCTAcceleratedCheckoutButtonsNativeComponent';
56

67
export enum RenderState {
@@ -88,6 +89,13 @@ interface CommonAcceleratedCheckoutButtonsProps {
8889
*/
8990
onRenderStateChange?: (event: RenderStateChangeEvent) => void;
9091

92+
/**
93+
* Checkout Protocol event handlers scoped to this button instance.
94+
*
95+
* Currently supports CheckoutProtocol.start.
96+
*/
97+
events?: ProtocolHandlers;
98+
9199
/**
92100
* Called when a link is clicked within the checkout
93101
*/
@@ -139,6 +147,13 @@ export type AcceleratedCheckoutButtonsProps = (CartProps | VariantProps) &
139147
*/
140148

141149
const defaultStyles = {flex: 1};
150+
const nativeComponentName = 'RCTAcceleratedCheckoutButtons';
151+
const protocolEventTypesConstant = 'checkoutProtocolEventTypes';
152+
const checkoutProtocolEventTypeValues = Object.values(CheckoutProtocol);
153+
const checkoutProtocolEventTypes: ReadonlySet<string> = new Set(
154+
checkoutProtocolEventTypeValues,
155+
);
156+
let verifiedProtocolEventParitySignature: string | undefined;
142157

143158
export const AcceleratedCheckoutButtons: React.FC<
144159
AcceleratedCheckoutButtonsProps
@@ -151,6 +166,7 @@ export const AcceleratedCheckoutButtons: React.FC<
151166
onCancel,
152167
onRenderStateChange,
153168
onClickLink,
169+
events,
154170
...props
155171
}) => {
156172
const isCart = isCartProps(props);
@@ -198,6 +214,19 @@ export const AcceleratedCheckoutButtons: React.FC<
198214
[onClickLink],
199215
);
200216

217+
const handleDispatch = useCallback(
218+
(event: {nativeEvent: unknown}) => {
219+
const nativeEvent = event.nativeEvent as {value?: unknown};
220+
if (typeof nativeEvent?.value !== 'string') {
221+
logDispatchError('dispatch event is missing a string `value`');
222+
return;
223+
}
224+
225+
routeProtocolDispatchEnvelope(nativeEvent.value, events);
226+
},
227+
[events],
228+
);
229+
201230
const handleSizeChange = useCallback(
202231
(event: {nativeEvent: {height: number}}) => {
203232
setDynamicHeight(event.nativeEvent.height);
@@ -245,6 +274,8 @@ export const AcceleratedCheckoutButtons: React.FC<
245274
}
246275
}
247276

277+
verifyProtocolEventParity();
278+
248279
return (
249280
<RCTAcceleratedCheckoutButtons
250281
testID="accelerated-checkout-buttons"
@@ -258,6 +289,7 @@ export const AcceleratedCheckoutButtons: React.FC<
258289
onCancel={handleCancel}
259290
onRenderStateChange={handleRenderStateChange}
260291
onClickLink={handleClickLink}
292+
onDispatch={handleDispatch}
261293
onSizeChange={handleSizeChange}
262294
/>
263295
);
@@ -293,3 +325,145 @@ function isVariantProps(
293325
): props is VariantProps {
294326
return 'variantId' in props && 'quantity' in props && props.quantity > 0;
295327
}
328+
329+
function verifyProtocolEventParity(): void {
330+
const nativeTypes = getNativeProtocolEventTypes();
331+
const signature = buildProtocolEventParitySignature(nativeTypes);
332+
if (verifiedProtocolEventParitySignature === signature) return;
333+
334+
verifiedProtocolEventParitySignature = signature;
335+
336+
if (!Array.isArray(nativeTypes)) {
337+
logProtocolEventParityWarning(
338+
`native view manager did not report a \`${protocolEventTypesConstant}\` array. ` +
339+
'The bundled native component is likely older than this JS package.',
340+
);
341+
return;
342+
}
343+
344+
const jsSet = new Set<string>(checkoutProtocolEventTypeValues);
345+
const nativeSet = new Set<string>(nativeTypes);
346+
347+
const missingFromJs = [...nativeSet].filter(t => !jsSet.has(t)).sort();
348+
const missingFromNative = [...jsSet].filter(t => !nativeSet.has(t)).sort();
349+
350+
if (missingFromJs.length === 0 && missingFromNative.length === 0) {
351+
return;
352+
}
353+
354+
const lines = [
355+
`js = [${[...jsSet].sort().join(', ')}]`,
356+
`native = [${[...nativeSet].sort().join(', ')}]`,
357+
];
358+
if (missingFromJs.length > 0) {
359+
lines.push(`events missing from js: ${missingFromJs.join(', ')}`);
360+
}
361+
if (missingFromNative.length > 0) {
362+
lines.push(`events missing from native: ${missingFromNative.join(', ')}`);
363+
}
364+
365+
logProtocolEventParityWarning(lines.join('\n '));
366+
}
367+
368+
function buildProtocolEventParitySignature(
369+
nativeTypes: readonly string[] | undefined | null,
370+
): string {
371+
return JSON.stringify({
372+
js: [...checkoutProtocolEventTypeValues].sort(),
373+
native: Array.isArray(nativeTypes) ? [...nativeTypes].sort() : nativeTypes,
374+
});
375+
}
376+
377+
function getNativeProtocolEventTypes(): readonly string[] | undefined | null {
378+
const viewManagerConfig = UIManager.getViewManagerConfig?.(
379+
nativeComponentName,
380+
) as
381+
| {
382+
Constants?: Record<string, unknown>;
383+
}
384+
| undefined;
385+
386+
return viewManagerConfig?.Constants?.[protocolEventTypesConstant] as
387+
| readonly string[]
388+
| undefined
389+
| null;
390+
}
391+
392+
function routeProtocolDispatchEnvelope(
393+
envelopeJson: string,
394+
events: ProtocolHandlers | undefined,
395+
): void {
396+
let envelope: unknown;
397+
try {
398+
envelope = JSON.parse(envelopeJson);
399+
} catch {
400+
logDispatchError('dispatch envelope is not valid JSON', envelopeJson);
401+
return;
402+
}
403+
404+
if (!isPlainObject(envelope) || typeof envelope.type !== 'string') {
405+
logDispatchError(
406+
'dispatch envelope is missing a string `type` discriminator',
407+
envelopeJson,
408+
);
409+
return;
410+
}
411+
412+
if (!checkoutProtocolEventTypes.has(envelope.type)) {
413+
logUnknownDispatchType(envelope.type);
414+
return;
415+
}
416+
417+
const handler = (events as Record<
418+
string,
419+
((payload: unknown) => void) | undefined
420+
> | undefined)?.[envelope.type];
421+
422+
if (handler == null) {
423+
return;
424+
}
425+
426+
if (!isPlainObject(envelope.payload)) {
427+
logDispatchError(
428+
`protocol envelope "${envelope.type}" payload is not an object`,
429+
envelopeJson,
430+
);
431+
return;
432+
}
433+
434+
handler(envelope.payload);
435+
}
436+
437+
function isPlainObject(value: unknown): value is Record<string, unknown> {
438+
return typeof value === 'object' && value !== null && !Array.isArray(value);
439+
}
440+
441+
function logUnknownDispatchType(type: string): void {
442+
// eslint-disable-next-line no-console
443+
console.warn(
444+
`[ShopifyAcceleratedCheckouts] Ignoring protocol dispatch envelope with unknown type "${type}". ` +
445+
'Native emitted a Checkout Protocol event this JS package does not know how to handle. ' +
446+
'Confirm native and JS package versions are compatible.',
447+
);
448+
}
449+
450+
function logProtocolEventParityWarning(detail: string): void {
451+
// eslint-disable-next-line no-console
452+
console.warn(
453+
'[ShopifyAcceleratedCheckouts] Checkout Protocol event list out of sync between JS ' +
454+
'and native. Rebuild your host app so the bundled native component matches ' +
455+
`this version of '@shopify/checkout-kit-react-native'.\n ${detail}`,
456+
);
457+
}
458+
459+
function logDispatchError(detail: string, raw?: string): void {
460+
const message = `[ShopifyAcceleratedCheckouts] Failed to handle protocol dispatch: ${detail}`;
461+
if (raw == null) {
462+
// eslint-disable-next-line no-console
463+
console.error(message);
464+
return;
465+
}
466+
467+
// eslint-disable-next-line no-console
468+
console.error(message, raw);
469+
}

platforms/react-native/modules/@shopify/checkout-kit-react-native/src/specs/RCTAcceleratedCheckoutButtonsNativeComponent.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ type RenderStateChangeEvent = Readonly<{
1919
}>;
2020

2121
type ClickLinkEvent = Readonly<{url: string}>;
22+
type DispatchEvent = Readonly<{value: string}>;
2223
type SizeChangeEvent = Readonly<{height: Double}>;
2324

2425
type CheckoutIdentifierSpec = Readonly<{
@@ -37,6 +38,7 @@ interface NativeProps extends ViewProps {
3738
onCancel?: BubblingEventHandler<null>;
3839
onRenderStateChange?: BubblingEventHandler<RenderStateChangeEvent>;
3940
onClickLink?: BubblingEventHandler<ClickLinkEvent>;
41+
onDispatch?: DirectEventHandler<DispatchEvent>;
4042
onSizeChange?: DirectEventHandler<SizeChangeEvent>;
4143
}
4244

0 commit comments

Comments
 (0)