DEV Community

Cover image for Creating a React-Native library using New Architecture (JSI, C++)
Sergei Kazakov
Sergei Kazakov

Posted on

1

Creating a React-Native library using New Architecture (JSI, C++)

In this article I would like to look at the creation of a React-Native C++ Turbo Module for iOS and Android from scratch. As a basis, I took the existing react-native-fs library and tried to completely rewrite it with a C++ interface, as far as possible.
I'll try to cover basic examples from the documentation, as well as non-obvious things (like working with platform-dependent code, JNI, threads).

Rewriting old libraries to Bridgeless architecture using C++ will most likely increase the speed of work several times (and sometimes tens of times). In my library I managed to increase the speed of methods for Android by ~7 times, for iOS by ~2.3 times (you can see the benchmarks at the link at the bottom of the article).

As references I will use the official documentation, as well as the stable React-Native MMKV library.
I decided not to waste time creating may own component architecture and used the react-native-mmkv architecture.

As a result, we will have two modules:

  1. RNFSTurboPlatformContextModule (TurboModule) - a platform-specific module that returns all the standard paths (like DocumentDirectory)
  2. RNFSTurboModule (C++ TurboModule) - the main module with the implementation of all methods.

In this article I will only consider the creation of several exists methods.

RNFSTurboPlatformContextModule (TurboModule)

First of all, we'll write RNFSTurboPlatformContextModule, because it's the easiest part.

Let's create a TypeScript file src/NativeRNFSTurboPlatformContextModule.ts, which will contain a description of the specification and initialization of the module itself:

import type {TurboModule} from 'react-native';
import {TurboModuleRegistry} from 'react-native';

export interface Spec extends TurboModule {
  getDocumentDirectoryPath(): string;
  /* ... */
}

let module: Spec | null;

export function getRNFSTurboPlatformContextTurboModule(): Spec {
  try {
    if (module == null) {
      // 1. Get the TurboModule
      module = TurboModuleRegistry.getEnforcing<Spec>(
        'RNFSTurboPlatformContextModule',
      );
    }
    return module;
  } catch {
    // TurboModule could not be found!
    throw new Error('Module not found');
  }
}
Enter fullscreen mode Exit fullscreen mode

Next, we need to write an implementation of this module. Since this module is platform-dependent, we'll write a separate implementation for Android and iOS.

Let's look at the implementation on iOS. First, we need to create a header file ios/RNFSTurboPlatformContextModule.h:

#import <Foundation/Foundation.h>
#import <RNFSTurboSpec/RNFSTurboSpec.h>

NS_ASSUME_NONNULL_BEGIN

@interface RNFSTurboPlatformContextModule : NSObject <NativeRNFSTurboPlatformContextModuleSpec>

@end

NS_ASSUME_NONNULL_END

Enter fullscreen mode Exit fullscreen mode

Everything is simple here. Next, we'll write its implementation in ios/RNFSTurboPlatformContextModule.mm:

#import "RNFSTurboPlatformContextModule.h"
#import <Foundation/Foundation.h>

@implementation RNFSTurboPlatformContextModule

RCT_EXPORT_MODULE()

- (std::shared_ptr<facebook::react::TurboModule>)getTurboModule:
    (const facebook::react::ObjCTurboModule::InitParams&)params {
  return std::make_shared<facebook::react::NativeRNFSTurboPlatformContextModuleSpecJSI>(params);
}

- (NSString*)getDocumentDirectoryPath {
  NSArray *paths = NSSearchPathForDirectoriesInDomains(NSDocumentDirectory, NSUserDomainMask, YES);
  return [paths firstObject];
}

/* ... */

@end
Enter fullscreen mode Exit fullscreen mode

Everything here is according to the official React-Native documentation. The main thing to remember is that the name of the JSI module specifications generated by Codegen will start with Native.
With Android, everything is about the same (we will use com/test/rnfsturbo package name). First, we create a package file android/src/main/java/com/test/rnfsturbo/RNFSTurboPackage.java, which loads the module:

package com.test.rnfsturbo;

import androidx.annotation.NonNull;
import androidx.annotation.Nullable;

import com.facebook.react.bridge.NativeModule;
import com.facebook.react.bridge.ReactApplicationContext;
import com.facebook.react.module.model.ReactModuleInfo;
import com.facebook.react.module.model.ReactModuleInfoProvider;
import com.facebook.react.TurboReactPackage;

import java.util.HashMap;
import java.util.Map;

public class RNFSTurboPackage extends TurboReactPackage {
  @Nullable
  @Override
  public NativeModule getModule(String name, @NonNull ReactApplicationContext reactContext) {
    if (name.equals(RNFSTurboPlatformContextModule.NAME)) {
      return new RNFSTurboPlatformContextModule(reactContext);
    } else {
      return null;
    }
  }

  @Override
  public ReactModuleInfoProvider getReactModuleInfoProvider() {
    return () -> {
      final Map<String, ReactModuleInfo> moduleInfos = new HashMap<>();
      moduleInfos.put(
        RNFSTurboPlatformContextModule.NAME,
        new ReactModuleInfo(
          RNFSTurboPlatformContextModule.NAME,
          RNFSTurboPlatformContextModule.NAME,
          false, // canOverrideExistingModule
          false, // needsEagerInit
          true, // hasConstants
          false, // isCxxModule
          true // isTurboModule
        )
      );
      return moduleInfos;
    };
  }
}
Enter fullscreen mode Exit fullscreen mode

Then we create the module itself - android/src/main/java/com/test/rnfsturbo/RNFSTurboPlatformContextModule.java:

package com.test.rnfsturbo;

import android.content.Context;
import com.facebook.react.bridge.ReactApplicationContext;

public class RNFSTurboPlatformContextModule extends NativeRNFSTurboPlatformContextModuleSpec {
  private final ReactApplicationContext context;

  public static Context externalContext;

  public RNFSTurboPlatformContextModule(ReactApplicationContext reactContext) {
    super(reactContext);
    context = reactContext;
    externalContext = reactContext;
  }

  public static Context getContext() {
    return externalContext;
  }

  @Override
  public String getDocumentDirectoryPath() {
    return this.context.getFilesDir().getAbsolutePath();
  }

  /* ... */
}
Enter fullscreen mode Exit fullscreen mode

In this file you can see the creation of a static externalContext variable for the context. We'll need it to access the context from the JNI (we'll look into this later).

RNFSTurboModule (C++ TurboModule)

Next, we'll implement the main RNFSTurboModule module. Initially, I wanted to make a full implementation in C++, but later I realized that some methods from react-native-fs would be almost impossible to reproduce in C++, since there are platform-dependent (such as downloading/uploading files, working with assets, etc). In the native Java/Objective-C code, I left them almost untouched with all copyrights indicated. But I think it will be interesting to figure out how the helper was written for the Java/Objective-C code, especially for Android, since it uses Java Native Interface (JNI) to call Java methods from C++.

Initially, I looked at the implementation of react-native-mmkv library and tried to follow its approaches. So I planned for the returned paths from NativeRNFSTurboPlatformContextModule to be used inside of the main module. Because of this, I subsequently encountered incompatibility errors between old and new versions of React-Native.

1. First implementation (NativeRNFSTurboModule)

Here I have reviewed the first unsuccessful implementation. If you want to skip it, go directly to Final implementation

So, first we'll create a header file cpp/NativeRNFSTurboModule.h:

#pragma once

#if __has_include(<React-Codegen/RNFSTurboSpecJSI.h>)
// CocoaPods include (iOS)
#include <React-Codegen/RNFSTurboSpecJSI.h>
#elif __has_include(<RNFSTurboSpecJSI.h>)
// CMake include on Android
#include <RNFSTurboSpecJSI.h>
#else
#error Cannot find react-native-fs-turbo spec! Try cleaning your cache and re-running CodeGen!
#endif

// The RNFSTurboConfiguration type from JS
using RNFSTurboConfig = RNFSTurboModuleConfiguration<
  std::string,
  std::string,
  std::string,
  std::string,
  std::string,
  std::string,
  std::string,
  std::string,
  std::string,
  std::string,
  std::string
>;
template <> struct Bridging<RNFSTurboConfig> : RNFSTurboModuleConfigurationBridging<RNFSTurboConfig> {};

namespace facebook::react {

// The TurboModule itself
class NativeRNFSTurboModule : public NativeRNFSTurboModuleCxxSpec<NativeRNFSTurboModule> {
public:
  NativeRNFSTurboModule(std::shared_ptr<CallInvoker> jsInvoker);
  ~NativeRNFSTurboModule();

  jsi::Object createRNFSTurbo(jsi::Runtime& runtime, RNFSTurboConfig config);
};

} // namespace facebook::react
Enter fullscreen mode Exit fullscreen mode

And its implementation in cpp/NativeRNFSTurboModule.cpp:

#include "NativeRNFSTurboModule.h"
#include "RNFSTurboHostObject.h"

namespace facebook::react {

NativeRNFSTurboModule::NativeRNFSTurboModule(std::shared_ptr<CallInvoker> jsInvoker)
    : NativeRNFSTurboModuleCxxSpec(jsInvoker) {}

NativeRNFSTurboModule::~NativeRNFSTurboModule() {}

jsi::Object NativeRNFSTurboModule::createRNFSTurbo(jsi::Runtime& runtime, RNFSTurboConfig config) {
  auto instance = std::make_shared<RNFSTurboHostObject>();
  return jsi::Object::createFromHostObject(runtime, instance);
}

} // namespace facebook::react
Enter fullscreen mode Exit fullscreen mode

And this is where the problem was hidden, which later appeared in the new version of React-Native (0.75+). Codegen started generating a ConfigurationBridging file with a different name. And instead of RNFSTurboModuleConfigurationBridging, it became called NativeRNFSTurboModuleConfigurationBridging.

The maintainer of react-native-mmkv decided to simply drop support of the new architecture for react-native 0.74, which is quite reasonable. But I thought - why do I need RNFSTurboConfig inside RNFSTurboModule at all and completely removed it from there (since it didn't give any practical benefit). At the same time, support for React-Native 0.74+ appeared.

2. Final implementation (NativeRNFSTurboModule)

cpp/NativeRNFSTurboModule.h:

#pragma once

#if __has_include(<React-Codegen/RNFSTurboSpecJSI.h>)
// CocoaPods include (iOS)
#include <React-Codegen/RNFSTurboSpecJSI.h>
#elif __has_include(<RNFSTurboSpecJSI.h>)
// CMake include on Android
#include <RNFSTurboSpecJSI.h>
#else
#error Cannot find react-native-fs-turbo spec! Try cleaning your cache and re-running CodeGen!
#endif

namespace facebook::react {

// The TurboModule itself
class NativeRNFSTurboModule : public NativeRNFSTurboModuleCxxSpec<NativeRNFSTurboModule> {
public:
  NativeRNFSTurboModule(std::shared_ptr<CallInvoker> jsInvoker);
  ~NativeRNFSTurboModule();

  jsi::Object createRNFSTurbo(jsi::Runtime& runtime);
};

} // namespace facebook::react
Enter fullscreen mode Exit fullscreen mode

cpp/NativeRNFSTurboModule.cpp:

#include "NativeRNFSTurboModule.h"
#include "RNFSTurboHostObject.h"

namespace facebook::react {

NativeRNFSTurboModule::NativeRNFSTurboModule(std::shared_ptr<CallInvoker> jsInvoker)
    : NativeRNFSTurboModuleCxxSpec(jsInvoker) {}

NativeRNFSTurboModule::~NativeRNFSTurboModule() {}

jsi::Object NativeRNFSTurboModule::createRNFSTurbo(jsi::Runtime& runtime) {
  auto instance = std::make_shared<RNFSTurboHostObject>();
  return jsi::Object::createFromHostObject(runtime, instance);
}

} // namespace facebook::react
Enter fullscreen mode Exit fullscreen mode

Okay, we've got that sorted out. Now let's write a cpp/RNFSTurboHostObject.h header for the main module:

#pragma once

#include <sys/stat.h>
#include "NativeRNFSTurboModule.h"

using namespace facebook;

class RNFSTurboHostObject : public jsi::HostObject {
public:
  RNFSTurboHostObject();
  ~RNFSTurboHostObject();

public:
  jsi::Value get(jsi::Runtime&, const jsi::PropNameID& name) override;
  std::vector<jsi::PropNameID> getPropertyNames(jsi::Runtime& rt) override;
};
Enter fullscreen mode Exit fullscreen mode

getPropertyNames will return a list of all available methods. get calls the required method.

Implementation in cpp/RNFSTurboHostObject.cpp:

#include "RNFSTurboHostObject.h"

using namespace facebook;

RNFSTurboHostObject::RNFSTurboHostObject() {
  // initialization
}

RNFSTurboHostObject::~RNFSTurboHostObject() {
  // remove created objects here
}

std::vector<jsi::PropNameID> RNFSTurboHostObject::getPropertyNames(jsi::Runtime& rt) {
  return jsi::PropNameID::names(rt, "exists", "existsAssets", "existsRes");
}

jsi::Value RNFSTurboHostObject::get(jsi::Runtime& runtime, const jsi::PropNameID& propNameId) {
  std::string propName = propNameId.utf8(runtime);

  // implementation here

  return jsi::Value::undefined();
}
Enter fullscreen mode Exit fullscreen mode

Now let's write an implementation of the simplest function - exists. It checks the existence of a file/folder and returns true / false.

...
jsi::Value RNFSTurboHostObject::get(jsi::Runtime& runtime, const jsi::PropNameID& propNameId) {
  std::string propName = propNameId.utf8(runtime);

  if (propName == "exists") {
    return jsi::Function::createFromHostFunction(
      runtime, jsi::PropNameID::forAscii(runtime, propName),
      1,
      [this, propName](jsi::Runtime& runtime, const jsi::Value& thisValue, const jsi::Value* arguments, size_t count) -> jsi::Value {
        if (count != 1 || !arguments[0].isString()) [[unlikely]] {
          throw jsi::JSError(runtime, "First argument ('filepath') has to be of type string");
        }

        std::string filePath = arguments[0].asString(runtime).utf8(runtime);

        bool exists{false};
        struct stat t_stat;
        exists = stat(filePath.c_str(), &t_stat) == 0;

        return jsi::Value(exists);
      }
    );
  }

  return jsi::Value::undefined();
}
...

Enter fullscreen mode Exit fullscreen mode

Probably it would be easier to use the exists method from the standard filesystem library (C++17), but the old C solution is also quite functional. In my library for the rest of the methods I used the filesystem library.

3. Platform-dependent methods (C++)

Let's consider the platform-dependent methods - existsAssets and existsRes. They should only be available on Android. And also consider a new method for iOS (it's not needed, but we'll consider it as an example) - existsIOS.

To do this, first we'll add a platform-dependent module. Let's call it RNFSTurboPlatformHelper and describe it in cpp/RNFSTurboPlatformHelper.h:

#pragma once

class RNFSTurboPlatformHelper {
#ifdef __ANDROID__
public:
  RNFSTurboPlatformHelper(JNIEnv *env);
private:
  JNIEnv *jniEnv;
  jobject jniObj;
#endif
#ifdef __APPLE__
public:
  RNFSTurboPlatformHelper();
#endif

public:
  bool existsAssetsOrRes(const char* filePath, bool isRes);

  bool existsIOS(const char* filePath);
};
Enter fullscreen mode Exit fullscreen mode

Here you can see the declaration of JNIEnv *jniEnv and jobject jniObj. These variables will be used in JNI to access the environment.

Then we add our helper to cpp/RNFSTurboHostObject.h:

...
#ifdef __ANDROID__
#include <fbjni/fbjni.h>
#include <jni.h>
#endif
#include "RNFSTurboPlatformHelper.h"
...

private:
  RNFSTurboPlatformHelper* platformHelper;
Enter fullscreen mode Exit fullscreen mode

And we'll rewrite the constructor and destructor in cpp/RNFSTurboHostObject.cpp:

RNFSTurboHostObject::RNFSTurboHostObject() {
#ifdef __ANDROID__
  JNIEnv *env = facebook::jni::Environment::current();
  platformHelper = new RNFSTurboPlatformHelper(env);
#endif
#ifdef __APPLE__
  platformHelper = new RNFSTurboPlatformHelper();
#endif
}

RNFSTurboHostObject::~RNFSTurboHostObject() {
  delete platformHelper;
  platformHelper = nullptr;
}
Enter fullscreen mode Exit fullscreen mode

Here we use the built-in constant (__ANDROID__ and __APPLE__) to determine which version of PlatformHelper we need to use.

4. Platform-dependent methods (Android)

For Android, we'll use the current JNI Environment taken from React-Native.

Let's create a basic implementation of the existsAssetsOrRes method in android/src/main/java/com/test/rnfsturbo/RNFSTurboPlatformHelper.java (in my library I copied this from react-native-fs library, combining the existsAssets and existsRes methods):

package com.test.rnfsturbo;

import android.content.Context;
/* ... */

public class RNFSTurboPlatformHelper {

  public static String tag = "RNFSTurboPlatformHelper";

  private Context context;

  public RNFSTurboPlatformHelper(Context ctx) {
    context = ctx;
  }

  public boolean existsAssetsOrRes(String filePath, boolean isRes) throws Exception {
    /* ... */
    return true;
  }
}
Enter fullscreen mode Exit fullscreen mode

Now let's create a С++ implementation in Android - android/src/main/cpp/RNFSTurboPlatformHelper.cpp:

#include <jni.h>
#include "RNFSTurboPlatformHelper.h"

RNFSTurboPlatformHelper::RNFSTurboPlatformHelper(JNIEnv *env) {
  jniEnv = env;
  jclass jniCls = env->FindClass("com/test/rnfsturbo/RNFSTurboPlatformHelper");
  jmethodID initObject = jniEnv->GetMethodID(jniCls, "<init>", "(Landroid/content/Context;)V");
  jclass contextCls = env->FindClass("com/test/rnfsturbo/RNFSTurboPlatformContextModule");
  jmethodID mid = env->GetStaticMethodID(contextCls, "getContext", "()Landroid/content/Context;");
  jobject context = env->CallStaticObjectMethod(contextCls, mid);
  jobject obj = jniEnv->NewObject(jniCls, initObject, context);
  jniObj = jniEnv->NewGlobalRef(obj);
  jniEnv->DeleteLocalRef(jniCls);
  jniEnv->DeleteLocalRef(context);
  jniEnv->DeleteLocalRef(obj);
}

bool RNFSTurboPlatformHelper::existsAssetsOrRes(const char *filePath, bool isRes) {
  jclass jniCls = jniEnv->GetObjectClass(jniObj);
  jmethodID mid = jniEnv->GetMethodID(
    jniCls,
    "existsAssetsOrRes",
    "(Ljava/lang/String;Z)Z"
  );
  jboolean isExists = jniEnv->CallBooleanMethod(
    jniObj,
    mid,
    jniEnv->NewStringUTF(filePath),
    isRes
  );
  jniEnv->DeleteLocalRef(jniCls);
  if (jniEnv->ExceptionCheck()) {
    jniEnv->ExceptionClear();
    throw isRes ? "Failed to open asset" : "Failed to open res";
  }

  return (bool)isExists;
}
Enter fullscreen mode Exit fullscreen mode

Here we use JNI to initialize the RNFSTurboPlatformHelper Java class, and then to call the Java existsAssetsOrRes method. One of the main things is to remember to delete all created links to avoid memory leaks. The rest can be found in the documentation for JNI.

It remains to add the CPP implementation in cpp/RNFSTurboHostObject.cpp:

...
if (propName == "exists" || propName == "existsAssets" || propName == "existsRes") {
    return jsi::Function::createFromHostFunction(
      runtime, jsi::PropNameID::forAscii(runtime, propName),
      1,
      [this, propName](jsi::Runtime& runtime, const jsi::Value& thisValue, const jsi::Value* arguments, size_t count) -> jsi::Value {
#ifndef __ANDROID__
        if (propName == "existsAssets" || propName == "existsRes") {
          throw jsi::JSError(runtime, "Command only for Android");
        }
#endif
        if (count != 1 || !arguments[0].isString()) [[unlikely]] {
          throw jsi::JSError(runtime, "First argument ('filepath') has to be of type string");
        }

        std::string filePath = arguments[0].asString(runtime).utf8(runtime);

        bool exists{false};
        if (propName == "existsAssets" || propName == "existsRes") {
#ifdef __ANDROID__
          exists = platformHelper->existsAssetsOrRes(filePath.c_str(), propName == "existsRes");
#endif
        } else {
          struct stat t_stat;
          exists = stat(filePath.c_str(), &t_stat) == 0;
        }

        return jsi::Value(exists);
      }
    );
  }
...
Enter fullscreen mode Exit fullscreen mode

5. Platform-dependent methods (iOS)

Now let's look at PlatformHelper for iOS. It's much simpler because you don't have to deal with a different interface (like JNI) and the code is written in Objective-C++.

We want to add a new method - existsIOS.

In this case we only need to create an implementation in ios/RNFSTurboPlatformHelper.mm:

#import <Foundation/Foundation.h>
#import "RNFSTurboPlatformHelper.h"

RNFSTurboPlatformHelper::RNFSTurboPlatformHelper() {}

bool RNFSTurboPlatformHelper::existsIOS(const char* path) {
  /* ... */
  return true;
}
Enter fullscreen mode Exit fullscreen mode

And that's all. Now let's finish cpp/RNFSTurboHostObject.cpp:

...
        bool exists{false};
        if (propName == "existsAssets" || propName == "existsRes") {
#ifdef __ANDROID__
          exists = platformHelper->existsAssetsOrRes(filePath.c_str(), propName == "existsRes");
#endif
        } else if (propName == "existsIOS") {
#ifdef __APPLE__
          exists = platformHelper->existsIOS(filePath.c_str());
#endif
        } else {
          struct stat t_stat;
          exists = stat(filePath.c_str(), &t_stat) == 0;
        }
...
Enter fullscreen mode Exit fullscreen mode

Essentially, this is all we need to successfully implement the basic methods.

6. Working with Threads

This is just an informative part to show how to work with Threads. If you don't plan to use them, you can skip it.

The next problem appeared when I reached the downloadFile and uploadFiles methods. Initially, I implemented them with simple callbacks, which I called from native Java and Objective-C code.
To avoid going into details, I'll describe an example implementation of these methods via std::thread:

...
typedef std::function<void (int jobId, int statusCode, float bytesWritten)> RNFSTurboCompleteDownloadCallback;

...

  if (propName == "downloadFile") {
    return jsi::Function::createFromHostFunction(
      runtime, jsi::PropNameID::forAscii(runtime, propName),
      1,
      [this](jsi::Runtime& runtime, const jsi::Value& thisValue, const jsi::Value* arguments, size_t count) -> jsi::Value {
        ...
        std::shared_ptr<jsi::Function> completeFunc = std::make_shared<jsi::Function>(arguments[1].asObject(runtime).asFunction(runtime));

        RNFSTurboCompleteDownloadCallback completeCallback = [&runtime, completeFunc, this](int jobId, int statusCode, float bytesWritten) -> void {
          jsi::Object result = jsi::Object(runtime);
          result.setProperty(runtime, "jobId", jsi::Value(jobId));
          result.setProperty(runtime, "statusCode", jsi::Value(statusCode));
          result.setProperty(runtime, "bytesWritten", jsi::Value(bytesWritten));
          completeFunc->call(runtime, std::move(result));
        };

        std::thread([completeCallback, &runtime]() {
          completeCallback(1, 200, 100);
        }).detach();
      }
    );
  }
Enter fullscreen mode Exit fullscreen mode

Here we create a C++ callback function completeFunc from JS callback param (jsi::Function), which will be called after the file download is complete (I used std::thread as an example to show what happens in this case when working with threads).

And here the problem awaited me - I periodically got an application crash (since the callback sometimes returned in another thread).

After digging through the documentation and asking in React-Native Github Issue Tracker, I realized that I need to use CallInvoker or RuntimeExecutor to switch back to the JavaScript thread (see docs).

To do this, we'll slightly change cpp/RNFSTurboHostObject.h:

...
public:
  ...
  std::shared_ptr<facebook::react::CallInvoker> jsInvoker;
...
Enter fullscreen mode Exit fullscreen mode

And complete cpp/NativeRNFSTurboModule.cpp:

...
jsi::Object NativeRNFSTurboModule::createRNFSTurbo(jsi::Runtime& runtime) {
  auto instance = std::make_shared<RNFSTurboHostObject>();
  instance->jsInvoker = jsInvoker_;
  return jsi::Object::createFromHostObject(runtime, instance);
}
...
Enter fullscreen mode Exit fullscreen mode

Now we can implement callbacks through jsInvoker in cpp/RNFSTurboHostObject.cpp:

...
  if (propName == "downloadFile") {
    return jsi::Function::createFromHostFunction(
      runtime, jsi::PropNameID::forAscii(runtime, propName),
      1,
      [this](jsi::Runtime& runtime, const jsi::Value& thisValue, const jsi::Value* arguments, size_t count) -> jsi::Value {
        ...
        std::shared_ptr<jsi::Function> completeFunc = std::make_shared<jsi::Function>(arguments[1].asObject(runtime).asFunction(runtime));

        RNFSTurboCompleteDownloadCallback completeCallback = [&runtime, completeFunc, this](int jobId, int statusCode, float bytesWritten) -> void {
          jsInvoker->invokeAsync([&runtime, completeFunc, jobId, statusCode, bytesWritten]() {
            jsi::Object result = jsi::Object(runtime);
            result.setProperty(runtime, "jobId", jsi::Value(jobId));
            result.setProperty(runtime, "statusCode", jsi::Value(statusCode));
            result.setProperty(runtime, "bytesWritten", jsi::Value(bytesWritten));
            completeFunc->call(runtime, std::move(result));
          });
        };

        std::thread([completeCallback, &runtime]() {
          completeCallback(1, 200, 100);
        }).detach();
      }
    );
  }
...
Enter fullscreen mode Exit fullscreen mode

That's it, the app didn't crash anymore.

7. TypeScript module

Now let's prepare the TypeScript part. Create a file src/NativeRNFSTurboModule.ts

import type {TurboModule} from 'react-native';
import {TurboModuleRegistry} from 'react-native';
import {UnsafeObject} from 'react-native/Libraries/Types/CodegenTypes';
import {getRNFSTurboPlatformContextTurboModule} from './NativeRNFSTurboPlatformContextModule';

export interface Configuration {
  documentDirectoryPath: string;
  /* ... */
}

export interface Spec extends TurboModule {
  /**
   * Create a new instance of RNFSTurbo.
   * The returned {@linkcode UnsafeObject} is a `jsi::HostObject`.
   */
  readonly createRNFSTurbo: () => UnsafeObject;
}

let module: Spec | null;
let configuration: Configuration;

export function getRNFSTurboModule(): {
  configuration: Configuration;
  module: Spec;
} {
  try {
    if (module == null) {
      // 1. Load RNFS TurboModule
      module = TurboModuleRegistry.getEnforcing<Spec>('RNFSTurboModule');

      // 2. Get the PlatformContext TurboModule as well
      const platformContext = getRNFSTurboPlatformContextTurboModule();

      // 3. Initialize it with the storage directores from platform-specific context
      configuration = {
        documentDirectoryPath: platformContext.getDocumentDirectoryPath(),
        /* ... */
      };
    }

    return {configuration, module};
  } catch {
    // TurboModule could not be found!
    throw new Error('Module not found');
  }
}
Enter fullscreen mode Exit fullscreen mode

Then the main file src/index.ts:

import {
  getRNFSTurboModule,
  type Configuration,
} from "./NativeRNFSTurboModule";

export interface RNFSTurboInterface {
  readonly DocumentDirectoryPath: string;
  /* ... */

  exists(filepath: string): boolean;
  existsAssets(filepath: string): boolean;
  existsRes(filepath: string): boolean;
}

const createRNFSTurbo = (): {
  configuration: Configuration;
  instance: RNFSTurboInterface;
} => {
  const { configuration, module } = getRNFSTurboModule();

  const instance = module.createRNFSTurbo() as RNFSTurboInterface;
  if (__DEV__) {
    if (typeof instance !== "object" || instance == null) {
      throw new Error(
        "Failed to create RNFSTurbo instance - an unknown object was returned by createRNFSTurbo(..)!",
      );
    }
  }
  return { configuration, instance };
};

/**
 * A single RNFSTurbo instance.
 */
class RNFSTurbo implements RNFSTurboInterface {
  private nativeInstance: RNFSTurboInterface;

  private functionCache: Partial<RNFSTurboInterface>;

  private configuration: Configuration;

  /**
   * Creates a new RNFSTurbo instance with the given Configuration.
   * If no custom `id` is supplied, `'rnfsturbo'` will be used.
   */
  constructor() {
    const { configuration, instance } = createRNFSTurbo();
    this.nativeInstance = instance;
    this.configuration = configuration;
    this.functionCache = {};
  }

  private getFunctionFromCache<T extends keyof RNFSTurboInterface>(
    functionName: T,
  ): RNFSTurbo[T] {
    if (this.functionCache[functionName] == null) {
      this.functionCache[functionName] = this.nativeInstance[functionName];
    }
    return this.functionCache[functionName] as RNFSTurbo[T];
  }

  get DocumentDirectoryPath(): string {
    return this.configuration.documentDirectoryPath;
  }

  /* ... */

  exists(filepath: string): boolean {
    const func = this.getFunctionFromCache("exists");
    return func(filepath);
  }

  existsAssets(filepath: string): boolean {
    const func = this.getFunctionFromCache("existsAssets");
    return func(filepath);
  }

  existsRes(filepath: string): boolean {
    const func = this.getFunctionFromCache("existsRes");
    return func(filepath);
  }

}

const RNFSTurboInstance = new RNFSTurbo();

export default RNFSTurboInstance;
Enter fullscreen mode Exit fullscreen mode

I don't think I need to explain what's going on here. But I'd like to draw your attention to a few things.

I used the same getFunctionFromCache method from the react-native-mmkv library, which caches the native function to have quick access to it. I haven't measured whether this increases speed, but in theory it should work faster when frequently requesting the same methods.

Next, we create an instance of the RNFSTurbo class, which we export. When importing into the app, this single instance will always be used to avoid re-creating the class in memory.

8. Android Module description

First, create a react-native.config.js file

module.exports = {
  dependency: {
    platforms: {
      /**
       * @type {import('@react-native-community/cli-types').IOSDependencyParams}
       */
      ios: {},
      /**
       * @type {import('@react-native-community/cli-types').AndroidDependencyParams}
       */
      android: {
        cxxModuleCMakeListsModuleName: 'test-turbo-module',
        cxxModuleCMakeListsPath: 'CMakeLists.txt',
        cxxModuleHeaderName: 'NativeRNFSTurboModule',
      },
    },
  },
};
Enter fullscreen mode Exit fullscreen mode

Here we specify the bundle for Android and some C++ instructions.

Then, create android/build.gradle:

buildscript {
  repositories {
    google()
    mavenCentral()
  }

  dependencies {
    classpath "com.android.tools.build:gradle:7.2.1"
  }
}

apply plugin: "com.android.library"
apply plugin: "com.facebook.react"

android {
  defaultConfig {
    compileSdkVersion 31
    minSdkVersion 21
  }

  buildFeatures {
    buildConfig true
  }

  compileOptions {
    sourceCompatibility JavaVersion.VERSION_1_8
    targetCompatibility JavaVersion.VERSION_1_8
  }

  sourceSets {
    main {
      java.srcDirs += [
        // This is needed to build Kotlin project with NewArch enabled
        "${project.buildDir}/generated/source/codegen/java"
      ]
    }
  }
}

repositories {
  mavenCentral()
  google()
}

dependencies {
  implementation "com.facebook.react:react-native:+"
}

react {
  jsRootDir = file("../src/")
  libraryName = "RNFSTurbo"
  codegenJavaPackageName = "com.test.rnfsturbo"
}
Enter fullscreen mode Exit fullscreen mode

Please note that you need to use actual versions of packages and your bundle name.

Add android/src/main/AndroidManifest.xml:

<manifest xmlns:android="http://schemas.android.com/apk/res/android"
          package="com.test.rnfsturbo">
</manifest>
Enter fullscreen mode Exit fullscreen mode

And then create android/CMakeLists.txt file:

cmake_minimum_required(VERSION 3.9.0)
project(RNFSTurbo)

set(CMAKE_VERBOSE_MAKEFILE ON)
set(CMAKE_CXX_STANDARD 17)

# Compile sources
add_library(
    test-turbo-module
    SHARED
    src/main/cpp/RNFSTurboPlatformHelper.cpp
    ../cpp/RNFSTurboHostObject.cpp
    ../cpp/NativeRNFSTurboModule.cpp
)

# Add headers search paths
target_include_directories(test-turbo-module PUBLIC ../cpp)

# Add android/log dependency
find_library(log-lib log)

target_link_libraries(
    test-turbo-module
    ${log-lib}                  # <-- Logcat logger
    android                     # <-- Android JNI core
    react_codegen_RNFSTurboSpec    # <-- Generated Specs from CodeGen
)
Enter fullscreen mode Exit fullscreen mode

CMakeLists.txt contains instructions for the C++ compiler. Use your own package name and file names.

9. iOS Module description

Create a RNFSTurbo.podspec in the root of package:

require "json"

package = JSON.parse(File.read(File.join(__dir__, "./package.json")))

Pod::Spec.new do |s|
  s.name            = "RNFSTurbo"
  s.version         = package["version"]
  s.summary         = package["description"]
  s.description     = package["description"]
  s.homepage        = package["homepage"]
  s.license         = package["license"]
  s.platforms       = { :ios => "12.4" }
  s.author          = package["author"]
  s.source          = { :git => "https://github.com/path/to/source.git", :tag => "#{s.version}" }
  s.source_files = [
    "ios/**/*.{h,m,mm}",
    "cpp/**/*.{hpp,cpp,c,h}",
  ]
  s.compiler_flags = '-x objective-c++'
  s.pod_target_xcconfig = {
    "CLANG_CXX_LANGUAGE_STANDARD" => "c++17"
  }
  install_modules_dependencies(s)
end
Enter fullscreen mode Exit fullscreen mode

There are almost no differences from the usual podspec file, except that we indicate compiler_flags and pod_target_xcconfig.

Then create ios/RNFSTurboOnLoad.mm file:

#import <Foundation/Foundation.h>
#import "NativeRNFSTurboModule.h"
#import <ReactCommon/CxxTurboModuleUtils.h>

@interface RNFSTurboOnLoad : NSObject
@end

@implementation RNFSTurboOnLoad

+ (void)load {
  facebook::react::registerCxxModuleToGlobalModuleMap(
      std::string(facebook::react::NativeRNFSTurboModule::kModuleName),
      [&](std::shared_ptr<facebook::react::CallInvoker> jsInvoker) {
        return std::make_shared<facebook::react::NativeRNFSTurboModule>(jsInvoker);
      });
}

@end
Enter fullscreen mode Exit fullscreen mode

It will register the RNFSTurbo module in the React-Native application's module registry.

10. Creating a package.json

{
  "name": "test-turbo-module",
  "version": "0.0.1",
  "description": "Test React-Native C++ library",
  "react-native": "src/index",
  "source": "src/index",
  "license": "MIT",
  "scripts": {
    "typescript": "tsc --noEmit"
  },
  "repository": {
    "type": "git",
    "url": "git+https://github.com/path/to/source.git"
  },
  "homepage": "https://github.com/path/to/source",
  "author": "John Doe <john@doe.com>",
  "codegenConfig": {
    "name": "RNFSTurboSpec",
    "type": "modules",
    "jsSrcsDir": "src"
  },
  "devDependencies": {
    "react": "^18.3.1",
    "react-native": "^0.74.2",
    "typescript": "^5.1.6"
  },
  "peerDependencies": {
    "react": "*",
    "react-native": "*"
  }
}
Enter fullscreen mode Exit fullscreen mode

The final package structure will look like this:

package structure

Adding a library to your application

Now we can add the package test-turbo-module to the package.json in your application:

...
"test-turbo-module": "file:./packages/test-turbo-module"
...
Enter fullscreen mode Exit fullscreen mode

And import the module in your application code:

import RNFSTurbo from 'test-turbo-module';

const fileExists = RNFSTurbo.exists(`${RNFSTurbo.DocumentDirectoryPath}/..`);
Enter fullscreen mode Exit fullscreen mode

Conclusion

I can't say that everything is written perfectly, especially in terms of C++ code (since I started studying it quite recently and I don't have good practice in working with it).

But nevertheless, I got a lot of experience working on this library. In our company, we use it in production on one of the projects and it works more than stably.

I hope this article will help other developers understand C++ React-Native modules and start writing new fast and cross-platform libraries (or rewriting old and abandoned ones).

You can check the full library code here - https://github.com/cmpayc/react-native-fs-turbo

Top comments (0)