Skip to content

chore: migrate from npm workspaces to pnpm - #381

Merged
kraenhansen merged 10 commits into
mainfrom
claude/migrate-pnpm-workspaces-qp0m9t
Jul 23, 2026
Merged

chore: migrate from npm workspaces to pnpm#381
kraenhansen merged 10 commits into
mainfrom
claude/migrate-pnpm-workspaces-qp0m9t

Conversation

@kraenhansen

Copy link
Copy Markdown
Collaborator

Motivation: pnpm's recursive runner (pnpm -r run) is fail-fast by default
and runs in topological (dependency-graph) order, so the root bootstrap
and prerelease no longer need the non-fail-fast npm run <s> --workspaces
pattern that buried the real root cause under cascading failures.

Workspace + package manager:

  • Replace the root workspaces array with pnpm-workspace.yaml
  • Add packageManager: pnpm@10.33.0 and switch devEngines to pnpm ^10
  • Allow only esbuild's build script via onlyBuiltDependencies (pnpm 10 blocks
    dependency lifecycle scripts by default); no shamefully-hoist needed
  • Replace package-lock.json with pnpm-lock.yaml; keep node_modules isolated

Internal deps -> workspace:* protocol (cmake-rn, ferric, gyp-to-cmake, host,
node-addon-examples, node-tests, ferric-example, test-app). Add explicit
weak-node-api edges to node-addon-examples and node-tests so the topological
bootstrap sequences weak-node-api (which builds the xcframework/.so they link)
before its consumers.

Phantom dependencies surfaced by pnpm's isolated node_modules (npm hoisting
had masked these):

  • host: add @types/babel__core (used by src/node/babel-plugin/plugin.ts)
  • host: add weak-node-api as a devDependency (used by
    scripts/generate-injector.mts; previously only a peerDependency)

Scripts:

  • bootstrap: tsc --build && pnpm -r run bootstrap (fail-fast, topological)
  • prerelease/release: make the build explicit instead of relying on npm's
    implicit prerelease hook (pnpm disables pre/post scripts by default)
  • test: pnpm --filter ... run test
  • depcheck/run-in-published: replace npm query .workspace with pnpm ls -r
  • Pin prettier to 3.6.2: 3.7+ is incompatible with @prettier/plugin-oxc@0.0.4
    (regenerating any lockfile floated it to 3.9.5 and crashed the plugin)

CI: port check.yml and release.yml to pnpm (pnpm/action-setup, cache: pnpm,
pnpm install --frozen-lockfile, --filter, pnpm exec). The ephemeral,
non-workspace macOS test app keeps its own npm install in
scripts/init-macos-test-app.ts.

Co-Authored-By: Claude Opus 4.8 noreply@anthropic.com
Claude-Session: https://claude.ai/code/session_01DnwodAoNbqPec77191HVXn

@kraenhansen kraenhansen self-assigned this Jul 19, 2026
claude added 4 commits July 19, 2026 19:42
Motivation: pnpm's recursive runner (`pnpm -r run`) is fail-fast by default
and runs in topological (dependency-graph) order, so the root `bootstrap`
and `prerelease` no longer need the non-fail-fast `npm run <s> --workspaces`
pattern that buried the real root cause under cascading failures.

Workspace + package manager:
- Replace the root `workspaces` array with pnpm-workspace.yaml
- Add `packageManager: pnpm@10.33.0` and switch devEngines to pnpm ^10
- Allow only esbuild's build script via onlyBuiltDependencies (pnpm 10 blocks
  dependency lifecycle scripts by default); no shamefully-hoist needed
- Replace package-lock.json with pnpm-lock.yaml; keep node_modules isolated

Internal deps -> workspace:* protocol (cmake-rn, ferric, gyp-to-cmake, host,
node-addon-examples, node-tests, ferric-example, test-app). Add explicit
`weak-node-api` edges to node-addon-examples and node-tests so the topological
bootstrap sequences weak-node-api (which builds the xcframework/.so they link)
before its consumers.

Phantom dependencies surfaced by pnpm's isolated node_modules (npm hoisting
had masked these):
- host: add `@types/babel__core` (used by src/node/babel-plugin/plugin.ts)
- host: add `weak-node-api` as a devDependency (used by
  scripts/generate-injector.mts; previously only a peerDependency)

Scripts:
- bootstrap: `tsc --build && pnpm -r run bootstrap` (fail-fast, topological)
- prerelease/release: make the build explicit instead of relying on npm's
  implicit prerelease hook (pnpm disables pre/post scripts by default)
