Skip to content

Commit 920e549

Browse files
feat(RN): add first protocol event
1 parent 4a684d8 commit 920e549

47 files changed

Lines changed: 4717 additions & 243 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ apollo-ios-cli
3131

3232
# Android / Gradle
3333
.gradle/
34+
.kotlin/
3435
build/
3536
captures/
3637
.externalNativeBuild

AGENTS.md

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,3 +9,56 @@ protocol/ # cross-platform communication layer based on UCP
99
e2e/ # cross-platform end-to-end tests
1010
.github/ # workflows, issue templates, CODEOWNERS
1111
```
12+
13+
## React Native development with local native SDK changes
14+
15+
Until the new native SDK libraries have stable released versions, assume React Native validation needs the local native SDK workflow. Use `--local` whenever running the React Native sample or native React Native tests that depend on the in-repo Swift/Kotlin SDKs.
16+
17+
Use the React Native `--local` workflow when you need to test React Native against native SDK changes that exist in this repository but have not been released as a SemVer/CocoaPods/Maven version yet.
18+
19+
This applies when changes are made under:
20+
21+
- `platforms/swift/` — the iOS Swift SDK / CocoaPods sources
22+
- `platforms/android/` — the Android SDK / Maven artifact sources
23+
24+
It does **not** refer to the React Native wrapper platform folders:
25+
26+
- `platforms/react-native/modules/@shopify/checkout-kit-react-native/ios/`
27+
- `platforms/react-native/modules/@shopify/checkout-kit-react-native/android/`
28+
29+
### What `--local` does
30+
31+
- For React Native iOS, `--local` wires CocoaPods to the in-repo `platforms/swift/` sources via a local path instead of a released pod version.
32+
- For React Native Android, `--local` publishes/uses the in-repo `platforms/android/` SDK through Maven Local so Gradle resolves the local SDK artifact instead of a released Maven version.
33+
34+
### When to use it
35+
36+
Use `--local` whenever you are validating React Native behavior that depends on unreleased native SDK changes, for example:
37+
38+
- a new Swift SDK API that the React Native iOS bridge calls
39+
- a new Android SDK API that the React Native Android bridge calls
40+
- generated protocol/model changes under the native SDKs that the React Native module consumes
41+
- any change in `platforms/swift/` or `platforms/android/` that has not yet been released and consumed through normal dependency versions
42+
43+
Re-run the relevant local workflow whenever `platforms/swift/` or `platforms/android/` changes, because the React Native sample/tests need to re-resolve those local native SDK sources/artifacts.
44+
45+
```bash
46+
# iOS sample using local platforms/swift sources
47+
dev rn ios --local
48+
49+
# Android sample using local platforms/android via Maven Local
50+
dev rn android --local
51+
52+
# React Native Android unit tests using local platforms/android via Maven Local
53+
# `dev rn test android` publishes platforms/android/lib to ~/.m2 first, then runs the RN module tests.
54+
dev rn test android
55+
```
56+
57+
For ad-hoc Android Gradle test commands, publish the local Android SDK first and set `USE_LOCAL_SDK=1` so the React Native module resolves `com.shopify:checkout-kit:1.0.0` from Maven Local instead of the unreleased placeholder artifact:
58+
59+
```bash
60+
cd platforms/react-native
61+
USE_LOCAL_SDK=1 ./scripts/publish_android_snapshot
62+
cd sample/android
63+
USE_LOCAL_SDK=1 ./gradlew :shopify_checkout-kit-react-native:testDebugUnitTest
64+
```

dev.yml

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -276,6 +276,35 @@ commands:
276276
build:
277277
desc: Build the @shopify/checkout-kit-react-native module
278278
run: cd platforms/react-native && pnpm module build
279+
test:
280+
desc: Run React Native module tests (JS + iOS + Android)
281+
long_desc: |
282+
Runs unit tests across all three React Native targets:
283+
- JS: Jest tests in `platforms/react-native/modules/@shopify/checkout-kit-react-native/tests/`
284+
- iOS: Swift Package tests at `platforms/react-native/modules/@shopify/checkout-kit-react-native/ios/`
285+
- Android: Gradle JVM tests for `:shopify_checkout-kit-react-native` (requires a local Maven publish of `:lib`)
286+
run: |
287+
set -e
288+
cd platforms/react-native && pnpm test
289+
cd modules/@shopify/checkout-kit-react-native/ios && swift test
290+
cd ../../../../
291+
USE_LOCAL_SDK=1 ./scripts/publish_android_snapshot
292+
cd sample/android && USE_LOCAL_SDK=1 ./gradlew :shopify_checkout-kit-react-native:test
293+
subcommands:
294+
js:
295+
desc: Run JS unit tests via jest
296+
run: cd platforms/react-native && pnpm test
297+
ios:
298+
desc: Run native iOS unit tests (Swift Package at modules/.../ios)
299+
run: cd platforms/react-native/modules/@shopify/checkout-kit-react-native/ios && swift test
300+
android:
301+
desc: Run native Android unit tests for the RN module (publishes/uses local platforms/android SDK)
302+
run: |
303+
set -e
304+
cd platforms/react-native
305+
USE_LOCAL_SDK=1 ./scripts/publish_android_snapshot
306+
cd sample/android
307+
USE_LOCAL_SDK=1 ./gradlew :shopify_checkout-kit-react-native:test
279308
lint:
280309
desc: Run all React Native lint checks (Swift, module, sample)
281310
aliases: [style]

platforms/react-native/README.md

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -621,10 +621,16 @@ shopify.present(checkoutUrl, {
621621
`onClose` and `onFail` are mutually exclusive — exactly one of them fires
622622
per `present(...)` call, after which both handles are released.
623623

624-
> Protocol-level callbacks (`start`, `complete`, `error` on the protocol
625-
> client) are not part of this section and will land in a follow-up release
626-
> alongside a `<CheckoutSheet>` component. Checkout completion is not
627-
> currently surfaced through the per-call callbacks.
624+
> [!IMPORTANT]
625+
> `present(...)` supports one active checkout presentation at a time. Starting
626+
> another presentation while a checkout sheet is already active is unsupported;
627+
> callbacks and protocol handlers are scoped to the currently active
628+
> presentation. For multiple inline checkout surfaces, use component-scoped APIs
629+
> where available.
630+
631+
> Protocol-level callbacks are configured separately from these SDK lifecycle
632+
> callbacks. Checkout completion is not currently surfaced through the per-call
633+
> SDK lifecycle callbacks.
628634

629635
## Identity & customer accounts
630636

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

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -49,13 +49,17 @@ const exampleConfig = {
4949
colorScheme: 'automatic',
5050
logLevel: 'error',
5151
};
52+
const shopifyCheckoutKitEventEmitter = createMockEmitter();
5253

5354
const ShopifyCheckoutKit = {
5455
version: '0.7.0',
5556
getConstants: jest.fn(() => ({
5657
version: '0.7.0',
5758
dispatchEventTypes: ['close', 'fail', 'geolocationRequest'],
5859
})),
60+
onDispatch: jest.fn((callback: (envelopeJson: string) => void) =>
61+
shopifyCheckoutKitEventEmitter.addListener('onDispatch', callback),
62+
),
5963
preload: jest.fn(),
6064
present: jest.fn(),
6165
dismiss: jest.fn(),
@@ -76,7 +80,7 @@ module.exports = {
7680
PermissionsAndroid: {
7781
requestMultiple: jest.fn(async () => ({})),
7882
},
79-
NativeEventEmitter: jest.fn(() => createMockEmitter()),
83+
NativeEventEmitter: jest.fn(() => shopifyCheckoutKitEventEmitter),
8084
requireNativeComponent,
8185
codegenNativeComponent,
8286
TurboModuleRegistry: {
@@ -90,7 +94,7 @@ module.exports = {
9094
NativeModules: {
9195
ShopifyCheckoutKit: {
9296
...ShopifyCheckoutKit,
93-
eventEmitter: createMockEmitter(),
97+
eventEmitter: shopifyCheckoutKitEventEmitter,
9498
},
9599
},
96100
StyleSheet,

platforms/react-native/modules/@shopify/checkout-kit-react-native/RNShopifyCheckoutKit.podspec

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,11 @@ Pod::Spec.new do |s|
1414
s.source = { :git => "https://github.com/Shopify/checkout-kit.git", :tag => "#{s.version}" }
1515

1616
s.source_files = "ios/*.{h,m,mm,swift}"
17+
# `ios/Package.swift` is the manifest for the nested SwiftPM test package
18+
# (CasingTransform / ProtocolRelay unit tests). It imports `PackageDescription`
19+
# which only exists in the SwiftPM toolchain, so it must not be compiled by
20+
# CocoaPods/Xcode when the RN module is consumed from an iOS app.
21+
s.exclude_files = "ios/Package.swift"
1722

1823
s.dependency "React-Core"
1924

platforms/react-native/modules/@shopify/checkout-kit-react-native/android/build.gradle

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,22 @@
11
buildscript {
2+
ext.kotlin_version = rootProject.ext.has("kotlinVersion") ? rootProject.ext.get("kotlinVersion") : "2.1.20"
3+
24
repositories {
35
google()
46
mavenCentral()
57
}
68

79
dependencies {
810
classpath "com.android.tools.build:gradle:8.11.0"
11+
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
12+
classpath "org.jetbrains.kotlin:kotlin-serialization:$kotlin_version"
913
}
1014
}
1115

1216
apply plugin: "com.android.library"
1317
apply plugin: "com.facebook.react"
18+
apply plugin: "org.jetbrains.kotlin.android"
19+
apply plugin: "org.jetbrains.kotlin.plugin.serialization"
1420

1521
def getExtOrIntegerDefault(name) {
1622
return rootProject.ext.has(name) ? rootProject.ext.get(name) : (project.properties[name]).toInteger()
@@ -73,8 +79,17 @@ android {
7379
sourceCompatibility JavaVersion.VERSION_1_8
7480
targetCompatibility JavaVersion.VERSION_1_8
7581
}
82+
83+
kotlinOptions {
84+
jvmTarget = "1.8"
85+
}
86+
87+
testOptions {
88+
unitTests.includeAndroidResources = true
89+
}
7690
}
7791

92+
7893
repositories {
7994
mavenLocal()
8095
mavenCentral()
@@ -97,6 +112,11 @@ dependencies {
97112

98113
implementation(shopifySdkArtifact)
99114
implementation("com.fasterxml.jackson.core:jackson-databind:2.12.5")
115+
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.3")
100116
debugImplementation(shopifySdkArtifact)
117+
118+
testImplementation "junit:junit:4.13.2"
119+
testImplementation "org.assertj:assertj-core:3.27.7"
120+
testImplementation "org.robolectric:robolectric:4.16.1"
101121
}
102122

platforms/react-native/modules/@shopify/checkout-kit-react-native/android/gradle.properties

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,8 @@ targetSdkVersion=35
33
compileSdkVersion=36
44
ndkVersion=23.1.7779620
55
buildToolsVersion = "35.0.0"
6+
7+
# Opt out of the React Native Gradle plugin's JdkConfiguratorUtils, which otherwise
8+
# silently rewrites compileOptions to 17 and pins the Kotlin JVM toolchain to 17 for
9+
# every com.android.library it sees. We mirror :lib's pinned JVM 1.8 contract instead.
10+
react.internal.disableJavaVersionAlignment=true
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
package com.shopify.reactnative.checkoutkit
2+
3+
import kotlinx.serialization.json.Json
4+
import kotlinx.serialization.json.JsonArray
5+
import kotlinx.serialization.json.JsonElement
6+
import kotlinx.serialization.json.JsonObject
7+
import kotlinx.serialization.json.decodeFromJsonElement
8+
import kotlinx.serialization.json.encodeToJsonElement
9+
10+
/**
11+
* Bridges typed snake_case payloads (per @SerialName annotations on the native models)
12+
* with camelCase JSON expected by JavaScript consumers.
13+
*/
14+
internal object CasingTransform {
15+
16+
internal val json: Json = Json { ignoreUnknownKeys = true }
17+
18+
fun snakeToCamel(s: String): String {
19+
if (s.isEmpty() || !s.contains('_')) return s
20+
val builder = StringBuilder(s.length)
21+
var upperNext = false
22+
for (ch in s) {
23+
if (ch == '_') {
24+
upperNext = true
25+
} else if (upperNext) {
26+
builder.append(ch.uppercaseChar())
27+
upperNext = false
28+
} else {
29+
builder.append(ch)
30+
}
31+
}
32+
return builder.toString()
33+
}
34+
35+
fun camelToSnake(s: String): String {
36+
if (s.isEmpty()) return s
37+
val uppercaseCount = s.count { it.isUpperCase() }
38+
val builder = StringBuilder(s.length + uppercaseCount)
39+
for (ch in s) {
40+
if (ch.isUpperCase()) {
41+
builder.append('_').append(ch.lowercaseChar())
42+
} else {
43+
builder.append(ch)
44+
}
45+
}
46+
return builder.toString()
47+
}
48+
49+
fun transformKeys(element: JsonElement, fn: (String) -> String): JsonElement = when (element) {
50+
is JsonObject -> JsonObject(element.entries.associate { (key, value) -> fn(key) to transformKeys(value, fn) })
51+
is JsonArray -> JsonArray(element.map { transformKeys(it, fn) })
52+
else -> element
53+
}
54+
55+
inline fun <reified T> encodeForJS(payload: T): String {
56+
val element = json.encodeToJsonElement(payload)
57+
val transformed = transformKeys(element, ::snakeToCamel)
58+
return json.encodeToString(JsonElement.serializer(), transformed)
59+
}
60+
61+
inline fun <reified T> decodeFromJS(json: String): T {
62+
val element = Json.parseToJsonElement(json)
63+
val transformed = transformKeys(element, ::camelToSnake)
64+
return CasingTransform.json.decodeFromJsonElement(transformed)
65+
}
66+
}

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

Lines changed: 19 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@
77
import androidx.annotation.Nullable;
88

99
import com.shopify.checkoutkit.*;
10-
import com.facebook.react.bridge.Callback;
1110
import com.fasterxml.jackson.databind.ObjectMapper;
1211
import com.fasterxml.jackson.databind.node.ObjectNode;
1312
import java.io.IOException;
@@ -19,16 +18,19 @@ public class CustomCheckoutListener extends DefaultCheckoutListener {
1918

2019
private final ObjectMapper mapper = new ObjectMapper();
2120

22-
@Nullable
23-
private Callback dispatchCallback;
21+
private final DispatchHandle dispatch;
2422

2523
// Geolocation-specific variables
2624

2725
private String geolocationOrigin;
2826
private GeolocationPermissions.Callback geolocationCallback;
2927

30-
public CustomCheckoutListener(@Nullable Callback dispatch) {
31-
this.dispatchCallback = dispatch;
28+
public CustomCheckoutListener(@NonNull DispatchCallback dispatch) {
29+
this(new DispatchHandle(dispatch));
30+
}
31+
32+
public CustomCheckoutListener(@NonNull DispatchHandle dispatch) {
33+
this.dispatch = dispatch;
3234
}
3335

3436
// Public methods
@@ -42,7 +44,7 @@ public void invokeGeolocationCallback(boolean allow) {
4244
}
4345

4446
public void release() {
45-
dispatchCallback = null;
47+
dispatch.release();
4648
geolocationCallback = null;
4749
geolocationOrigin = null;
4850
}
@@ -63,20 +65,21 @@ public void release() {
6365
public void onGeolocationPermissionsShowPrompt(@NonNull String origin,
6466
@NonNull GeolocationPermissions.Callback callback) {
6567

66-
this.geolocationCallback = callback;
67-
this.geolocationOrigin = origin;
68-
69-
if (dispatchCallback == null) {
68+
if (dispatch.isReleased()) {
7069
// Multi-shot geolocation requests can in principle arrive after a
71-
// terminal event has nulled the dispatcher. Log so the silence is
72-
// observable rather than mystifying.
73-
Log.w(TAG, "Dropping geolocationRequest \u2014 dispatcher already released by a terminal event.");
70+
// terminal event or explicit dismiss has released the dispatcher. Log
71+
// so the silence is observable rather than mystifying.
72+
Log.w(TAG, "Dropping geolocationRequest dispatcher already released.");
7473
return;
7574
}
75+
76+
this.geolocationCallback = callback;
77+
this.geolocationOrigin = origin;
78+
7679
try {
7780
Map<String, Object> payload = new HashMap<>();
7881
payload.put("origin", origin);
79-
dispatchCallback.invoke(buildEnvelope(DispatchEventTypes.GEOLOCATION_REQUEST, payload));
82+
dispatch.invoke(buildEnvelope(DispatchEventTypes.GEOLOCATION_REQUEST, payload));
8083
} catch (IOException e) {
8184
Log.e(TAG, "Error emitting \"geolocationRequest\" event", e);
8285
}
@@ -92,9 +95,7 @@ public void onGeolocationPermissionsHidePrompt() {
9295

9396
@Override
9497
public void onCheckoutFailed(CheckoutException checkoutError) {
95-
Callback dispatch = dispatchCallback;
96-
if (dispatch == null) {
97-
release();
98+
if (dispatch.isReleased()) {
9899
return;
99100
}
100101
try {
@@ -108,9 +109,7 @@ public void onCheckoutFailed(CheckoutException checkoutError) {
108109

109110
@Override
110111
public void onCheckoutCanceled() {
111-
Callback dispatch = dispatchCallback;
112-
if (dispatch == null) {
113-
release();
112+
if (dispatch.isReleased()) {
114113
return;
115114
}
116115
try {

0 commit comments

Comments
 (0)