Skip to content

fix: find hermesc under pnpm and fail the build when Hermes compilation fails - #1520

Draft
dannyhw wants to merge 2 commits into
mainfrom
fix/hermes-plugin-hermesc-lookup
Draft

dannyhw wants to merge 2 commits into
mainfrom
fix/hermes-plugin-hermesc-lookup

Conversation

@dannyhw

@dannyhw dannyhw commented Nov 4, 2026

Copy link
Copy Markdown
Collaborator

Summary

Fixes two HermesBytecodePlugin bugs. Both showed up when validating 5.4.3-canary-20261104085523 in a pnpm monorepo (super-app-showcase: pnpm 11, RN 0.88.1, Rspack 2.3.1, Node 26). Neither comes from #1516: the hermesc lookup is byte-identical in 5.4.2.

  1. The default hermesc lookup fails under pnpm on RN ≥ 0.82.
  2. A failed Hermes transform still exits 0, leaving plain JS chunks in a "successful" release build.

Important

Behaviour change: a failed Hermes bytecode compilation now fails the build instead of exiting 0. Builds where hermesc fails used to pass and ship un-compiled chunks; they now exit non-zero. Both changesets are patch. Maintainers may prefer to ship this in a minor release.

Bug 1: hermesc lookup under pnpm

Root cause

reactNativePath defaults to <context>/node_modules/react-native. getHermesCLIPath tried path.join(reactNativePath, '..', 'hermes-compiler', …), then fell back to <reactNativePath>/sdks/hermesc/… without checking that it exists.

Under pnpm, <context>/node_modules/react-native is a symlink into node_modules/.pnpm/react-native@…/node_modules/react-native. hermes-compiler is a dependency of react-native and is installed next to the real package inside .pnpm/…/node_modules. The lexical .. join doesn't follow the symlink, so it checked <context>/node_modules/hermes-compiler and missed. It then returned sdks/hermesc, which RN 0.82+ no longer ships:

[RepackHermesBytecodePlugin] Failed to transform bundle …chunk.bundle. Reason:
Error: Command failed with ENOENT: …/packages/trading/node_modules/react-native/sdks/hermesc/osx-bin/hermesc …

Fix (backwards compatible)

Lookup order:

  1. hermesCLIPath, if set, still wins and no lookup runs.
  2. Unchanged, same order, same resulting path: <reactNativePath>/../hermes-compiler/hermesc/<os>/hermesc, then <reactNativePath>/sdks/hermesc/<os>/hermesc. The first check is the same fs.existsSync. The legacy path is now only returned if it exists (on Windows, hermesc or hermesc.exe), and it is returned as the same extensionless string as before.
  3. New, only when neither exists:
    • hermes-compiler next to fs.realpathSync(reactNativePath). This is the pnpm case.
    • hermes-compiler found by walking node_modules up from the real and the lexical react-native dirs. This covers npm/yarn nesting (react-native/node_modules/hermes-compiler when versions conflict) and hoisting further up.
    • If reactNativePath was not set and <context>/node_modules/react-native is not a directory: the same candidates for react-native resolved from compiler.context. This fallback is used only for the hermesc lookup.
    • On Windows, the first location is also checked as hermesc.exe. Before, it never matched there, because the binary is hermesc.exe.
  4. If nothing exists, it throws a clear error listing every checked path, and suggests hermesCLIPath / reactNativePath. It no longer spawns a missing binary.