- test: `pnpm --filter ... run test`
- depcheck/run-in-published: replace `npm query .workspace` with `pnpm ls -r`
- Pin prettier to 3.6.2: 3.7+ is incompatible with @prettier/plugin-oxc@0.0.4
  (regenerating any lockfile floated it to 3.9.5 and crashed the plugin)

CI: port check.yml and release.yml to pnpm (pnpm/action-setup, cache: pnpm,
`pnpm install --frozen-lockfile`, `--filter`, `pnpm exec`). The ephemeral,
non-workspace macOS test app keeps its own `npm install` in
scripts/init-macos-test-app.ts.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DnwodAoNbqPec77191HVXn
pnpm records GitHub git dependencies (node-addon-examples) with an SSH repo
URL (git@github.com:...). Stock CI runners have no SSH key, so the clone
fails. Add an ad-hoc git config via workflow-level env
(GIT_CONFIG_COUNT/KEY_0/VALUE_0) that rewrites git@github.com: to
https://github.com/, so the public repo is fetched anonymously over HTTPS in
every job without a per-job step.

Also drop the explicit `--frozen-lockfile` from `pnpm install`: pnpm enables
it by default when the CI environment variable is set, so it was redundant.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DnwodAoNbqPec77191HVXn
weak-node-api's `prebuild:build` script invokes the `cmake-rn` CLI, but the
package never declared cmake-rn. Under npm's hoisting every workspace bin was
linked into the root node_modules/.bin, so `cmake-rn` was always on PATH.
pnpm only links a package's *declared* dependencies' bins, so on CI the
bootstrap failed with `cmake-rn: not found` (a phantom bin dependency that
only surfaces when the native prebuild runs).

Declare `cmake-rn` as a devDependency (workspace:*) so pnpm links its bin into
weak-node-api/node_modules/.bin. This introduces a benign dev-time cycle
(cmake-rn imports weak-node-api's JS for prebuild paths; weak-node-api's build
uses the cmake-rn CLI) which pnpm reports as a warning and handles fine; the
topological bootstrap still sequences weak-node-api before its consumers.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DnwodAoNbqPec77191HVXn
Reconcile the pnpm-migrated check.yml with two changes that landed on main
while this branch was in review:

- #382 fixed the CMake 4.2 framework-HEADERS root cause in
  weak-node-api/CMakeLists.txt (included via the rebase) and removed the
  now-redundant "Install compatible CMake version" pin from all five macOS
  jobs. Drop those steps here too; CMAKE_VERSION is retained since
  test-android still uses it to select the Android SDK cmake package.
- #380 gated test-android to labeled PRs only (the ubuntu-self-hosted runner
  is offline and otherwise leaves the job queued forever on main).

With this, check.yml differs from main purely by the pnpm conversion.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DnwodAoNbqPec77191HVXn
@kraenhansen
kraenhansen force-pushed the claude/migrate-pnpm-workspaces-qp0m9t branch from 92ff669 to 61a5437 Compare July 19, 2026 19:45
…filter

Two unit-test failures surfaced on CI that are artifacts of the migration, not
real behavioural changes:

1. host apple.test.ts (macOS) — `@expo/plist` floated from 0.4.7 (held by
   main's package-lock) to 0.4.9 when the lockfile was regenerated. 0.4.9's
   `parse()` returns a null-prototype object, so the test's strict
   `deepEqual` against a plain object literal fails on the prototype. Pin
   `@expo/plist` to 0.4.7 to match main's resolved version (same class of fix
   as the prettier 3.6.2 pin). The null prototype only affects the test's
   strict equality, not runtime property access.

2. node-addon-examples test (ubuntu/windows) — its `verify-prebuilds` step
   requires all four Android ABIs, but the unit-tests job only builds
   x86_64 (no CMAKE_RN_TRIPLETS). This test never actually ran on main:
   `npm test --workspace node-addon-examples` does not match the package's
   scoped name (@react-native-node-api/node-addon-examples), so npm silently
   skipped it. The faithful pnpm `--filter <scoped-name>` translation ran it
   for the first time and it failed. Drop it from the root `test` filter to
   preserve main's effective behaviour; properly enabling it would require
   building every ABI in the job (out of scope for the package-manager
   migration).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DnwodAoNbqPec77191HVXn
