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 @@ -276,6 +276,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
5 changes: 0 additions & 5 deletions platforms/react-native/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -621,11 +621,6 @@ 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.

## Identity & customer accounts

Buyer-aware checkout experience reduces friction and increases conversion.
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
2 changes: 1 addition & 1 deletion platforms/react-native/jest.config.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
module.exports = {
preset: 'react-native',
modulePathIgnorePatterns: ['modules/@shopify/checkout-kit-react-native/lib'],
modulePaths: ['<rootDir>/sample/node_modules'],
modulePaths: ['<rootDir>/node_modules', '<rootDir>/sample/node_modules'],
setupFiles: ['<rootDir>/jest.setup.ts'],
transform: {
'\\.[jt]sx?$': 'babel-jest',
Expand Down
5 changes: 4 additions & 1 deletion platforms/react-native/metro.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ const {getDefaultConfig, mergeConfig} = require('@react-native/metro-config');

const root = path.resolve(__dirname);
const sample = path.resolve(root, 'sample');
const protocol = path.resolve(root, '../../protocol/languages/typescript');

/**
* Metro configuration
Expand All @@ -13,7 +14,7 @@ const sample = path.resolve(root, 'sample');
const config = mergeConfig(getDefaultConfig(__dirname), {
projectRoot: sample,

watchFolders: [root],
watchFolders: [root, protocol],

resolver: {
resolveRequest: (context, moduleName, platform) => {
Expand Down Expand Up @@ -46,6 +47,8 @@ const config = mergeConfig(getDefaultConfig(__dirname), {
'modules',
'@shopify/checkout-kit-react-native',
),
'@shopify/checkout-kit-protocol': protocol,
'@babel/runtime': path.resolve(root, 'node_modules', '@babel/runtime'),
},
},

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
# (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
Expand Up @@ -7,7 +7,6 @@
import androidx.annotation.Nullable;

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

private final ObjectMapper mapper = new ObjectMapper();

@Nullable
private Callback dispatchCallback;
private final DispatchHandle dispatch;

// Geolocation-specific variables

private String geolocationOrigin;
private GeolocationPermissions.Callback geolocationCallback;

public CustomCheckoutListener(@Nullable Callback dispatch) {
this.dispatchCallback = dispatch;
public CustomCheckoutListener(@NonNull DispatchCallback dispatch) {
this(new DispatchHandle(dispatch));
}

public CustomCheckoutListener(@NonNull DispatchHandle dispatch) {
this.dispatch = dispatch;
}

// Public methods
Expand All @@ -42,7 +44,7 @@ public void invokeGeolocationCallback(boolean allow) {
}

public void release() {
dispatchCallback = null;
dispatch.release();
geolocationCallback = null;
geolocationOrigin = null;
}
Expand All @@ -63,20 +65,21 @@ public void release() {
public void onGeolocationPermissionsShowPrompt(@NonNull String origin,
@NonNull GeolocationPermissions.Callback callback) {

this.geolocationCallback = callback;
this.geolocationOrigin = origin;

if (dispatchCallback == null) {
if (dispatch.isReleased()) {
// Multi-shot geolocation requests can in principle arrive after a
// terminal event has nulled the dispatcher. Log so the silence is
// observable rather than mystifying.
Log.w(TAG, "Dropping geolocationRequest \u2014 dispatcher already released by a terminal event.");
// terminal event or explicit dismiss has released the dispatcher. Log
// so the silence is observable rather than mystifying.
Log.w(TAG, "Dropping geolocationRequest dispatcher already released.");
return;
}

this.geolocationCallback = callback;
this.geolocationOrigin = origin;

try {
Map<String, Object> payload = new HashMap<>();
payload.put("origin", origin);
dispatchCallback.invoke(buildEnvelope(DispatchEventTypes.GEOLOCATION_REQUEST, payload));
dispatch.invoke(buildEnvelope(DispatchEventTypes.GEOLOCATION_REQUEST, payload));
} catch (IOException e) {
Log.e(TAG, "Error emitting \"geolocationRequest\" event", e);
}
Expand All @@ -92,9 +95,7 @@ public void onGeolocationPermissionsHidePrompt() {

@Override
public void onCheckoutFailed(CheckoutException checkoutError) {
Callback dispatch = dispatchCallback;
if (dispatch == null) {
release();
if (dispatch.isReleased()) {
return;
}
try {
Expand All @@ -108,9 +109,7 @@ public void onCheckoutFailed(CheckoutException checkoutError) {

@Override
public void onCheckoutCanceled() {
Callback dispatch = dispatchCallback;
if (dispatch == null) {
release();
if (dispatch.isReleased()) {
return;
}
try {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
package com.shopify.reactnative.checkoutkit;

import androidx.annotation.NonNull;

/**
* Shared per-presentation dispatch handle.
*
* SDK lifecycle events and protocol events both invoke the same handle. Terminal
* lifecycle events release it so subsequent protocol emissions are dropped,
* matching the iOS pendingDispatchCallback lifecycle.
*/
public class DispatchHandle implements DispatchCallback {
private final DispatchCallback downstream;
private boolean released = false;

public DispatchHandle(@NonNull DispatchCallback downstream) {
this.downstream = downstream;
}

@Override
public synchronized void invoke(String json) {
if (!released) {
downstream.invoke(json);
}
}

public synchronized void release() {
released = true;
}

public synchronized boolean isReleased() {
return released;
}
}
Loading
Loading