Skip to content

Commit 6edc610

Browse files
feat: add accelerated checkout protocol events
1 parent c75d0f4 commit 6edc610

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
@@ -74,7 +74,7 @@ class RCTAcceleratedCheckoutButtonsManager: RCTViewManager {
7474
}
7575

7676
override func constantsToExport() -> [AnyHashable: Any]! {
77-
return [:]
77+
return ["checkoutProtocolEventTypes": supportedProtocolRelayMethods]
7878
}
7979
}
8080

@@ -130,6 +130,7 @@ class RCTAcceleratedCheckoutButtonsView: UIView {
130130
@objc var onCancel: RCTBubblingEventBlock?
131131
@objc var onRenderStateChange: RCTBubblingEventBlock?
132132
@objc var onClickLink: RCTBubblingEventBlock?
133+
@objc var onDispatch: RCTDirectEventBlock?
133134

134135
// MARK: - Private
135136

@@ -296,6 +297,14 @@ class RCTAcceleratedCheckoutButtonsView: UIView {
296297
// Attach event handlers
297298
buttons = attachEventListeners(to: buttons)
298299

300+
let client = makeRelayClient(
301+
subscribedMethods: supportedProtocolRelayMethods,
302+
dispatch: { [weak self] json in
303+
self?.onDispatch?(["value": json])
304+
}
305+
)
306+
buttons = buttons.connect(client)
307+
299308
var view: AnyView
300309

301310
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
@@ -28,6 +28,10 @@ import Foundation
2828
import ShopifyCheckoutProtocol
2929
#endif
3030

31+
let supportedProtocolRelayMethods = [
32+
CheckoutProtocol.start.method
33+
]
34+
3135
func makeRelayClient(
3236
subscribedMethods: [String],
3337
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
@@ -152,6 +152,11 @@ @interface RCT_EXTERN_MODULE (RCTAcceleratedCheckoutButtonsManager, RCTViewManag
152152
*/
153153
RCT_EXPORT_VIEW_PROPERTY(onClickLink, RCTBubblingEventBlock)
154154

155+
/**
156+
* Emitted when a subscribed Checkout Protocol event fires. Payload contains { value } where value is a JSON envelope.
157+
*/
158+
RCT_EXPORT_VIEW_PROPERTY(onDispatch, RCTDirectEventBlock)
159+
155160
/**
156161
* Emitted when the intrinsic height of the native view changes. Payload contains { height }.
157162
*/

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
@@ -22,8 +22,9 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SO
2222
*/
2323

2424
import React, {useCallback, useMemo, useState} from 'react';
25-
import {Platform} from 'react-native';
25+
import {Platform, UIManager} from 'react-native';
2626
import type {AcceleratedCheckoutWallet, CheckoutException} from '..';
27+
import {CheckoutProtocol, type ProtocolHandlers} from '../protocol';
2728
import RCTAcceleratedCheckoutButtons from '../specs/RCTAcceleratedCheckoutButtonsNativeComponent';
2829

2930
export enum RenderState {
@@ -111,6 +112,13 @@ interface CommonAcceleratedCheckoutButtonsProps {
111112
*/
112113
onRenderStateChange?: (event: RenderStateChangeEvent) => void;
113114

115+
/**
116+
* Checkout Protocol event handlers scoped to this button instance.
117+
*
118+
* Currently supports CheckoutProtocol.start.
119+
*/
120+
events?: ProtocolHandlers;
121+
114122
/**
115123
* Called when a link is clicked within the checkout
116124
*/
@@ -162,6 +170,13 @@ export type AcceleratedCheckoutButtonsProps = (CartProps | VariantProps) &
162170
*/
163171

164172
const defaultStyles = {flex: 1};
173+
const nativeComponentName = 'RCTAcceleratedCheckoutButtons';
174+
const protocolEventTypesConstant = 'checkoutProtocolEventTypes';
175+
const checkoutProtocolEventTypeValues = Object.values(CheckoutProtocol);
176+
const checkoutProtocolEventTypes: ReadonlySet<string> = new Set(
177+
checkoutProtocolEventTypeValues,
178+
);
179+
let verifiedProtocolEventParitySignature: string | undefined;
165180

166181
export const AcceleratedCheckoutButtons: React.FC<
167182
AcceleratedCheckoutButtonsProps
@@ -174,6 +189,7 @@ export const AcceleratedCheckoutButtons: React.FC<
174189
onCancel,
175190
onRenderStateChange,
176191
onClickLink,
192+
events,
177193
...props
178194
}) => {
179195
const isCart = isCartProps(props);
@@ -221,6 +237,19 @@ export const AcceleratedCheckoutButtons: React.FC<
221237
[onClickLink],
222238
);
223239

240+
const handleDispatch = useCallback(
241+
(event: {nativeEvent: unknown}) => {
242+
const nativeEvent = event.nativeEvent as {value?: unknown};
243+
if (typeof nativeEvent?.value !== 'string') {
244+
logDispatchError('dispatch event is missing a string `value`');
245+
return;
246+
}
247+
248+
routeProtocolDispatchEnvelope(nativeEvent.value, events);
249+
},
250+
[events],
251+
);
252+
224253
const handleSizeChange = useCallback(
225254
(event: {nativeEvent: {height: number}}) => {
226255
setDynamicHeight(event.nativeEvent.height);
@@ -268,6 +297,8 @@ export const AcceleratedCheckoutButtons: React.FC<
268297
}
269298
}
270299

300+
verifyProtocolEventParity();
301+
271302
return (
272303
<RCTAcceleratedCheckoutButtons
273304
testID="accelerated-checkout-buttons"
@@ -281,6 +312,7 @@ export const AcceleratedCheckoutButtons: React.FC<
281312
onCancel={handleCancel}
282313
onRenderStateChange={handleRenderStateChange}
283314
onClickLink={handleClickLink}
315+
onDispatch={handleDispatch}
284316
onSizeChange={handleSizeChange}
285317
/>
286318
);
@@ -316,3 +348,145 @@ function isVariantProps(
316348
): props is VariantProps {
317349
return 'variantId' in props && 'quantity' in props && props.quantity > 0;
318350
}
351+
352+
function verifyProtocolEventParity(): void {
353+
const nativeTypes = getNativeProtocolEventTypes();
354+
const signature = buildProtocolEventParitySignature(nativeTypes);
355+
if (verifiedProtocolEventParitySignature === signature) return;
356+
357+
verifiedProtocolEventParitySignature = signature;
358+
359+
if (!Array.isArray(nativeTypes)) {
360+
logProtocolEventParityWarning(
361+
`native view manager did not report a \`${protocolEventTypesConstant}\` array. ` +
362+
'The bundled native component is likely older than this JS package.',
363+
);
364+
return;
365+
}
366+
367+
const jsSet = new Set<string>(checkoutProtocolEventTypeValues);
368+
const nativeSet = new Set<string>(nativeTypes);
369+
370+
const missingFromJs = [...nativeSet].filter(t => !jsSet.has(t)).sort();
371+
const missingFromNative = [...jsSet].filter(t => !nativeSet.has(t)).sort();
372+
373+
if (missingFromJs.length === 0 && missingFromNative.length === 0) {
374+
return;
375+
}
376+
377+
const lines = [
378+
`js = [${[...jsSet].sort().join(', ')}]`,
379+
`native = [${[...nativeSet].sort().join(', ')}]`,
380+
];
381+
if (missingFromJs.length > 0) {
382+
lines.push(`events missing from js: ${missingFromJs.join(', ')}`);
383+
}
384+
if (missingFromNative.length > 0) {
385+
lines.push(`events missing from native: ${missingFromNative.join(', ')}`);
386+
}
387+
388+
logProtocolEventParityWarning(lines.join('\n '));
389+
}
390+
391+
function buildProtocolEventParitySignature(
392+
nativeTypes: readonly string[] | undefined | null,
393+
): string {
394+
return JSON.stringify({
395+
js: [...checkoutProtocolEventTypeValues].sort(),
396+
native: Array.isArray(nativeTypes) ? [...nativeTypes].sort() : nativeTypes,
397+
});
398+
}
399+
400+
function getNativeProtocolEventTypes(): readonly string[] | undefined | null {
401+
const viewManagerConfig = UIManager.getViewManagerConfig?.(
402+
nativeComponentName,
403+
) as
404+
| {
405+
Constants?: Record<string, unknown>;
406+
}
407+
| undefined;
408+
409+
return viewManagerConfig?.Constants?.[protocolEventTypesConstant] as
410+
| readonly string[]
411+
| undefined
412+
| null;
413+
}
414+
415+
function routeProtocolDispatchEnvelope(
416+
envelopeJson: string,
417+
events: ProtocolHandlers | undefined,
418+
): void {
419+
let envelope: unknown;
420+
try {
421+
envelope = JSON.parse(envelopeJson);
422+
} catch {
423+
logDispatchError('dispatch envelope is not valid JSON', envelopeJson);
424+
return;
425+
}
426+
427+
if (!isPlainObject(envelope) || typeof envelope.type !== 'string') {
428+
logDispatchError(
429+
'dispatch envelope is missing a string `type` discriminator',
430+
envelopeJson,
431+
);
432+
return;
433+
}
434+
435+
if (!checkoutProtocolEventTypes.has(envelope.type)) {
436+
logUnknownDispatchType(envelope.type);
437+
return;
438+
}
439+
440+
const handler = (events as Record<
441+
string,
442+
((payload: unknown) => void) | undefined
443+
> | undefined)?.[envelope.type];
444+
445+
if (handler == null) {
446+
return;
447+
}
448+
449+
if (!isPlainObject(envelope.payload)) {
450+
logDispatchError(
451+
`protocol envelope "${envelope.type}" payload is not an object`,
452+
envelopeJson,
453+
);
454+
return;
455+
}
456+
457+
handler(envelope.payload);
458+
}
459+
460+
function isPlainObject(value: unknown): value is Record<string, unknown> {
461+
return typeof value === 'object' && value !== null && !Array.isArray(value);
462+
}
463+
464+
function logUnknownDispatchType(type: string): void {
465+
// eslint-disable-next-line no-console
466+
console.warn(
467+
`[ShopifyAcceleratedCheckouts] Ignoring protocol dispatch envelope with unknown type "${type}". ` +
468+
'Native emitted a Checkout Protocol event this JS package does not know how to handle. ' +
469+
'Confirm native and JS package versions are compatible.',
470+
);
471+
}
472+
473+
function logProtocolEventParityWarning(detail: string): void {
474+
// eslint-disable-next-line no-console
475+
console.warn(
476+
'[ShopifyAcceleratedCheckouts] Checkout Protocol event list out of sync between JS ' +
477+
'and native. Rebuild your host app so the bundled native component matches ' +
478+
`this version of '@shopify/checkout-kit-react-native'.\n ${detail}`,
479+
);
480+
}
481+
482+
function logDispatchError(detail: string, raw?: string): void {
483+
const message = `[ShopifyAcceleratedCheckouts] Failed to handle protocol dispatch: ${detail}`;
484+
if (raw == null) {
485+
// eslint-disable-next-line no-console
486+
console.error(message);
487+
return;
488+
}
489+
490+
// eslint-disable-next-line no-console
491+
console.error(message, raw);
492+
}

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
@@ -42,6 +42,7 @@ type RenderStateChangeEvent = Readonly<{
4242
}>;
4343

4444
type ClickLinkEvent = Readonly<{url: string}>;
45+
type DispatchEvent = Readonly<{value: string}>;
4546
type SizeChangeEvent = Readonly<{height: Double}>;
4647

4748
type CheckoutIdentifierSpec = Readonly<{
@@ -60,6 +61,7 @@ interface NativeProps extends ViewProps {
6061
onCancel?: BubblingEventHandler<null>;
6162
onRenderStateChange?: BubblingEventHandler<RenderStateChangeEvent>;
6263
onClickLink?: BubblingEventHandler<ClickLinkEvent>;
64+
onDispatch?: DirectEventHandler<DispatchEvent>;
6365
onSizeChange?: DirectEventHandler<SizeChangeEvent>;
6466
}
6567

0 commit comments

Comments
 (0)