@kraenhansen kraenhansen added Apple 🍎 Anything related to the Apple platform (iOS, macOS, Cocoapods, Xcode, XCFrameworks, etc.) Ferric 🦀 MacOS 💻 Anything related to the Apple MacOS platform or React Native MacOS support weak-node-api labels Jul 20, 2026 — with Claude
claude added 2 commits July 20, 2026 09:16
Empty commit to start a fresh Check run now that the Apple 🍎 / MacOS 💻 /
Ferric 🦀 / weak-node-api labels are applied, so the label-gated iOS, macOS,
ferric-apple-triplet and weak-node-api jobs actually run against the pnpm
migration. (The workflow only triggers on opened/synchronize/reopened, not
on labeling.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DnwodAoNbqPec77191HVXn
…s error)

The test-macos job failed at `init-macos-test-app`: it scaffolds the app with
`npx @react-native-community/cli init` run from the workspace root, whose
package.json now declares `devEngines.packageManager: pnpm`. npm 11 refuses to
run (EBADDEVENGINES) because it isn't pnpm.

Switch that single root-level invocation to `pnpm dlx` — pnpm doesn't enforce
devEngines.packageManager (and satisfies it anyway). The remaining steps
(`npm install`, `npx react-native-macos-init`) run inside the scaffolded
standalone app directory, which isn't linked to the root as a workspace, so
they keep using npm/npx unaffected. Keeps the devEngines guardrail intact.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DnwodAoNbqPec77191HVXn
kraenhansen and others added 3 commits July 23, 2026 11:36
* fix(macos-test-app): unblock the macOS test app bundle and native build

Two independent failures kept the label-gated `test-macos` job red.

Metro bundle: the babel plugin rewrites `require("*.node")` in the workspace
packages into `require("react-native-node-api").requireNodeAddon(...)`. Those
files live outside the (intentionally non-workspace) macOS app, so Metro
resolves the bare `react-native-node-api` specifier by walking up from the
package directory. npm's hoisted workspaces happened to place it in the
repo-root node_modules; pnpm's isolated node_modules does not, so the rewritten
require failed with "Unable to resolve module react-native-node-api". Add the
app's own node_modules (where its `file:` deps are installed) to Metro's
`nodeModulesPaths` so resolution no longer depends on the root package
manager's hoisting layout.

Native build: GitHub's macos-latest runner now ships Xcode 26.4 / Apple clang
21, which enforces C++20 `consteval` strictly and rejects fmt 11.0.2's
FMT_STRING() usages ("call to consteval function ... is not a constant
expression") in fmt, Yoga and React-logger. React Native 0.81 bundles fmt
11.0.2 and the upstream fix (fmt 12.1.0) only reached RN >= 0.83.9, so patch the
generated Podfile to define FMT_USE_CONSTEVAL=0 across all pods, falling back to
runtime format-string validation.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011PM3HdJVbivXpzc2MQJV9T

* fix(macos-test-app): patch fmt header directly, add mocha dep, pipefail build

First CI run showed the Metro-bundle fix works (the job reached xcodebuild), but
surfaced two more issues:

- fmt consteval still failed: the GCC_PREPROCESSOR_DEFINITIONS FMT_USE_CONSTEVAL=0
  define did not reach every fmt-consuming translation unit. Patch the vendored
  fmt headers directly instead (flip `#define FMT_USE_CONSTEVAL 1` to 0), the
  approach known to work for RN 0.81 on Xcode 26.4.

- "Run test app" failed with "Cannot find module 'mocha'": mocha-remote-server
  needs mocha at runtime. It resolves via hoisting in the workspace apps, but the
  standalone macOS app must depend on it explicitly, so add mocha to the deps
  transferred from apps/test-app.

Also add `set -o pipefail` to the xcodebuild step so a build failure is not
masked by xcbeautify's exit code (which is what let the previous run limp past a
failed archive into the run step).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011PM3HdJVbivXpzc2MQJV9T

* chore(macos-test-app): trim inline comments to essentials

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011PM3HdJVbivXpzc2MQJV9T

* fix(macos-test-app): bump react-native-macos to 0.81.8, drop fmt patch

react-native-macos 0.81.8 bumps its vendored fmt from 11.0.2 to 12.1.0
(verified: third-party-podspecs/fmt.podspec pins 11.0.2 at v0.81.1 and 12.1.0
at v0.81.8), which resolves the Xcode 26.4 / Apple clang 21 consteval build
failure at its source. Bump REACT_NATIVE_MACOS_VERSION from 0.81.1 to 0.81.8 and
remove the manual Podfile header patch that forced FMT_USE_CONSTEVAL off.

Core react-native stays at 0.81.5 (facebook's 0.81 line has no 0.81.8; the two
packages track independent patch cadences within the same minor).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011PM3HdJVbivXpzc2MQJV9T

