Skip to content

Commit 4cf6e67

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 2c65b9a commit 4cf6e67

12 files changed

Lines changed: 610 additions & 73 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: 122 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -28,16 +28,31 @@ 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+
/// Per-call dispatcher passed in from JS. Holds onto an
52+
/// `RCTResponseSenderBlock` for the duration of one `present()` call;
53+
/// nulled on the first terminal SDK lifecycle event so a single
54+
/// presentation can only ever fire `close` or `fail` once. Matches
55+
/// the Android `CustomCheckoutListener.dispatchCallback` lifecycle.
4156
private var pendingDispatchCallback: RCTResponseSenderBlock?
4257

4358
@objc var methodQueue: DispatchQueue {
@@ -58,7 +73,10 @@ class RCTShopifyCheckoutKit: NSObject {
5873

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

@@ -107,7 +125,7 @@ class RCTShopifyCheckoutKit: NSObject {
107125

108126
DispatchQueue.main.async {
109127
if let url = URL(string: checkoutURL), let viewController = self.getCurrentViewController() {
110-
let view = CheckoutViewController(checkout: url)
128+
let view = CheckoutViewController(checkout: url, delegate: self)
111129
viewController.present(view, animated: true)
112130
self.checkoutSheet = view
113131
}
@@ -278,3 +296,102 @@ class RCTShopifyCheckoutKit: NSObject {
278296
}
279297
}
280298
}
299+
300+
// MARK: - CheckoutDelegate
301+
302+
extension RCTShopifyCheckoutKit: CheckoutDelegate {
303+
/// Fired by the iOS SDK when the buyer dismisses the checkout sheet
304+
/// without a terminal error. Mirrors
305+
/// `CustomCheckoutListener.onCheckoutCanceled()` on Android.
306+
func checkoutDidCancel() {
307+
emitDispatchEnvelope(type: .close, payload: nil)
308+
}
309+
310+
/// Fired by the iOS SDK when checkout terminates with an error.
311+
/// Mirrors `CustomCheckoutListener.onCheckoutFailed()` on Android.
312+
/// The error is serialised into the JS-side `CheckoutNativeError`
313+
/// shape (`__typename` / `message` / `code` / `recoverable` /
314+
/// optional `statusCode`) so it can be coerced into the matching
315+
/// `CheckoutException` subclass on the JS side.
316+
func checkoutDidFail(error: CheckoutError) {
317+
emitDispatchEnvelope(type: .fail, payload: Self.errorPayload(from: error))
318+
}
319+
}
320+
321+
// MARK: - Dispatch envelope helpers
322+
323+
private extension RCTShopifyCheckoutKit {
324+
/// Builds a `{ "type": ..., "payload": ... }` envelope and forwards
325+
/// it to the pending JS dispatcher. SDK lifecycle envelopes are
326+
/// single-shot: the callback is released after emission so the same
327+
/// presentation can only fire one terminal event.
328+
func emitDispatchEnvelope(type: DispatchEventType, payload: [String: Any]?) {
329+
guard let dispatch = pendingDispatchCallback else { return }
330+
// Single-shot for SDK lifecycle events — release before invoking
331+
// so a delegate callback that re-enters this code path (e.g. via
332+
// a synchronous JS callback that triggers `dismiss()`) cannot
333+
// emit a second envelope on the same handle.
334+
pendingDispatchCallback = nil
335+
336+
var envelope: [String: Any] = ["type": type.rawValue]
337+
if let payload {
338+
envelope["payload"] = payload
339+
}
340+
341+
do {
342+
let data = try JSONSerialization.data(withJSONObject: envelope, options: [])
343+
guard let json = String(data: data, encoding: .utf8) else {
344+
NSLog("[ShopifyCheckoutKit] Failed to encode dispatch envelope for \(type.rawValue): non-UTF8 result")
345+
return
346+
}
347+
dispatch([json])
348+
} catch {
349+
NSLog("[ShopifyCheckoutKit] Failed to serialize dispatch envelope for \(type.rawValue): \(error)")
350+
}
351+
}
352+
353+
/// Maps an iOS `CheckoutError` into the JSON-friendly dictionary
354+
/// shape the JS dispatcher expects. Field names match Android's
355+
/// `CustomCheckoutListener.populateErrorDetails` so the JS-side
356+
/// `parseCheckoutError` works identically on both platforms.
357+
static func errorPayload(from error: CheckoutError) -> [String: Any] {
358+
switch error {
359+
case let .sdkError(underlying, recoverable):
360+
return [
361+
"__typename": "InternalError",
362+
"message": underlying.localizedDescription,
363+
"code": CheckoutErrorCode.unknown.rawValue,
364+
"recoverable": recoverable
365+
]
366+
367+
case let .checkoutUnavailable(message, code, recoverable):
368+
switch code {
369+
case let .clientError(clientCode):
370+
return [
371+
"__typename": "CheckoutClientError",
372+
"message": message,
373+
"code": clientCode.rawValue,
374+
"recoverable": recoverable
375+
]
376+
case let .httpError(statusCode):
377+
return [
378+
"__typename": "CheckoutHTTPError",
379+
"message": message,
380+
// Matches the JS-side `CheckoutErrorCode.httpError`
381+
// string and Android's HttpException code value.
382+
"code": "http_error",
383+
"recoverable": recoverable,
384+
"statusCode": statusCode
385+
]
386+
}
387+
388+
case let .checkoutExpired(message, code, recoverable):
389+
return [
390+
"__typename": "CheckoutExpiredError",
391+
"message": message,
392+
"code": code.rawValue,
393+
"recoverable": recoverable
394+
]
395+
}
396+
}
397+
}

platforms/react-native/modules/@shopify/checkout-kit-react-native/package.snapshot.json

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
"android/src/main/AndroidManifest.xml",
66
"android/src/main/AndroidManifestNew.xml",
77
"android/src/main/java/com/shopify/reactnative/checkoutkit/CustomCheckoutListener.java",
8+
"android/src/main/java/com/shopify/reactnative/checkoutkit/DispatchEventTypes.java",
89
"android/src/main/java/com/shopify/reactnative/checkoutkit/ShopifyCheckoutKitModule.java",
910
"android/src/main/java/com/shopify/reactnative/checkoutkit/ShopifyCheckoutKitPackage.java",
1011
"ios/AcceleratedCheckoutButtons.swift",
@@ -18,6 +19,8 @@
1819
"lib/commonjs/components/AcceleratedCheckoutButtons.js.map",
1920
"lib/commonjs/context.js",
2021
"lib/commonjs/context.js.map",
22+
"lib/commonjs/dispatch-events.js",
23+
"lib/commonjs/dispatch-events.js.map",
2124
"lib/commonjs/errors.d.js",
2225
"lib/commonjs/errors.d.js.map",
2326
"lib/commonjs/index.d.js",
@@ -32,6 +35,8 @@
3235
"lib/module/components/AcceleratedCheckoutButtons.js.map",
3336
"lib/module/context.js",
3437
"lib/module/context.js.map",
38+
"lib/module/dispatch-events.js",
39+
"lib/module/dispatch-events.js.map",
3540
"lib/module/errors.d.js",
3641
"lib/module/errors.d.js.map",
3742
"lib/module/index.d.js",
@@ -46,6 +51,8 @@
4651
"lib/typescript/src/components/AcceleratedCheckoutButtons.d.ts.map",
4752
"lib/typescript/src/context.d.ts",
4853
"lib/typescript/src/context.d.ts.map",
54+
"lib/typescript/src/dispatch-events.d.ts",
55+
"lib/typescript/src/dispatch-events.d.ts.map",
4956
"lib/typescript/src/index.d.ts",
5057
"lib/typescript/src/index.d.ts.map",
5158
"lib/typescript/src/specs/NativeShopifyCheckoutKit.d.ts",
@@ -57,6 +64,7 @@
5764
"RNShopifyCheckoutKit.podspec",
5865
"src/components/AcceleratedCheckoutButtons.tsx",
5966
"src/context.tsx",
67+
"src/dispatch-events.ts",
6068
"src/errors.d.ts",
6169
"src/index.d.ts",
6270
"src/index.ts",

0 commit comments

Comments
 (0)