From 233fe3450010b0dc69aeeebaee9076dca6b40515 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kr=C3=A6n=20Hansen?= Date: Thu, 13 Aug 2026 14:09:04 +0000 Subject: [PATCH] Use the prebuilt Hermes archive for iOS builds Hermes is the iOS build: 17m15s of an 18m58s "Build test app" step. Point pod install at the archive built by `prebuilt-hermes` through HERMES_ENGINE_TARBALL_PATH, so hermes-engine.podspec vendors the prebuilt frameworks and its two Hermes script phases don't run at all. Building from source stays available behind REACT_NATIVE_NODE_API_HERMES_FROM_SOURCE=1, which is the faster loop while iterating on Hermes itself. react-native-macos stays on that path for now, see #392. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01UkNbgdyuKgHaFwT27RahGH --- .changeset/prebuilt-hermes-archive.md | 15 ++++++++ .github/workflows/check.yml | 21 +++++++++++ AGENTS.md | 2 +- docs/CLI.md | 6 +++- packages/host/scripts/patch-hermes.rb | 51 ++++++++++++++++++++------- 5 files changed, 81 insertions(+), 14 deletions(-) create mode 100644 .changeset/prebuilt-hermes-archive.md diff --git a/.changeset/prebuilt-hermes-archive.md b/.changeset/prebuilt-hermes-archive.md new file mode 100644 index 00000000..3de83cf9 --- /dev/null +++ b/.changeset/prebuilt-hermes-archive.md @@ -0,0 +1,15 @@ +--- +"react-native-node-api": minor +--- + +Stop compiling Hermes as part of every iOS app build. The Cocoapods integration +now resolves the pinned commit with `prebuilt-hermes` and hands the archive's +path to React Native through `HERMES_ENGINE_TARBALL_PATH` — so +`hermes-engine.podspec` vendors the prebuilt frameworks instead of running its +"Build Hermesc" and "Build Hermes" script phases. + +Building Hermes from source remains available and is the faster loop while +iterating on Hermes itself, since Xcode then rebuilds it incrementally: set +`REACT_NATIVE_NODE_API_HERMES_FROM_SOURCE=1` before `pod install`. Setting +`REACT_NATIVE_OVERRIDE_HERMES_DIR` or `HERMES_ENGINE_TARBALL_PATH` yourself +still takes precedence, and Android is unchanged. diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index 53cdb585..77c0254e 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -244,6 +244,27 @@ jobs: ccache --set-config file_clone=true ccache --set-config depend_mode=true ccache --set-config inode_cache=true + # Hermes dominates the iOS build (17m15s of an 18m58s "Build test app" + # step, measured in #439) and only changes when the pinned commit does, so + # it is built once into an archive that `pod install` injects through + # HERMES_ENGINE_TARBALL_PATH. The archive name covers every input that + # changes its contents — the pinned commit, the React Native version whose + # JSI it is compiled against, the build type and the platforms — which + # makes it the cache key too. + - name: Resolve prebuilt Hermes name + id: hermes + run: echo "archive=$(pnpm exec react-native-node-api prebuilt-hermes --print name)" >> "$GITHUB_OUTPUT" + working-directory: apps/test-app + - name: Cache prebuilt Hermes + uses: actions/cache@v6 + with: + path: ~/Library/Caches/react-native-node-api/hermes-prebuilt + key: ${{ steps.hermes.outputs.archive }} + # Explicit rather than left to `pod install`, so a cold cache shows up as + # its own step in the job log instead of as a mysteriously slow install. + - name: Build prebuilt Hermes + run: pnpm exec react-native-node-api prebuilt-hermes + working-directory: apps/test-app # Must precede `pod install`: react-native-test-app embeds the resources # declared in app.json when generating the workspace, skipping missing # ones, and the app would then expect a Metro dev server at runtime. diff --git a/AGENTS.md b/AGENTS.md index ca13226d..2cc40564 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -60,7 +60,7 @@ patch or workaround: ## Critical Build Dependencies -- **Vendored Hermes**: Builds Hermes from a pinned commit on the `static_h` branch, which carries Hermes' first-party Node-API implementation (`API/napi`, target `hermesNapi`). The pin lives in `packages/host/src/node/cli/hermes.ts` and is fetched by the `vendor-hermes` command. +- **Vendored Hermes**: Builds Hermes from a pinned commit on the `static_h` branch, which carries Hermes' first-party Node-API implementation (`API/napi`, target `hermesNapi`). The pin lives in `packages/host/src/node/cli/hermes.ts` and is fetched by the `vendor-hermes` command. On Apple platforms it is built once into an archive by the `prebuilt-hermes` command and injected into `pod install` through `HERMES_ENGINE_TARBALL_PATH`; Android and the opt-in `REACT_NATIVE_NODE_API_HERMES_FROM_SOURCE=1` path build it from that checkout instead. See [docs/CLI.md](docs/CLI.md). - **Prebuilt Binary Spec**: All tools must output to the exact naming scheme: - Android: `*.android.node/` with jniLibs structure + `react-native-node-api-module` marker file - iOS: `*.apple.node` (XCFramework renamed) + marker file diff --git a/docs/CLI.md b/docs/CLI.md index a8c62549..fab4b1a5 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -13,7 +13,7 @@ Run `npx react-native-node-api help` or `npx react-native-node-api help ` — Print `name`, `tag` or `url` of the archive instead of resolving it. +To build Hermes from source as part of the app build instead — which is the faster loop while iterating on Hermes itself, since Xcode then rebuilds it incrementally — set `REACT_NATIVE_NODE_API_HERMES_FROM_SOURCE=1` before running `pod install`. Setting `REACT_NATIVE_OVERRIDE_HERMES_DIR` or `HERMES_ENGINE_TARBALL_PATH` yourself also takes precedence. + ## `vendor-hermes [from]` Clones the pinned commit of Hermes' `static_h` branch (which carries Hermes' first-party Node-API implementation) into the `sdks/node-api-hermes` directory of the app's `react-native` package, so the native build can compile against it. Prints the path to the vendored checkout on success. +This is how Hermes is built on Android, and on Apple when the from-source path described above is selected. + - `[from]` — Path to a file inside the app package. Defaults to the current working directory. - `--react-native-package ` — The React Native package to vendor Hermes into. Defaults to `react-native`. - `--silent` — Don't print anything except the final path. Defaults to `false`. diff --git a/packages/host/scripts/patch-hermes.rb b/packages/host/scripts/patch-hermes.rb index 986f5d8f..1e5093cc 100644 --- a/packages/host/scripts/patch-hermes.rb +++ b/packages/host/scripts/patch-hermes.rb @@ -1,24 +1,51 @@ Pod::UI.warn "!!! CONFIGURING HERMES WITH NODE-API SUPPORT !!!" -if ENV['REACT_NATIVE_OVERRIDE_HERMES_DIR'].nil? - def get_react_native_package - if caller.any? { |frame| frame.include?("node_modules/react-native-macos/") } - return "react-native-macos" - elsif caller.any? { |frame| frame.include?("node_modules/react-native/") } - return "react-native" - else - raise "Unable to determine React Native package from call stack." - end +def node_api_react_native_package + if caller.any? { |frame| frame.include?("node_modules/react-native-macos/") } + return "react-native-macos" + elsif caller.any? { |frame| frame.include?("node_modules/react-native/") } + return "react-native" + else + raise "Unable to determine React Native package from call stack." end +end + +def node_api_run_cli(command, react_native_package) + args = [ + command, + "--react-native-package", react_native_package, + "--silent", Pod::Config.instance.installation_root.to_s + ].map { |arg| "'#{arg}'" }.join(" ") + result = `npx react-native-node-api #{args}`.strip + raise "Hermes setup failed: 'react-native-node-api #{command}' exited with #{$?.exitstatus}" unless $?.success? + result +end - VENDORED_HERMES_DIR ||= `npx react-native-node-api vendor-hermes --react-native-package '#{get_react_native_package()}' --silent '#{Pod::Config.instance.installation_root}'`.strip - ENV['REACT_NATIVE_OVERRIDE_HERMES_DIR'] = VENDORED_HERMES_DIR +if ENV['REACT_NATIVE_OVERRIDE_HERMES_DIR'].nil? && ENV['HERMES_ENGINE_TARBALL_PATH'].nil? + react_native_package = node_api_react_native_package() + # Building from source keeps Hermes inside the Xcode build, where it rebuilds + # incrementally — the faster loop while iterating on Hermes itself. Otherwise + # the pinned commit is resolved to an archive built once and reused. + # + # react-native-macos stays on the source path: the archive is only produced + # and exercised for the iOS platforms today. + if ENV['REACT_NATIVE_NODE_API_HERMES_FROM_SOURCE'].to_s == '1' || react_native_package == "react-native-macos" + ENV['REACT_NATIVE_OVERRIDE_HERMES_DIR'] = node_api_run_cli("vendor-hermes", react_native_package) + else + ENV['HERMES_ENGINE_TARBALL_PATH'] = node_api_run_cli("prebuilt-hermes", react_native_package) + end end if ENV['REACT_NATIVE_OVERRIDE_HERMES_DIR'] && !ENV['REACT_NATIVE_OVERRIDE_HERMES_DIR'].empty? if Dir.exist?(ENV['REACT_NATIVE_OVERRIDE_HERMES_DIR']) - Pod::UI.info "[Node-API] Using overridden Hermes in #{ENV['REACT_NATIVE_OVERRIDE_HERMES_DIR'].inspect}" + Pod::UI.info "[Node-API] Building Hermes from source in #{ENV['REACT_NATIVE_OVERRIDE_HERMES_DIR'].inspect}" else raise "Hermes setup failed: Expected override to exist in #{ENV['REACT_NATIVE_OVERRIDE_HERMES_DIR'].inspect}" end +elsif ENV['HERMES_ENGINE_TARBALL_PATH'] && !ENV['HERMES_ENGINE_TARBALL_PATH'].empty? + if File.exist?(ENV['HERMES_ENGINE_TARBALL_PATH']) + Pod::UI.info "[Node-API] Using prebuilt Hermes from #{ENV['HERMES_ENGINE_TARBALL_PATH'].inspect}" + else + raise "Hermes setup failed: Expected prebuilt archive to exist at #{ENV['HERMES_ENGINE_TARBALL_PATH'].inspect}" + end end