An experimental library enabling the use of the PrimJS engine, which is used in Lynx—as a replacement for the default Hermes engine.
The engine is built from source as part of your app. No prebuilt binaries. Built and tested against React Native 0.87 with the New Architecture.
Measured with the example app in release mode on React Native 0.87.1, each engine running its own bytecode. The chart shows how many times faster one engine ran each test than the other.
| PrimJS | Hermes | |
|---|---|---|
| Time to interactive | 68.3 ms | 63.3 ms |
| Memory after startup | 45.4 MB | 38.9 MB |
| PrimJS | Hermes | |
|---|---|---|
| Time to interactive | 60.8 ms | 62.7 ms |
| Memory after startup | 99.5 MB | 91.0 MB |
npm install react-native-primjs-engine
cd ios && pod installThe library is autolinked. Four things then have to change in the app.
PrimJS implements roughly ES2020. The React Native preset leaves newer syntax untouched because Hermes understands it, so add two transforms to
babel.config.js:
module.exports = {
presets: ['module:@react-native/babel-preset'],
plugins: [
'@babel/plugin-transform-logical-assignment-operators',
'@babel/plugin-transform-class-static-block',
],
};npm install --save-dev @babel/plugin-transform-logical-assignment-operators @babel/plugin-transform-class-static-blockNewer built-in functions (Array.prototype.at, Object.hasOwn, String.prototype.replaceAll, findLast, toSorted and friends) are added by the runtime at startup; see cpp/Polyfills.h.
android/gradle.properties:
hermesEnabled=falseMainApplication.kt:
import com.primjsengine.PrimJSRuntimeFactory
override val reactHost: ReactHost by lazy {
getDefaultReactHost(
context = applicationContext,
packageList = PackageList(this).packages,
jsRuntimeFactory = PrimJSRuntimeFactory(),
)
}android/app/build.gradle: remove the else { implementation jscFlavor } branch from dependencies. With Hermes disabled the template would otherwise pull in JavaScriptCore.
AppDelegate.swift:
import PrimJSEngine
class ReactNativeDelegate: RCTDefaultReactNativeFactoryDelegate {
// ...
override func createJSRuntimeFactory() -> JSRuntimeFactoryRef {
jsrt_create_primjs_factory()
}
}ios/.xcode.env, so that the bundle is not compiled to Hermes bytecode:
export USE_HERMES=falseThe Hermes pod stays installed, because React Native 0.87 has no supported way to leave it out. It is not used.
Optional, and what makes startup competitive. See Bytecode.
Android, in android/app/build.gradle, after the React Native plugin:
apply from: "../../node_modules/react-native-primjs-engine/android/bytecode.gradle"iOS, in the Podfile, followed by pod install:
require_relative '../node_modules/react-native-primjs-engine/scripts/primjs_pods'
target 'MyApp' do
# ...
primjs_compile_bundle!
endPrimJSInternal is the counterpart of HermesInternal:
console.log(globalThis.PrimJSInternal);
// { version: '2.11.1-rc.1', commit: 'f78a8bb…', gc: true }It is undefined when the app is running on another engine.
tools/primjsc is to PrimJS what hermesc is to Hermes: a program for the build machine that compiles a JS bundle to the engine's bytecode. The build steps above run it on the bundle Metro produced, and the runtime recognises the result by its header and loads it instead of parsing source. Source bundles keep working, so debug builds and Metro are unaffected.
The effect on startup, measured with the example app (a 1.3 MB bundle): time from process start until the bundle has been evaluated and React starts the app, as the median of repeated launches.
| Launches | Median | Quartiles | |
|---|---|---|---|
| PrimJS, source bundle | 20 | 328 ms | 304 to 348 ms |
| PrimJS, bytecode | 60 | 156 ms | 142 to 175 ms |
| Hermes | 30 | 155 ms | 131 to 169 ms |
Memory in use after startup went from about 110 MB to about 70 MB in the same app, because nothing has to be parsed.
To keep the bundle as source, pass -PprimjsBytecode=false on Android or put export PRIMJS_BYTECODE=false in ios/.xcode.env.
primjsc also works by hand:
scripts/compile-bundle.sh path/to/index.android.bundle # builds primjsc if needed, compiles in place
build/primjsc/primjsc --source-url index.bundle -o out.bc in.jsPrimJS can be built two ways, and the choice is made at compile time.
| Tracing GC | Reference counting | |
|---|---|---|
| Interpreter | pre-generated arm64 assembly (the "template interpreter") | portable C++ |
| Architectures | arm64 | all |
fib(30) on an M-series Mac |
about 23 ms | about 110 ms |
The GC build is the default wherever it is available: arm64 iOS devices and simulators, and arm64-v8a on Android. Other architectures fall back to reference counting automatically. To force reference counting everywhere:
- Android:
primjsEnableGC=falseingradle.properties. - iOS:
PRIMJS_ENABLE_GC=0 pod install.
Bytecode is the same in both modes.
- No debugger. React Native DevTools cannot attach.
console.logand LogBox work. - No
Intl. - ES2020 baseline. Newer syntax needs Babel, newer built-ins need a polyfill.
Error.causeand the RegExpdandvflags are not supported.
example/ is a React Native 0.87.1 app that runs a fixed sequence of measurements on launch and shows the result as a table and as JSON. The tests come from react-native-engine-benchmark. The ones reported here are startup time, memory, and a set of Octane and SunSpider programs.
cd example && npm install
npx react-native run-android --mode release
cd ios && pod install && cd .. && npx react-native run-ios --mode ReleaseMIT, see LICENSE.
The vendored engine in cpp/primjs is PrimJS by the Lynx authors, licensed under Apache-2.0, and is in turn based on QuickJS by Fabrice Bellard and Charlie Gordon. See cpp/primjs/LICENSE and cpp/primjs/NOTICE.