Skip to content

Commit 538a16b

Browse files
feat(rn): tighten dispatcher with parity check, payload validation, and loud unknown-type warning
- Centralise SDK lifecycle event names in a single source of truth per language: src/dispatch-events.ts (TS), DispatchEventTypes (Java), DispatchEventType enum (Swift). Native emits envelopes using these constants instead of free string literals. - Expose the native list via getConstants(); JS verifies set-equality in the ShopifyCheckout constructor and throws DispatchEventParityError with a 'rebuild native code' message on mismatch (memoised per process). - Per-case payload validation in the dispatcher: malformed 'fail', 'geolocationRequest', or protocol payloads now log a LifecycleEventParseError instead of feeding undefined into user code. - Unknown envelope types now console.warn instead of silently routing to a missing handler — preserves the protocol dispatch fallthrough while making contract drift visible. - Drop dead code: sendEventWithStringData, unused Context param and reactContext field on CustomCheckoutListener; unused mockContext in the Android test. - Log warning instead of silent return when a multi-shot geolocation prompt arrives after the dispatcher has been released by a terminal event.
1 parent 9f4b936 commit 538a16b

10 files changed

Lines changed: 537 additions & 76 deletions

File tree

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

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,10 @@ const exampleConfig = {preloading: true};
4949

5050
const ShopifyCheckoutKit = {
5151
version: '0.7.0',
52-
getConstants: jest.fn(() => ({version: '0.7.0'})),
52+
getConstants: jest.fn(() => ({
53+
version: '0.7.0',
54+
dispatchEventTypes: ['close', 'fail', 'geolocationRequest'],
55+
})),
5356
preload: jest.fn(),
5457
present: jest.fn(),
5558
dismiss: jest.fn(),

platforms/react-native/modules/@shopify/checkout-kit-react-native/android/src/main/java/com/shopify/reactnative/checkoutkit/CustomCheckoutListener.java

Lines changed: 13 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,6 @@ of this software and associated documentation files (the "Software"), to deal
2323

2424
package com.shopify.reactnative.checkoutkit;
2525

26-
import android.content.Context;
2726
import android.util.Log;
2827
import android.webkit.GeolocationPermissions;
2928

@@ -32,16 +31,15 @@ of this software and associated documentation files (the "Software"), to deal
3231

3332
import com.shopify.checkoutkit.*;
3433
import com.facebook.react.bridge.Callback;
35-
import com.facebook.react.modules.core.DeviceEventManagerModule;
36-
import com.facebook.react.bridge.ReactApplicationContext;
3734
import com.fasterxml.jackson.databind.ObjectMapper;
3835
import com.fasterxml.jackson.databind.node.ObjectNode;
3936
import java.io.IOException;
4037
import java.util.HashMap;
4138
import java.util.Map;
4239

4340
public class CustomCheckoutListener extends DefaultCheckoutListener {
44-
private final ReactApplicationContext reactContext;
41+
private static final String TAG = "ShopifyCheckoutKit";
42+
4543
private final ObjectMapper mapper = new ObjectMapper();
4644

4745
@Nullable
@@ -52,9 +50,7 @@ public class CustomCheckoutListener extends DefaultCheckoutListener {
5250
private String geolocationOrigin;
5351
private GeolocationPermissions.Callback geolocationCallback;
5452

55-
public CustomCheckoutListener(Context context, ReactApplicationContext reactContext,
56-
@Nullable Callback dispatch) {
57-
this.reactContext = reactContext;
53+
public CustomCheckoutListener(@Nullable Callback dispatch) {
5854
this.dispatchCallback = dispatch;
5955
}
6056

@@ -88,14 +84,18 @@ public void onGeolocationPermissionsShowPrompt(@NonNull String origin,
8884
this.geolocationOrigin = origin;
8985

9086
if (dispatchCallback == null) {
87+
// Multi-shot geolocation requests can in principle arrive after a
88+
// terminal event has nulled the dispatcher. Log so the silence is
89+
// observable rather than mystifying.
90+
Log.w(TAG, "Dropping geolocationRequest \u2014 dispatcher already released by a terminal event.");
9191
return;
9292
}
9393
try {
9494
Map<String, Object> payload = new HashMap<>();
9595
payload.put("origin", origin);
96-
dispatchCallback.invoke(buildEnvelope("geolocationRequest", payload));
96+
dispatchCallback.invoke(buildEnvelope(DispatchEventTypes.GEOLOCATION_REQUEST, payload));
9797
} catch (IOException e) {
98-
Log.e("ShopifyCheckoutKit", "Error emitting \"geolocationRequest\" event", e);
98+
Log.e(TAG, "Error emitting \"geolocationRequest\" event", e);
9999
}
100100
}
101101

@@ -113,9 +113,9 @@ public void onCheckoutFailed(CheckoutException checkoutError) {
113113
return;
114114
}
115115
try {
116-
dispatchCallback.invoke(buildEnvelope("fail", populateErrorDetails(checkoutError)));
116+
dispatchCallback.invoke(buildEnvelope(DispatchEventTypes.FAIL, populateErrorDetails(checkoutError)));
117117
} catch (IOException e) {
118-
Log.e("ShopifyCheckoutKit", "Error processing checkout failed event", e);
118+
Log.e(TAG, "Error processing checkout failed event", e);
119119
} finally {
120120
dispatchCallback = null;
121121
}
@@ -127,9 +127,9 @@ public void onCheckoutCanceled() {
127127
return;
128128
}
129129
try {
130-
dispatchCallback.invoke(buildEnvelope("close", null));
130+
dispatchCallback.invoke(buildEnvelope(DispatchEventTypes.CLOSE, null));
131131
} catch (IOException e) {
132-
Log.e("ShopifyCheckoutKit", "Error processing checkout canceled event", e);
132+
Log.e(TAG, "Error processing checkout canceled event", e);
133133
} finally {
134134
dispatchCallback = null;
135135
}
@@ -176,9 +176,4 @@ private String getErrorTypeName(CheckoutException error) {
176176
}
177177
}
178178

179-
private void sendEventWithStringData(String name, String data) {
180-
reactContext
181-
.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter.class)
182-
.emit(name, data);
183-
}
184179
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
/*
2+
MIT License
3+
4+
Copyright 2023 - Present, Shopify Inc.
5+
6+
Permission is hereby granted, free of charge, to any person obtaining a copy
7+
of this software and associated documentation files (the "Software"), to deal
8+
in the Software without restriction, including without limitation the rights
9+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10+
copies of the Software, and to permit persons to whom the Software is
11+
furnished to do so, subject to the following conditions:
12+
13+
The above copyright notice and this permission notice shall be included in all
14+
copies or substantial portions of the Software.
15+
16+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22+
SOFTWARE.
23+
*/
24+
25+
package com.shopify.reactnative.checkoutkit;
26+
27+
import java.util.Arrays;
28+
import java.util.Collections;
29+
import java.util.List;
30+
31+
/**
32+
* Canonical list of SDK lifecycle event types emitted by the
33+
* per-{@code present()} dispatcher.
34+
*
35+
* Mirrors {@code SDK_LIFECYCLE_EVENT_TYPES} in the JS package and
36+
* {@code DispatchEventType} on iOS. Exposed to JS via
37+
* {@code getTypedExportedConstants()} so the JS layer can verify the
38+
* two sides agree at construction time.
39+
*
40+
* Protocol-layer method names (e.g. {@code ec.start}) are intentionally
41+
* not listed here \u2014 they belong to the shared protocol package.
42+
*/
43+
public final class DispatchEventTypes {
44+
public static final String CLOSE = "close";
45+
public static final String FAIL = "fail";
46+
public static final String GEOLOCATION_REQUEST = "geolocationRequest";
47+
48+
public static final List<String> ALL = Collections.unmodifiableList(
49+
Arrays.asList(CLOSE, FAIL, GEOLOCATION_REQUEST));
50+
51+
private DispatchEventTypes() {}
52+
}

platforms/react-native/modules/@shopify/checkout-kit-react-native/android/src/main/java/com/shopify/reactnative/checkoutkit/ShopifyCheckoutKitModule.java

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,9 @@ public ShopifyCheckoutKitModule(ReactApplicationContext reactContext) {
7070
protected Map<String, Object> getTypedExportedConstants() {
7171
final Map<String, Object> constants = new HashMap<>();
7272
constants.put("version", ShopifyCheckoutKit.version);
73+
// Exposed so the JS layer can verify the SDK lifecycle event set
74+
// it was built against matches what this native module emits.
75+
constants.put("dispatchEventTypes", DispatchEventTypes.ALL);
7376
return constants;
7477
}
7578

@@ -87,7 +90,7 @@ public void removeListeners(double count) {
8790
public void present(String checkoutURL, ReadableArray subscribedMethods, @Nullable Callback dispatch) {
8891
Activity currentActivity = getCurrentActivity();
8992
if (currentActivity instanceof ComponentActivity) {
90-
checkoutListener = new CustomCheckoutListener(currentActivity, this.reactContext, dispatch);
93+
checkoutListener = new CustomCheckoutListener(dispatch);
9194

9295
List<String> methods = new ArrayList<>();
9396
for (int i = 0; i < subscribedMethods.size(); i++) {

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

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -28,16 +28,32 @@ import ShopifyCheckoutKit
2828
import SwiftUI
2929
import UIKit
3030

31+
/// Canonical list of SDK lifecycle event types emitted by the
32+
/// per-`present()` dispatcher.
33+
///
34+
/// Mirrors `SDK_LIFECYCLE_EVENT_TYPES` in the JS package and
35+
/// `DispatchEventTypes` on Android. Exposed to JS via
36+
/// `constantsToExport()` so the JS layer can verify the two sides
37+
/// agree at construction time.
38+
enum DispatchEventType: String, CaseIterable {
39+
case close
40+
case fail
41+
case geolocationRequest
42+
}
43+
3144
@objc(RCTShopifyCheckoutKit)
3245
class RCTShopifyCheckoutKit: NSObject {
3346
internal var checkoutSheet: UIViewController?
3447
private var acceleratedCheckoutsConfiguration: Any?
3548
private var acceleratedCheckoutsApplePayConfiguration: Any?
3649
private var defaultLogLevel: LogLevel = .error
3750

38-
// TODO: invoke once the iOS CheckoutDelegate (or equivalent) lands upstream — until then,
39-
// the dispatcher is stored but never fired (Android is the only platform delivering events).
40-
// When wired, dispatch envelope JSON strings of the shape `{"type":"close"|"fail","payload":...}`.
51+
// TODO: invoke once the iOS CheckoutDelegate (or equivalent) lands upstream — until
52+
// then, the dispatcher is stored but never fired (Android is the only platform
53+
// delivering SDK lifecycle events).
54+
//
55+
// When wired, emit envelope JSON of the shape `{"type":"<DispatchEventType>","payload":...}`
56+
// using `DispatchEventType` rawValues so the JS-side parity check stays meaningful.
4157
private var pendingDispatchCallback: RCTResponseSenderBlock?
4258

4359
@objc var methodQueue: DispatchQueue {
@@ -58,7 +74,10 @@ class RCTShopifyCheckoutKit: NSObject {
5874

5975
@objc func constantsToExport() -> [AnyHashable: Any]! {
6076
return [
61-
"version": ShopifyCheckoutKit.version
77+
"version": ShopifyCheckoutKit.version,
78+
// Surfaced so the JS layer can verify the SDK lifecycle event set
79+
// it was built against matches what this native module emits.
80+
"dispatchEventTypes": DispatchEventType.allCases.map { $0.rawValue }
6281
]
6382
}
6483

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
/*
2+
MIT License
3+
4+
Copyright 2023 - Present, Shopify Inc.
5+
6+
Permission is hereby granted, free of charge, to any person obtaining a copy
7+
of this software and associated documentation files (the "Software"), to deal
8+
in the Software without restriction, including without limitation the rights
9+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10+
copies of the Software, and to permit persons to whom the Software is
11+
furnished to do so, subject to the following conditions:
12+
13+
The above copyright notice and this permission notice shall be included in all
14+
copies or substantial portions of the Software.
15+
16+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
22+
*/
23+
24+
/**
25+
* Canonical list of SDK lifecycle event types delivered through the
26+
* per-`present()` dispatcher.
27+
*
28+
* The set must be kept in sync with the native equivalents:
29+
* - android: `DispatchEventTypes.ALL` (Java)
30+
* - ios: `DispatchEventType.allCases` (Swift)
31+
*
32+
* Drift is detected at runtime by `verifyDispatchEventParity`, which is
33+
* invoked from the `ShopifyCheckout` constructor against the
34+
* `dispatchEventTypes` array reported by `RNShopifyCheckoutKit.getConstants()`.
35+
*
36+
* Protocol-layer method names (e.g. `ec.start`) are intentionally NOT
37+
* listed here — those are owned by `@shopify/checkout-kit-protocol` and
38+
* versioned independently of this bridge.
39+
*/
40+
export const SDK_LIFECYCLE_EVENT_TYPES = [
41+
'close',
42+
'fail',
43+
'geolocationRequest',
44+
] as const;
45+
46+
export type SdkLifecycleEventType = (typeof SDK_LIFECYCLE_EVENT_TYPES)[number];
47+
48+
const sdkLifecycleEventSet: ReadonlySet<string> = new Set(
49+
SDK_LIFECYCLE_EVENT_TYPES,
50+
);
51+
52+
export function isSdkLifecycleEventType(
53+
value: string,
54+
): value is SdkLifecycleEventType {
55+
return sdkLifecycleEventSet.has(value);
56+
}
57+
58+
/**
59+
* Thrown when the SDK lifecycle event list reported by the native
60+
* module does not match the list this JS package was built against.
61+
*
62+
* This almost always means the bundled native module is older or newer
63+
* than the JS package — the host app needs a clean native rebuild.
64+
*/
65+
export class DispatchEventParityError extends Error {
66+
constructor(message: string) {
67+
super(message);
68+
this.name = 'DispatchEventParityError';
69+
70+
if (Error.captureStackTrace) {
71+
Error.captureStackTrace(this, DispatchEventParityError);
72+
}
73+
}
74+
}
75+
76+
let parityVerified = false;
77+
78+
/**
79+
* Compares the JS-side SDK lifecycle event list against the list the
80+
* native module reports through `getConstants()`. Throws a
81+
* `DispatchEventParityError` describing the diff on mismatch — the
82+
* dispatch contract is unsafe to use otherwise.
83+
*
84+
* Set-equality, order-independent. Memoised: runs at most once per JS
85+
* process. Use `__resetDispatchEventParityForTests` to reset in tests.
86+
*/
87+
export function verifyDispatchEventParity(
88+
nativeTypes: readonly string[] | undefined | null,
89+
): void {
90+
if (parityVerified) return;
91+
92+
if (!Array.isArray(nativeTypes)) {
93+
throw new DispatchEventParityError(
94+
buildMessage(
95+
'native module did not report a `dispatchEventTypes` array in getConstants(). ' +
96+
'The bundled native module is likely older than this JS package.',
97+
),
98+
);
99+
}
100+
101+
const jsSet = new Set<string>(SDK_LIFECYCLE_EVENT_TYPES);
102+
const nativeSet = new Set<string>(nativeTypes);
103+
104+
const missingFromJs = [...nativeSet].filter(t => !jsSet.has(t)).sort();
105+
const missingFromNative = [...jsSet].filter(t => !nativeSet.has(t)).sort();
106+
107+
if (missingFromJs.length === 0 && missingFromNative.length === 0) {
108+
parityVerified = true;
109+
return;
110+
}
111+
112+
const lines = [
113+
`js = [${[...jsSet].sort().join(', ')}]`,
114+
`native = [${[...nativeSet].sort().join(', ')}]`,
115+
];
116+
if (missingFromJs.length > 0) {
117+
lines.push(`events missing from js: ${missingFromJs.join(', ')}`);
118+
}
119+
if (missingFromNative.length > 0) {
120+
lines.push(`events missing from native: ${missingFromNative.join(', ')}`);
121+
}
122+
123+
throw new DispatchEventParityError(buildMessage(lines.join('\n ')));
124+
}
125+
126+
function buildMessage(detail: string): string {
127+
return (
128+
'[ShopifyCheckoutKit] SDK lifecycle event list out of sync between JS ' +
129+
"and native. Rebuild your host app so the bundled native module matches " +
130+
"this version of '@shopify/checkout-kit-react-native'.\n " +
131+
detail
132+
);
133+
}
134+
135+
/**
136+
* Test-only — resets the cached verification flag so unit tests can
137+
* exercise both success and failure paths in isolation. Not part of
138+
* the public API.
139+
*/
140+
export function __resetDispatchEventParityForTests(): void {
141+
parityVerified = false;
142+
}

0 commit comments

Comments
 (0)