Skip to content

Commit 0967be1

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' or 'geolocationRequest' 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, surfacing native/JS contract drift that slips past the parity check. - 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 7c6e3f8 commit 0967be1

10 files changed

Lines changed: 491 additions & 70 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,49 @@
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+
public final class DispatchEventTypes {
41+
public static final String CLOSE = "close";
42+
public static final String FAIL = "fail";
43+
public static final String GEOLOCATION_REQUEST = "geolocationRequest";
44+
45+
public static final List<String> ALL = Collections.unmodifiableList(
46+
Arrays.asList(CLOSE, FAIL, GEOLOCATION_REQUEST));
47+
48+
private DispatchEventTypes() {}
49+
}

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
@@ -68,6 +68,9 @@ public ShopifyCheckoutKitModule(ReactApplicationContext reactContext) {
6868
protected Map<String, Object> getTypedExportedConstants() {
6969
final Map<String, Object> constants = new HashMap<>();
7070
constants.put("version", ShopifyCheckoutKit.version);
71+
// Exposed so the JS layer can verify the SDK lifecycle event set
72+
// it was built against matches what this native module emits.
73+
constants.put("dispatchEventTypes", DispatchEventTypes.ALL);
7174
return constants;
7275
}
7376

@@ -85,7 +88,7 @@ public void removeListeners(double count) {
8588
public void present(String checkoutURL, @Nullable Callback dispatch) {
8689
Activity currentActivity = getCurrentActivity();
8790
if (currentActivity instanceof ComponentActivity) {
88-
checkoutListener = new CustomCheckoutListener(currentActivity, this.reactContext, dispatch);
91+
checkoutListener = new CustomCheckoutListener(dispatch);
8992
currentActivity.runOnUiThread(() -> {
9093
checkoutSheet = ShopifyCheckoutKit.present(checkoutURL, (ComponentActivity) currentActivity,
9194
checkoutListener);

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: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
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+
export const SDK_LIFECYCLE_EVENT_TYPES = [
37+
'close',
38+
'fail',
39+
'geolocationRequest',
40+
] as const;
41+
42+
export type SdkLifecycleEventType = (typeof SDK_LIFECYCLE_EVENT_TYPES)[number];
43+
44+
const sdkLifecycleEventSet: ReadonlySet<string> = new Set(
45+
SDK_LIFECYCLE_EVENT_TYPES,
46+
);
47+
48+
export function isSdkLifecycleEventType(
49+
value: string,
50+
): value is SdkLifecycleEventType {
51+
return sdkLifecycleEventSet.has(value);
52+
}
53+
54+
/**
55+
* Thrown when the SDK lifecycle event list reported by the native
56+
* module does not match the list this JS package was built against.
57+
*
58+
* This almost always means the bundled native module is older or newer
59+
* than the JS package — the host app needs a clean native rebuild.
60+
*/
61+
export class DispatchEventParityError extends Error {
62+
constructor(message: string) {
63+
super(message);
64+
this.name = 'DispatchEventParityError';
65+
66+
if (Error.captureStackTrace) {
67+
Error.captureStackTrace(this, DispatchEventParityError);
68+
}
69+
}
70+
}
71+
72+
let parityVerified = false;
73+
74+
/**
75+
* Compares the JS-side SDK lifecycle event list against the list the
76+
* native module reports through `getConstants()`. Throws a
77+
* `DispatchEventParityError` describing the diff on mismatch — the
78+
* dispatch contract is unsafe to use otherwise.
79+
*
80+
* Set-equality, order-independent. Memoised: runs at most once per JS
81+
* process. Use `__resetDispatchEventParityForTests` to reset in tests.
82+
*/
83+
export function verifyDispatchEventParity(
84+
nativeTypes: readonly string[] | undefined | null,
85+
): void {
86+
if (parityVerified) return;
87+
88+
if (!Array.isArray(nativeTypes)) {
89+
throw new DispatchEventParityError(
90+
buildMessage(
91+
'native module did not report a `dispatchEventTypes` array in getConstants(). ' +
92+
'The bundled native module is likely older than this JS package.',
93+
),
94+
);
95+
}
96+
97+
const jsSet = new Set<string>(SDK_LIFECYCLE_EVENT_TYPES);
98+
const nativeSet = new Set<string>(nativeTypes);
99+
100+
const missingFromJs = [...nativeSet].filter(t => !jsSet.has(t)).sort();
101+
const missingFromNative = [...jsSet].filter(t => !nativeSet.has(t)).sort();
102+
103+
if (missingFromJs.length === 0 && missingFromNative.length === 0) {
104+
parityVerified = true;
105+
return;
106+
}
107+
108+
const lines = [
109+
`js = [${[...jsSet].sort().join(', ')}]`,
110+
`native = [${[...nativeSet].sort().join(', ')}]`,
111+
];
112+
if (missingFromJs.length > 0) {
113+
lines.push(`events missing from js: ${missingFromJs.join(', ')}`);
114+
}
115+
if (missingFromNative.length > 0) {
116+
lines.push(`events missing from native: ${missingFromNative.join(', ')}`);
117+
}
118+
119+
throw new DispatchEventParityError(buildMessage(lines.join('\n ')));
120+
}
121+
122+
function buildMessage(detail: string): string {
123+
return (
124+
'[ShopifyCheckoutKit] SDK lifecycle event list out of sync between JS ' +
125+
"and native. Rebuild your host app so the bundled native module matches " +
126+
"this version of '@shopify/checkout-kit-react-native'.\n " +
127+
detail
128+
);
129+
}
130+
131+
/**
132+
* Test-only — resets the cached verification flag so unit tests can
133+
* exercise both success and failure paths in isolation. Not part of
134+
* the public API.
135+
*/
136+
export function __resetDispatchEventParityForTests(): void {
137+
parityVerified = false;
138+
}

0 commit comments

Comments
 (0)