Skip to content

Commit ead9377

Browse files
nduaartemeta-codesync[bot]
authored andcommitted
Fix Hermes bytecode version mismatch in SwiftPM Release builds (#57928)
Summary: `react-native spm add`'s Release builds crash on launch with: ``` Compiling JS failed: Wrong bytecode version. Expected 99 but got 98 ``` This happens because the SwiftPM integration resolves the Hermes **runtime** (the downloaded `hermes-engine.xcframework`) and the Hermes **compiler** (the `hermesc` binary that turns the JS bundle into bytecode) from two independent, unsynced sources: - `download-spm-artifacts.js`'s `resolveHermesArtifact()` picked the runtime by querying the `hermes-compiler` package's `latest-v1` dist-tag on the npm registry **live, at build time**. - `generate-spm-xcodeproj.js`'s `resolveHermesCliPathSetting()` (and `react-native-xcode.sh`, for the CocoaPods-free fallback) points `HERMES_CLI_PATH` at the `hermes-compiler` package **already installed in this project's own `node_modules`** — whatever got pinned the last time `npm install` ran. If the `latest-v1` dist-tag advances on npm between `npm install` and the Release build (which happens routinely as new Hermes builds are published), the downloaded VM and the locally pinned `hermesc` fall out of sync and the app crashes at launch. `react-native-xcode.sh` already documents this exact invariant ("react native pins the hermes-compiler version, so the compiler's bytecode version always matches the prebuilt hermes VM artifacts") — SwiftPM's artifact download just wasn't honoring it. This PR makes `resolveHermesArtifact()` read the pinned `hermes-compiler` version from `node_modules` first (the same `require.resolve` lookup already used for `HERMES_CLI_PATH`), so the runtime download and the compiler always agree. It falls back to the previous `latest-v1` npm lookup only when `hermes-compiler` isn't locally resolvable (e.g. `USE_HERMES=false` apps that never installed it). Explicit `HERMES_VERSION` overrides (`nightly`, `latest-v1`, a literal version) are unchanged. Fixes #57917. ## Changelog: [IOS] [FIXED] - Fix Hermes runtime/compiler version mismatch causing "Wrong bytecode version" crashes in SwiftPM Release builds Pull Request resolved: #57928 Test Plan: Added unit tests covering the new local-resolution path, the fallback when `hermes-compiler` isn't installed, and confirming existing `HERMES_VERSION` overrides still take precedence over the local pin. ``` $ node_modules/.bin/jest packages/react-native/scripts/spm/__tests__/download-spm-artifacts-test.js Test Suites: 1 passed, 1 total Tests: 70 passed, 70 total $ node_modules/.bin/flow check packages/react-native/scripts/spm/download-spm-artifacts.js No errors! $ node_modules/.bin/eslint packages/react-native/scripts/spm/download-spm-artifacts.js packages/react-native/scripts/spm/__tests__/download-spm-artifacts-test.js (no output — clean) $ node_modules/.bin/prettier --check packages/react-native/scripts/spm/download-spm-artifacts.js packages/react-native/scripts/spm/__tests__/download-spm-artifacts-test.js All matched files use Prettier code style! ``` Reproduced the crash and confirmed the fix end-to-end using the public reproducer linked from the issue (https://github.com/marandaneto/react-native-087-swiftpm-hermes-bytecode-repro): - Before the fix: `npm run reproduce` builds successfully but launching the app in the iOS Simulator crashes with `Compiling JS failed: Wrong bytecode version. Expected 99 but got 98`. - After applying the equivalent fix to the reproducer's installed `react-native` copy: the log shows `Using locally pinned hermes-compiler: 250829098.0.16`, and both the debug and release Hermes runtime artifacts resolve to that exact version — matching the `hermesc` used for `HERMES_CLI_PATH`. `xcodebuild ... -configuration Release` succeeds, and the app installs and launches cleanly on an iPhone 17 Pro (iOS 26.5) simulator with no crash. Reviewed By: cortinico Differential Revision: D115859923 Pulled By: cipolleschi fbshipit-source-id: 1b65a7aa28f502374a3553c1849b8ff29b5afd10
1 parent 8804333 commit ead9377

2 files changed

Lines changed: 184 additions & 21 deletions

File tree

packages/react-native/scripts/spm/__tests__/download-spm-artifacts-test.js

Lines changed: 120 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ const {
2323
resolveCacheSlotVersion,
2424
resolveHermesArtifact,
2525
resolveLatestV1Version,
26+
resolveLocalHermesCompilerVersion,
2627
resolveNightlyVersion,
2728
resolveRNCoreArtifact,
2829
resolveRNDepsArtifact,
@@ -95,6 +96,67 @@ function routerFetch(routes /*: {[string]: any} */) {
9596
// artifact at the RN nightly version (which won't exist on Maven).
9697
// ---------------------------------------------------------------------------
9798

99+
// Creates a scratch dir with (optionally) a `node_modules/hermes-compiler`
100+
// package inside it, mimicking a real project root for
101+
// resolveLocalHermesCompilerVersion()'s require.resolve({paths: [rnRoot]}).
102+
function makeFakeRnRoot(hermesCompilerVersion /*: ?string */) /*: string */ {
103+
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'rn-root-'));
104+
if (hermesCompilerVersion != null) {
105+
const pkgDir = path.join(root, 'node_modules', 'hermes-compiler');
106+
fs.mkdirSync(pkgDir, {recursive: true});
107+
fs.writeFileSync(
108+
path.join(pkgDir, 'package.json'),
109+
JSON.stringify({name: 'hermes-compiler', version: hermesCompilerVersion}),
110+
);
111+
}
112+
return root;
113+
}
114+
115+
// Forces resolveLocalHermesCompilerVersion() to behave as if hermes-compiler
116+
// isn't installed, regardless of any workspace-hoisted hermes-compiler the host
117+
// environment provides. Jest's require.resolve ignores the {paths: [rnRoot]}
118+
// scoping and still finds the hoisted package, so stubbing module resolution is
119+
// ineffective here; instead we stub fs.readFileSync to throw MODULE_NOT_FOUND
120+
// for the resolved hermes-compiler/package.json — the exact signal a genuine
121+
// resolution miss produces — which drives the function down its "not installed"
122+
// branch. Reads of any other file fall through to the real implementation.
123+
// Undo with jest.restoreAllMocks().
124+
function mockHermesCompilerUnresolvable() {
125+
const realReadFileSync = fs.readFileSync;
126+
jest.spyOn(fs, 'readFileSync').mockImplementation((file, ...rest) => {
127+
if (String(file).includes(`${path.sep}hermes-compiler${path.sep}`)) {
128+
const error = new Error(
129+
"Cannot find module 'hermes-compiler/package.json'",
130+
);
131+
error.code = 'MODULE_NOT_FOUND';
132+
throw error;
133+
}
134+
return realReadFileSync.call(fs, file, ...rest);
135+
});
136+
}
137+
138+
describe('resolveLocalHermesCompilerVersion', () => {
139+
afterEach(() => {
140+
jest.restoreAllMocks();
141+
});
142+
143+
it('reads the version from the locally installed hermes-compiler package', () => {
144+
const root = makeFakeRnRoot('0.13.7');
145+
expect(resolveLocalHermesCompilerVersion(root)).toBe('0.13.7');
146+
});
147+
148+
it('returns null when no hermes-compiler is resolvable from the given root', () => {
149+
// Resolve from a real, isolated scratch root that has no hermes-compiler
150+
// installed (same setup as the fallback test below) so require.resolve
151+
// stays scoped to that root and misses, rather than falling back to
152+
// whatever hermes-compiler the host environment happens to hoist. The
153+
// function must report absence rather than fabricating a version.
154+
const root = makeFakeRnRoot(null);
155+
mockHermesCompilerUnresolvable();
156+
expect(resolveLocalHermesCompilerVersion(root)).toBeNull();
157+
});
158+
});
159+
98160
describe('resolveHermesArtifact', () => {
99161
let origFetch;
100162
let origHermesEnv;
@@ -112,6 +174,7 @@ describe('resolveHermesArtifact', () => {
112174
} else {
113175
delete process.env.HERMES_VERSION;
114176
}
177+
jest.restoreAllMocks();
115178
});
116179

117180
// Mock fetch with a router: each entry's key is a URL substring; the value
@@ -122,57 +185,90 @@ describe('resolveHermesArtifact', () => {
122185
}
123186

124187
describe('default behavior (no HERMES_VERSION set)', () => {
125-
it('resolves to the latest-v1 hermes-compiler dist-tag, NOT the RN version', async () => {
188+
it('uses the locally pinned hermes-compiler version, without hitting npm', async () => {
189+
const rnRoot = makeFakeRnRoot('0.13.7');
126190
mockFetch({
127-
'hermes-compiler/latest-v1': {json: {version: '0.13.0'}},
128-
// Pretend the release URL exists once we ask for 0.13.0.
129-
'hermes-ios/0.13.0/hermes-ios-0.13.0': {ok: true},
191+
'hermes-ios/0.13.7/hermes-ios-0.13.7': {ok: true},
130192
});
131193
const result = await resolveHermesArtifact(
132194
'0.87.0-nightly-20260519-58cd1bf58',
133195
'debug',
134196
null,
197+
rnRoot,
198+
);
199+
expect(result.version).toBe('0.13.7');
200+
expect(result.url).toContain('/0.13.7/');
201+
// Must resolve straight from node_modules — no npm registry round trip.
202+
expect(globalThis.fetch).not.toHaveBeenCalledWith(
203+
expect.stringContaining('registry.npmjs.org'),
204+
expect.anything(),
135205
);
136-
expect(result.version).toBe('0.13.0');
137-
expect(result.url).toContain('/0.13.0/');
138-
// The RN nightly hash MUST NOT leak into the hermes URL.
139-
expect(result.url).not.toContain('20260519');
140206
});
141207

142208
it('ignores rawVersion (the RN --version arg) when HERMES_VERSION is unset', async () => {
209+
const rnRoot = makeFakeRnRoot('0.13.7');
143210
mockFetch({
144-
'hermes-compiler/latest-v1': {json: {version: '0.13.0'}},
145-
'hermes-ios/0.13.0/hermes-ios-0.13.0': {ok: true},
211+
'hermes-ios/0.13.7/hermes-ios-0.13.7': {ok: true},
146212
});
147213
// Caller passes the original RN --version verbatim; hermes should
148-
// still default to latest-v1 instead of using this.
214+
// still use the locally pinned version instead of using this.
149215
const result = await resolveHermesArtifact(
150216
'0.87.0-nightly-20260519-58cd1bf58',
151217
'debug',
152218
'0.87.0-nightly-20260519-58cd1bf58',
219+
rnRoot,
153220
);
154-
expect(result.version).toBe('0.13.0');
221+
expect(result.version).toBe('0.13.7');
155222
expect(result.url).not.toContain('20260519');
156223
});
224+
225+
it('falls back to the latest-v1 npm dist-tag when hermes-compiler is not locally installed', async () => {
226+
const rnRoot = makeFakeRnRoot(null);
227+
// Stub require.resolve to fail so the local lookup is guaranteed to miss,
228+
// even in environments that hoist a workspace hermes-compiler. The
229+
// resolver must then fall through to the latest-v1 dist-tag (0.13.0).
230+
mockHermesCompilerUnresolvable();
231+
mockFetch({
232+
'hermes-compiler/latest-v1': {json: {version: '0.13.0'}},
233+
'hermes-ios/0.13.0/hermes-ios-0.13.0': {ok: true},
234+
});
235+
const result = await resolveHermesArtifact(
236+
'0.87.0-nightly-20260519-58cd1bf58',
237+
'debug',
238+
null,
239+
rnRoot,
240+
);
241+
expect(result.version).toBe('0.13.0');
242+
expect(result.url).toContain('/0.13.0/');
243+
// Confirm the dist-tag lookup actually ran — the fallback path, not a
244+
// locally pinned version, produced this result.
245+
const hitLatestV1 = globalThis.fetch.mock.calls.some(([url]) =>
246+
String(url).includes('hermes-compiler/latest-v1'),
247+
);
248+
expect(hitLatestV1).toBe(true);
249+
});
157250
});
158251

159252
describe('HERMES_VERSION escape hatches', () => {
160-
it('HERMES_VERSION=<literal-version> uses it verbatim', async () => {
253+
it('HERMES_VERSION=<literal-version> uses it verbatim, even with a local package installed', async () => {
161254
process.env.HERMES_VERSION = '0.13.5';
255+
const rnRoot = makeFakeRnRoot('0.13.7');
162256
mockFetch({
163257
'hermes-ios/0.13.5/hermes-ios-0.13.5': {ok: true},
164258
});
165259
const result = await resolveHermesArtifact(
166260
'0.87.0-nightly-anything',
167261
'debug',
168262
null,
263+
rnRoot,
169264
);
170265
expect(result.version).toBe('0.13.5');
171266
expect(result.url).toContain('/0.13.5/');
172267
});
173268

174-
it('HERMES_VERSION=latest-v1 resolves via npm dist-tag', async () => {
269+
it('HERMES_VERSION=latest-v1 resolves via npm dist-tag, even with a local package installed', async () => {
175270
process.env.HERMES_VERSION = 'latest-v1';
271+
const rnRoot = makeFakeRnRoot('0.13.7');
176272
mockFetch({
177273
'hermes-compiler/latest-v1': {json: {version: '0.13.0'}},
178274
'hermes-ios/0.13.0/hermes-ios-0.13.0': {ok: true},
@@ -181,12 +277,14 @@ describe('resolveHermesArtifact', () => {
181277
'0.87.0-nightly-anything',
182278
'debug',
183279
null,
280+
rnRoot,
184281
);
185282
expect(result.version).toBe('0.13.0');
186283
});
187284

188285
it('HERMES_VERSION=nightly resolves hermes-compiler@nightly from npm', async () => {
189286
process.env.HERMES_VERSION = 'nightly';
287+
const rnRoot = makeFakeRnRoot(null);
190288
mockFetch({
191289
'hermes-compiler/nightly': {json: {version: '0.14.0-nightly-abc'}},
192290
'hermes-ios/0.14.0-nightly-abc/hermes-ios-0.14.0-nightly-abc': {
@@ -197,12 +295,14 @@ describe('resolveHermesArtifact', () => {
197295
'0.87.0-nightly-anything',
198296
'debug',
199297
null,
298+
rnRoot,
200299
);
201300
expect(result.version).toBe('0.14.0-nightly-abc');
202301
});
203302

204303
it('falls back to the hermes snapshot URL when the release is missing', async () => {
205304
process.env.HERMES_VERSION = '0.13.5';
305+
const rnRoot = makeFakeRnRoot(null);
206306
globalThis.fetch = jest.fn(async (url, opts) => {
207307
if (opts && opts.method === 'HEAD') {
208308
return {status: 404};
@@ -215,7 +315,12 @@ describe('resolveHermesArtifact', () => {
215315
'<buildNumber>2</buildNumber></metadata>',
216316
};
217317
});
218-
const result = await resolveHermesArtifact('0.87.0', 'debug', null);
318+
const result = await resolveHermesArtifact(
319+
'0.87.0',
320+
'debug',
321+
null,
322+
rnRoot,
323+
);
219324
expect(result.url).toContain('maven-snapshots');
220325
expect(result.url).toContain('hermes-ios-debug.tar.gz');
221326
});

packages/react-native/scripts/spm/download-spm-artifacts.js

Lines changed: 64 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -452,15 +452,63 @@ async function resolveRNDepsArtifact(
452452
return {url: snapshotUrl, version};
453453
}
454454

455+
/**
456+
* Resolves the `hermes-compiler` npm package's version from THIS project's own
457+
* node_modules — the exact same lookup generate-spm-xcodeproj.js's
458+
* resolveHermesCliPathSetting() uses to find the hermesc binary that will
459+
* compile the JS bundle, and the same one react-native-xcode.sh falls back to
460+
* for SwiftPM builds. Returns null when the package isn't resolvable (e.g.
461+
* USE_HERMES=false apps that never installed it) so the caller can fall back
462+
* to the npm dist-tag lookup.
463+
*/
464+
function resolveLocalHermesCompilerVersion(
465+
rnRoot /*: string */,
466+
) /*: string | null */ {
467+
try {
468+
const pkgPath = require.resolve('hermes-compiler/package.json', {
469+
paths: [rnRoot],
470+
});
471+
// $FlowFixMe[incompatible-type] JSON.parse returns any
472+
const pkg /*: {version: string} */ = JSON.parse(
473+
fs.readFileSync(pkgPath, 'utf8'),
474+
);
475+
assertSafeVersion(pkg.version, 'local hermes-compiler/package.json');
476+
return pkg.version;
477+
} catch (error) {
478+
// A MODULE_NOT_FOUND resolution failure is the expected case (e.g.
479+
// USE_HERMES=false apps that never installed hermes-compiler) — fall back
480+
// silently. Any other failure means hermes-compiler IS installed but its
481+
// package.json is unreadable/malformed or carries an unsafe version; warn
482+
// loudly rather than silently regressing to the live latest-v1 dist-tag,
483+
// which would re-introduce the version-skew crash this resolves (#57917).
484+
if (error.code !== 'MODULE_NOT_FOUND') {
485+
log(
486+
` WARNING: hermes-compiler is installed but its version could not be resolved (${error.message}); falling back to the latest-v1 dist-tag, which may not match the pinned hermesc and can crash at launch with "Wrong bytecode version".`,
487+
);
488+
}
489+
return null;
490+
}
491+
}
492+
455493
/**
456494
* Returns {url, version} for Hermes. Hermes uses its own version space
457495
* decoupled from React Native's nightly cadence — RN's `hermes-compiler`
458496
* npm package publishes a `latest-v1` dist-tag that always resolves to a
459-
* binary that's been built and uploaded to Maven. Our default mirrors RN's
460-
* CocoaPods prebuild path (see scripts/ios-prebuild/hermes.js):
497+
* binary that's been built and uploaded to Maven.
461498
*
462-
* HERMES_VERSION unset → 'latest-v1' dist-tag
463-
* HERMES_VERSION=latest-v1 → same (explicit)
499+
* HERMES_VERSION unset → version pinned by the locally installed
500+
* hermes-compiler package (node_modules).
501+
* This is the SAME source
502+
* resolveHermesCliPathSetting() reads for
503+
* HERMES_CLI_PATH, so the downloaded VM and
504+
* the hermesc that compiles the JS bundle
505+
* always agree — a mismatched pair crashes at
506+
* launch with "Wrong bytecode version" (#57917).
507+
* Falls back to the 'latest-v1' npm dist-tag
508+
* (RN's CocoaPods prebuild default; see
509+
* scripts/ios-prebuild/hermes.js) only when
510+
* hermes-compiler isn't locally resolvable.
511+
* HERMES_VERSION=latest-v1 → 'latest-v1' dist-tag (explicit)
464512
* HERMES_VERSION=nightly → hermes-compiler@nightly dist-tag
465513
* HERMES_VERSION=<literal> → use that version verbatim
466514
*
@@ -472,8 +520,17 @@ async function resolveHermesArtifact(
472520
rnVersion /*: string */,
473521
flavor /*: string */,
474522
rawVersion /*: string | null */,
523+
rnRoot /*: string */,
475524
) /*: Promise<ResolvedArtifact> */ {
476-
let version = process.env.HERMES_VERSION ?? 'latest-v1';
525+
let version = process.env.HERMES_VERSION;
526+
527+
if (version == null) {
528+
const localVersion = resolveLocalHermesCompilerVersion(rnRoot);
529+
if (localVersion != null) {
530+
log(` Using locally pinned hermes-compiler: ${localVersion}`);
531+
}
532+
version = localVersion ?? 'latest-v1';
533+
}
477534

478535
if (version === 'nightly') {
479536
version = await resolveNightlyVersion('hermes-compiler');
@@ -1178,7 +1235,7 @@ async function main(argv /*:: ?: Array<string> */) /*: Promise<void> */ {
11781235
label: 'hermes',
11791236
name: 'hermes-engine',
11801237
resolve: () =>
1181-
resolveHermesArtifact(resolvedRnVersion, flavor, rawVersion),
1238+
resolveHermesArtifact(resolvedRnVersion, flavor, rawVersion, rnRoot),
11821239
sharedName: (v /*: string */) => `hermes-ios-${v}-${flavor}.tar.gz`,
11831240
},
11841241
];
@@ -1449,6 +1506,7 @@ module.exports = {
14491506
main,
14501507
resolveCacheSlotVersion,
14511508
resolveHermesArtifact,
1509+
resolveLocalHermesCompilerVersion,
14521510
REQUIRED_ARTIFACTS,
14531511
validateArtifactsCache,
14541512
// Exposed for unit tests (pure / fetch-stubbable helpers).

0 commit comments

Comments
 (0)