Skip to content

Commit b8d50a9

Browse files
nduaartereact-native-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 c6ebb06 commit b8d50a9

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
@@ -20,6 +20,7 @@ const {
2020
resolveCacheSlotVersion,
2121
resolveHermesArtifact,
2222
resolveLatestV1Version,
23+
resolveLocalHermesCompilerVersion,
2324
resolveNightlyVersion,
2425
resolveRNCoreArtifact,
2526
resolveRNDepsArtifact,
@@ -65,6 +66,67 @@ function routerFetch(routes /*: {[string]: any} */) {
6566
// artifact at the RN nightly version (which won't exist on Maven).
6667
// ---------------------------------------------------------------------------
6768

69+
// Creates a scratch dir with (optionally) a `node_modules/hermes-compiler`
70+
// package inside it, mimicking a real project root for
71+
// resolveLocalHermesCompilerVersion()'s require.resolve({paths: [rnRoot]}).
72+
function makeFakeRnRoot(hermesCompilerVersion /*: ?string */) /*: string */ {
73+
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'rn-root-'));
74+
if (hermesCompilerVersion != null) {
75+
const pkgDir = path.join(root, 'node_modules', 'hermes-compiler');
76+
fs.mkdirSync(pkgDir, {recursive: true});
77+
fs.writeFileSync(
78+
path.join(pkgDir, 'package.json'),
79+
JSON.stringify({name: 'hermes-compiler', version: hermesCompilerVersion}),
80+
);
81+
}
82+
return root;
83+
}
84+
85+
// Forces resolveLocalHermesCompilerVersion() to behave as if hermes-compiler
86+
// isn't installed, regardless of any workspace-hoisted hermes-compiler the host
87+
// environment provides. Jest's require.resolve ignores the {paths: [rnRoot]}
88+
// scoping and still finds the hoisted package, so stubbing module resolution is
89+
// ineffective here; instead we stub fs.readFileSync to throw MODULE_NOT_FOUND
90+
// for the resolved hermes-compiler/package.json — the exact signal a genuine
91+
// resolution miss produces — which drives the function down its "not installed"
92+
// branch. Reads of any other file fall through to the real implementation.
93+
// Undo with jest.restoreAllMocks().
94+
function mockHermesCompilerUnresolvable() {
95+
const realReadFileSync = fs.readFileSync;
96+
jest.spyOn(fs, 'readFileSync').mockImplementation((file, ...rest) => {
97+
if (String(file).includes(`${path.sep}hermes-compiler${path.sep}`)) {
98+
const error = new Error(
99+
"Cannot find module 'hermes-compiler/package.json'",
100+
);
101+
error.code = 'MODULE_NOT_FOUND';
102+
throw error;
103+
}
104+
return realReadFileSync.call(fs, file, ...rest);
105+
});
106+
}
107+
108+
describe('resolveLocalHermesCompilerVersion', () => {
109+
afterEach(() => {
110+
jest.restoreAllMocks();
111+
});
112+
113+
it('reads the version from the locally installed hermes-compiler package', () => {
114+
const root = makeFakeRnRoot('0.13.7');
115+
expect(resolveLocalHermesCompilerVersion(root)).toBe('0.13.7');
116+
});
117+
118+
it('returns null when no hermes-compiler is resolvable from the given root', () => {
119+
// Resolve from a real, isolated scratch root that has no hermes-compiler
120+
// installed (same setup as the fallback test below) so require.resolve
121+
// stays scoped to that root and misses, rather than falling back to
122+
// whatever hermes-compiler the host environment happens to hoist. The
123+
// function must report absence rather than fabricating a version.
124+
const root = makeFakeRnRoot(null);
125+
mockHermesCompilerUnresolvable();
126+
expect(resolveLocalHermesCompilerVersion(root)).toBeNull();
127+
});
128+
});
129+
68130
describe('resolveHermesArtifact', () => {
69131
let origFetch;
70132
let origHermesEnv;
@@ -82,6 +144,7 @@ describe('resolveHermesArtifact', () => {
82144
} else {
83145
delete process.env.HERMES_VERSION;
84146
}
147+
jest.restoreAllMocks();
85148
});
86149

87150
// Mock fetch with a router: each entry's key is a URL substring; the value
@@ -92,57 +155,90 @@ describe('resolveHermesArtifact', () => {
92155
}
93156

94157
describe('default behavior (no HERMES_VERSION set)', () => {
95-
it('resolves to the latest-v1 hermes-compiler dist-tag, NOT the RN version', async () => {
158+
it('uses the locally pinned hermes-compiler version, without hitting npm', async () => {
159+
const rnRoot = makeFakeRnRoot('0.13.7');
96160
mockFetch({
97-
'hermes-compiler/latest-v1': {json: {version: '0.13.0'}},
98-
// Pretend the release URL exists once we ask for 0.13.0.
99-
'hermes-ios/0.13.0/hermes-ios-0.13.0': {ok: true},
161+
'hermes-ios/0.13.7/hermes-ios-0.13.7': {ok: true},
100162
});
101163
const result = await resolveHermesArtifact(
102164
'0.87.0-nightly-20260519-58cd1bf58',
103165
'debug',
104166
null,
167+
rnRoot,
168+
);
169+
expect(result.version).toBe('0.13.7');
170+
expect(result.url).toContain('/0.13.7/');
171+
// Must resolve straight from node_modules — no npm registry round trip.
172+
expect(globalThis.fetch).not.toHaveBeenCalledWith(
173+
expect.stringContaining('registry.npmjs.org'),
174+
expect.anything(),
105175
);
106-
expect(result.version).toBe('0.13.0');
107-
expect(result.url).toContain('/0.13.0/');
108-
// The RN nightly hash MUST NOT leak into the hermes URL.
109-
expect(result.url).not.toContain('20260519');
110176
});
111177

112178
it('ignores rawVersion (the RN --version arg) when HERMES_VERSION is unset', async () => {
179+
const rnRoot = makeFakeRnRoot('0.13.7');
113180
mockFetch({
114-
'hermes-compiler/latest-v1': {json: {version: '0.13.0'}},
115-
'hermes-ios/0.13.0/hermes-ios-0.13.0': {ok: true},
181+
'hermes-ios/0.13.7/hermes-ios-0.13.7': {ok: true},
116182
});
117183
// Caller passes the original RN --version verbatim; hermes should
118-
// still default to latest-v1 instead of using this.
184+
// still use the locally pinned version instead of using this.
119185
const result = await resolveHermesArtifact(
120186
'0.87.0-nightly-20260519-58cd1bf58',
121187
'debug',
122188
'0.87.0-nightly-20260519-58cd1bf58',
189+
rnRoot,
123190
);
124-
expect(result.version).toBe('0.13.0');
191+
expect(result.version).toBe('0.13.7');
125192
expect(result.url).not.toContain('20260519');
126193
});
194+
195+
it('falls back to the latest-v1 npm dist-tag when hermes-compiler is not locally installed', async () => {
196+
const rnRoot = makeFakeRnRoot(null);
197+
// Stub require.resolve to fail so the local lookup is guaranteed to miss,
198+
// even in environments that hoist a workspace hermes-compiler. The
199+
// resolver must then fall through to the latest-v1 dist-tag (0.13.0).
200+
mockHermesCompilerUnresolvable();
201+
mockFetch({
202+
'hermes-compiler/latest-v1': {json: {version: '0.13.0'}},
203+
'hermes-ios/0.13.0/hermes-ios-0.13.0': {ok: true},
204+
});
205+
const result = await resolveHermesArtifact(
206+
'0.87.0-nightly-20260519-58cd1bf58',
207+
'debug',
208+
null,
209+
rnRoot,
210+
);
211+
expect(result.version).toBe('0.13.0');
212+
expect(result.url).toContain('/0.13.0/');
213+
// Confirm the dist-tag lookup actually ran — the fallback path, not a
214+
// locally pinned version, produced this result.
215+
const hitLatestV1 = globalThis.fetch.mock.calls.some(([url]) =>
216+
String(url).includes('hermes-compiler/latest-v1'),
217+
);
218+
expect(hitLatestV1).toBe(true);
219+
});
127220
});
128221

129222
describe('HERMES_VERSION escape hatches', () => {
130-
it('HERMES_VERSION=<literal-version> uses it verbatim', async () => {
223+
it('HERMES_VERSION=<literal-version> uses it verbatim, even with a local package installed', async () => {
131224
process.env.HERMES_VERSION = '0.13.5';
225+
const rnRoot = makeFakeRnRoot('0.13.7');
132226
mockFetch({
133227
'hermes-ios/0.13.5/hermes-ios-0.13.5': {ok: true},
134228
});
135229
const result = await resolveHermesArtifact(
136230
'0.87.0-nightly-anything',
137231
'debug',
138232
null,
233+
rnRoot,
139234
);
140235
expect(result.version).toBe('0.13.5');
141236
expect(result.url).toContain('/0.13.5/');
142237
});
143238

144-
it('HERMES_VERSION=latest-v1 resolves via npm dist-tag', async () => {
239+
it('HERMES_VERSION=latest-v1 resolves via npm dist-tag, even with a local package installed', async () => {
145240
process.env.HERMES_VERSION = 'latest-v1';
241+
const rnRoot = makeFakeRnRoot('0.13.7');
146242
mockFetch({
147243
'hermes-compiler/latest-v1': {json: {version: '0.13.0'}},
148244
'hermes-ios/0.13.0/hermes-ios-0.13.0': {ok: true},
@@ -151,12 +247,14 @@ describe('resolveHermesArtifact', () => {
151247
'0.87.0-nightly-anything',
152248
'debug',
153249
null,
250+
rnRoot,
154251
);
155252
expect(result.version).toBe('0.13.0');
156253
});
157254

158255
it('HERMES_VERSION=nightly resolves hermes-compiler@nightly from npm', async () => {
159256
process.env.HERMES_VERSION = 'nightly';
257+
const rnRoot = makeFakeRnRoot(null);
160258
mockFetch({
161259
'hermes-compiler/nightly': {json: {version: '0.14.0-nightly-abc'}},
162260
'hermes-ios/0.14.0-nightly-abc/hermes-ios-0.14.0-nightly-abc': {
@@ -167,12 +265,14 @@ describe('resolveHermesArtifact', () => {
167265
'0.87.0-nightly-anything',
168266
'debug',
169267
null,
268+
rnRoot,
170269
);
171270
expect(result.version).toBe('0.14.0-nightly-abc');
172271
});
173272

174273
it('falls back to the hermes snapshot URL when the release is missing', async () => {
175274
process.env.HERMES_VERSION = '0.13.5';
275+
const rnRoot = makeFakeRnRoot(null);
176276
globalThis.fetch = jest.fn(async (url, opts) => {
177277
if (opts && opts.method === 'HEAD') {
178278
return {status: 404};
@@ -185,7 +285,12 @@ describe('resolveHermesArtifact', () => {
185285
'<buildNumber>2</buildNumber></metadata>',
186286
};
187287
});
188-
const result = await resolveHermesArtifact('0.87.0', 'debug', null);
288+
const result = await resolveHermesArtifact(
289+
'0.87.0',
290+
'debug',
291+
null,
292+
rnRoot,
293+
);
189294
expect(result.url).toContain('maven-snapshots');
190295
expect(result.url).toContain('hermes-ios-debug.tar.gz');
191296
});

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

Lines changed: 64 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -393,15 +393,63 @@ async function resolveRNDepsArtifact(
393393
return {url: snapshotUrl, version};
394394
}
395395

396+
/**
397+
* Resolves the `hermes-compiler` npm package's version from THIS project's own
398+
* node_modules — the exact same lookup generate-spm-xcodeproj.js's
399+
* resolveHermesCliPathSetting() uses to find the hermesc binary that will
400+
* compile the JS bundle, and the same one react-native-xcode.sh falls back to
401+
* for SwiftPM builds. Returns null when the package isn't resolvable (e.g.
402+
* USE_HERMES=false apps that never installed it) so the caller can fall back
403+
* to the npm dist-tag lookup.
404+
*/
405+
function resolveLocalHermesCompilerVersion(
406+
rnRoot /*: string */,
407+
) /*: string | null */ {
408+
try {
409+
const pkgPath = require.resolve('hermes-compiler/package.json', {
410+
paths: [rnRoot],
411+
});
412+
// $FlowFixMe[incompatible-type] JSON.parse returns any
413+
const pkg /*: {version: string} */ = JSON.parse(
414+
fs.readFileSync(pkgPath, 'utf8'),
415+
);
416+
assertSafeVersion(pkg.version, 'local hermes-compiler/package.json');
417+
return pkg.version;
418+
} catch (error) {
419+
// A MODULE_NOT_FOUND resolution failure is the expected case (e.g.
420+
// USE_HERMES=false apps that never installed hermes-compiler) — fall back
421+
// silently. Any other failure means hermes-compiler IS installed but its
422+
// package.json is unreadable/malformed or carries an unsafe version; warn
423+
// loudly rather than silently regressing to the live latest-v1 dist-tag,
424+
// which would re-introduce the version-skew crash this resolves (#57917).
425+
if (error.code !== 'MODULE_NOT_FOUND') {
426+
log(
427+
` 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".`,
428+
);
429+
}
430+
return null;
431+
}
432+
}
433+
396434
/**
397435
* Returns {url, version} for Hermes. Hermes uses its own version space
398436
* decoupled from React Native's nightly cadence — RN's `hermes-compiler`
399437
* npm package publishes a `latest-v1` dist-tag that always resolves to a
400-
* binary that's been built and uploaded to Maven. Our default mirrors RN's
401-
* CocoaPods prebuild path (see scripts/ios-prebuild/hermes.js):
438+
* binary that's been built and uploaded to Maven.
402439
*
403-
* HERMES_VERSION unset → 'latest-v1' dist-tag
404-
* HERMES_VERSION=latest-v1 → same (explicit)
440+
* HERMES_VERSION unset → version pinned by the locally installed
441+
* hermes-compiler package (node_modules).
442+
* This is the SAME source
443+
* resolveHermesCliPathSetting() reads for
444+
* HERMES_CLI_PATH, so the downloaded VM and
445+
* the hermesc that compiles the JS bundle
446+
* always agree — a mismatched pair crashes at
447+
* launch with "Wrong bytecode version" (#57917).
448+
* Falls back to the 'latest-v1' npm dist-tag
449+
* (RN's CocoaPods prebuild default; see
450+
* scripts/ios-prebuild/hermes.js) only when
451+
* hermes-compiler isn't locally resolvable.
452+
* HERMES_VERSION=latest-v1 → 'latest-v1' dist-tag (explicit)
405453
* HERMES_VERSION=nightly → hermes-compiler@nightly dist-tag
406454
* HERMES_VERSION=<literal> → use that version verbatim
407455
*
@@ -413,8 +461,17 @@ async function resolveHermesArtifact(
413461
rnVersion /*: string */,
414462
flavor /*: string */,
415463
rawVersion /*: string | null */,
464+
rnRoot /*: string */,
416465
) /*: Promise<ResolvedArtifact> */ {
417-
let version = process.env.HERMES_VERSION ?? 'latest-v1';
466+
let version = process.env.HERMES_VERSION;
467+
468+
if (version == null) {
469+
const localVersion = resolveLocalHermesCompilerVersion(rnRoot);
470+
if (localVersion != null) {
471+
log(` Using locally pinned hermes-compiler: ${localVersion}`);
472+
}
473+
version = localVersion ?? 'latest-v1';
474+
}
418475

419476
if (version === 'nightly') {
420477
version = await resolveNightlyVersion('hermes-compiler');
@@ -1119,7 +1176,7 @@ async function main(argv /*:: ?: Array<string> */) /*: Promise<void> */ {
11191176
label: 'hermes',
11201177
name: 'hermes-engine',
11211178
resolve: () =>
1122-
resolveHermesArtifact(resolvedRnVersion, flavor, rawVersion),
1179+
resolveHermesArtifact(resolvedRnVersion, flavor, rawVersion, rnRoot),
11231180
sharedName: (v /*: string */) => `hermes-ios-${v}-${flavor}.tar.gz`,
11241181
},
11251182
];
@@ -1390,6 +1447,7 @@ module.exports = {
13901447
main,
13911448
resolveCacheSlotVersion,
13921449
resolveHermesArtifact,
1450+
resolveLocalHermesCompilerVersion,
13931451
REQUIRED_ARTIFACTS,
13941452
validateArtifactsCache,
13951453
// Exposed for unit tests (pure / fetch-stubbable helpers).

0 commit comments

Comments
 (0)