Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions platforms/react-native/__mocks__/react-native.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(() => ({
Expand Down Expand Up @@ -81,6 +94,7 @@ module.exports = {
requestMultiple: jest.fn(async () => ({})),
},
NativeEventEmitter: jest.fn(() => shopifyCheckoutKitEventEmitter),
UIManager,
requireNativeComponent,
codegenNativeComponent,
TurboModuleRegistry: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ class RCTAcceleratedCheckoutButtonsManager: RCTViewManager {
}

override func constantsToExport() -> [AnyHashable: Any]! {
return [:]
return ["checkoutProtocolEventTypes": supportedProtocolRelayMethods]
}
}

Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,10 @@ import Foundation
import ShopifyCheckoutProtocol
#endif

let supportedProtocolRelayMethods = [
CheckoutProtocol.start.method
]

func makeRelayClient(
subscribedMethods: [String],
dispatch: @escaping @MainActor @Sendable (String) -> Void
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 }.
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
*/
Expand Down Expand Up @@ -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<string> = new Set(
checkoutProtocolEventTypeValues,
);
let verifiedProtocolEventParitySignature: string | undefined;

export const AcceleratedCheckoutButtons: React.FC<
AcceleratedCheckoutButtonsProps
Expand All @@ -174,6 +189,7 @@ export const AcceleratedCheckoutButtons: React.FC<
onCancel,
onRenderStateChange,
onClickLink,
events,
...props
}) => {
const isCart = isCartProps(props);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -268,6 +297,8 @@ export const AcceleratedCheckoutButtons: React.FC<
}
}

verifyProtocolEventParity();

return (
<RCTAcceleratedCheckoutButtons
testID="accelerated-checkout-buttons"
Expand All @@ -281,6 +312,7 @@ export const AcceleratedCheckoutButtons: React.FC<
onCancel={handleCancel}
onRenderStateChange={handleRenderStateChange}
onClickLink={handleClickLink}
onDispatch={handleDispatch}
onSizeChange={handleSizeChange}
/>
);
Expand Down Expand Up @@ -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<string>(checkoutProtocolEventTypeValues);
const nativeSet = new Set<string>(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<string, unknown>;
}
| 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<string, unknown> {
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);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<{
Expand All @@ -60,6 +61,7 @@ interface NativeProps extends ViewProps {
onCancel?: BubblingEventHandler<null>;
onRenderStateChange?: BubblingEventHandler<RenderStateChangeEvent>;
onClickLink?: BubblingEventHandler<ClickLinkEvent>;
onDispatch?: DirectEventHandler<DispatchEvent>;
onSizeChange?: DirectEventHandler<SizeChangeEvent>;
}

Expand Down
Loading
Loading