Other properties:

  • Lazy and cached. The lookup moved out of apply() and runs on the first afterEmit that actually has matching assets. The result is cached. Projects that register the plugin but emit no matching assets, or that disable it for a mode, can't start failing because of the new error.
  • reactNativePath default untouched. It is still used as-is to find scripts/compose-source-maps.js. Symlinks are only resolved inside the hermesc lookup, so re-resolving can't pick a different RN copy for source-map composition in monorepos.
  • Why not require.resolve(…, { paths })? Even with paths, Node's resolution still consults NODE_PATH/global folders, and pnpm's bin shims set NODE_PATH to node_modules/.pnpm/node_modules. That can return an arbitrary hermes-compiler or react-native version. A mismatched hermesc emits bytecode the app's Hermes runtime can't load, which is worse than a build error. The lookup therefore walks node_modules directories explicitly: the same algorithm, minus global folders. Jest's resolver also showed this leak in tests.
  • Context fallback, and its limit. When RN is hoisted to a monorepo root, <context>/node_modules/react-native doesn't exist. Resolving RN from compiler.context lets hermesc be found there. I kept it to the hermesc lookup, as asked, so that reactNativePath semantics for compose-source-maps.js don't change. For such projects, source-map composition still needs reactNativePath set explicitly; that is unchanged from today. If maintainers want it, a follow-up could apply the same fallback to compose-source-maps.js.
  • ChunksToHermesBytecodePlugin extends HermesBytecodePlugin, so it gets the fix automatically. No other code in the repo uses the reactNativePath/.. pattern.

Bug 2: a failed Hermes transform exits 0

Root cause

  • commands/bundle.ts called compiler.run(cb), and cb called compiler.close(async (closeErr) => { await errorHandler(error, stats); resolve(); }). errorHandler is async and throws CLIError for compiler errors. Errors from afterEmit reach run's callback as error in both Rspack and webpack. The throw rejected the close callback's promise, which nobody observed, so the outer new Promise((resolve) => …) never settled and the rejection was unhandled. --watch passed the async errorHandler straight to compiler.watch, with the same problem.
  • LoggerPlugin registers process.on('uncaughtException', …). Node routes the unhandled rejection there, and having a listener disables Node's default crash with exit code 1. The listener only logged and flushed, so the process exited 0 once the event loop drained.

Fix

  • bundle: the run/watch wiring moved into an exported runCompiler helper, which also serves as the test seam. The promise now settles with the handler's result: onResult(error, stats).then(resolve, reject) for run, and .catch(reject) for watch. The CLI's handleError then exits 1, consistent with the existing process.exit(2) for stats.hasErrors(). In --watch, a fatal compiler error now ends the command. Before, it was logged and watching continued. That matches how stats errors already behave there (process.exit(2)).
  • LoggerPlugin: the uncaughtException listener sets process.exitCode = 1. It does not call process.exit synchronously, so the reporter still flushes.

Not done: reporting hermesc failures as compilation errors

I skipped pushing a WebpackError into compilation.errors from afterEmit. With the two fixes above, a thrown error in afterEmit already reaches run's callback as error in both bundlers and exits non-zero. I haven't verified that Rspack's Stats.hasErrors() picks up errors pushed after emit. That change would also move this failure from exit 1 to exit 2 and change its formatting. It can be a follow-up if wanted.

Tests

  • plugins/__tests__/getHermesCLIPath.test.ts (new). It builds real temp-dir layouts, using junction symlinks so it also works on the Windows CI job, and covers:
    • hoisted layout: first candidate, unchanged, and it wins over legacy
    • RN < 0.82 sdks/hermesc fallback: unchanged, and it wins over the new candidates
    • pnpm symlink layout: realpath candidate
    • nested react-native/node_modules/hermes-compiler: resolved candidate
    • context fallback when reactNativePath doesn't exist, and that it's not used when reactNativePath exists
    • nothing found: error lists the checked paths and mentions hermesCLIPath
    • unknown OS
    • Windows .exe handling (legacy path still returned extensionless)
  • plugins/__tests__/HermesBytecodePlugin.test.ts:
    • hermesCLIPath override skips the lookup
    • no lookup and no throw when no asset matches (lazy)
    • not found fails the emit with the checked paths
    • the lookup is cached across rebuilds
    • The old mocked getHermesCLIPath cases moved to the new file.
  • commands/__tests__/bundle.test.ts (new): runCompiler resolves on success, rejects on compiler error in run and watch mode, and resolves when watching stops.
  • plugins/__tests__/LoggerPlugin.test.ts (new): an uncaught exception sets process.exitCode = 1.
  • I checked that the new lazy, bundle and LoggerPlugin tests fail when the corresponding fix is reverted.
  • pnpm lint:ci, pnpm typecheck and pnpm test all pass locally (@callstack/repack: 45 suites, 397 tests).

