Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ apollo-ios-cli

# Android / Gradle
.gradle/
.kotlin/
build/
captures/
.externalNativeBuild
Expand Down
53 changes: 53 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,56 @@ protocol/ # cross-platform communication layer based on UCP
e2e/ # cross-platform end-to-end tests
.github/ # workflows, issue templates, CODEOWNERS
```

## React Native development with local native SDK changes

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.

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.

This applies when changes are made under:

- `platforms/swift/` — the iOS Swift SDK / CocoaPods sources
- `platforms/android/` — the Android SDK / Maven artifact sources

It does **not** refer to the React Native wrapper platform folders:

- `platforms/react-native/modules/@shopify/checkout-kit-react-native/ios/`
- `platforms/react-native/modules/@shopify/checkout-kit-react-native/android/`

### What `--local` does

- For React Native iOS, `--local` wires CocoaPods to the in-repo `platforms/swift/` sources via a local path instead of a released pod version.
- 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.

### When to use it

Use `--local` whenever you are validating React Native behavior that depends on unreleased native SDK changes, for example:

- a new Swift SDK API that the React Native iOS bridge calls
- a new Android SDK API that the React Native Android bridge calls
- generated protocol/model changes under the native SDKs that the React Native module consumes
- any change in `platforms/swift/` or `platforms/android/` that has not yet been released and consumed through normal dependency versions

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.

```bash
# iOS sample using local platforms/swift sources
dev rn ios --local

# Android sample using local platforms/android via Maven Local
dev rn android --local

