Article

PeruneJS integration with React Native

· 4 min read

The New Architecture of React Native isn't hard-wired to Hermes. All native code talks to the engine through the JSI interface (facebook::jsi::Runtime), and the engine itself is provided by a runtime factory (JSRuntimeFactory). Hermes is just the default implementation. To swap it for PeruneJS (my own engine), a few things had to be done. In my case, I ran the experiment on iOS only.

Testing the bundle on desktop

To check compatibility and debug the interpreter, the first step was running a real app bundle in the engine. This exposed gaps in the engine, and I had to add built-ins from outside ES5.1:

  • globalThis: the bundle reads it in its very first statement;
  • Symbol: core-js checks for native symbols, and without them it installs a shim and falls into infinite recursion;
  • Promise and a microtask queue;
  • Map, Set, WeakMap, WeakSet;
  • __proto__: this is how TurboModules attach their methods.

The problem with React Native was that Hermes accepts, by default, the ES6 code produced by Babel. In my case, I had to convert it down to ES5.1 syntax, and fill the gaps in the standard library with core-js.

babel.config.js
presets: [
  ['@babel/preset-env', { targets: { ie: '11' }, useBuiltIns: 'entry', corejs: 3 }],
  ['module:@react-native/babel-preset'],
],
index.js
import 'core-js/stable';

In metro.config.js, inlineRequires has to be disabled for core-js modules. core-js needs to capture the native functions before they get replaced. Lazy require reverses that order and ends in recursion inside Function.prototype.toString.

On top of that, one variable in ios/.xcode.env. Without it, the Release build compiles the bundle to Hermes bytecode (which we obviously don't need):

bash
export USE_HERMES=false

An embedding API aligned with JSI

Instead of writing the adapter directly against the engine's internals, I added an embed/ layer. Its shape deliberately mirrors jsi::Runtime, so the adapter only has to translate types:

cpp
class Runtime {
public:
    JSValue evaluate(const std::string &source);
    JSValue call(const JSValue &fn, const JSValue &self, const std::vector<JSValue> &args);
    JSValue create_function(const std::string &name, unsigned arity, HostFunctionCallback cb);
    JSValue create_object(std::shared_ptr<HostObject> host);
    PersistentValue persist(const JSValue &value);
    void drain_microtasks();
    // ...
};

The jsi::Runtime adapter

The PerunejsJsiRuntime : jsi::Runtime class overrides 67 virtual methods. The most important decision concerns value lifetimes. The PeruneJS GC scans the stack conservatively, while JSI objects live in fields of C++ objects on the heap, which the scanner can't see. That's why every JSI pointer holds its value through a PersistentValue handle:

cpp
struct ValueHolder final : jsi::Runtime::PointerValue {
    PersistentValue handle;
    ValueHolder(Heap &heap, const JSValue &value) : handle(heap, value) {}
    void invalidate() noexcept override { delete this; }
};

The rest is translating values and errors in both directions:

cpp
jsi::Value PerunejsJsiRuntime::call(const jsi::Function &fn, const jsi::Value &self,
                                    const jsi::Value *args, size_t count) {
    return on_engine([&]() -> jsi::Value {
        std::vector<JSValue> converted;
        for (size_t i = 0; i < count; ++i) converted.push_back(to_engine(args[i]));
        try {
            return to_jsi(engine_.call(value_of(fn), to_engine(self), converted));
        } catch (const embed::JSError &error) {
            rethrow(error);   // -> jsi::JSError
        }
    });
}

A custom stack

This was the least obvious obstacle. The JS thread in React Native has a 1 MB stack. That's enough for Hermes, because it has its own register stack. An AST-walking interpreter uses several to a dozen or so KB of native stack per JS call, and the fiber tree commit of the app template alone (around 60 frames) doesn't fit.

The solution: 16 MB reserved with mmap, with a guard page, and switching the stack pointer in assembly. Every entry into the engine (on_engine) moves onto this stack:

plaintext
_perunejs_call_on_stack:            // (argument, function, stack_top)
    stp  x29, x30, [sp, #-16]!
    mov  x29, sp
    mov  x9, sp                     // old SP
    and  x2, x2, #~15
    mov  sp, x2                     // new stack
    stp  x9, xzr, [sp, #-16]!
    blr  x1                         // function(argument)
    ldp  x9, x10, [sp], #16
    mov  sp, x9                     // switch back
    ldp  x29, x30, [sp], #16
    ret

The engine also has to know it's running on a different stack. Otherwise, overflow checks and the GC scan would look at the thread stack's bounds. NativeStackSegment, set for the duration of each entry, takes care of that. C++ exceptions are caught on the new stack and rethrown after returning, because they can't be unwound through the assembly frame.

The factory, the pod and one line of Swift

The factory follows the same contract as jsrt_create_hermes_factory:

cpp
class PerunejsRuntimeFactory final : public facebook::react::JSRuntimeFactory {
    std::unique_ptr<facebook::react::JSRuntime> createJSRuntime(
        std::shared_ptr<facebook::react::MessageQueueThread>) noexcept override {
        return std::make_unique<PerunejsJSRuntime>();
    }
};

extern "C" void *jsrt_create_perunejs_factory(void) {
    // RN frees this as a JSRuntimeFactory*, so we cast through the base type
    facebook::react::JSRuntimeFactory *factory = new PerunejsRuntimeFactory();
    return factory;
}

The engine and the adapter are packaged as a local CocoaPods pod (pod 'PerunejsEngine', :path => './PerunejsEngine') with a public header in plain C. The engine's headers are deliberately kept out of source_files, because CocoaPods would flatten parser.h or heap.h into a shared directory, where they could shadow other pods' headers.

Swapping the engine in the app itself comes down to a single override in the delegate:

swift
class ReactNativeDelegate: RCTDefaultReactNativeFactoryDelegate {
  override func createJSRuntimeFactory() -> JSRuntimeFactoryRef {
    jsrt_create_perunejs_factory()
  }
}

Pitfalls that don't exist on Hermes

An infinite loop through queueMicrotask. 

RN installs queueMicrotask as a lazy getter. core-js reads the descriptor's .value, sees undefined, and forces its own version built on Promise. On an engine other than Hermes, RN in turn replaces Promise with a polyfill built on setImmediate, which uses queueMicrotask. None of these steps is asynchronous, so the stack runs out after about 120 frames. The fix was to mark queueMicrotask on the global object as non-configurable before the bundle starts.

Bytecode instead of source. 

If you forget USE_HERMES=false, the engine receives Hermes bytecode and reports a misleading UTF-8 encoding error. So the adapter checks the first bytes of the file (the Hermes bytecode signature) and tells you straight away what to fix.

Result

The production bundle of the React Native 0.87 template (1.2 MB):

  • lexer: 499k tokens in 0.27 s;
  • parser: 0.11 s;
  • execution: about 2 s, with 11 GC cycles.

The start screen renders with an AST interpreter written from scratch. It's not fit for production, but I went all the way from AppDelegate to evaluate() with my own code.

More details about the engine in my previous post: https://lukaszkurant.com/blog/perunejs-or-how-i-built-my-own-javascript-engine