From cc6d8dd9b1514ae7a4f8f7de7fa1534c597f4e03 Mon Sep 17 00:00:00 2001 From: Christian Falch Date: Wed, 2 Sep 2026 19:11:48 +0200 Subject: [PATCH] SwiftPM: stop baking an absolute HERMES_CLI_PATH into the app pbxproj MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `spm add` wrote the machine-specific absolute path of hermesc from the hermes-compiler npm package into every build configuration, and apps commit that pbxproj. react-native-xcode.sh already resolves hermesc at build time through react-native's own dependency graph, so stop producing the value: resolveHermesCliPathSetting() is deleted and injectSpmIntoExistingXcodeproj passes null. No signature changes. injectSpmIntoPbxproj and mergeReactBuildSettings keep their `hermesCliPath` parameter, so an out-of-tree caller passing arguments positionally is unaffected and can still pin hermesc itself. react-native-xcode.sh is untouched. Existing injected projects clean up on the next `spm add`/`update`, which strips every recorded scalar before re-injecting. Known gap, deliberately left for a follow-up: the shell fallback is gated on PODS_ROOT being absent, so a SwiftPM app that keeps side-by-side non-RN pods never reaches it and needs an explicit HERMES_CLI_PATH — set in an xcconfig, not the pbxproj, because the recorded-scalar cleanup is value-blind. Keying that gate on the hermes-engine pod directory would close it. Co-Authored-By: Claude Fable 5.1 --- .../__tests__/inject-spm-xcodeproj-test.js | 35 +++++++--- .../__tests__/remove-spm-injection-test.js | 56 ++++++++++++++++ .../scripts/spm/download-spm-artifacts.js | 11 ++-- .../scripts/spm/generate-spm-xcodeproj.js | 65 ++++--------------- 4 files changed, 100 insertions(+), 67 deletions(-) diff --git a/packages/react-native/scripts/spm/__tests__/inject-spm-xcodeproj-test.js b/packages/react-native/scripts/spm/__tests__/inject-spm-xcodeproj-test.js index a7146820502e..d442a5498ae6 100644 --- a/packages/react-native/scripts/spm/__tests__/inject-spm-xcodeproj-test.js +++ b/packages/react-native/scripts/spm/__tests__/inject-spm-xcodeproj-test.js @@ -68,8 +68,8 @@ function withHeaderSearchPaths(value) { const RN_PATH = '../node_modules/react-native'; -// Absolute, mirroring resolveHermesCliPathSetting (a `..`-relative path through -// a symlinked react-native would resolve to the wrong dir at build time). +// Only an explicit caller-supplied value reaches HERMES_CLI_PATH now; React +// Native itself passes null. const TEST_HERMES_CLI_PATH = '/abs/node_modules/hermes-compiler/hermesc/osx-bin/hermesc'; const TEST_FRAMEWORKS = [ @@ -104,7 +104,7 @@ const TEST_FRAMEWORKS = [ function inject( text, remote = null, - hermesCliPath = TEST_HERMES_CLI_PATH, + hermesCliPath = null, generatedSources = [], scriptPhases = [], ) { @@ -237,10 +237,6 @@ describe('injectSpmIntoPbxproj — Tier 2 (build settings + phase)', () => { expect(text.match(/CLANG_CXX_LANGUAGE_STANDARD = "c\+\+20"/g)).toHaveLength( 2, ); - // HERMES_CLI_PATH points react-native-xcode.sh at the hermes-compiler npm - // package (no hermes-engine pod under SPM), injected into both configs. - expect(text.match(/HERMES_CLI_PATH = /g)).toHaveLength(2); - expect(text).toContain(TEST_HERMES_CLI_PATH); expect(text).toContain('RN_SPM_FLAVOR = debug'); expect(text).toContain('RN_SPM_FLAVOR = release'); expect(text).toContain('RN_SPM_REACT_BINARY[sdk=iphoneos*]'); @@ -248,9 +244,30 @@ describe('injectSpmIntoPbxproj — Tier 2 (build settings + phase)', () => { expect(text).toContain('$(RN_SPM_REACT_BINARY)'); }); - it('omits HERMES_CLI_PATH when hermesc could not be resolved', () => { - const {text} = inject(PLAIN, null, null); + // An absolute hermesc path is machine-specific, and the app commits its + // project.pbxproj. react-native-xcode.sh resolves hermesc through + // react-native's own dependency graph at build time instead. + it('writes no HERMES_CLI_PATH by default', () => { + const {text, buildSettingChanges} = inject(PLAIN); expect(text).not.toContain('HERMES_CLI_PATH'); + expect( + buildSettingChanges.flatMap(change => change.createdScalars ?? []), + ).not.toContain('HERMES_CLI_PATH'); + }); + + // The parameter stays for out-of-tree callers that pin hermesc themselves. + it('still honors an explicitly supplied hermesCliPath', () => { + const {text, buildSettingChanges} = inject( + PLAIN, + null, + TEST_HERMES_CLI_PATH, + ); + expect(text.match(/HERMES_CLI_PATH = /g)).toHaveLength(2); + expect(text).toContain(TEST_HERMES_CLI_PATH); + // Recorded, so `deinit` reverses it. + expect( + buildSettingChanges.flatMap(change => change.createdScalars ?? []), + ).toContain('HERMES_CLI_PATH'); }); // Swift's `#if DEBUG` — which AppDelegate.swift's bundleURL() uses to pick the diff --git a/packages/react-native/scripts/spm/__tests__/remove-spm-injection-test.js b/packages/react-native/scripts/spm/__tests__/remove-spm-injection-test.js index 348fb3b99234..2974e310af4e 100644 --- a/packages/react-native/scripts/spm/__tests__/remove-spm-injection-test.js +++ b/packages/react-native/scripts/spm/__tests__/remove-spm-injection-test.js @@ -86,6 +86,26 @@ function scaffoldApp(pbxproj /*: string */ = PLAIN) { return {appRoot, xcodeprojPath, rnRoot}; } +// The hoisted `hermes-compiler` layout a real installed app has: the package +// sits in the app's node_modules, NOT next to react-native, and carries the +// host hermesc binary. Returns its absolute path — the value the injector used +// to bake into the project. +function withHermesCompiler(appRoot /*: string */) { + const pkgRoot = path.join(appRoot, 'node_modules', 'hermes-compiler'); + const binDir = path.join(pkgRoot, 'hermesc', 'osx-bin'); + fs.mkdirSync(binDir, {recursive: true}); + fs.writeFileSync( + path.join(pkgRoot, 'package.json'), + JSON.stringify({name: 'hermes-compiler', version: '1.0.0'}), + 'utf8', + ); + const hermesc = path.join(binDir, 'hermesc'); + fs.writeFileSync(hermesc, '#!/bin/sh\n', 'utf8'); + // os.tmpdir() is a symlink on macOS; require.resolve reports the real path, + // which is what the injector would have written. + return fs.realpathSync(hermesc); +} + function pbxprojOf(xcodeprojPath) { return fs.readFileSync(path.join(xcodeprojPath, 'project.pbxproj'), 'utf8'); } @@ -156,6 +176,42 @@ function schemePathOf(xcodeprojPath) { ); } +// An absolute hermesc path is machine-specific and the app commits its +// project.pbxproj, so `spm add` must never write one. react-native-xcode.sh +// resolves hermesc through react-native's own dependency graph at build time. +describe('injectSpmIntoExistingXcodeproj — HERMES_CLI_PATH', () => { + it('writes no HERMES_CLI_PATH, in any configuration or the marker', () => { + const {appRoot, xcodeprojPath, rnRoot} = scaffoldApp(); + const hermesc = withHermesCompiler(appRoot); + // Guard against a vacuous pass: hermesc must be resolvable from rnRoot, + // which is the only condition under which a path could be written at all. + expect( + require.resolve('hermes-compiler/package.json', {paths: [rnRoot]}), + ).toBe( + fs.realpathSync( + path.join(appRoot, 'node_modules', 'hermes-compiler', 'package.json'), + ), + ); + expect(fs.existsSync(hermesc)).toBe(true); + + expect( + injectSpmIntoExistingXcodeproj({ + appRoot, + reactNativeRoot: rnRoot, + xcodeprojPath, + }).status, + ).toBe('injected'); + + expect(pbxprojOf(xcodeprojPath)).not.toContain('HERMES_CLI_PATH'); + expect(pbxprojOf(xcodeprojPath)).not.toContain(hermesc); + expect( + readMarker(xcodeprojPath).buildSettingChanges.flatMap( + change => change.createdScalars ?? [], + ), + ).not.toContain('HERMES_CLI_PATH'); + }); +}); + describe('removeSpmInjection — the surgical inverse of add', () => { it('round-trips: add then deinit restores the pbxproj byte-for-byte', () => { const {appRoot, xcodeprojPath, rnRoot} = scaffoldApp(); diff --git a/packages/react-native/scripts/spm/download-spm-artifacts.js b/packages/react-native/scripts/spm/download-spm-artifacts.js index ba6cc6ea74b3..f84dc017b60b 100644 --- a/packages/react-native/scripts/spm/download-spm-artifacts.js +++ b/packages/react-native/scripts/spm/download-spm-artifacts.js @@ -454,10 +454,9 @@ async function resolveRNDepsArtifact( /** * 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. + * node_modules — the same lookup react-native-xcode.sh falls back to for + * SwiftPM builds to find the hermesc binary that compiles the JS bundle. + * 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. */ @@ -499,8 +498,8 @@ function resolveLocalHermesCompilerVersion( * 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 + * react-native-xcode.sh resolves hermesc + * from, 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). diff --git a/packages/react-native/scripts/spm/generate-spm-xcodeproj.js b/packages/react-native/scripts/spm/generate-spm-xcodeproj.js index 5c0c5d35ce33..436d1151ed8c 100644 --- a/packages/react-native/scripts/spm/generate-spm-xcodeproj.js +++ b/packages/react-native/scripts/spm/generate-spm-xcodeproj.js @@ -1659,48 +1659,6 @@ function findApplicationTargetByUuid( return obj; } -/** - * Merge the React build settings into one XCBuildConfiguration's dict. Returns - * the modified text plus a precise record of what was actually added — so - * `deinit` (removeSpmInjection) can reverse exactly these edits, never touching - * a value the user already had (key insight: ensureScalarField/ - * addArrayStringValues are no-ops / dedupe when a value is already present). - */ -/** - * Resolves the host `hermesc` from the `hermes-compiler` npm package and returns - * its ABSOLUTE path as the HERMES_CLI_PATH value, or null when it can't be found - * (e.g. USE_HERMES=false apps without the package). require.resolve (anchored at - * reactNativeRoot) follows Node's lookup, so a hoisted monorepo layout — where - * hermes-compiler sits in the workspace-root node_modules, NOT next to - * react-native — resolves correctly. - * - * The value is intentionally ABSOLUTE, not `$(REACT_NATIVE_PATH)/../...`: when - * react-native is a symlink (the monorepo default, and common in real apps), a - * `..` after it resolves — kernel-side — to the symlink TARGET's parent, not the - * node_modules dir, so the relative form points at a non-existent - * `/../hermes-compiler`. An absolute path sidesteps that entirely - * (and matches how the CocoaPods hermes-engine pod sets HERMES_CLI_PATH). It is - * regenerated on every `spm add`, so machine-specificity is a non-issue. - */ -function resolveHermesCliPathSetting( - reactNativeRoot /*: string */, -) /*: ?string */ { - try { - const pkg = require.resolve('hermes-compiler/package.json', { - paths: [reactNativeRoot], - }); - const hermesc = path.join( - path.dirname(pkg), - 'hermesc', - 'osx-bin', - 'hermesc', - ); - return fs.existsSync(hermesc) ? hermesc : null; - } catch { - return null; - } -} - /** Strip the surrounding plist quotes from a build-setting token, if any. */ function unquotePlist(s /*: string */) /*: string */ { return s.replace(/^"/, '').replace(/"$/, ''); @@ -1723,6 +1681,13 @@ function buildSettingValueTokens(value /*: string */) /*: Set */ { ); } +/** + * Merge the React build settings into one XCBuildConfiguration's dict. Returns + * the modified text plus a precise record of what was actually added — so + * `deinit` (removeSpmInjection) can reverse exactly these edits, never touching + * a value the user already had (key insight: ensureScalarField/ + * addArrayStringValues are no-ops / dedupe when a value is already present). + */ function mergeReactBuildSettings( input /*: string */, configUuid /*: string */, @@ -1735,14 +1700,10 @@ function mergeReactBuildSettings( const scalars = [ {key: 'CLANG_CXX_LANGUAGE_STANDARD', value: '"c++20"'}, {key: 'REACT_NATIVE_PATH', value: quoteIfNeeded(reactNativePath)}, - // Under SwiftPM there is no hermes-engine pod, so react-native-xcode.sh's - // fallback ($PODS_ROOT/hermes-engine/destroot/bin/hermesc) resolves to a - // non-existent "/hermes-engine/..." and the Release JS→Hermes bundling - // fails. Point HERMES_CLI_PATH at the hermes-compiler npm package's host - // hermesc (an ABSOLUTE path resolved by the caller — see - // resolveHermesCliPathSetting). react-native-xcode.sh honors an already-set - // HERMES_CLI_PATH before its pod fallback; ensureScalarField leaves any - // user-provided value untouched. + // Opt-in only, for an out-of-tree caller that must pin hermesc. React + // Native itself passes null: an absolute path here is machine-specific and + // the app commits its pbxproj, and react-native-xcode.sh already resolves + // hermesc through react-native's own dependency graph at build time. ...(hermesCliPath != null ? [{key: 'HERMES_CLI_PATH', value: quoteIfNeeded(hermesCliPath)}] : []), @@ -2368,7 +2329,6 @@ function injectSpmIntoExistingXcodeproj( } const reactNativePath = path.relative(appRoot, reactNativeRoot); const remote = remotePackageConfig(appRoot); - const hermesCliPath = resolveHermesCliPathSetting(reactNativeRoot); const generatedSources = readGeneratedSourcesManifest(appRoot); const scriptPhases = readScriptPhasesManifest(appRoot); const flavoredFrameworks = readFlavoredFrameworksManifest(appRoot).frameworks; @@ -2441,7 +2401,8 @@ function injectSpmIntoExistingXcodeproj( }, reactNativePath, remote, - hermesCliPath, + // No baked HERMES_CLI_PATH — see mergeReactBuildSettings. + null, generatedSources, flavoredFrameworks, scriptPhases,