# React Native Android unit tests using local platforms/android via Maven Local
# `dev rn test android` publishes platforms/android/lib to ~/.m2 first, then runs the RN module tests.
dev rn test android
```

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:

```bash
cd platforms/react-native
USE_LOCAL_SDK=1 ./scripts/publish_android_snapshot
cd sample/android
USE_LOCAL_SDK=1 ./gradlew :shopify_checkout-kit-react-native:testDebugUnitTest
```
29 changes: 29 additions & 0 deletions dev.yml
Original file line number Diff line number Diff line change
Expand Up @@ -281,6 +281,35 @@ commands:
build:
desc: Build the @shopify/checkout-kit-react-native module
run: cd platforms/react-native && pnpm module build
test:
desc: Run React Native module tests (JS + iOS + Android)
long_desc: |
Runs unit tests across all three React Native targets:
- JS: Jest tests in `platforms/react-native/modules/@shopify/checkout-kit-react-native/tests/`
- iOS: Swift Package tests at `platforms/react-native/modules/@shopify/checkout-kit-react-native/ios/`
- Android: Gradle JVM tests for `:shopify_checkout-kit-react-native` (requires a local Maven publish of `:lib`)
run: |
set -e
cd platforms/react-native && pnpm test
cd modules/@shopify/checkout-kit-react-native/ios && swift test
cd ../../../../
USE_LOCAL_SDK=1 ./scripts/publish_android_snapshot
cd sample/android && USE_LOCAL_SDK=1 ./gradlew :shopify_checkout-kit-react-native:test
subcommands:
js:
desc: Run JS unit tests via jest
run: cd platforms/react-native && pnpm test
ios:
desc: Run native iOS unit tests (Swift Package at modules/.../ios)
run: cd platforms/react-native/modules/@shopify/checkout-kit-react-native/ios && swift test
android:
desc: Run native Android unit tests for the RN module (publishes/uses local platforms/android SDK)
run: |
set -e
cd platforms/react-native
USE_LOCAL_SDK=1 ./scripts/publish_android_snapshot
cd sample/android
USE_LOCAL_SDK=1 ./gradlew :shopify_checkout-kit-react-native:test
lint:
desc: Run all React Native lint checks (Swift, module, sample)
aliases: [style]
Expand Down
14 changes: 10 additions & 4 deletions platforms/react-native/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -615,10 +615,16 @@ shopify.present(checkoutUrl, {
`onClose` and `onFail` are mutually exclusive — exactly one of them fires
per `present(...)` call, after which both handles are released.

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

> Protocol-level callbacks are configured separately from these SDK lifecycle
> callbacks. Checkout completion is not currently surfaced through the per-call
> SDK lifecycle callbacks.

## Identity & customer accounts

Expand Down
8 changes: 6 additions & 2 deletions platforms/react-native/__mocks__/react-native.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,13 +49,17 @@ const exampleConfig = {
colorScheme: 'automatic',
logLevel: 'error',
};
const shopifyCheckoutKitEventEmitter = createMockEmitter();

const ShopifyCheckoutKit = {
version: '0.7.0',
getConstants: jest.fn(() => ({
version: '0.7.0',
dispatchEventTypes: ['close', 'fail', 'geolocationRequest'],
})),
onDispatch: jest.fn((callback: (envelopeJson: string) => void) =>
shopifyCheckoutKitEventEmitter.addListener('onDispatch', callback),
),
preload: jest.fn(),
present: jest.fn(),
dismiss: jest.fn(),
Expand All @@ -76,7 +80,7 @@ module.exports = {
PermissionsAndroid: {
requestMultiple: jest.fn(async () => ({})),
},
NativeEventEmitter: jest.fn(() => createMockEmitter()),
NativeEventEmitter: jest.fn(() => shopifyCheckoutKitEventEmitter),
requireNativeComponent,
codegenNativeComponent,
TurboModuleRegistry: {
Expand All @@ -90,7 +94,7 @@ module.exports = {
NativeModules: {
ShopifyCheckoutKit: {
...ShopifyCheckoutKit,
eventEmitter: createMockEmitter(),
eventEmitter: shopifyCheckoutKitEventEmitter,
},
},
StyleSheet,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,11 @@ Pod::Spec.new do |s|
s.source = { :git => "https://github.com/Shopify/checkout-kit.git", :tag => "#{s.version}" }

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

s.dependency "React-Core"

Expand Down
Original file line number Diff line number Diff line change
@@ -1,16 +1,22 @@
buildscript {
ext.kotlin_version = rootProject.ext.has("kotlinVersion") ? rootProject.ext.get("kotlinVersion") : "2.1.20"

repositories {
google()
mavenCentral()
}

dependencies {
classpath "com.android.tools.build:gradle:8.11.0"
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
classpath "org.jetbrains.kotlin:kotlin-serialization:$kotlin_version"
}
}

apply plugin: "com.android.library"
apply plugin: "com.facebook.react"
apply plugin: "org.jetbrains.kotlin.android"
apply plugin: "org.jetbrains.kotlin.plugin.serialization"

def getExtOrIntegerDefault(name) {
return rootProject.ext.has(name) ? rootProject.ext.get(name) : (project.properties[name]).toInteger()
Expand Down Expand Up @@ -73,8 +79,17 @@ android {
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
}

kotlinOptions {
Comment thread
kieran-osgood-shopify marked this conversation as resolved.
jvmTarget = "1.8"
}

testOptions {
unitTests.includeAndroidResources = true
}
}


repositories {
mavenLocal()
mavenCentral()
Expand All @@ -97,6 +112,11 @@ dependencies {

implementation(shopifySdkArtifact)
implementation("com.fasterxml.jackson.core:jackson-databind:2.12.5")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.3")
debugImplementation(shopifySdkArtifact)

testImplementation "junit:junit:4.13.2"
testImplementation "org.assertj:assertj-core:3.27.7"
testImplementation "org.robolectric:robolectric:4.16.1"
}

Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,8 @@ targetSdkVersion=35
compileSdkVersion=36
ndkVersion=23.1.7779620
buildToolsVersion = "35.0.0"

# Opt out of the React Native Gradle plugin's JdkConfiguratorUtils, which otherwise
# silently rewrites compileOptions to 17 and pins the Kotlin JVM toolchain to 17 for
# every com.android.library it sees. We mirror :lib's pinned JVM 1.8 contract instead.
react.internal.disableJavaVersionAlignment=true
Comment thread
kieran-osgood-shopify marked this conversation as resolved.
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
/*
* MIT License
*
* Copyright 2023-present, Shopify Inc.
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.shopify.reactnative.checkoutkit

import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.decodeFromJsonElement
import kotlinx.serialization.json.encodeToJsonElement

/**
* Bridges typed snake_case payloads (per @SerialName annotations on the native models)
* with camelCase JSON expected by JavaScript consumers.
*/
internal object CasingTransform {

internal val json: Json = Json { ignoreUnknownKeys = true }

fun snakeToCamel(s: String): String {
if (s.isEmpty() || !s.contains('_')) return s
val builder = StringBuilder(s.length)
var upperNext = false
for (ch in s) {
if (ch == '_') {
upperNext = true
} else if (upperNext) {
builder.append(ch.uppercaseChar())
upperNext = false
} else {
builder.append(ch)
}
}
return builder.toString()
}

fun camelToSnake(s: String): String {
if (s.isEmpty()) return s
val uppercaseCount = s.count { it.isUpperCase() }
val builder = StringBuilder(s.length + uppercaseCount)
for (ch in s) {
if (ch.isUpperCase()) {
builder.append('_').append(ch.lowercaseChar())
} else {
builder.append(ch)
}
}
return builder.toString()
}

fun transformKeys(element: JsonElement, fn: (String) -> String): JsonElement = when (element) {
is JsonObject -> JsonObject(element.entries.associate { (key, value) -> fn(key) to transformKeys(value, fn) })
is JsonArray -> JsonArray(element.map { transformKeys(it, fn) })
else -> element
}

inline fun <reified T> encodeForJS(payload: T): String {
val element = json.encodeToJsonElement(payload)
val transformed = transformKeys(element, ::snakeToCamel)
return json.encodeToString(JsonElement.serializer(), transformed)
}

inline fun <reified T> decodeFromJS(json: String): T {
val element = Json.parseToJsonElement(json)
val transformed = transformKeys(element, ::camelToSnake)
return CasingTransform.json.decodeFromJsonElement(transformed)
}
}
Loading
Loading