Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
a2d43af
Phase 1: vendor static_h Hermes, bump to RN 0.87 nightly
kraenhansen Jul 13, 2026
42d7e5a
Resolve Xcode app project resiliently in workspaces
kraenhansen Jul 13, 2026
31cb1a4
Fix test-app tooling for RN 0.87 / Metro 0.84
kraenhansen Jul 13, 2026
2b53fe7
Phase 2: create a real Node-API env via hermes_napi_create_env
kraenhansen Jul 13, 2026
3105198
Phase 2: bump Node-API to v10, drop engine/runtime split
kraenhansen Jul 14, 2026
1ba1901
vendor-hermes: export public hermes_napi_* entry points
kraenhansen Jul 15, 2026
9fa7c3e
vendor-hermes: apply prettier formatting
kraenhansen Jul 16, 2026
7755667
Regenerate pnpm-lock.yaml for RN 0.87 dependency bumps
claude Jul 23, 2026
ae54eac
vendor-hermes: advance pin to include upstream napi C-linkage fix
claude Jul 24, 2026
9147dd2
host: match hermes_napi_create_env C linkage after upstream #2106
claude Jul 24, 2026
973e12b
android: inject ExecOperations for Gradle 9 compatibility (#386)
kraenhansen Jul 24, 2026
da1b88a
android: patch RN settings.gradle.kts /tmp projectDir for Windows (#387)
kraenhansen Aug 9, 2026
772e59b
host: apply the Kotlin plugin only when built-in Kotlin is unavailable
kraenhansen Aug 9, 2026
9d990a5
deps: bump react-native to 0.87.0-rc.4
kraenhansen Aug 9, 2026
7c1abbc
test-app: adopt built-in Kotlin on Android, opt out of the AGP 9 DSL
kraenhansen Aug 9, 2026
4b46e1b
deps: bump react-native to a 0.88 nightly and drop the Windows patch
kraenhansen Aug 9, 2026
c453478
host: link the renamed hermesvm prefab module on Android
kraenhansen Aug 9, 2026
c6ef0ac
test-app: opt out of built-in Kotlin after all
kraenhansen Aug 9, 2026
2fca5b5
test-app: fail the Android run as soon as the app crashes
kraenhansen Aug 10, 2026
77bfb13
test-app: don't let the crash watchdog hold the step's stderr open
kraenhansen Aug 10, 2026
c0c468a
vendor-hermes: advance the pin past Hermes' JSI_UNSTABLE default flip
kraenhansen Aug 10, 2026
54bd0ac
host: create one Node-API env per addon
kraenhansen Aug 10, 2026
8d818c1
Add changeset for the static_h Node-API adoption
kraenhansen Aug 10, 2026
3bacef6
docs: describe the vendored Hermes instead of a patched one
kraenhansen Aug 10, 2026
78c8d58
docs: describe the Node-API host struct in HOW-IT-WORKS
kraenhansen Aug 10, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .changeset/eighty-moons-shave.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
"react-native-node-api": major
"weak-node-api": minor
---

Adopt Hermes' first-party Node-API (the `hermesNapi` target on the `static_h`
branch) instead of patching Hermes with our own implementation. Addons now run
against a real Node-API environment created with `hermes_napi_create_env()`, one
per addon as in Node, and Node-API is bumped from v8 to v10.

This drops support for React Native 0.79–0.81: the vendored Hermes is built from
a pinned `static_h` commit and requires the Hermes build scripts shipped with
React Native 0.87 and later. Older React Native versions are still served by
previously published releases.
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ patch or workaround:

## Critical Build Dependencies

- **Custom Hermes**: Currently depends on a patched Hermes with Node-API support (see [facebook/hermes#1377](https://github.com/facebook/hermes/pull/1377))
- **Vendored Hermes**: Builds Hermes from a pinned commit on the `static_h` branch, which carries Hermes' first-party Node-API implementation (`API/napi`, target `hermesNapi`). The pin lives in `packages/host/src/node/cli/hermes.ts` and is fetched by the `vendor-hermes` command.
- **Prebuilt Binary Spec**: All tools must output to the exact naming scheme:
- Android: `*.android.node/` with jniLibs structure + `react-native-node-api-module` marker file
- iOS: `*.apple.node` (XCFramework renamed) + marker file
Expand Down
12 changes: 6 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,9 @@
## How does this work?

> [!IMPORTANT]
> This library is currently dependent on a custom version of Hermes and therefore has a very limited range of supported React Native versions.
> Once the [PR adding Node-API support to Hermes](https://github.com/facebook/hermes/pull/1377) merges, we expect this restriction to be lifted.
> This library builds Hermes from a pinned commit on its `static_h` branch, which carries [Hermes' first-party Node-API implementation](https://github.com/facebook/hermes/tree/static_h/API/napi).
> React Native has not shipped that Hermes yet, so the range of supported React Native versions is very limited — see the `react-native` peer dependency of the [host package](packages/host/package.json) for the version we currently build against.
> We expect this restriction to be lifted once React Native ships a Hermes with Node-API included.

> [!NOTE]
> This library only works for iOS and Android and we want to eventually support React Native for Windows, macOS, visionOS and other out-of-tree platforms too.
Expand All @@ -35,10 +36,9 @@ This mono-repository hosts the development of a few packages:

Responsible for adding Node-API support to your React Native application:

- Declares a Podspec which downloads a special version of Hermes, with Node-API support,
- instructing React Native's Hermes Podspecs to compile from this custom source-code.
- patching React Native's JSI copy, with the updates introduced by our special version of Hermes.
- we expect this to eventually be removed, as Node-API support gets merged into Hermes upstream.
- Declares a Podspec which vendors Hermes from a pinned commit on its `static_h` branch, where Node-API is implemented,
- instructing React Native's Hermes Podspecs to compile from this checkout.
- we expect this to eventually be removed, as React Native starts shipping a Hermes with Node-API included.
- Automatically discovers and adds Node-API binaries, matching the [the prebuilt binary specification](./docs/PREBUILDS.md)
- This is driven by the platform specific build tools (through the Podspec on iOS and eventually Gradle on Android)
- Implements a TurboModule with a `requireNodeAddon` function responsible for
Expand Down
20 changes: 19 additions & 1 deletion apps/test-app/android/gradle.properties
Original file line number Diff line number Diff line change
Expand Up @@ -50,4 +50,22 @@ react.buildFromSource=true
#ANDROID_NDK_VERSION=26.1.10909125

# Version of Kotlin to build against.
#KOTLIN_VERSION=1.8.22
#KOTLIN_VERSION=1.8.22

# Opt out of built-in Kotlin and the new DSL, both of which ship enabled in
# AGP 9. AGP 10 removes both opt outs, so they are on borrowed time — tracked in
# https://github.com/callstackincubator/react-native-node-api/issues/389, which
# links the upstream code that has to migrate first.
#
# Both are blocked on react-native-test-app, not on us. Its Gradle modules still
# use the old DSL (`compileSdkVersion`, `lintOptions`), and its app module adds
# version-specific Kotlin sources through `main.java.srcDirs +=`. The Kotlin
# plugin compiles those; AGP's built-in Kotlin only picks up the standard source
# directories, so building without this leaves every symbol defined in an added
# directory (`testApp`, `ComponentActivityDelegate`, …) unresolved.
#
# Note that react-native-node-api itself is built-in-Kotlin ready — it applies
# the Kotlin plugin only when built-in Kotlin is unavailable — so this is purely
# about the test harness.
android.builtInKotlin=false
android.newDsl=false
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.1-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
Expand Down
17 changes: 9 additions & 8 deletions apps/test-app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,11 @@
"scripts": {
"metro": "react-native start --no-interactive",
"android": "react-native run-android --no-packager --active-arch-only",
"android:crash-watchdog": "node scripts/android-crash-watchdog.mts",
"ios": "react-native run-ios --no-packager",
"pod-install": "cd ios && pod install",
"mocha-and-metro": "mocha-remote --watch -- react-native start",
"test:android": "mocha-remote --exit-on-error -- concurrently --kill-others-on-fail --passthrough-arguments npm:metro 'npm:android -- {@}' --",
"test:android": "mocha-remote --exit-on-error -- concurrently --kill-others-on-fail --passthrough-arguments npm:metro 'npm:android -- {@}' npm:android:crash-watchdog --",
"test:android:allTests": "MOCHA_REMOTE_CONTEXT=allTests node --run test:android -- ",
"test:android:nodeAddonExamples": "MOCHA_REMOTE_CONTEXT=nodeAddonExamples node --run test:android -- ",
"test:android:nodeTests": "MOCHA_REMOTE_CONTEXT=nodeTests node --run test:android -- ",
Expand All @@ -30,20 +31,20 @@
"@react-native-node-api/ferric-example": "workspace:*",
"@react-native-node-api/node-addon-examples": "workspace:*",
"@react-native-node-api/node-tests": "workspace:*",
"@react-native/babel-preset": "0.81.4",
"@react-native/metro-config": "0.81.4",
"@react-native/typescript-config": "0.81.4",
"@rnx-kit/metro-config": "^2.1.1",
"@react-native/babel-preset": "0.88.0-nightly-20260809-db662caea",
"@react-native/metro-config": "0.88.0-nightly-20260809-db662caea",
"@react-native/typescript-config": "0.88.0-nightly-20260809-db662caea",
"@rnx-kit/metro-config": "^2.2.4",
"@types/mocha": "^10.0.10",
"@types/react": "^19.1.0",
"concurrently": "^9.1.2",
"mocha": "^11.6.0",
"mocha-remote-cli": "^1.13.2",
"mocha-remote-react-native": "^1.13.2",
"react": "19.1.0",
"react-native": "0.81.4",
"react": "19.2.3",
"react-native": "0.88.0-nightly-20260809-db662caea",
"react-native-node-api": "workspace:*",
"react-native-test-app": "^4.4.7",
"react-native-test-app": "^5.4.8",
"weak-node-api": "workspace:*"
}
}
101 changes: 101 additions & 0 deletions apps/test-app/scripts/android-crash-watchdog.mts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
/**
* Fails the Android test run as soon as the app crashes.
*
* `mocha-remote` waits indefinitely for a client to connect and has no notion
* of the app dying: when the app crashes on startup, nothing ever connects and
* the run hangs until the CI job hits its timeout — 68 minutes of an emulator
* idling for a crash that happened one second after `am start`.
*
* Run alongside the app (through `concurrently --kill-others-on-fail`), this
* turns such a crash into an immediate failure with the stack trace inlined in
* the log, instead of a timeout with the cause buried in a logcat artifact.
*
* It only reacts to crashes: a hung or never-launched app still relies on the
* job timeout.
*/
import cp from "node:child_process";
import readline from "node:readline";

// The application id used by react-native-test-app, which the CI workflow also
// hardcodes when uninstalling any leftover copy of the app.
const APP_ID = "com.microsoft.reacttestapp";

// How long to keep reading after the first line mentioning the app, to capture
// the rest of the stack trace before exiting.
const TRACE_GRACE_MS = 1000;

/**
* Runs adb, resolving false if it couldn't run at all (not installed, no
* device, etc). The watchdog stays out of the way in that case: the build or
* the run itself will fail with a better message than anything we could add.
*/
function adb(...args: string[]): Promise<boolean> {
return new Promise((resolve) => {
const child = cp.spawn("adb", args, { stdio: "ignore" });
child.on("error", () => resolve(false));
child.on("close", (code) => resolve(code === 0));
});
}

function skip(reason: string): never {
console.warn(`[crash-watchdog] Not watching for crashes: ${reason}`);
process.exit(0);
}

async function main() {
if (!(await adb("wait-for-device"))) {
skip("failed to wait for an adb device");
}

// Drop any crash from an earlier run, so we only react to this one. The app
// hasn't been installed yet at this point, so this can't discard a crash we
// care about.
await adb("logcat", "-b", "crash", "-c");

// Never let this child inherit our stderr: GitHub's `@actions/exec` resolves
// a step only once the stdio streams it handed out are closed, so an adb
// orphaned by our exit would hold the step open long after we failed it.
const logcat = cp.spawn("adb", ["logcat", "-b", "crash"], {
stdio: ["ignore", "pipe", "ignore"],
});

// ... and don't leave it running at all: killing it on the way out covers
// both failing on a crash and getting terminated once the tests pass.
process.on("exit", () => logcat.kill("SIGKILL"));
for (const signal of ["SIGINT", "SIGTERM"] as const) {
process.on(signal, () => process.exit(0));
}

// The line naming the app is preceded by the header of the crash it belongs
// to ("FATAL EXCEPTION: main"), so keep a few lines of lead-in around.
const LEAD_IN_LINES = 5;
const trace: string[] = [];
let crashed = false;

logcat.on("error", () => skip("failed to spawn adb logcat"));
logcat.on("close", () => {
// Getting killed once the tests pass is the expected way for this to end.
if (!crashed) {
skip("adb logcat exited");
}
});

for await (const line of readline.createInterface({ input: logcat.stdout })) {
trace.push(line);
if (crashed) {
continue;
} else if (line.includes(APP_ID)) {
crashed = true;
// Give the rest of the stack trace a moment to arrive before printing it.
setTimeout(() => {
console.error(`\n[crash-watchdog] ${APP_ID} crashed:\n`);
console.error(trace.join("\n"));
process.exit(1);
}, TRACE_GRACE_MS);
} else if (trace.length > LEAD_IN_LINES) {
trace.shift();
}
}
}

await main();
2 changes: 1 addition & 1 deletion apps/test-app/tsconfig.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"extends": "@react-native/typescript-config/tsconfig.json",
"extends": "@react-native/typescript-config",
"compilerOptions": {
"types": ["react-native", "mocha"]
},
Expand Down
2 changes: 1 addition & 1 deletion apps/test-app/tsconfig.node-scripts.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,5 +7,5 @@
"rootDir": "scripts",
"types": ["node"]
},
"include": ["scripts/**/*.ts"]
"include": ["scripts"]
}
6 changes: 3 additions & 3 deletions docs/ANDROID.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

## Building Hermes from source

Because we're using a version of Hermes patched with Node-API support, we need to build React Native from source.
Because we build Hermes from source (a pinned commit carrying its Node-API implementation), we need to build React Native from source too.

Follow [the React Native documentation on how to build from source](https://reactnative.dev/contributing/how-to-build-from-source#update-your-project-to-build-from-source).

Expand All @@ -23,7 +23,7 @@ In particular, you will have to edit the `android/settings.gradle` file as follo
> + }
> ```

To download our custom version of Hermes, you need to run from your app package:
To fetch the pinned Hermes, you need to run from your app package:

```
npx react-native-node-api vendor-hermes
Expand All @@ -39,7 +39,7 @@ export REACT_NATIVE_OVERRIDE_HERMES_DIR=$(npx react-native-node-api vendor-herme

## Cleaning your React Native build folders

If you've accidentally built your app without Hermes patched, you can clean things up by deleting the `ReactAndroid` build folder.
If you've accidentally built your app without the vendored Hermes, you can clean things up by deleting the `ReactAndroid` build folder.

```
rm -rf node_modules/react-native/ReactAndroid/build
Expand Down
16 changes: 11 additions & 5 deletions docs/HOW-IT-WORKS.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,16 +39,22 @@ The native implementation of `requireNodeAddon` is responsible for loading the d

In any case the native code stores the initialization function in a data-structure.

## `react-native-node-api` creates a `node_env` and initialize the Node-API module
## `react-native-node-api` creates a `napi_env` and initialize the Node-API module

The initialization function of a Node-API module expects a `node_env`, which we create by calling `createNodeApiEnv` on the `jsi::Runtime`.
The initialization function of a Node-API module expects a `napi_env`, which we create by calling `hermes_napi_create_env` with the low-level Hermes VM runtime behind the `jsi::Runtime`. As in Node.js, each addon gets its own environment.

## The library's C++ code initialize the `exports` object

An `exports` object is created for the Node-API module and both the `napi_env` and `exports` object is passed to the Node-API module's initialization function and the third party code is able to call the Node-API free functions:
An `exports` object is created for the Node-API module and both the `napi_env` and `exports` object is passed to the Node-API module's initialization function and the third party code is able to call the Node-API free functions.

- The engine-specific functions (see [js_native_api.h](https://github.com/nodejs/node/blob/main/src/js_native_api.h)) are implemented by the `jsi::Runtime` (currently only Hermes supports this).
- The runtime-specific functions (see [node_api.h](https://github.com/nodejs/node/blob/main/src/node_api.h)) are implemented by `react-native-node-api`.
Hermes implements both halves of Node-API: the engine-specific functions (see [js_native_api.h](https://github.com/nodejs/node/blob/main/src/js_native_api.h)) and the runtime-specific ones (see [node_api.h](https://github.com/nodejs/node/blob/main/src/node_api.h)). Node.js implements the latter on top of libuv, which React Native doesn't have — so Hermes leaves the host to supply the primitives they need, as a `hermes_napi_host` struct passed when the environment is created:

- `post_work` / `cancel_work` — run a unit of work on a worker thread and report back on the JavaScript thread. This is what backs `napi_create_async_work` and friends.
- `post_task` — schedule a callback on the JavaScript thread, used by thread-safe functions to dispatch queued calls.
- `ref_loop` / `unref_loop` — keep the event loop alive while a thread-safe function is referenced, modelling libuv's "ref" semantics.
- `fatal_exception` and, for embedders that have one, a libuv loop pointer for `napi_get_uv_event_loop`.

`react-native-node-api` provides that struct, backed by React Native's `CallInvoker` for anything that has to land on the JavaScript thread and a worker pool for the rest.

## `my-app` regain control and call `add`

Expand Down
1 change: 1 addition & 0 deletions eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export default tseslint.config(
globalIgnores([
"**/dist/**",
"**/build/**",
"**/build-tests/**",
"apps/test-app/ios/**",
"apps/macos-test-app/**",
"packages/host/hermes/**",
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@
"globals": "^16.0.0",
"prettier": "3.6.2",
"publint": "^0.3.15",
"react-native": "0.81.4",
"react-native": "0.88.0-nightly-20260809-db662caea",
"read-pkg": "^9.0.1",
"tsx": "^4.20.6",
"typescript": "^5.8.0",
Expand Down
2 changes: 1 addition & 1 deletion packages/host/android/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ target_link_libraries(node-api-host
log
ReactAndroid::reactnative
ReactAndroid::jsi
hermes-engine::libhermes
hermes-engine::hermesvm
weak-node-api
# react_codegen_NodeApiHostSpec
)
30 changes: 27 additions & 3 deletions packages/host/android/build.gradle
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import java.nio.file.Paths
import groovy.json.JsonSlurper
import javax.inject.Inject
import org.gradle.internal.os.OperatingSystem
import org.gradle.process.ExecOperations

if (!System.getenv("REACT_NATIVE_OVERRIDE_HERMES_DIR")) {
throw new GradleException([
Expand Down Expand Up @@ -48,7 +50,21 @@ def reactNativeArchitectures() {
}

apply plugin: "com.android.library"
apply plugin: "kotlin-android"

// AGP 9 ships built-in Kotlin support and enables it by default, which
// registers the `kotlin` extension itself. Applying the Kotlin plugin on top of
// that fails the consumer's build with "Cannot add extension with name
// 'kotlin'", so only apply it when built-in Kotlin isn't doing the job — either
// because the consumer is on AGP 8, or because they opted out of it while they
// migrate. See https://developer.android.com/build/migrate-to-built-in-kotlin
def useBuiltInKotlin() {
def major = com.android.Version.ANDROID_GRADLE_PLUGIN_VERSION.tokenize('.')[0].toInteger()
return major >= 9 && project.findProperty("android.builtInKotlin") != "false"
}

if (!useBuiltInKotlin()) {
apply plugin: "kotlin-android"
}

apply plugin: "com.facebook.react"

Expand Down Expand Up @@ -161,17 +177,25 @@ dependencies {
def commandLinePrefix = OperatingSystem.current().isWindows() ? ["cmd", "/c", "node"] : []
def cliPath = file("../bin/react-native-node-api.mjs")

// Gradle 9 removed Project.exec(), so the ExecOperations service has to be
// injected and used explicitly instead of the bare `exec {}` closure.
interface InjectedExecOps {
@Inject
ExecOperations getExecOps()
}
def injectedExecOps = project.objects.newInstance(InjectedExecOps)

// Custom task to fetch jniLibs paths via CLI
task linkNodeApiModules {
doLast {
exec {
injectedExecOps.execOps.exec {
commandLine commandLinePrefix + [cliPath, 'link', '--android', rootProject.rootDir.absolutePath]
standardOutput = System.out
errorOutput = System.err
// Enable color output
environment "FORCE_COLOR", "1"
}

android.sourceSets.main.jniLibs.srcDirs += file("../auto-linked/android").listFiles()
}
}
Expand Down
Loading
Loading