Deep dive into React Native JSI

Zafer Ayan
Teknasyon Engineering

--

Image Source: https://cornerstoneseducation.co.uk/news/what-to-expect-from-ofsteds-subject-deep-dives/

In November of last year, I spoke about React Native JSI architecture. And in this article, I will talk about the new architecture and how to use it in our projects. But first of all, we need to mention old React Native Architecture.

How old React Native Architecture works

As some of you know, in React Native, the JS side and Native side communication work with Bridge foundation. Because of JS side already has a well-isolated environment, it doesn’t have any mechanism to talk with the Native side. For example, you cannot access the device name, or you are unable to get the local IP address of a current device in a JavaScript environment without creating Native modules.

You need to create native modules to get local IP address for current device.

In Native Modules, you create APIs with Java and ObjC languages to access the Native side from your JS code. And this situation isn’t only specific to React Native. In Java, you need to use JNI to call C++ and native C codes. Similarly, you need to create a Bridge layer using C or ObjC language to call C++ APIs in Swift. In React Native, the Bridge structure works in the same manner.

The bridge structure in React Native enables communication via JSON messages.

The bridge provides a tunnel, and with this tunnel, it performs message transmission between JS and Native. For example, to get the IP address of a device, we need to call a “getIPAddress” method of the Native side. When the native side obtains this message, it gets the real IP address of the device, and after that, it envelopes this in another message and sends it through Bridge to JS side. JS side obtains this IP address from the message and can display it on the screen.

In a React Native application, sample camera opening flow is indicated as in the image

As some of you know, bridge flow isn’t an ideal solution for calling the Native side. It batches these messages for processing. Because the batching system batches these messages, some lags happen on user devices. For that reason, messages cannot be sent instantly to another side. Besides, some serialization operations take place to envelop messages in JSON format. For example, even if you want to send a simple number variable to the Native side, this variable must be converted to a JSON string. And this operation is incredibly slow compared to native communication. For this reason, we need to replace Bridge with the new architecture.

Emerging of JSI

JSC (JavaScript Core), Hermes and V8 engines have JSI support.

JavaScript runtimes such as JSC, Hermes, and V8 are written in C and C++ because they need to work with high performance. Developers who want to take advantage of this situation have created C++ APIs that can talk to the native side. And they called it JSI (JavaScript Interface). JSI provides an abstraction layer to a JavaScript Runtime. You can think of this layer as the concept of the interface in object-oriented languages. In these languages, you specify functions to be defined in the interface and the classes that implement it become obliged to override it. Similarly, by performing this operation on the JSI side, you can directly send a number value from the C++ side and obtain it by the JavaScript side without type conversion.

Variable definitions in JavaScript and JSI

Number type definition

On the JavaScript side, we know how to define a number variable as follows:

// Javascript
const number = 42

So how do we define a variable on the C++ side and pass it on to the JS side? First, let’s take a closer look at how numbers are defined in C++:

// JSI (C++)
jsi::Value number = jsi::Value(42);

As you can see here, an instance of the Value class is created using the Value() constructor in the jsi namespace. Later, this instance can be directly used on the JavaScript side as a number.

String type definition

Similarly, string variable definition in JS and C++ side as follows:

// JavaScript
const name = "Marc"
// JSI (C++)
jsi::Value name = jsi::String::createFromUtf8(runtime, "Marc")

Here, string variables are created using the createFromUtf8 method in the String in the jsi namespace.

Function definition

As you know, functions can be defined on the JavaScript side as follows:

// JS
const add = (first, second) => {
return first + second
}

To create a function on the C++ side and use it on the JavaScript side, the createFromHostFunction method is used as follows:

// JSI (C++)
auto add = jsi::Function::createFromHostFunction(
runtime,
jsi::PropNameID::forAscii(runtime, "add"), // add function name
2, // first, second variables (2 variables)
[](
jsi::Runtime& runtime,
const jsi::Value& thisValue,
const jsi::Value* arguments, // function arguments
size_t count
) -> jsi::Value {
double result = arguments[0].asNumber() + arguments[1].asNumber();
return jsi::Value(result);
}
);

Numbers in JSI are always a type of double. The add method created as above can be directly used on the JavaScript side

// JavaScript
const result = add(5, 8)

This method can be used on the C++ side too:

// JSI (C++)
auto result = add.call(runtime, 5, 8);

Of course, in order to use add function from global namespace as above, we need to define it as follows:

// Javascript
global.add = add;// JSI (C++)
runtime.global().setProperty(runtime, "add", std::move(add));