End-to-end verification

The checks ran in super-app-showcase packages/trading (branch chore/rn-0.88-repack-canary-skia-rename), with an untracked probe config that adds HermesBytecodePlugin({ enabled: env.mode === 'production', test: /\.(js)?bundle$/, exclude: /index\.bundle$/ }) and no reactNativePath:

npx react-native bundle --config rspack.hermes-probe.config.ts --platform ios \
  --entry-file index.js --dev false --bundle-output <tmp>/index.bundle --sourcemap-output <tmp>/index.bundle.map
Scenario Canary 5.4.3-canary-20261104085523 This branch
Default lookup ENOENT …/react-native/sdks/hermesc/osx-bin/hermesc, chunks left as JS, exit 0 exit 0, 66/66 chunks transformed. All 66 start with Hermes magic c6 1f bc 03 c1 03 19 1f, and all 66 .map files contain x_hermes_function_offsets
hermesCLIPath: '/nonexistent/hermesc' error logged, exit 0 error printed, exit 1

With this branch, the default lookup resolved to node_modules/.pnpm/react-native@0.88.1_…/node_modules/hermes-compiler/hermesc/osx-bin/hermesc.

This branch's dist went into a copy of the installed package, placed next to it inside the .pnpm entry so its dependencies resolve. packages/trading's symlink pointed at that copy for the test, then was restored and the copy deleted. The pnpm store and the showcase repo were not modified: git status is clean, and pnpm install reports up to date. Only iOS was exercised end-to-end. The webpack path is covered by reading the code (an afterEmit rejection is passed to run's callback) and by unit tests, not by an end-to-end webpack build.

The default lookup joined `<reactNativePath>/../hermes-compiler` lexically.
Under pnpm `node_modules/react-native` is a symlink into `.pnpm`, where
`hermes-compiler` is installed next to the real react-native, so the join
missed it and the plugin fell back to `sdks/hermesc`, which RN 0.82+ no
longer ships (ENOENT when spawning hermesc).

The existing two locations are still checked first, in the same order, and
resolve to the same path. Only when neither exists does the lookup try
`hermes-compiler` next to the symlink-resolved react-native, then the
react-native `node_modules` hierarchy, then react-native resolved from the
compiler context when the default `reactNativePath` doesn't exist. If nothing
is found it throws an error listing every checked path.

The lookup runs lazily on the first transformed asset and is cached, so builds
that register the plugin but emit no matching assets are unaffected.
`bundle` awaited its async error handler inside the compiler's `close`
callback, so a compiler error (e.g. a failed hermesc run in
HermesBytecodePlugin) became an unhandled rejection and the command promise
never settled. LoggerPlugin's `uncaughtException` listener then logged it and,
because registering the listener disables Node's default exit code, the
process exited 0 with un-compiled chunks on disk.

`bundle` now rejects with the handler's error in run and watch mode, and
LoggerPlugin sets `process.exitCode = 1` for uncaught exceptions.
@vercel

vercel Bot commented Nov 4, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
repack-website Ready Ready Preview Nov 4, 2026 1:48pm UTC

Request Review

@changeset-bot

changeset-bot Bot commented Nov 4, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 47e35c7

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 6 packages
Name Type
@callstack/repack Patch
@callstack/repack-plugin-expo-modules Patch
@callstack/repack-plugin-nativewind Patch
@callstack/repack-plugin-reanimated Patch
@callstack/repack-dev-server Patch
@callstack/repack-init Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

This branch was successfully deployed

1 active deployment
Preview — 47e35c79 Deployed Nov 4, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant