mcpbeat

Add Native Feature

stripe/add-native-feature

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.

4k tokens
context cost
the whole folder, loaded on every use
1
files
instructions only
0
copies elsewhere
how many repositories repackaged it
1429
stars on the repo
on the repository, not the skill itself

Install

one command, takes just this skill from the repository
npx skills add https://github.com/stripe/stripe-react-native --skill add-native-feature

The instruction itself

38 sections, as written by the author

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:

  • Native -> JavaScript: Native code emits events that JavaScript listens for
  • 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

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)

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

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)

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

// Store the continuation as a property
var clientSecretProviderSetupIntentClientSecretCallback: ((String) -> Void)? = nil

File: ios/StripeSdkImpl+CustomerSheet.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:

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

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

@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

export interface Spec extends TurboModule {
  // ... existing methods ...

  clientSecretProviderSetupIntentClientSecretCallback(
    setupIntentClientSecret: string
  ): Promise<void>;
}
3b. Android Spec

File: android/src/oldarch/java/com/reactnativestripesdk/NativeStripeSdkModuleSpec.java

@ReactMethod
@DoNotStrip
public abstract void clientSecretProviderSetupIntentClientSecretCallback(
  String setupIntentClientSecret,
  Promise promise
);
3c. iOS Bridge Declaration

File: ios/StripeSdk.mm

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)

// 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:

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

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

@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.

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.

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:

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

How to use it

Copy the folder

Take stripe/add-native-feature from the repository into ~/.claude/skills for personal use, or into .claude/skills inside a project.

Check the name does not clash

The agent identifies a skill by the name field in its header. Two skills with the same name cannot sit side by side — one of them will be ignored.