From b1c65e01c4143ca81b481a3b5aa56b6add353815 Mon Sep 17 00:00:00 2001 From: Nycollas Date: Wed, 12 Aug 2026 18:39:44 -0300 Subject: [PATCH] fix(spm): resolve Hermes runtime from the locally pinned hermes-compiler download-spm-artifacts.js picked the Hermes runtime xcframework by querying the hermes-compiler npm package's latest-v1 dist-tag live, at build time. generate-spm-xcodeproj.js's HERMES_CLI_PATH resolution (and react-native-xcode.sh's SwiftPM fallback) instead point at the hermes-compiler package already pinned in the project's own node_modules. When the live dist-tag advances between `npm install` and a Release build, the two disagree and the app crashes at launch with "Wrong bytecode version". Make resolveHermesArtifact() read the pinned hermes-compiler version from node_modules first, falling back to the latest-v1 lookup only when the package isn't locally resolvable. Fixes #57917. Co-Authored-By: Claude Sonnet 5 --- .../__tests__/download-spm-artifacts-test.js | 91 ++++++++++++++++--- .../scripts/spm/download-spm-artifacts.js | 59 ++++++++++-- 2 files changed, 129 insertions(+), 21 deletions(-) diff --git a/packages/react-native/scripts/spm/__tests__/download-spm-artifacts-test.js b/packages/react-native/scripts/spm/__tests__/download-spm-artifacts-test.js index 97c56c26e5b..983f6674a98 100644 --- a/packages/react-native/scripts/spm/__tests__/download-spm-artifacts-test.js +++ b/packages/react-native/scripts/spm/__tests__/download-spm-artifacts-test.js @@ -23,6 +23,7 @@ const { resolveCacheSlotVersion, resolveHermesArtifact, resolveLatestV1Version, + resolveLocalHermesCompilerVersion, resolveNightlyVersion, resolveRNCoreArtifact, resolveRNDepsArtifact, @@ -95,6 +96,34 @@ function routerFetch(routes /*: {[string]: any} */) { // artifact at the RN nightly version (which won't exist on Maven). // --------------------------------------------------------------------------- +// Creates a scratch dir with (optionally) a `node_modules/hermes-compiler` +// package inside it, mimicking a real project root for +// resolveLocalHermesCompilerVersion()'s require.resolve({paths: [rnRoot]}). +function makeFakeRnRoot(hermesCompilerVersion /*: ?string */) /*: string */ { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'rn-root-')); + if (hermesCompilerVersion != null) { + const pkgDir = path.join(root, 'node_modules', 'hermes-compiler'); + fs.mkdirSync(pkgDir, {recursive: true}); + fs.writeFileSync( + path.join(pkgDir, 'package.json'), + JSON.stringify({name: 'hermes-compiler', version: hermesCompilerVersion}), + ); + } + return root; +} + +describe('resolveLocalHermesCompilerVersion', () => { + it('reads the version from the locally installed hermes-compiler package', () => { + const root = makeFakeRnRoot('0.13.7'); + expect(resolveLocalHermesCompilerVersion(root)).toBe('0.13.7'); + }); + + it('returns null when hermes-compiler is not installed', () => { + const root = makeFakeRnRoot(null); + expect(resolveLocalHermesCompilerVersion(root)).toBeNull(); + }); +}); + describe('resolveHermesArtifact', () => { let origFetch; let origHermesEnv; @@ -122,43 +151,64 @@ describe('resolveHermesArtifact', () => { } describe('default behavior (no HERMES_VERSION set)', () => { - it('resolves to the latest-v1 hermes-compiler dist-tag, NOT the RN version', async () => { + it('uses the locally pinned hermes-compiler version, without hitting npm', async () => { + const rnRoot = makeFakeRnRoot('0.13.7'); mockFetch({ - 'hermes-compiler/latest-v1': {json: {version: '0.13.0'}}, - // Pretend the release URL exists once we ask for 0.13.0. - 'hermes-ios/0.13.0/hermes-ios-0.13.0': {ok: true}, + 'hermes-ios/0.13.7/hermes-ios-0.13.7': {ok: true}, }); const result = await resolveHermesArtifact( '0.87.0-nightly-20260519-58cd1bf58', 'debug', null, + rnRoot, + ); + expect(result.version).toBe('0.13.7'); + expect(result.url).toContain('/0.13.7/'); + // Must resolve straight from node_modules — no npm registry round trip. + expect(globalThis.fetch).not.toHaveBeenCalledWith( + expect.stringContaining('registry.npmjs.org'), + expect.anything(), ); - expect(result.version).toBe('0.13.0'); - expect(result.url).toContain('/0.13.0/'); - // The RN nightly hash MUST NOT leak into the hermes URL. - expect(result.url).not.toContain('20260519'); }); it('ignores rawVersion (the RN --version arg) when HERMES_VERSION is unset', async () => { + const rnRoot = makeFakeRnRoot('0.13.7'); mockFetch({ - 'hermes-compiler/latest-v1': {json: {version: '0.13.0'}}, - 'hermes-ios/0.13.0/hermes-ios-0.13.0': {ok: true}, + 'hermes-ios/0.13.7/hermes-ios-0.13.7': {ok: true}, }); // Caller passes the original RN --version verbatim; hermes should - // still default to latest-v1 instead of using this. + // still use the locally pinned version instead of using this. const result = await resolveHermesArtifact( '0.87.0-nightly-20260519-58cd1bf58', 'debug', '0.87.0-nightly-20260519-58cd1bf58', + rnRoot, ); - expect(result.version).toBe('0.13.0'); + expect(result.version).toBe('0.13.7'); expect(result.url).not.toContain('20260519'); }); + + it('falls back to the latest-v1 npm dist-tag when hermes-compiler is not locally installed', async () => { + const rnRoot = makeFakeRnRoot(null); + mockFetch({ + 'hermes-compiler/latest-v1': {json: {version: '0.13.0'}}, + 'hermes-ios/0.13.0/hermes-ios-0.13.0': {ok: true}, + }); + const result = await resolveHermesArtifact( + '0.87.0-nightly-20260519-58cd1bf58', + 'debug', + null, + rnRoot, + ); + expect(result.version).toBe('0.13.0'); + expect(result.url).toContain('/0.13.0/'); + }); }); describe('HERMES_VERSION escape hatches', () => { - it('HERMES_VERSION= uses it verbatim', async () => { + it('HERMES_VERSION= uses it verbatim, even with a local package installed', async () => { process.env.HERMES_VERSION = '0.13.5'; + const rnRoot = makeFakeRnRoot('0.13.7'); mockFetch({ 'hermes-ios/0.13.5/hermes-ios-0.13.5': {ok: true}, }); @@ -166,13 +216,15 @@ describe('resolveHermesArtifact', () => { '0.87.0-nightly-anything', 'debug', null, + rnRoot, ); expect(result.version).toBe('0.13.5'); expect(result.url).toContain('/0.13.5/'); }); - it('HERMES_VERSION=latest-v1 resolves via npm dist-tag', async () => { + it('HERMES_VERSION=latest-v1 resolves via npm dist-tag, even with a local package installed', async () => { process.env.HERMES_VERSION = 'latest-v1'; + const rnRoot = makeFakeRnRoot('0.13.7'); mockFetch({ 'hermes-compiler/latest-v1': {json: {version: '0.13.0'}}, 'hermes-ios/0.13.0/hermes-ios-0.13.0': {ok: true}, @@ -181,12 +233,14 @@ describe('resolveHermesArtifact', () => { '0.87.0-nightly-anything', 'debug', null, + rnRoot, ); expect(result.version).toBe('0.13.0'); }); it('HERMES_VERSION=nightly resolves hermes-compiler@nightly from npm', async () => { process.env.HERMES_VERSION = 'nightly'; + const rnRoot = makeFakeRnRoot(null); mockFetch({ 'hermes-compiler/nightly': {json: {version: '0.14.0-nightly-abc'}}, 'hermes-ios/0.14.0-nightly-abc/hermes-ios-0.14.0-nightly-abc': { @@ -197,12 +251,14 @@ describe('resolveHermesArtifact', () => { '0.87.0-nightly-anything', 'debug', null, + rnRoot, ); expect(result.version).toBe('0.14.0-nightly-abc'); }); it('falls back to the hermes snapshot URL when the release is missing', async () => { process.env.HERMES_VERSION = '0.13.5'; + const rnRoot = makeFakeRnRoot(null); globalThis.fetch = jest.fn(async (url, opts) => { if (opts && opts.method === 'HEAD') { return {status: 404}; @@ -215,7 +271,12 @@ describe('resolveHermesArtifact', () => { '2', }; }); - const result = await resolveHermesArtifact('0.87.0', 'debug', null); + const result = await resolveHermesArtifact( + '0.87.0', + 'debug', + null, + rnRoot, + ); expect(result.url).toContain('maven-snapshots'); expect(result.url).toContain('hermes-ios-debug.tar.gz'); }); diff --git a/packages/react-native/scripts/spm/download-spm-artifacts.js b/packages/react-native/scripts/spm/download-spm-artifacts.js index 5a786f090da..2744d9dfbd4 100644 --- a/packages/react-native/scripts/spm/download-spm-artifacts.js +++ b/packages/react-native/scripts/spm/download-spm-artifacts.js @@ -452,15 +452,52 @@ async function resolveRNDepsArtifact( return {url: snapshotUrl, version}; } +/** + * Resolves the `hermes-compiler` npm package's version from THIS project's own + * node_modules — the exact same lookup generate-spm-xcodeproj.js's + * resolveHermesCliPathSetting() uses to find the hermesc binary that will + * compile the JS bundle, and the same one react-native-xcode.sh falls back to + * for SwiftPM builds. Returns null when the package isn't resolvable (e.g. + * USE_HERMES=false apps that never installed it) so the caller can fall back + * to the npm dist-tag lookup. + */ +function resolveLocalHermesCompilerVersion( + rnRoot /*: string */, +) /*: string | null */ { + try { + const pkgPath = require.resolve('hermes-compiler/package.json', { + paths: [rnRoot], + }); + // $FlowFixMe[incompatible-type] JSON.parse returns any + const pkg /*: {version: string} */ = JSON.parse( + fs.readFileSync(pkgPath, 'utf8'), + ); + assertSafeVersion(pkg.version, 'local hermes-compiler/package.json'); + return pkg.version; + } catch { + return null; + } +} + /** * Returns {url, version} for Hermes. Hermes uses its own version space * decoupled from React Native's nightly cadence — RN's `hermes-compiler` * npm package publishes a `latest-v1` dist-tag that always resolves to a - * binary that's been built and uploaded to Maven. Our default mirrors RN's - * CocoaPods prebuild path (see scripts/ios-prebuild/hermes.js): + * binary that's been built and uploaded to Maven. * - * HERMES_VERSION unset → 'latest-v1' dist-tag - * HERMES_VERSION=latest-v1 → same (explicit) + * HERMES_VERSION unset → version pinned by the locally installed + * hermes-compiler package (node_modules). + * This is the SAME source + * resolveHermesCliPathSetting() reads for + * HERMES_CLI_PATH, so the downloaded VM and + * the hermesc that compiles the JS bundle + * always agree — a mismatched pair crashes at + * launch with "Wrong bytecode version" (#57917). + * Falls back to the 'latest-v1' npm dist-tag + * (RN's CocoaPods prebuild default; see + * scripts/ios-prebuild/hermes.js) only when + * hermes-compiler isn't locally resolvable. + * HERMES_VERSION=latest-v1 → 'latest-v1' dist-tag (explicit) * HERMES_VERSION=nightly → hermes-compiler@nightly dist-tag * HERMES_VERSION= → use that version verbatim * @@ -472,8 +509,17 @@ async function resolveHermesArtifact( rnVersion /*: string */, flavor /*: string */, rawVersion /*: string | null */, + rnRoot /*: string */, ) /*: Promise */ { - let version = process.env.HERMES_VERSION ?? 'latest-v1'; + let version = process.env.HERMES_VERSION; + + if (version == null) { + const localVersion = resolveLocalHermesCompilerVersion(rnRoot); + if (localVersion != null) { + log(` Using locally pinned hermes-compiler: ${localVersion}`); + } + version = localVersion ?? 'latest-v1'; + } if (version === 'nightly') { version = await resolveNightlyVersion('hermes-compiler'); @@ -1178,7 +1224,7 @@ async function main(argv /*:: ?: Array */) /*: Promise */ { label: 'hermes', name: 'hermes-engine', resolve: () => - resolveHermesArtifact(resolvedRnVersion, flavor, rawVersion), + resolveHermesArtifact(resolvedRnVersion, flavor, rawVersion, rnRoot), sharedName: (v /*: string */) => `hermes-ios-${v}-${flavor}.tar.gz`, }, ]; @@ -1449,6 +1495,7 @@ module.exports = { main, resolveCacheSlotVersion, resolveHermesArtifact, + resolveLocalHermesCompilerVersion, REQUIRED_ARTIFACTS, validateArtifactsCache, // Exposed for unit tests (pure / fetch-stubbable helpers).