If we compare the method we created with other native modules in the bridge, we can notice this function is not defined as async and therefore runs synchronously. As you can see here, the result of an operation is created in the host function and used directly by the JS side. If add function was a bridge function, we would need to use with an await keyword as follows:

const resul = await global.add(5, 2)

As you have noticed, JSI functions are direct, synchronous, and the fastest invocation method in the JavaScript runtime.

Going back to the IP address example, to realize this scenario, we first need to create a method that returns IP address in C++, then we use global property to load this function on the JS side, and finally simply call the function. Now, we don’t need to use await keyword as we can call the function directly, and we can call that just like any other JS method. In addition, since there is no serialization process, we are freed from an additional processing load.

Now, take a closer look at the implementation:

// JSI (C++)
auto getIpAddress = jsi::Function::createFromHostFunction(
runtime,
jsi::PropNameID::forAscii(runtime, "getIpAddress"),
0, // Takes no parameter so it have 0 parameters
[](
jsi::Runtime& runtime,
// thisValue, arguments ve count variables not necessary
const jsi::Value&,
const jsi::Value*,
size_t
) -> jsi::Value {
// iOS or android side method will be called
auto ip = SomeIosApi.getIpAddress();
return jsi::String::createFromUtf8(runtime, ip.toString());
}
);
runtime.global().setProperty(runtime, "getIpAddress", std::move(getIpAddress));

And after that we can call it from JS side as follows:

// JavaScript
const ip = global.getIpAddress();

Bridge and JSI differences

To summarize, we can say that JSI technology will replace Bridge. Although JSI and bridge will be included in projects for a while, Bridge will be completely removed soon and all native modules will be using JSI. JSI creates a more performant structure than Bridge as it provides both faster and direct access to the JS runtime.

In Bridge, on the other hand, JS and the Native side communication happens asynchronously and messages are processed as batches, a simple operation like the addition of 2 numbers is required to use the await keyword.

Since everything works as synchronous by default in JSI, they can be used in top-level scope as well. Of course, asynchronous methods can be created for long-running operations and promises can be easily used.

As a disadvantage, it’s not possible to use remote debuggers like Google Chrome as JSI accesses JS runtime. Instead of this, we can use the Flipper Desktop app to debug our applications.

Because the JSI became an abstraction layer for native implementation, we don’t need to directly use the JSI and we don’t need to know C++ internals. We just call native functions from the JS side as we used to be. Also, Turbo Modules API is almost the same as the Native Modules API. Thus, each existing Native Module in the RN ecosystem can be easily migrated to Turbo Modules without rewriting from scratch.

Now, take a closer look at the MMKV library to understand how JSI implementation works.

MMKV library as an example for JSI

react-native-mmkv is a library that performs a simple key-value storage operation with the help of JSI. By making synchronous calls, it performs read/write operations 30 times faster than AsyncStorage.

1000 times read operation time (ms) from storage on iPhone 8 device. While MMKV is around 10ms, AsyncStorage is around 230ms. Approximately, MMKV is 23 times faster than AsyncStorage.

Because of these features, mmkv library is also a good example of JSI. Let’s take a look at the Android project structure for JSI implementation. We can start from MainApplicaton.java to see how it implemented:

public class MainApplication extends Application implements ReactApplication { 

private final ReactNativeHost mReactNativeHost =
new ReactNativeHost(this) {
@Override
public boolean getUseDeveloperSupport() {
return BuildConfig.DEBUG;
}

@Override
protected List<ReactPackage> getPackages() {
return new PackageList(this).getPackages();
}
@Override
protected String getJSMainModuleName() {
return "index";
}

@Override
protected JSIModulePackage getJSIModulePackage() {
return new MmkvModulePackage();
}
};

@Override
public ReactNativeHost getReactNativeHost() {
return mReactNativeHost;
}
}

Unlike Bridge, there is no auto-linking mechanism for the JSI module. For this reason, we need to do linking operations manually. In MainApplication.java, we need to override getJSIModulePackage method in the ReactNativeHost instance. This method returns MmkvModulePackage() that implements JSIModulePackage.

When we look at the MmkvModulePackage.java file, we see that this class override the getJSIModules function:

public class MmkvModulePackage implements JSIModulePackage {
@Override
public List<JSIModuleSpec> getJSIModules(
ReactApplicationContext ctx,
JavaScriptContextHolder jsContext) {

MmkvModule.install(jsContext,ctx.getFilesDir().getAbsolutePath() + "/mmkv");

return Collections.emptyList();
}
}

As you can see, the getJSIModules function returns JSIModuleSpec instances as a list. And in the return line, the function simply returns an empty list. This is because this function is only called on the JS thread. Since such a call is made before the bundle is created, the mmkv module can easily be loaded into the global namespace. If we had done this in a different thread, such as the Native Module thread, our application would have crashed by getting an error in the runtime.

Now, let’s look at how the install method works:

public static void install(
JavaScriptContextHolder jsContext,
String storageDirectory) {

nativeInstall(jsContext.get(), storageDirectory);
}
private static native void nativeInstall(long jsiPtr, String path);

As you can see here, the install method takes an instance parameter named JavaScriptContextHolder. This class is a hybrid Java class and also includes JavaScript runtime as C++ instance. Thus, transferring values from Java to C++ makes it possible. The nativeInstall function here is just a JNI function and allows the native function in C++ to be called by Java. Also, data transfer between languages takes place here.

The nativeInstall function is defined in C++ file as follows:

extern "C"
JNIEXPORT void JNICALL
Java_com_reactnativemmkv_MmkvModule_nativeInstall(
JNIEnv *env,
jobject clazz,
jlong jsiPtr,
jstring path) {

MMKV::initializeMMKV(jstringToStdString(env, path));

auto runtime = reinterpret_cast<jsi::Runtime*>(jsiPtr);
if (runtime) {
install(*runtime);
}
// if runtime was nullptr, MMKV will not be installed. This should only happen while Remote Debugging (Chrome), but will be weird either way.
}

As you can see here, the Java_com_reactnativemmkv_MmkvModule part of this file matches with the native module namespace. The env expression is the JNI environment, and the clazz parameter is the MMKV module. The jsiPtr parameter holds JavaScript Runtime instance. The path parameter represents file’s path where the mmkv module store the variables. Notice that the last two parameters are the same as the ones called in Java:

nativeInstall(jsContext.get(), storageDirectory);

Then, thanks to the reinterpret_cast method, the jsiPtr variable is cast to jsi::Runtime. If cast operation is successful, the install method is called. If it fails, an environment that does not support JSI is being used. This environment can be an engine other than the 3 environments we have mentioned, or it can be the Chrome Remote Debugger.

Now let’s look at the install method:

The createFromHostFunction instance in this method is very similar to the createFromHostFunction part in the add example that we mentioned earlier. key/value pairs are taken from the arguments variable and set with the function in MMKV namespace.

Let’s look at another example, the vision camera library.

Using JSI in Vision Camera Library

In this library, we can directly access the width and height properties of the frame and transfer them directly to the native processor plugin.

const frameProcessor = useFrameProcessor((frame) => {
'worklet';

console.log(`A new ${frame.width} x ${frame.height} frame arrived!`);

const values = examplePlugin(frame);
console.log(`Return values ${JSON.stringify(values)}`);
},[]);

This worklet expression means that relevant will run in the background. Thus, the operation will be performed without blocking the main thread. The frame parameter is a JSI host object. In the other words it’s an object generated in C++ and can be accessed directly by JavaScript. frame.height access here triggers the getProperty method, which actually C++ code.

Now let’s look at Frame object properties:

export interface Frame {
isValid: boolean;
width: number;
height: number;
bytesPerRow: number;
planesCount: number;
toString(): string;
close(): void;
}

We see that this properties are only introduced on the TypeScript side and do not have any code in the JavaScript side. In fact, properties of the Frame object is only exists on C++ side. For this, we can get more detailed information by looking at the HostObject file:

Now let’s look at how properties called:

Now we can move on to the custom host object creation process.

Custom Host Object creation

By creating a custom host object, different types of data can be returned as follows:

Accessing to a global async function in the JS runtime

If that function is a global function like Promise, then we can invoke as follows:

auto promiseCtor = runtime.global().getPropertyAsFunction(runtime, "Promise");auto promise = promiseCtor.callAsConstructor(runtime, resolve, reject);

If its a anonymous function like (const x = () => …), we need to pass it to the C++ function as a parameter:

auto nativeFunc = jsi::Function::createFromHostFunction(runtime,
jsi::PropNameID::forAsci(runtime, "someFunc"),
1, // a function
[](jsi::Runtime& runtime,
const jsi::Value& thisValue,
constjsi::Value* arguments,
size_t count) -> jsi::Value {
auto func = arguments[0].asObject().asFunction();
return func.call(runtime, jsi::Value(42));
});

Conclusion

With JSI, it seems obvious that the performance of modules and applications that are using these modules is increasing. Since JSI is only changing infrastructure, I don’t think it will affect everyday React Native app development. However, if you’re a library maintainer, I think it’s useful to learn some C++ and migrate a simple library to JSI.

See you again in my next article…

Resources

--

--