add-native-feature
$
npx mdskill add stripe/stripe-react-native/add-native-featureImplement bidirectional communication for new Stripe React Native features bridging JS and native code.
- Adds platform-specific functionality requiring data flow between JavaScript and native layers.
- Integrates with Stripe React Native SDK, requiring knowledge of TypeScript, Kotlin, and Swift.
- Guides developers through setting up event emitters and callbacks for robust communication.
- Provides structured steps for defining types and implementing native module specifications.
SKILL.md
.github/skills/add-native-featureView on GitHub ↗
---
name: add-native-feature
description: Step-by-step guide for adding features requiring JS-to-native bridge communication in the Stripe React Native SDK. Covers TypeScript types, Android Kotlin, iOS Swift, event emitters, bidirectional callbacks, and native module specs.
when_to_use: Use when adding new native functionality, payment methods, extending components with platform-specific capabilities, implementing native-to-JS event communication, or adding new parameters that flow from JavaScript to native iOS/Android code.
---
# How to add features to React Native SDK
This guide explains how to add new features that require communication between React Native (JavaScript) and native code (iOS/Android). Use this when adding new native functionality, payment methods, or extending existing components with platform-specific capabilities.
## Overview
The SDK uses a **bidirectional communication pattern**:
1. **Native -> JavaScript**: Native code emits events that JavaScript listens for
2. **JavaScript -> Native**: JavaScript invokes callbacks to return data to native code
## Part 1: Passing Simple Data to Native SDKs
Use this when adding new configuration parameters that flow one-way from JavaScript to native code.
### Step 1: Update TypeScript Types
Add your new parameter to the relevant type definition in `src/types/`.
**Example:** Adding `onBehalfOf` to `PaymentSheet.IntentConfiguration`
**File:** `src/types/PaymentSheet.ts`
```typescript
export type IntentConfiguration = {
mode: Mode;
paymentMethodTypes?: PaymentMethod.Type[];
onBehalfOf?: string; // New parameter
};
```
### Step 2: Parse Parameters in Native Code
Extract the parameter from the bridge arguments and pass it to the native SDK.
#### Android Implementation
**File:** `android/src/main/java/com/reactnativestripesdk/PaymentSheetManager.kt` (or similar)
```kotlin
override fun onCreate() {
// Parse the parameter from arguments
val onBehalfOf = arguments?.getString("onBehalfOf")
// Pass it to the native SDK
val intentConfiguration = PaymentSheet.IntentConfiguration(
mode = mode,
// ... other existing parameters ...
onBehalfOf = onBehalfOf
)
}
```
#### iOS Implementation
**File:** `ios/StripeSdkImpl+PaymentSheet.swift`
```swift
guard let intentConfiguration = params["intentConfiguration"] as? NSDictionary else {
// handle error
return
}
// Extract the parameter
let onBehalfOf = intentConfiguration["onBehalfOf"] as? String
// Build the configuration
let intentConfig = PaymentSheet.IntentConfiguration(
mode: mode,
// ... other existing parameters ...
onBehalfOf: onBehalfOf
)
```
---
## Part 2: Implementing Bidirectional Communication
Use this when native code needs to request data from JavaScript (e.g., fetching client secrets, custom validation).
### Communication Flow
```
React Native (JS) -> Registers Event Listener
|
Native Code (iOS/Android) -> Emits Event -> JS Listener Triggered
|
JS Executes Logic (API call, user input, etc.)
|
JS Invokes Native Callback -> Native Code Receives Result
|
Native Code Continues Execution
```
### Step 1: Emit an Event from Native Code
Create the native code that will request data from JavaScript.
#### Android Implementation
**File:** `android/src/main/java/com/reactnativestripesdk/ReactNativeCustomerSessionProvider.kt` (or similar)
```kotlin
internal var provideSetupIntentClientSecretCallback: CompletableDeferred<String>? = null
override suspend fun provideSetupIntentClientSecret(customerId: String): Result<String> {
return suspendCancellableCoroutine { continuation ->
// Store the continuation to resume later
provideSetupIntentClientSecretCallback = continuation
// Emit the event to JavaScript
stripeSdkModule?.eventEmitter?.emitOnCustomerSessionProviderSetupIntentClientSecret()
}
}
```
#### iOS Implementation
**File:** `ios/StripeSdkImpl.swift`
```swift
// Store the continuation as a property
var clientSecretProviderSetupIntentClientSecretCallback: ((String) -> Void)? = nil
```
**File:** `ios/StripeSdkImpl+CustomerSheet.swift`
```swift
let intentConfiguration = CustomerSheet.IntentConfiguration(
// ... other parameters ...
setupIntentClientSecretProvider: {
return try await withCheckedThrowingContinuation { continuation in
// Store the continuation to be resumed later
self.clientSecretProviderSetupIntentClientSecretCallback = { clientSecret in
continuation.resume(returning: clientSecret)
}
// Emit the event to JavaScript
self.emitter?.emitOnCustomerSessionProviderSetupIntentClientSecret()
}
}
)
```
### Step 2: Define and Implement the Event Emitter
#### 2a. Define the Event Type
**File:** `src/events.ts`
Add your event to the `Events` type:
```typescript
type Events = {
// ... existing events ...
onCustomerSessionProviderSetupIntentClientSecret: EventEmitter<void>; // No parameters
// OR if you need to pass data:
onCustomerSessionProviderSetupIntentClientSecret: EventEmitter<{
customerId: string;
}>;
};
```
**Guidelines:**
- Use `EventEmitter<void>` if no data is passed from native to JS
- Use `EventEmitter<{ param: type }>` for simple parameters
- Use `EventEmitter<UnsafeObject<any>>` for complex objects (use sparingly)
#### 2b. Implement Android Emitter
**File:** `android/src/main/java/com/reactnativestripesdk/EventEmitterCompat.kt`
```kotlin
fun emitOnCustomerSessionProviderSetupIntentClientSecret(value: ReadableMap? = null) {
invoke("onCustomerSessionProviderSetupIntentClientSecret", value)
}
// For events with no parameters:
fun emitOnCustomerSessionProviderSetupIntentClientSecret() {
invoke("onCustomerSessionProviderSetupIntentClientSecret")
}
```
#### 2c. Implement iOS Emitter
**File:** `ios/StripeSdkEmitter.swift`
```swift
@objc public protocol StripeSdkEmitter {
// ... existing methods ...
// For events with parameters:
func emitOnCustomerSessionProviderSetupIntentClientSecret(_ value: [String: Any])
// For events without parameters:
func emitOnCustomerSessionProviderSetupIntentClientSecret()
}
```
### Step 3: Define Native Callback Signatures
These are the methods JavaScript will call to return data to native code.
#### 3a. TypeScript Spec
**File:** `src/specs/NativeStripeSdkModule.ts`
```typescript
export interface Spec extends TurboModule {
// ... existing methods ...
clientSecretProviderSetupIntentClientSecretCallback(
setupIntentClientSecret: string
): Promise<void>;
}
```
#### 3b. Android Spec
**File:** `android/src/oldarch/java/com/reactnativestripesdk/NativeStripeSdkModuleSpec.java`
```java
@ReactMethod
@DoNotStrip
public abstract void clientSecretProviderSetupIntentClientSecretCallback(
String setupIntentClientSecret,
Promise promise
);
```
#### 3c. iOS Bridge Declaration
**File:** `ios/StripeSdk.mm`
```objc
RCT_EXPORT_METHOD(clientSecretProviderSetupIntentClientSecretCallback:(nonnull NSString *)setupIntentClientSecret
resolve:(nonnull RCTPromiseResolveBlock)resolve
reject:(nonnull RCTPromiseRejectBlock)reject)
{
[StripeSdkImpl.shared clientSecretProviderSetupIntentClientSecretCallback:setupIntentClientSecret
resolver:resolve
rejecter:reject];
}
```
### Step 4: Implement JavaScript Event Listener
Listen for the native event and invoke the callback with the result.
**File:** `src/components/CustomerSheet.tsx` (or relevant component)
```typescript
// Declare the EventSubscription at the top of the file
let setupIntentClientSecretProviderCallback: EventSubscription | null = null;
const configureClientSecretProviderEventListeners = (
clientSecretProvider: ClientSecretProvider
): void => {
// Remove existing listener to prevent duplicates
setupIntentClientSecretProviderCallback?.remove();
// Register the event listener
setupIntentClientSecretProviderCallback = addListener(
'onCustomerSessionProviderSetupIntentClientSecret',
async () => {
try {
// Execute the user-provided function (e.g., API call)
const setupIntentClientSecret =
await clientSecretProvider.provideSetupIntentClientSecret();
// Return the result to native code
await NativeStripeSdk.clientSecretProviderSetupIntentClientSecretCallback(
setupIntentClientSecret
);
} catch (error) {
// Handle errors appropriately
console.error('Failed to provide setup intent client secret:', error);
}
}
);
};
```
**If the event includes parameters from native:**
```typescript
setupIntentClientSecretProviderCallback = addListener(
'onCustomerSessionProviderSetupIntentClientSecret',
async ({ customerId }) => { // Destructure parameters
const setupIntentClientSecret =
await clientSecretProvider.provideSetupIntentClientSecret(customerId);
await NativeStripeSdk.clientSecretProviderSetupIntentClientSecretCallback(
setupIntentClientSecret
);
}
);
```
**Important:** Don't forget to clean up listeners when the component unmounts or is reconfigured.
### Step 5: Complete the Native Callback Implementation
Resume the async operation started in Step 1 with the data from JavaScript.
#### Android Implementation
**File:** `android/src/main/java/com/reactnativestripesdk/StripeSdkModule.kt`
```kotlin
override fun clientSecretProviderSetupIntentClientSecretCallback(
setupIntentClientSecret: String,
promise: Promise
) {
customerSheetFragment?.let {
// Resume the coroutine with the result from JavaScript
it.customerSessionProvider?.provideSetupIntentClientSecretCallback?.resume(
Result.success(setupIntentClientSecret)
)
promise.resolve(null)
} ?: run {
promise.reject(
"CustomerSheetNotInitialized",
"Customer Sheet must be initialized before calling this callback"
)
}
}
```
#### iOS Implementation
**File:** `ios/StripeSdkImpl+CustomerSheet.swift`
```swift
@objc(clientSecretProviderSetupIntentClientSecretCallback:resolver:rejecter:)
public func clientSecretProviderSetupIntentClientSecretCallback(
setupIntentClientSecret: String,
resolver resolve: @escaping RCTPromiseResolveBlock,
rejecter reject: @escaping RCTPromiseRejectBlock
) -> Void {
// Resume the continuation with the result from JavaScript
self.clientSecretProviderSetupIntentClientSecretCallback?(setupIntentClientSecret)
// Clear the callback
self.clientSecretProviderSetupIntentClientSecretCallback = nil
resolve([])
}
```
---
## Implementation Checklist
### Part 1: Simple Data Passing
- [ ] TypeScript types updated in `src/types/`
- [ ] Android parameter parsing implemented
- [ ] iOS parameter parsing implemented
### Part 2: Bidirectional Communication
- [ ] Event emission added in Android native code
- [ ] Event emission added in iOS native code
- [ ] Event type defined in `src/events.ts`
- [ ] Android emitter implemented in `EventEmitterCompat.kt`
- [ ] iOS emitter declared in `StripeSdkEmitter.swift`
- [ ] TypeScript callback spec added to `NativeStripeSdkModule.ts`
- [ ] Android callback spec added to `NativeStripeSdkModuleSpec.java`
- [ ] iOS bridge method added to `StripeSdk.mm`
- [ ] JavaScript event listener implemented in component
- [ ] Android callback completion implemented in `StripeSdkModule.kt`
- [ ] iOS callback completion implemented in Swift
### Testing & Documentation
- [ ] Unit tests written for TypeScript code
- [ ] Native tests written (iOS XCTest / Android)
- [ ] Example app updated to demonstrate feature
- [ ] E2E tests written using Maestro
- [ ] Code runs without linter errors (`yarn lint`)
- [ ] TypeScript compiles without errors (`yarn typescript`)
- [ ] Tested on both iOS and Android
- [ ] Tested with both Old and New Architecture
---
## Common Pitfalls
### Memory Leaks
**Problem:** Forgetting to remove event listeners.
**Solution:** Always call `.remove()` on subscriptions before creating new ones or when unmounting.
```typescript
useEffect(() => {
// Setup listener
const subscription = addListener('myEvent', handler);
return () => {
// Cleanup on unmount
subscription?.remove();
};
}, []);
```
### Missing Error Handling
**Problem:** Not handling errors in async callbacks.
**Solution:** Wrap callback logic in try-catch blocks and handle failures gracefully.
```typescript
async () => {
try {
const result = await userProvidedFunction();
await NativeStripeSdk.callback(result);
} catch (error) {
console.error('Error:', error);
// Consider how to communicate errors back to native
}
}
```
### Thread Safety (iOS)
**Problem:** Updating UI from background threads.
**Solution:** Ensure UI updates happen on the main thread:
```swift
DispatchQueue.main.async {
// UI updates here
}
```
### Incomplete Callback Resolution
**Problem:** Not calling `promise.resolve()` or `promise.reject()` in native code.
**Solution:** Always resolve or reject promises, even in error cases.
### Type Mismatches
**Problem:** TypeScript types don't match native expectations.
**Solution:** Use `UnsafeObject<T>` for complex types and validate in native code.
---
## Platform-Specific Considerations
### iOS
- **Async/Await:** Uses Swift continuations (`withCheckedThrowingContinuation`)
- **Callbacks:** Stored as optional closures (`((String) -> Void)?`)
- **Threading:** UI operations must run on main thread
- **Memory:** Be careful with retain cycles; use `[weak self]` when needed
### Android
- **Async/Await:** Uses Kotlin coroutines and `suspendCancellableCoroutine`
- **Callbacks:** Uses `CancellableContinuation` or `CompletableDeferred`
- **Threading:** React Native bridge handles threading automatically
- **Lifecycle:** Be aware of Activity/Fragment lifecycle when storing callbacks
---
## Additional Resources
- React Native TurboModules: https://reactnative.dev/docs/the-new-architecture/pillars-turbomodules
- Stripe iOS SDK: https://stripe.dev/stripe-ios
- Stripe Android SDK: https://stripe.dev/stripe-android