* fix(macos-test-app): bump react-native core to 0.81.6 for the macos 0.81.8 peer

react-native-macos-init failed to install react-native-macos@0.81.8 because it
peer-pins react-native 0.81.6 exactly, while REACT_NATIVE_VERSION was still
0.81.5 (the peer for the previous 0.81.1). Bump core to 0.81.6 so the two align.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011PM3HdJVbivXpzc2MQJV9T

* test(macos-test-app): drop repo-root watchFolders to check if still needed

Experiment: with nodeModulesPaths in place, is the watchFolders push still
required for Metro to serve the out-of-tree workspace package sources? Revert
if the bundle step fails.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011PM3HdJVbivXpzc2MQJV9T

* Revert "test(macos-test-app): drop repo-root watchFolders to check if still needed"

This reverts commit 30f8e58.

---------

Co-authored-by: Claude <noreply@anthropic.com>
The react-native-node-api babel plugin rewrites `require("./x.node")` into
`require("react-native-node-api").requireNodeAddon(...)`, so every package that
ships a Node-API addon has an implicit *runtime* dependency on
react-native-node-api once its JS is bundled by Metro. npm hoisted
react-native-node-api to the root node_modules, so Metro resolved it from those
packages; pnpm's isolated node_modules does not, so the Metro bundle failed with
`Unable to resolve module react-native-node-api from
packages/ferric-example/ferric_example.js`.

This is what made the iOS test app hang for ~6h: Metro errored on the first
bundle, but `test:ios:allTests` runs Metro under `mocha-remote -- concurrently`,
which never exits on a bundle error and waits for a client that never connects
until the job hits GitHub's 6h timeout. It affects the Android app the same way.

Declare `react-native-node-api` (workspace:*) in the two addon packages that were
missing it — ferric-example and node-addon-examples (node-tests already declares
it) — so pnpm links it into their node_modules and Metro can resolve the injected
require. Verified locally that it now resolves from each package.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DnwodAoNbqPec77191HVXn
…lved imports (#385)

* fix(node-tests): declare assert dependency and fail bundling on unresolved imports

The bundled Node.js test for 2_function_arguments requires 'assert', which
was previously satisfied as a phantom dependency: npm workspaces hoisted
node-addon-examples' assert@2.1.0 ponyfill to the root node_modules, where
rolldown resolved and inlined it. Under pnpm's strict node_modules layout the
package is no longer reachable from node-tests, so rolldown silently kept a
runtime __require("assert") call in the bundle (UNRESOLVED_IMPORT is only a
warning), which then fails at runtime on device where Metro cannot resolve it.

The failure surfaced as the masked 'test.titlePath(...).forEach is not a
function' error, a secondary crash in mocha-remote-server's failure formatter.

Declaring assert as a dependency of node-tests lets rolldown inline it again.
Also make the bundle step fail hard on unresolved imports, so any future
phantom dependency breaks bootstrap loudly instead of failing masked on-device.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MBr6N8caYidCuijvV5ak2A

* ci: trigger label-gated jobs

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MBr6N8caYidCuijvV5ak2A

---------

Co-authored-by: Claude <noreply@anthropic.com>
@kraenhansen
kraenhansen merged commit 4e5b831 into main Jul 23, 2026
17 checks passed
@kraenhansen
kraenhansen deleted the claude/migrate-pnpm-workspaces-qp0m9t branch July 23, 2026 19:31
kraenhansen pushed a commit that referenced this pull request Jul 23, 2026
Main migrated from npm workspaces to pnpm (#381). Update the SessionStart
hook to install via pnpm (provisioned through Corepack from the pinned
packageManager field) and refresh the npm→pnpm command references in
AGENTS.md and CLAUDE.md.

Also fix the hook's Node selection: resolve the bin dir for the .nvmrc
version by name instead of `nvm which current`, which reported the worker's
default Node 22 when it sat earlier on PATH — the hook was persisting Node 22
into the session env.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NvxEaPhyEq2HCZ6XFjEAde
kraenhansen added a commit that referenced this pull request Jul 23, 2026
…373)

* Add Claude Code worker bootstrap hook and context

Add a SessionStart hook and settings so Claude Code on the web workers
install and build the repo automatically, plus a CLAUDE.md that points at
the existing shared instructions and documents environment specifics.

- .claude/hooks/session-start.sh: selects Node 24 via nvm (required by
  devEngines), runs npm install and npm run build; remote-only and idempotent
- .claude/settings.json: registers the SessionStart hook
- CLAUDE.md: imports .github/copilot-instructions.md and documents bootstrap,
  common commands, and that native iOS/Android builds need mobile SDKs

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NvxEaPhyEq2HCZ6XFjEAde

* Move shared agent instructions to top-level AGENTS.md

Promote the tool-agnostic instructions to a top-level AGENTS.md (the general
cross-agent standard) and add environment/bootstrap notes. CLAUDE.md now
imports AGENTS.md, and .github/copilot-instructions.md redirects to it so the
content has a single source of truth.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NvxEaPhyEq2HCZ6XFjEAde

* Rename cmake-file-api package instructions to AGENTS.md

Agents pick up nested AGENTS.md files in subdirectories, so align the
package-level instructions with the same convention as the root.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NvxEaPhyEq2HCZ6XFjEAde

* Pin Node version in .nvmrc instead of hardcoding it in the hook

Add a .nvmrc set to lts/krypton (matching CI's setup-node) and have the
SessionStart hook run `nvm install`/`nvm use` without a version argument so
the pinned version is the single source of truth.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NvxEaPhyEq2HCZ6XFjEAde

* Document preferring an upstream fix over a local workaround

Add guidance to AGENTS.md for handling failures that look like known
upstream bugs: verify the fix at the source, prefer the smallest installable
version bump over a workaround, treat upgrades as revertible hypotheses, and
comment any unavoidable workaround with its removal condition.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NvxEaPhyEq2HCZ6XFjEAde

* Adopt pnpm in worker bootstrap and docs

Main migrated from npm workspaces to pnpm (#381). Update the SessionStart
hook to install via pnpm (provisioned through Corepack from the pinned
packageManager field) and refresh the npm→pnpm command references in
AGENTS.md and CLAUDE.md.

Also fix the hook's Node selection: resolve the bin dir for the .nvmrc
version by name instead of `nvm which current`, which reported the worker's
default Node 22 when it sat earlier on PATH — the hook was persisting Node 22
into the session env.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NvxEaPhyEq2HCZ6XFjEAde

* Format AGENTS.md with Prettier

The workaround section used *...* emphasis; the repo's Prettier config
normalizes emphasis to _..._. Apply it so prettier:check passes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NvxEaPhyEq2HCZ6XFjEAde

* Add PostToolUse hook to format files with Prettier on write

Runs the workspace Prettier on each file Claude Code writes or edits inside
the repo, using --ignore-unknown so it's a no-op on unsupported files and
honors .prettierignore. Keeps the tree formatted without a separate pass;
CI's prettier:check stays the source of truth.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NvxEaPhyEq2HCZ6XFjEAde

---------

Co-authored-by: Claude <noreply@anthropic.com>
kraenhansen added a commit that referenced this pull request Aug 9, 2026
npm and pnpm disagree on `npm/pnpm run <script> -- <args>`: npm swallows the
separator and appends only the args, pnpm appends the separator verbatim. The
migration in #381 rewrote these call sites mechanically, so the `--` started
leaking into the scripts' own argv.

For `packages/ferric-example` this is a hard failure — the script is
`ferric build`, so it ran as `ferric build -- --android`, commander read `--`
as the options terminator and `--android` became a positional operand of a
subcommand that takes none:

    error: too many arguments for 'build'. Expected 0 arguments but got 1.

For the `test:*:allTests` scripts (which end in `-- ` so `node --run` forwards
to the inner script) it is subtler: the extra separator survives as a literal
`--` in the forwarded args, ahead of `--mode Release`.

Dropping the separator restores exactly the argv these steps had under npm.

This only shows up on PRs labeled "Android 🤖": test-android is gated to those
while the self-hosted runner is offline (#379), so nothing had exercised the
Android lane since #381 landed.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
kraenhansen pushed a commit that referenced this pull request Aug 9, 2026
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
kraenhansen pushed a commit that referenced this pull request Aug 10, 2026
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
kraenhansen added a commit that referenced this pull request Aug 11, 2026
* 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>
kraenhansen added a commit that referenced this pull request Aug 11, 2026
* 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>
kraenhansen added a commit that referenced this pull request Aug 12, 2026
* 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>
kraenhansen added a commit that referenced this pull request Aug 12, 2026
* 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>
kraenhansen added a commit that referenced this pull request Aug 12, 2026
* 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>
kraenhansen added a commit that referenced this pull request Aug 13, 2026
* 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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Apple 🍎 Anything related to the Apple platform (iOS, macOS, Cocoapods, Xcode, XCFrameworks, etc.) Ferric 🦀 MacOS 💻 Anything related to the Apple MacOS platform or React Native MacOS support weak-node-api

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants