Skip to content

Commit f77c5ca

Browse files
feat: add first protocol event
1 parent d949cb6 commit f77c5ca

43 files changed

Lines changed: 2045 additions & 234 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: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,3 +9,41 @@ 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+
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.
16+
17+
This applies when changes are made under:
18+
19+
- `platforms/swift/` — the iOS Swift SDK / CocoaPods sources
20+
- `platforms/android/` — the Android SDK / Maven artifact sources
21+
22+
It does **not** refer to the React Native wrapper platform folders:
23+
24+
- `platforms/react-native/modules/@shopify/checkout-kit-react-native/ios/`
25+
- `platforms/react-native/modules/@shopify/checkout-kit-react-native/android/`
26+
27+
### What `--local` does
28+
29+
- For React Native iOS, `--local` wires CocoaPods to the in-repo `platforms/swift/` sources via a local path instead of a released pod version.
30+
- 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.
31+
32+
### When to use it
33+
34+
Use `--local` whenever you are validating React Native behavior that depends on unreleased native SDK changes, for example:
35+
36+
- a new Swift SDK API that the React Native iOS bridge calls
37+
- a new Android SDK API that the React Native Android bridge calls
38+
- generated protocol/model changes under the native SDKs that the React Native module consumes
39+
- any change in `platforms/swift/` or `platforms/android/` that has not yet been released and consumed through normal dependency versions
40+
41+
Re-run the relevant local workflow whenever `platforms/swift/` or `platforms/android/` changes, because the React Native sample needs to re-resolve those local native SDK sources/artifacts.
42+
43+
```bash
44+
# iOS sample using local platforms/swift sources
45+
dev rn ios --local
46+
47+
# Android sample using local platforms/android via Maven Local
48+
dev rn android --local
49+
```

dev.yml

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -267,6 +267,28 @@ commands:
267267
build:
268268
desc: Build the @shopify/checkout-kit-react-native module
269269
run: cd platforms/react-native && pnpm module build
270+
test:
271+
desc: Run React Native module tests (JS + iOS + Android)
272+
long_desc: |
273+
Runs unit tests across all three React Native targets:
274+
- JS: Jest tests in `platforms/react-native/modules/@shopify/checkout-kit-react-native/tests/`
275+
- iOS: Swift Package tests at `platforms/react-native/modules/@shopify/checkout-kit-react-native/ios/`
276+
- Android: Gradle JVM tests for `:shopify_checkout-kit-react-native` (requires a local Maven publish of `:lib`)
277+
run: |
278+
set -e
279+
cd platforms/react-native && pnpm test
280+
cd modules/@shopify/checkout-kit-react-native/ios && swift test
281+
cd ../../../../sample/android && USE_LOCAL_SDK=1 ./gradlew :shopify_checkout-kit-react-native:test
282+
subcommands:
283+
js:
284+
desc: Run JS unit tests via jest
285+
run: cd platforms/react-native && pnpm test
286+
ios:
287+
desc: Run native iOS unit tests (Swift Package at modules/.../ios)
288+
run: cd platforms/react-native/modules/@shopify/checkout-kit-react-native/ios && swift test
289+
android:
290+
desc: Run native Android unit tests for the RN module (uses local Maven publish of :lib)
291+
run: cd platforms/react-native/sample/android && USE_LOCAL_SDK=1 ./gradlew :shopify_checkout-kit-react-native:test
270292
lint:
271293
desc: Run all React Native lint checks (Swift, module, sample)
272294
aliases: [style]

platforms/react-native/README.md

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

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

623629
## Identity & customer accounts
624630

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,89 @@
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+
package com.shopify.reactnative.checkoutkit
24+
25+
import kotlinx.serialization.json.Json
26+
import kotlinx.serialization.json.JsonArray
27+
import kotlinx.serialization.json.JsonElement
28+
import kotlinx.serialization.json.JsonObject
29+
import kotlinx.serialization.json.decodeFromJsonElement
30+
import kotlinx.serialization.json.encodeToJsonElement
31+
32+
/**
33+
* Bridges typed snake_case payloads (per @SerialName annotations on the native models)
34+
* with camelCase JSON expected by JavaScript consumers.
35+
*/
36+
internal object CasingTransform {
37+
38+
private const val CAMEL_TO_SNAKE_BUFFER_PADDING: Int = 4
39+
40+
internal val json: Json = Json { ignoreUnknownKeys = true }
41+
42+
fun snakeToCamel(s: String): String {
43+
if (s.isEmpty() || !s.contains('_')) return s
44+
val builder = StringBuilder(s.length)
45+
var upperNext = false
46+
for (ch in s) {
47+
if (ch == '_') {
48+
upperNext = true
49+
} else if (upperNext) {
50+
builder.append(ch.uppercaseChar())
51+
upperNext = false
52+
} else {
53+
builder.append(ch)
54+
}
55+
}
56+
return builder.toString()
57+
}
58+
59+
fun camelToSnake(s: String): String {
60+
if (s.isEmpty()) return s
61+
val builder = StringBuilder(s.length + CAMEL_TO_SNAKE_BUFFER_PADDING)
62+
for (ch in s) {
63+
if (ch.isUpperCase()) {
64+
builder.append('_').append(ch.lowercaseChar())
65+
} else {
66+
builder.append(ch)
67+
}
68+
}
69+
return builder.toString()
70+
}
71+
72+
fun transformKeys(element: JsonElement, fn: (String) -> String): JsonElement = when (element) {
73+
is JsonObject -> JsonObject(element.entries.associate { (key, value) -> fn(key) to transformKeys(value, fn) })
74+
is JsonArray -> JsonArray(element.map { transformKeys(it, fn) })
75+
else -> element
76+
}
77+
78+
inline fun <reified T> encodeForJS(payload: T): String {
79+
val element = json.encodeToJsonElement(payload)
80+
val transformed = transformKeys(element, ::snakeToCamel)
81+
return json.encodeToString(JsonElement.serializer(), transformed)
82+
}
83+
84+
inline fun <reified T> decodeFromJS(json: String): T {
85+
val element = Json.parseToJsonElement(json)
86+
val transformed = transformKeys(element, ::camelToSnake)
87+
return CasingTransform.json.decodeFromJsonElement(transformed)
88+
}
89+
}

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
@@ -30,7 +30,6 @@ of this software and associated documentation files (the "Software"), to deal
3030
import androidx.annotation.Nullable;
3131

3232
import com.shopify.checkoutkit.*;
33-
import com.facebook.react.bridge.Callback;
3433
import com.fasterxml.jackson.databind.ObjectMapper;
3534
import com.fasterxml.jackson.databind.node.ObjectNode;
3635
import java.io.IOException;
@@ -42,16 +41,19 @@ public class CustomCheckoutListener extends DefaultCheckoutListener {
4241

4342
private final ObjectMapper mapper = new ObjectMapper();
4443

45-
@Nullable
46-
private Callback dispatchCallback;
44+
private final DispatchHandle dispatch;
4745

4846
// Geolocation-specific variables
4947

5048
private String geolocationOrigin;
5149
private GeolocationPermissions.Callback geolocationCallback;
5250

53-
public CustomCheckoutListener(@Nullable Callback dispatch) {
54-
this.dispatchCallback = dispatch;
51+
public CustomCheckoutListener(@NonNull DispatchCallback dispatch) {
52+
this(new DispatchHandle(dispatch));
53+
}
54+
55+
public CustomCheckoutListener(@NonNull DispatchHandle dispatch) {
56+
this.dispatch = dispatch;
5557
}
5658

5759
// Public methods
@@ -65,7 +67,7 @@ public void invokeGeolocationCallback(boolean allow) {
6567
}
6668

6769
public void release() {
68-
dispatchCallback = null;
70+
dispatch.release();
6971
geolocationCallback = null;
7072
geolocationOrigin = null;
7173
}
@@ -86,20 +88,21 @@ public void release() {
8688
public void onGeolocationPermissionsShowPrompt(@NonNull String origin,
8789
@NonNull GeolocationPermissions.Callback callback) {
8890

89-
this.geolocationCallback = callback;
90-
this.geolocationOrigin = origin;
91-
92-
if (dispatchCallback == null) {
91+
if (dispatch.isReleased()) {
9392
// Multi-shot geolocation requests can in principle arrive after a
94-
// terminal event has nulled the dispatcher. Log so the silence is
95-
// observable rather than mystifying.
96-
Log.w(TAG, "Dropping geolocationRequest \u2014 dispatcher already released by a terminal event.");
93+
// terminal event or explicit dismiss has released the dispatcher. Log
94+
// so the silence is observable rather than mystifying.
95+
Log.w(TAG, "Dropping geolocationRequest dispatcher already released.");
9796
return;
9897
}
98+
99+
this.geolocationCallback = callback;
100+
this.geolocationOrigin = origin;
101+
99102
try {
100103
Map<String, Object> payload = new HashMap<>();
101104
payload.put("origin", origin);
102-
dispatchCallback.invoke(buildEnvelope(DispatchEventTypes.GEOLOCATION_REQUEST, payload));
105+
dispatch.invoke(buildEnvelope(DispatchEventTypes.GEOLOCATION_REQUEST, payload));
103106
} catch (IOException e) {
104107
Log.e(TAG, "Error emitting \"geolocationRequest\" event", e);
105108
}
@@ -115,9 +118,7 @@ public void onGeolocationPermissionsHidePrompt() {
115118

116119
@Override
117120
public void onCheckoutFailed(CheckoutException checkoutError) {
118-
Callback dispatch = dispatchCallback;
119-
if (dispatch == null) {
120-
release();
121+
if (dispatch.isReleased()) {
121122
return;
122123
}
123124
try {
@@ -131,9 +132,7 @@ public void onCheckoutFailed(CheckoutException checkoutError) {
131132

132133
@Override
133134
public void onCheckoutCanceled() {
134-
Callback dispatch = dispatchCallback;
135-
if (dispatch == null) {
136-
release();
135+
if (dispatch.isReleased()) {
137136
return;
138137
}
139138
try {

0 commit comments

Comments
 (0)