Skip to content

Commit bfb433d

Browse files
kraenhansenclaude
andcommitted
Adopt Hermes' first-party Node-API (static_h) (#372)
* Phase 1: vendor static_h Hermes, bump to RN 0.87 nightly Begin migrating off the kraenhansen/hermes fork + JSI-patching path toward Hermes' first-party Node-API (the static_h branch). - vendor-hermes: shallow-fetch facebook/hermes at pinned static_h SHA 0ae42446d1ae669508368b0a18e60c789f76735d; drop the JSI-header copy step - patch-hermes.rb: rely on REACT_NATIVE_OVERRIDE_HERMES_DIR alone to trigger build-from-source; drop the no-op BUILD_FROM_SOURCE var and the obsolete RCT_USE_PREBUILT_RNCORE / JSI-patch guard - CxxNodeApiHostModule: stub env=nullptr (real env arrives in Phase 2 via hermes_napi_create_env) - bump react-native to 0.87.0-nightly-20260529-88857d22f (+ test-app deps, react-native-test-app 5.x); regenerate lockfile - RN 0.87 fallout: add @types/babel__core, fix test-app tsconfig extends for the tightened @react-native/typescript-config exports map, delete the podspec test asserting the removed guard Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Resolve Xcode app project resiliently in workspaces react-native-test-app 5.x generates the app's ReactTestApp.xcodeproj under the nearest node_modules, which in a workspace is the app-local node_modules (apps/test-app/node_modules/.generated), not the hoisted root. The workspace can also accumulate stale references to a project under a different node_modules. findXcodeProject took the first fileRef unconditionally, which could be the stale (non-existent) reference or the Pods project. Resolve every app project reference and pick the first whose project.pbxproj exists on disk, ignoring Pods.xcodeproj. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Fix test-app tooling for RN 0.87 / Metro 0.84 - Bump @rnx-kit/metro-config to ^2.2.4: 2.1.1 called metro-config's exclusionList as a bare function, but Metro 0.84 changed that module to a { default } export, breaking `react-native start`. - Gradle wrapper bumped to 9.3.1 by react-native-test-app 5.x's configureGradleWrapper during pod install (RN 0.87 alignment). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Phase 2: create a real Node-API env via hermes_napi_create_env Replace the `env = nullptr` stub in CxxNodeApiHostModule with a real Node-API environment: cast the JSI runtime to `IHermes`, read the underlying `vm::Runtime*` via `getVMRuntimeUnsafe()`, and create the env with `hermes_napi_create_env(vm, nullptr)`. The env is owned by the runtime and cached on the module (shared across all addons). This flips the Phase 1 baseline abort (`assert(status == napi_ok)` right after `napi_create_object(env=nullptr, …)`) green: with `MOCHA_REMOTE_CONTEXT=allTests` the iOS-sim suite now reports 14 passing (node-addon-examples getting-started incl. the Rust ferric addon, buffers, async, and a js-native-api node-test). Linking note: the RN `hermesvm` framework force-loads `hermesNapi`, and the public `hermes_napi_*` entry points are exported from it as long as Hermes is built from a checkout that includes facebook/hermes #2044 ("Export public hermes_napi entry points with NAPI macros") — which the pinned SHA (0ae42446) already contains. No pod-side linker surgery or source patching is required; just ensure the vendored checkout is actually at the pinned SHA (a stale pre-#2044 checkout is what stripped the symbol during bring-up). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Phase 2: bump Node-API to v10, drop engine/runtime split All Node-API symbols are now sourced from Hermes' hermesNapi, so the old engine (js_native_api → libhermes.so) / runtime (node_api → libnode-api-host.so) distinction and the hand-maintained IMPLEMENTED_RUNTIME_FUNCTIONS allow-list are obsolete. - weak-node-api: getNodeApiFunctions defaults to v10 and no longer computes the dead `kind`/`libraryPath` fields; CMake compiles the generated weak_node_api.cpp at NAPI_VERSION=10 (145 → 155 symbols, adding the v9/v10 node_api_* surface). - generate-injector.mts: bind every symbol (no filter) and emit `#include <Versions.hpp>` first so the injector TU also compiles at v10. - Versions.hpp: guarded bump to NAPI_VERSION 10. Regenerated (gitignored) WeakNodeApiInjector.cpp + weak-node-api/generated now expose all 155 symbols incl. TSFN and napi_make_callback. Verified: build, prettier, lint, workspace unit tests, and the weak-node-api native build + ctest all pass. iOS e2e pending (rides the cold re-vendor). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * vendor-hermes: export public hermes_napi_* entry points The clean Hermes build at the pinned SHA does NOT export hermes_napi_create_env (and the other hermes_napi_* entry points). They are declared in API/napi/hermes_napi.h with NAPI_EXTERN (visibility "default") but — unlike the sibling js_native_api.h / node_api.h headers — without any extern "C" wrapping, so they get C++ linkage. The mangled C++ symbols stay out of the framework's dynamic export table under Hermes' global -fvisibility=hidden, and a from-scratch build fails at the app link with "Undefined symbol: hermes_napi_create_env". vendor-hermes now wraps the hermes_napi.h declarations in EXTERN_C_START / EXTERN_C_END (both available via the node_api.h include), giving the entry points C linkage so they export under their unmangled C names. This mirrors the upstream fix in facebook/hermes#2106. The patch is idempotent (guarded on EXTERN_C_START) and asserts its anchors exist so a future Hermes bump fails loudly rather than silently no-op'ing. Also ignore **/build-tests/** in ESLint (CMake writes compiler_depend.ts dependency files there that aren't real TypeScript). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * vendor-hermes: apply prettier formatting Collapse the single-argument `.replace()` call in patchHermesNapiVisibility onto one line to satisfy prettier:check (fixup for the hermes_napi patch). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Regenerate pnpm-lock.yaml for RN 0.87 dependency bumps Rebased onto main after the npm->pnpm migration (#381). The original PR's two package-lock.json maintenance commits (restore public registry URLs, restore pruned optional platform binaries) are dropped: both addressed npm-specific lockfile problems that no longer exist under pnpm. Regenerate pnpm-lock.yaml against the RN 0.87 nightly / react-native-test-app 5.x / @rnx-kit/metro-config bumps so the lockfile matches the workspace manifests. Verified with pnpm install --frozen-lockfile. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TVfanKvtyfSsoMgZv3DJtY * vendor-hermes: advance pin to include upstream napi C-linkage fix Move the pinned Hermes commit forward from 0ae42446 to efcf68e2 on the static_h branch (a descendant, 18 commits ahead). The only relevant change in that range is facebook/hermes#2106 "give hermes_napi.h public API C linkage", which wraps the public hermes_napi_* entry points in extern "C". That is exactly the fix we were applying locally after cloning: without C linkage the mangled hermes_napi_create_env symbol stayed out of the framework export table under Hermes' global -fvisibility=hidden. Now that the fix is upstream at the pinned commit, drop patchHermesNapiVisibility and its header-anchor constants entirely — the vendored checkout exports the entry points as-is. No commit in the bumped range touches getVMRuntimeUnsafe or the IHermes JSI interface we depend on, so the unstable-accessor rationale for pinning still holds. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TVfanKvtyfSsoMgZv3DJtY * host: match hermes_napi_create_env C linkage after upstream #2106 The pinned Hermes commit now includes facebook/hermes#2106, which wraps the public hermes_napi_* entry points in extern "C". Hermes therefore exports the unmangled C symbol for hermes_napi_create_env. CxxNodeApiHostModule forward-declares that entry point (to avoid including Hermes' node_api.h) but did so with C++ linkage, so it referenced the mangled name. After the pin bump the two no longer matched and the iOS app failed to link with "Undefined symbol: hermes_napi_create_env". Wrap the forward declaration in extern "C" so the reference resolves to the exported unmangled symbol. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TVfanKvtyfSsoMgZv3DJtY * android: inject ExecOperations for Gradle 9 compatibility (#386) RN 0.87 bumps the Gradle wrapper to 9.x, which removed Project.exec(). The linkNodeApiModules task used the bare `exec {}` closure in its doLast action, failing every Android build (and gradle.test.ts on all platforms) with "Could not find method exec()". Inject the ExecOperations service via an @Inject-annotated interface and call injectedExecOps.execOps.exec {} instead. Greens the ubuntu and macOS unit-test lanes. Windows surfaces a separate, pre-existing RN 0.87 / Gradle 9 issue (missing react-native/tmp projectDir) tracked separately. * android: patch RN settings.gradle.kts /tmp projectDir for Windows (#387) * android: patch RN settings.gradle.kts /tmp projectDir for Windows The Windows unit-test lane failed configuring the React Native build-from- source composite build: Configuring project ':packages:react-native' without an existing directory is not allowed. The configured projectDirectory '...\react-native\tmp' does not exist React Native's own settings.gradle.kts declares the intermediate container projects :packages and :packages:react-native with projectDir = file("/tmp"), purely to satisfy Gradle 9's rule that every project in a path have an existing folder. "/tmp" exists on the posix CI hosts but on Windows it is not an absolute path, so Gradle resolves it to a non-existent <react-native>\tmp and the build fails before any task runs. This is why only windows-latest was red while ubuntu and macOS passed. Add a pnpm patch replacing file("/tmp") with file(System.getProperty("java.io.tmpdir", "/tmp")): the JVM temp dir is "/tmp" on posix and %TEMP% on Windows, both of which always exist. Remove the patch once React Native stops hardcoding "/tmp" upstream. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TVfanKvtyfSsoMgZv3DJtY * android: point RN /tmp patch at the merged upstream fix The upstream fix landed on react-native main as 908872a6 (2026-07-28, react/react-native#57706), after the 0.87 branch cut — so 0.87-stable does not carry it. Record that in the patch comment so the removal gate is a concrete react-native version rather than "once upstream fixes it". Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude <noreply@anthropic.com> * host: apply the Kotlin plugin only when built-in Kotlin is unavailable AGP 9 ships built-in Kotlin support and enables it by default, which registers the `kotlin` extension itself. Applying `kotlin-android` on top of that fails the consumer's build with "Cannot add extension with name 'kotlin'", so any consumer who has migrated off the `builtInKotlin=false` opt-out currently cannot build against this package. Gate the plugin on the AGP major version and the consumer's opt-out, so the library works both for consumers still on AGP 8 (or opted out while they migrate) and for those already on built-in Kotlin. React Native's own ReactAndroid no longer applies the Kotlin plugin either, as of 0.87. Reuses the `com.android.Version` idiom already used by supportsNamespace(). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * deps: bump react-native to 0.87.0-rc.4 Moves off the 0.87.0-nightly-20260529 pin onto the 0.87 release candidate. The motivating change is AGP: the nightly still resolved AGP 8.12, while AGP 9.2.1 landed on the 0.87 line in mid-June. AGP 9 is what react-native-test-app assumes for React Native >= 0.87 (it forces Gradle 9.4.1 and then uses the built-in Kotlin `kotlin {}` extension), so the test app could not configure against the old pin. The Windows `/tmp` projectDir patch is unchanged — settings.gradle.kts is byte-identical between the two versions (same blob 2036e0f), so only the file name and the patchedDependencies key move. The fix for it is still main-only, so the patch stays until we are on 0.88+. Also switches the two React Native facing tsconfigs to nodenext module resolution. 0.87.0-rc.4 drops react-native's top-level `types` field and flips the default `types` export condition to the generated strict API, neither of which the node10 resolution inherited from @tsconfig/react-native can see — the package stopped resolving entirely (TS2688). @tsconfig/react-native is stale at every published version through 3.0.9, so there is nothing to bump there. Emit is unaffected: both projects still produce CommonJS. The strict API exports TurboModule and TurboModuleRegistry, and still references react-native's globals, so console/require stay typed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * test-app: adopt built-in Kotlin on Android, opt out of the AGP 9 DSL With React Native 0.87 the test app builds against AGP 9.2.1, where built-in Kotlin is enabled by default. Nothing in the build needs the Kotlin plugin any more: ReactAndroid dropped it upstream, react-native-test-app's modules are gated on it, and react-native-node-api now only applies it when built-in Kotlin is unavailable. So unlike the React Native app template, we do not set `android.builtInKotlin=false`. The new DSL is a different matter and stays opted out: both of react-native-test-app's Gradle modules still use the old one, and that is third-party code. AGP 10 removes this opt out, so it is tracked in #389 along with the upstream code that has to migrate first. Also pins the Gradle wrapper at 9.4.1, which react-native-test-app rewrites it to at run time for React Native >= 0.87 — pinning it ourselves keeps CI from building with a dirty working tree. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * deps: bump react-native to a 0.88 nightly and drop the Windows patch React Native 57706 ("Fix build-from-source on Windows: use JVM temp dir instead of hardcoded /tmp", 908872a6, 2026-07-28) landed on main after the 0.87 branch cut, so it ships on the 0.88 line and not in 0.87.0-rc.4. Verified in the published artifact, not just the tree: the tarball for 0.88.0-nightly-20260809-db662caea carries the fix in settings.gradle.kts, the exact file (and path) we were patching. Our patch is now redundant. Dropping it is what makes Android build. Patching a dependency makes pnpm encode the patch hash into the virtual store directory as `..._patch_hash=<hash>`, and prefab — which the Android Gradle plugin runs over react-native's package directory — parses a positional path containing `=` as an option name and dies with "Error: no such option". That is google/prefab#187, open since March and hitting every pnpm user with a patched dependency. With no patched dependencies there is no `=` in the store, so the bug goes untriggered. Requires react-native-test-app >= 5.4.8, which widened its peer range to `0.76 - 0.87 || >=0.88.0-0 <0.88.0` — a prerelease window covering exactly these nightlies. 5.4.5 did not accept 0.88 at all, so the floor moves up. Everything the AGP 9 work depends on is unchanged on this line: AGP 9.2.1, Kotlin 2.2.0, and react-native-test-app still resolves Gradle 9.4.1 for 0.88, matching the pinned wrapper. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * host: link the renamed hermesvm prefab module on Android React Native renamed the prefab module published by `hermes-engine` from `libhermes` to `hermesvm` between 0.81 and 0.83 — the Android counterpart of the `hermesvm` framework this branch already links against on Apple platforms. This CMakeLists has been on `libhermes` since #308, which was correct while the repo targeted 0.81, and stayed behind when this branch jumped to 0.87/0.88. Without it CMake fails to configure: Target "node-api-host" links to target "hermes-engine::libhermes" but the target was not found. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * test-app: opt out of built-in Kotlin after all fa4424b deliberately left `android.builtInKotlin` unset, on the reasoning that nothing in the build still needs the Kotlin plugin. That reasoning was wrong, and only a real Android build showed it: ComponentActivity.kt:33:9 Unresolved reference 'ComponentActivityDelegate' react-native-test-app's app module pulls in version-specific sources with `main.java.srcDirs += [...]` — src/reactactivitydelegate-0.75/java, src/reactapplication-0.76/java, src/camera/java and others. The Kotlin plugin compiles the Kotlin in those directories; AGP's built-in Kotlin only picks up the standard source directories, so every symbol defined in an added one goes unresolved (`testApp`, `reactHost`, `canUseCamera`, `ComponentBottomSheetDialogFragment`, …). Their `useBuiltInKotlin` gate avoids the plugin-conflict failure but does not make the module itself built-in-Kotlin ready, which is why their template ships this opt out. react-native-node-api itself stays built-in-Kotlin ready via the conditional in ee41927 — with this flag set it applies the Kotlin plugin, and for a consumer on built-in Kotlin it steps aside. This is only about the test harness. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * test-app: fail the Android 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 test app crashed on startup, nothing ever connected: the run sat idle until the 75 minute step timeout, with the actual cause — a `FATAL EXCEPTION` one second after `am start` — only visible by downloading the logcat artifact afterwards. Add a watchdog that follows `adb logcat -b crash` alongside the app and exits non-zero when the crash buffer names the test app, printing the stack trace inline. `concurrently --kill-others-on-fail` then tears down Metro and the app run, and `mocha-remote` inherits the failing exit code, so a startup crash fails the job in seconds rather than in an hour. It deliberately only reacts to crashes — an app that hangs or never launches still falls back to the job timeout. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * test-app: don't let the crash watchdog hold the step's stderr open The watchdog correctly failed the run on the first crash it saw, but the job kept hanging afterwards: `@actions/exec` — how the emulator-runner action runs each line of the step's script — resolves a command only once the stdio streams it handed out are closed, and the `adb logcat` child inherited our stderr. Exiting orphaned it, so that pipe stayed open and the step waited on a dangling file descriptor long after everything else had been torn down. Give the child no stderr of its own and kill it on the way out. Verified by spawning the watchdog the way `@actions/exec` does: before, the process exited after 1.6s but its stdio never closed; now both happen together. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * vendor-hermes: advance the pin past Hermes' JSI_UNSTABLE default flip The Android test app crashed on startup, in `NodeApiHostPackage.<init>`: java.lang.UnsatisfiedLinkError: dlopen failed: cannot locate symbol "_ZTIN8facebook3jsi10SerializedE" referenced by ".../libhermesvm.so" com.facebook.soloader.SoLoaderDSONotFoundError: couldn't find DSO to load: libhermesvm.so That symbol is `typeinfo for facebook::jsi::Serialized`. JSI's `Serialized` / `ISerialization` APIs sit behind `#ifdef JSI_UNSTABLE`, and React Native never defines it when building the `libjsi.so` it ships in the ReactAndroid AAR. Our pinned Hermes still defaulted `JSI_UNSTABLE` to ON, so `hermesvm` compiled those APIs in and referenced symbols that nothing in the APK defines. Apple builds are unaffected because JSI is compiled into the `hermesvm` framework itself; on Android the two are separate shared libraries, and RN's hermes-engine build imports `libjsi.so` rather than packaging the copy Hermes builds for itself. facebook/hermes 5a795c9f8 ("Fix: JSI_UNSTABLE CMake flag should be OFF by default") is the immediate child of the previous pin, so this picks up the one-line fix and nothing else. Verified by rebuilding the release APK for x86_64: `libhermesvm.so` no longer references `jsi::Serialized`, and every undefined JSI symbol it does have is defined by a library shipped in the APK. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * host: create one Node-API env per addon Node creates a fresh napi_env for every addon it loads (see the "Create a new napi_env for this specific module" branch of napi_module_register_by_symbol in src/node_api.cc), because the env holds addon-scoped state: instance data, last error info and the addon's Node-API version. Sharing one env across all addons breaks that isolation most visibly for instance data, where the single slot on napi_env__ means two addons built on Napi::Addon<T> clobber each other — the second registration finalizes the first addon's object, and Addon::Unwrap then casts the wrong type. Move the env onto the addon record and create it during initialization. hermes_napi_create_env() allocates a fresh env per call and registers its teardown with the vm::Runtime, so ownership is unchanged: each env is torn down with the runtime. The call invoker registry is already keyed by env, so it needs no change beyond dropping entries when an env goes away — with an env per addon those would otherwise accumulate across reloads. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Add changeset for the static_h Node-API adoption Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * docs: describe the vendored Hermes instead of a patched one Node-API is implemented in Hermes itself now, so nothing is patched or forked: we build from a pinned commit on the static_h branch. Also corrects HOW-IT-WORKS, which described the removed jsi::Runtime::createNodeApiEnv. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * docs: describe the Node-API host struct in HOW-IT-WORKS Hermes implements both js_native_api.h and node_api.h; what it can't supply without libuv are the scheduling primitives, which the host passes in as a hermes_napi_host struct. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 5e10189 commit bfb433d

31 files changed

Lines changed: 708 additions & 1062 deletions

.changeset/eighty-moons-shave.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
"react-native-node-api": major
3+
"weak-node-api": minor
4+
---
5+
6+
Adopt Hermes' first-party Node-API (the `hermesNapi` target on the `static_h`
7+
branch) instead of patching Hermes with our own implementation. Addons now run
8+
against a real Node-API environment created with `hermes_napi_create_env()`, one
9+
per addon as in Node, and Node-API is bumped from v8 to v10.
10+
11+
This drops support for React Native 0.79–0.81: the vendored Hermes is built from
12+
a pinned `static_h` commit and requires the Hermes build scripts shipped with
13+
React Native 0.87 and later. Older React Native versions are still served by
14+
previously published releases.

AGENTS.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ patch or workaround:
6060

6161
## Critical Build Dependencies
6262

63-
- **Custom Hermes**: Currently depends on a patched Hermes with Node-API support (see [facebook/hermes#1377](https://github.com/facebook/hermes/pull/1377))
63+
- **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.
6464
- **Prebuilt Binary Spec**: All tools must output to the exact naming scheme:
6565
- Android: `*.android.node/` with jniLibs structure + `react-native-node-api-module` marker file
6666
- iOS: `*.apple.node` (XCFramework renamed) + marker file

README.md

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,9 @@
1919
## How does this work?
2020

2121
> [!IMPORTANT]
22-
> This library is currently dependent on a custom version of Hermes and therefore has a very limited range of supported React Native versions.
23-
> Once the [PR adding Node-API support to Hermes](https://github.com/facebook/hermes/pull/1377) merges, we expect this restriction to be lifted.
22+
> 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).
23+
> 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.
24+
> We expect this restriction to be lifted once React Native ships a Hermes with Node-API included.
2425
2526
> [!NOTE]
2627
> 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.
@@ -35,10 +36,9 @@ This mono-repository hosts the development of a few packages:
3536

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

38-
- Declares a Podspec which downloads a special version of Hermes, with Node-API support,
39-
- instructing React Native's Hermes Podspecs to compile from this custom source-code.
40-
- patching React Native's JSI copy, with the updates introduced by our special version of Hermes.
41-
- we expect this to eventually be removed, as Node-API support gets merged into Hermes upstream.
39+
- Declares a Podspec which vendors Hermes from a pinned commit on its `static_h` branch, where Node-API is implemented,
40+
- instructing React Native's Hermes Podspecs to compile from this checkout.
41+
- we expect this to eventually be removed, as React Native starts shipping a Hermes with Node-API included.
4242
- Automatically discovers and adds Node-API binaries, matching the [the prebuilt binary specification](./docs/PREBUILDS.md)
4343
- This is driven by the platform specific build tools (through the Podspec on iOS and eventually Gradle on Android)
4444
- Implements a TurboModule with a `requireNodeAddon` function responsible for

apps/test-app/android/gradle.properties

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,4 +50,22 @@ react.buildFromSource=true
5050
#ANDROID_NDK_VERSION=26.1.10909125
5151

5252
# Version of Kotlin to build against.
53-
#KOTLIN_VERSION=1.8.22
53+
#KOTLIN_VERSION=1.8.22
54+
55+
# Opt out of built-in Kotlin and the new DSL, both of which ship enabled in
56+
# AGP 9. AGP 10 removes both opt outs, so they are on borrowed time — tracked in
57+
# https://github.com/callstackincubator/react-native-node-api/issues/389, which
58+
# links the upstream code that has to migrate first.
59+
#
60+
# Both are blocked on react-native-test-app, not on us. Its Gradle modules still
61+
# use the old DSL (`compileSdkVersion`, `lintOptions`), and its app module adds
62+
# version-specific Kotlin sources through `main.java.srcDirs +=`. The Kotlin
63+
# plugin compiles those; AGP's built-in Kotlin only picks up the standard source
64+
# directories, so building without this leaves every symbol defined in an added
65+
# directory (`testApp`, `ComponentActivityDelegate`, …) unresolved.
66+
#
67+
# Note that react-native-node-api itself is built-in-Kotlin ready — it applies
68+
# the Kotlin plugin only when built-in Kotlin is unavailable — so this is purely
69+
# about the test harness.
70+
android.builtInKotlin=false
71+
android.newDsl=false

apps/test-app/android/gradle/wrapper/gradle-wrapper.properties

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
distributionBase=GRADLE_USER_HOME
22
distributionPath=wrapper/dists
3-
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip
3+
distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.1-bin.zip
44
networkTimeout=10000
55
validateDistributionUrl=true
66
zipStoreBase=GRADLE_USER_HOME

apps/test-app/package.json

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,11 @@
66
"scripts": {
77
"metro": "react-native start --no-interactive",
88
"android": "react-native run-android --no-packager --active-arch-only",
9+
"android:crash-watchdog": "node scripts/android-crash-watchdog.mts",
910
"ios": "react-native run-ios --no-packager",
1011
"pod-install": "cd ios && pod install",
1112
"mocha-and-metro": "mocha-remote --watch -- react-native start",
12-
"test:android": "mocha-remote --exit-on-error -- concurrently --kill-others-on-fail --passthrough-arguments npm:metro 'npm:android -- {@}' --",
13+
"test:android": "mocha-remote --exit-on-error -- concurrently --kill-others-on-fail --passthrough-arguments npm:metro 'npm:android -- {@}' npm:android:crash-watchdog --",
1314
"test:android:allTests": "MOCHA_REMOTE_CONTEXT=allTests node --run test:android -- ",
1415
"test:android:nodeAddonExamples": "MOCHA_REMOTE_CONTEXT=nodeAddonExamples node --run test:android -- ",
1516
"test:android:nodeTests": "MOCHA_REMOTE_CONTEXT=nodeTests node --run test:android -- ",
@@ -30,20 +31,20 @@
3031
"@react-native-node-api/ferric-example": "workspace:*",
3132
"@react-native-node-api/node-addon-examples": "workspace:*",
3233
"@react-native-node-api/node-tests": "workspace:*",
33-
"@react-native/babel-preset": "0.81.4",
34-
"@react-native/metro-config": "0.81.4",
35-
"@react-native/typescript-config": "0.81.4",
36-
"@rnx-kit/metro-config": "^2.1.1",
34+
"@react-native/babel-preset": "0.88.0-nightly-20260809-db662caea",
35+
"@react-native/metro-config": "0.88.0-nightly-20260809-db662caea",
36+
"@react-native/typescript-config": "0.88.0-nightly-20260809-db662caea",
37+
"@rnx-kit/metro-config": "^2.2.4",
3738
"@types/mocha": "^10.0.10",
3839
"@types/react": "^19.1.0",
3940
"concurrently": "^9.1.2",
4041
"mocha": "^11.6.0",
4142
"mocha-remote-cli": "^1.13.2",
4243
"mocha-remote-react-native": "^1.13.2",
43-
"react": "19.1.0",
44-
"react-native": "0.81.4",
44+
"react": "19.2.3",
45+
"react-native": "0.88.0-nightly-20260809-db662caea",
4546
"react-native-node-api": "workspace:*",
46-
"react-native-test-app": "^4.4.7",
47+
"react-native-test-app": "^5.4.8",
4748
"weak-node-api": "workspace:*"
4849
}
4950
}
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
/**
2+
* Fails the Android test run as soon as the app crashes.
3+
*
4+
* `mocha-remote` waits indefinitely for a client to connect and has no notion
5+
* of the app dying: when the app crashes on startup, nothing ever connects and
6+
* the run hangs until the CI job hits its timeout — 68 minutes of an emulator
7+
* idling for a crash that happened one second after `am start`.
8+
*
9+
* Run alongside the app (through `concurrently --kill-others-on-fail`), this
10+
* turns such a crash into an immediate failure with the stack trace inlined in
11+
* the log, instead of a timeout with the cause buried in a logcat artifact.
12+
*
13+
* It only reacts to crashes: a hung or never-launched app still relies on the
14+
* job timeout.
15+
*/
16+
import cp from "node:child_process";
17+
import readline from "node:readline";
18+
19+
// The application id used by react-native-test-app, which the CI workflow also
20+
// hardcodes when uninstalling any leftover copy of the app.
21+
const APP_ID = "com.microsoft.reacttestapp";
22+
23+
// How long to keep reading after the first line mentioning the app, to capture
24+
// the rest of the stack trace before exiting.
25+
const TRACE_GRACE_MS = 1000;
26+
27+
/**
28+
* Runs adb, resolving false if it couldn't run at all (not installed, no
29+
* device, etc). The watchdog stays out of the way in that case: the build or
30+
* the run itself will fail with a better message than anything we could add.
31+
*/
32+
function adb(...args: string[]): Promise<boolean> {
33+
return new Promise((resolve) => {
34+
const child = cp.spawn("adb", args, { stdio: "ignore" });
35+
child.on("error", () => resolve(false));
36+
child.on("close", (code) => resolve(code === 0));
37+
});
38+
}
39+
40+
function skip(reason: string): never {
41+
console.warn(`[crash-watchdog] Not watching for crashes: ${reason}`);
42+
process.exit(0);
43+
}
44+
45+
async function main() {
46+
if (!(await adb("wait-for-device"))) {
47+
skip("failed to wait for an adb device");
48+
}
49+
50+
// Drop any crash from an earlier run, so we only react to this one. The app
51+
// hasn't been installed yet at this point, so this can't discard a crash we
52+
// care about.
53+
await adb("logcat", "-b", "crash", "-c");
54+
55+
// Never let this child inherit our stderr: GitHub's `@actions/exec` resolves
56+
// a step only once the stdio streams it handed out are closed, so an adb
57+
// orphaned by our exit would hold the step open long after we failed it.
58+
const logcat = cp.spawn("adb", ["logcat", "-b", "crash"], {
59+
stdio: ["ignore", "pipe", "ignore"],
60+
});
61+
62+
// ... and don't leave it running at all: killing it on the way out covers
63+
// both failing on a crash and getting terminated once the tests pass.
64+
process.on("exit", () => logcat.kill("SIGKILL"));
65+
for (const signal of ["SIGINT", "SIGTERM"] as const) {
66+
process.on(signal, () => process.exit(0));
67+
}
68+
69+
// The line naming the app is preceded by the header of the crash it belongs
70+
// to ("FATAL EXCEPTION: main"), so keep a few lines of lead-in around.
71+
const LEAD_IN_LINES = 5;
72+
const trace: string[] = [];
73+
let crashed = false;
74+
75+
logcat.on("error", () => skip("failed to spawn adb logcat"));
76+
logcat.on("close", () => {
77+
// Getting killed once the tests pass is the expected way for this to end.
78+
if (!crashed) {
79+
skip("adb logcat exited");
80+
}
81+
});
82+
83+
for await (const line of readline.createInterface({ input: logcat.stdout })) {
84+
trace.push(line);
85+
if (crashed) {
86+
continue;
87+
} else if (line.includes(APP_ID)) {
88+
crashed = true;
89+
// Give the rest of the stack trace a moment to arrive before printing it.
90+
setTimeout(() => {
91+
console.error(`\n[crash-watchdog] ${APP_ID} crashed:\n`);
92+
console.error(trace.join("\n"));
93+
process.exit(1);
94+
}, TRACE_GRACE_MS);
95+
} else if (trace.length > LEAD_IN_LINES) {
96+
trace.shift();
97+
}
98+
}
99+
}
100+
101+
await main();

apps/test-app/tsconfig.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
{
2-
"extends": "@react-native/typescript-config/tsconfig.json",
2+
"extends": "@react-native/typescript-config",
33
"compilerOptions": {
44
"types": ["react-native", "mocha"]
55
},

apps/test-app/tsconfig.node-scripts.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,5 +7,5 @@
77
"rootDir": "scripts",
88
"types": ["node"]
99
},
10-
"include": ["scripts/**/*.ts"]
10+
"include": ["scripts"]
1111
}

docs/ANDROID.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
## Building Hermes from source
44

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

77
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).
88

@@ -23,7 +23,7 @@ In particular, you will have to edit the `android/settings.gradle` file as follo
2323
> + }
2424
> ```
2525
26-
To download our custom version of Hermes, you need to run from your app package:
26+
To fetch the pinned Hermes, you need to run from your app package:
2727
2828
```
2929
npx react-native-node-api vendor-hermes
@@ -39,7 +39,7 @@ export REACT_NATIVE_OVERRIDE_HERMES_DIR=$(npx react-native-node-api vendor-herme
3939
4040
## Cleaning your React Native build folders
4141
42-
If you've accidentally built your app without Hermes patched, you can clean things up by deleting the `ReactAndroid` build folder.
42+
If you've accidentally built your app without the vendored Hermes, you can clean things up by deleting the `ReactAndroid` build folder.
4343
4444
```
4545
rm -rf node_modules/react-native/ReactAndroid/build

0 commit comments

Comments
 (0)