Skip to content

Commit 6439916

Browse files
chrfalchclaude
andcommitted
SwiftPM: stop baking an absolute HERMES_CLI_PATH into the app pbxproj
`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 drop the write: resolveHermesCliPathSetting() and the hermesCliPath parameter it fed go with it. Everything involved arrived with SwiftPM in #57332 and is still Preview-labelled, so removing the parameter from injectSpmIntoPbxproj and mergeReactBuildSettings is in scope even though it shipped in 0.87.0/0.87.1. Nothing a CocoaPods build uses is touched: the pod-derived HERMES_CLI_PATH default, the "hermesc could not be found" error and the hermesc -emit-binary call all pre-date SwiftPM. react-native-xcode.sh is unchanged here. 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 <noreply@anthropic.com>
1 parent d4c8569 commit 6439916

4 files changed

Lines changed: 80 additions & 81 deletions

File tree

packages/react-native/scripts/spm/__tests__/inject-spm-xcodeproj-test.js

Lines changed: 12 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -67,11 +67,6 @@ function withHeaderSearchPaths(value) {
6767
}
6868

6969
const RN_PATH = '../node_modules/react-native';
70-
71-
// Absolute, mirroring resolveHermesCliPathSetting (a `..`-relative path through
72-
// a symlinked react-native would resolve to the wrong dir at build time).
73-
const TEST_HERMES_CLI_PATH =
74-
'/abs/node_modules/hermes-compiler/hermesc/osx-bin/hermesc';
7570
const TEST_FRAMEWORKS = [
7671
{
7772
id: 'react',
@@ -104,7 +99,10 @@ const TEST_FRAMEWORKS = [
10499
function inject(
105100
text,
106101
remote = null,
107-
hermesCliPath = TEST_HERMES_CLI_PATH,
102+
// Dead positional slot: injectSpmIntoPbxproj no longer takes a hermesCliPath
103+
// (see the HERMES_CLI_PATH test below). Kept so the call sites below, which
104+
// pass it as `null`, stay untouched.
105+
_hermesCliPath = null,
108106
generatedSources = [],
109107
scriptPhases = [],
110108
) {
@@ -121,7 +119,6 @@ function inject(
121119
},
122120
RN_PATH,
123121
remote,
124-
hermesCliPath,
125122
generatedSources,
126123
TEST_FRAMEWORKS,
127124
scriptPhases,
@@ -237,20 +234,22 @@ describe('injectSpmIntoPbxproj — Tier 2 (build settings + phase)', () => {
237234
expect(text.match(/CLANG_CXX_LANGUAGE_STANDARD = "c\+\+20"/g)).toHaveLength(
238235
2,
239236
);
240-
// HERMES_CLI_PATH points react-native-xcode.sh at the hermes-compiler npm
241-
// package (no hermes-engine pod under SPM), injected into both configs.
242-
expect(text.match(/HERMES_CLI_PATH = /g)).toHaveLength(2);
243-
expect(text).toContain(TEST_HERMES_CLI_PATH);
244237
expect(text).toContain('RN_SPM_FLAVOR = debug');
245238
expect(text).toContain('RN_SPM_FLAVOR = release');
246239
expect(text).toContain('RN_SPM_REACT_BINARY[sdk=iphoneos*]');
247240
expect(text).toContain('RN_SPM_REACT_BINARY[sdk=iphonesimulator*]');
248241
expect(text).toContain('$(RN_SPM_REACT_BINARY)');
249242
});
250243

251-
it('omits HERMES_CLI_PATH when hermesc could not be resolved', () => {
252-
const {text} = inject(PLAIN, null, null);
244+
// An absolute hermesc path is machine-specific, and the app commits its
245+
// project.pbxproj. react-native-xcode.sh resolves hermesc through
246+
// react-native's own dependency graph at build time instead.
247+
it('never writes HERMES_CLI_PATH into either configuration', () => {
248+
const {text, buildSettingChanges} = inject(PLAIN);
253249
expect(text).not.toContain('HERMES_CLI_PATH');
250+
expect(
251+
buildSettingChanges.flatMap(change => change.createdScalars ?? []),
252+
).not.toContain('HERMES_CLI_PATH');
254253
});
255254

256255
// Swift's `#if DEBUG` — which AppDelegate.swift's bundleURL() uses to pick the
@@ -419,7 +418,6 @@ describe('injectSpmIntoPbxproj — Tier 3 (plugin generated sources)', () => {
419418
},
420419
RN_PATH,
421420
null,
422-
null,
423421
[PROVIDER_SOURCE],
424422
TEST_FRAMEWORKS,
425423
).text;
@@ -459,7 +457,6 @@ describe('injectSpmIntoPbxproj — Tier 3 (plugin generated sources)', () => {
459457
},
460458
RN_PATH,
461459
null,
462-
null,
463460
[PROVIDER_SOURCE],
464461
TEST_FRAMEWORKS,
465462
);
@@ -1104,7 +1101,6 @@ describe('injectSpmIntoPbxproj — invariants', () => {
11041101
},
11051102
RN_PATH,
11061103
null,
1107-
null,
11081104
[],
11091105
TEST_FRAMEWORKS,
11101106
).text;
@@ -1145,7 +1141,6 @@ describe('injectSpmIntoPbxproj — invariants', () => {
11451141
},
11461142
RN_PATH,
11471143
null,
1148-
null,
11491144
[],
11501145
TEST_FRAMEWORKS,
11511146
).text;

packages/react-native/scripts/spm/__tests__/remove-spm-injection-test.js

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,26 @@ function scaffoldApp(pbxproj /*: string */ = PLAIN) {
8686
return {appRoot, xcodeprojPath, rnRoot};
8787
}
8888

89+
// The hoisted `hermes-compiler` layout a real installed app has: the package
90+
// sits in the app's node_modules, NOT next to react-native, and carries the
91+
// host hermesc binary. Returns its absolute path — the value the injector used
92+
// to bake into the project.
93+
function withHermesCompiler(appRoot /*: string */) {
94+
const pkgRoot = path.join(appRoot, 'node_modules', 'hermes-compiler');
95+
const binDir = path.join(pkgRoot, 'hermesc', 'osx-bin');
96+
fs.mkdirSync(binDir, {recursive: true});
97+
fs.writeFileSync(
98+
path.join(pkgRoot, 'package.json'),
99+
JSON.stringify({name: 'hermes-compiler', version: '1.0.0'}),
100+
'utf8',
101+
);
102+
const hermesc = path.join(binDir, 'hermesc');
103+
fs.writeFileSync(hermesc, '#!/bin/sh\n', 'utf8');
104+
// os.tmpdir() is a symlink on macOS; require.resolve reports the real path,
105+
// which is what the injector would have written.
106+
return fs.realpathSync(hermesc);
107+
}
108+
89109
function pbxprojOf(xcodeprojPath) {
90110
return fs.readFileSync(path.join(xcodeprojPath, 'project.pbxproj'), 'utf8');
91111
}
@@ -156,6 +176,42 @@ function schemePathOf(xcodeprojPath) {
156176
);
157177
}
158178

179+
// An absolute hermesc path is machine-specific and the app commits its
180+
// project.pbxproj, so `spm add` must never write one. react-native-xcode.sh
181+
// resolves hermesc through react-native's own dependency graph at build time.
182+
describe('injectSpmIntoExistingXcodeproj — HERMES_CLI_PATH', () => {
183+
it('writes no HERMES_CLI_PATH, in any configuration or the marker', () => {
184+
const {appRoot, xcodeprojPath, rnRoot} = scaffoldApp();
185+
const hermesc = withHermesCompiler(appRoot);
186+
// Guard against a vacuous pass: hermesc must be resolvable from rnRoot,
187+
// which is the only condition under which a path could be written at all.
188+
expect(
189+
require.resolve('hermes-compiler/package.json', {paths: [rnRoot]}),
190+
).toBe(
191+
fs.realpathSync(
192+
path.join(appRoot, 'node_modules', 'hermes-compiler', 'package.json'),
193+
),
194+
);
195+
expect(fs.existsSync(hermesc)).toBe(true);
196+
197+
expect(
198+
injectSpmIntoExistingXcodeproj({
199+
appRoot,
200+
reactNativeRoot: rnRoot,
201+
xcodeprojPath,
202+
}).status,
203+
).toBe('injected');
204+
205+
expect(pbxprojOf(xcodeprojPath)).not.toContain('HERMES_CLI_PATH');
206+
expect(pbxprojOf(xcodeprojPath)).not.toContain(hermesc);
207+
expect(
208+
readMarker(xcodeprojPath).buildSettingChanges.flatMap(
209+
change => change.createdScalars ?? [],
210+
),
211+
).not.toContain('HERMES_CLI_PATH');
212+
});
213+
});
214+
159215
describe('removeSpmInjection — the surgical inverse of add', () => {
160216
it('round-trips: add then deinit restores the pbxproj byte-for-byte', () => {
161217
const {appRoot, xcodeprojPath, rnRoot} = scaffoldApp();

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

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -454,10 +454,9 @@ async function resolveRNDepsArtifact(
454454

455455
/**
456456
* 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.
457+
* node_modules — the same lookup react-native-xcode.sh falls back to for
458+
* SwiftPM builds to find the hermesc binary that compiles the JS bundle.
459+
* Returns null when the package isn't resolvable (e.g.
461460
* USE_HERMES=false apps that never installed it) so the caller can fall back
462461
* to the npm dist-tag lookup.
463462
*/
@@ -499,8 +498,8 @@ function resolveLocalHermesCompilerVersion(
499498
* HERMES_VERSION unset → version pinned by the locally installed
500499
* hermes-compiler package (node_modules).
501500
* This is the SAME source
502-
* resolveHermesCliPathSetting() reads for
503-
* HERMES_CLI_PATH, so the downloaded VM and
501+
* react-native-xcode.sh resolves hermesc
502+
* from, so the downloaded VM and
504503
* the hermesc that compiles the JS bundle
505504
* always agree — a mismatched pair crashes at
506505
* launch with "Wrong bytecode version" (#57917).

packages/react-native/scripts/spm/generate-spm-xcodeproj.js

Lines changed: 7 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -1250,7 +1250,6 @@ function injectSpmIntoPbxproj(
12501250
plan /*: {rootUuid: string, targetUuid: string, configUuids: Array<string>, frameworksPhaseUuid: string, sourcesPhaseUuid?: ?string} */,
12511251
reactNativePath /*: string */,
12521252
remote /*: ?RemoteCfg */,
1253-
hermesCliPath /*: ?string */ = null,
12541253
generatedSources /*: ReadonlyArray<GeneratedSource> */ = [],
12551254
flavoredFrameworks /*: ReadonlyArray<FlavoredFrameworkManifestEntry> */ = [],
12561255
scriptPhases /*: ReadonlyArray<PluginScriptPhase> */ = [],
@@ -1350,7 +1349,6 @@ function injectSpmIntoPbxproj(
13501349
configUuid,
13511350
buildConfigurationName(text, configUuid),
13521351
reactNativePath,
1353-
hermesCliPath,
13541352
flavoredFrameworks,
13551353
);
13561354
text = merged.text;
@@ -1659,48 +1657,6 @@ function findApplicationTargetByUuid(
16591657
return obj;
16601658
}
16611659

1662-
/**
1663-
* Merge the React build settings into one XCBuildConfiguration's dict. Returns
1664-
* the modified text plus a precise record of what was actually added — so
1665-
* `deinit` (removeSpmInjection) can reverse exactly these edits, never touching
1666-
* a value the user already had (key insight: ensureScalarField/
1667-
* addArrayStringValues are no-ops / dedupe when a value is already present).
1668-
*/
1669-
/**
1670-
* Resolves the host `hermesc` from the `hermes-compiler` npm package and returns
1671-
* its ABSOLUTE path as the HERMES_CLI_PATH value, or null when it can't be found
1672-
* (e.g. USE_HERMES=false apps without the package). require.resolve (anchored at
1673-
* reactNativeRoot) follows Node's lookup, so a hoisted monorepo layout — where
1674-
* hermes-compiler sits in the workspace-root node_modules, NOT next to
1675-
* react-native — resolves correctly.
1676-
*
1677-
* The value is intentionally ABSOLUTE, not `$(REACT_NATIVE_PATH)/../...`: when
1678-
* react-native is a symlink (the monorepo default, and common in real apps), a
1679-
* `..` after it resolves — kernel-side — to the symlink TARGET's parent, not the
1680-
* node_modules dir, so the relative form points at a non-existent
1681-
* `<rn-target>/../hermes-compiler`. An absolute path sidesteps that entirely
1682-
* (and matches how the CocoaPods hermes-engine pod sets HERMES_CLI_PATH). It is
1683-
* regenerated on every `spm add`, so machine-specificity is a non-issue.
1684-
*/
1685-
function resolveHermesCliPathSetting(
1686-
reactNativeRoot /*: string */,
1687-
) /*: ?string */ {
1688-
try {
1689-
const pkg = require.resolve('hermes-compiler/package.json', {
1690-
paths: [reactNativeRoot],
1691-
});
1692-
const hermesc = path.join(
1693-
path.dirname(pkg),
1694-
'hermesc',
1695-
'osx-bin',
1696-
'hermesc',
1697-
);
1698-
return fs.existsSync(hermesc) ? hermesc : null;
1699-
} catch {
1700-
return null;
1701-
}
1702-
}
1703-
17041660
/** Strip the surrounding plist quotes from a build-setting token, if any. */
17051661
function unquotePlist(s /*: string */) /*: string */ {
17061662
return s.replace(/^"/, '').replace(/"$/, '');
@@ -1723,29 +1679,24 @@ function buildSettingValueTokens(value /*: string */) /*: Set<string> */ {
17231679
);
17241680
}
17251681

1682+
/**
1683+
* Merge the React build settings into one XCBuildConfiguration's dict. Returns
1684+
* the modified text plus a precise record of what was actually added — so
1685+
* `deinit` (removeSpmInjection) can reverse exactly these edits, never touching
1686+
* a value the user already had (key insight: ensureScalarField/
1687+
* addArrayStringValues are no-ops / dedupe when a value is already present).
1688+
*/
17261689
function mergeReactBuildSettings(
17271690
input /*: string */,
17281691
configUuid /*: string */,
17291692
configurationName /*: string */,
17301693
reactNativePath /*: string */,
1731-
hermesCliPath /*: ?string */ = null,
17321694
flavoredFrameworks /*: ReadonlyArray<FlavoredFrameworkManifestEntry> */ = [],
17331695
) /*: {text: string, change: BuildSettingChange} */ {
17341696
let text = input;
17351697
const scalars = [
17361698
{key: 'CLANG_CXX_LANGUAGE_STANDARD', value: '"c++20"'},
17371699
{key: 'REACT_NATIVE_PATH', value: quoteIfNeeded(reactNativePath)},
1738-
// Under SwiftPM there is no hermes-engine pod, so react-native-xcode.sh's
1739-
// fallback ($PODS_ROOT/hermes-engine/destroot/bin/hermesc) resolves to a
1740-
// non-existent "/hermes-engine/..." and the Release JS→Hermes bundling
1741-
// fails. Point HERMES_CLI_PATH at the hermes-compiler npm package's host
1742-
// hermesc (an ABSOLUTE path resolved by the caller — see
1743-
// resolveHermesCliPathSetting). react-native-xcode.sh honors an already-set
1744-
// HERMES_CLI_PATH before its pod fallback; ensureScalarField leaves any
1745-
// user-provided value untouched.
1746-
...(hermesCliPath != null
1747-
? [{key: 'HERMES_CLI_PATH', value: quoteIfNeeded(hermesCliPath)}]
1748-
: []),
17491700
];
17501701
// Re-locate the buildSettings dict before each edit (offsets shift).
17511702
const dict = () => {
@@ -2368,7 +2319,6 @@ function injectSpmIntoExistingXcodeproj(
23682319
}
23692320
const reactNativePath = path.relative(appRoot, reactNativeRoot);
23702321
const remote = remotePackageConfig(appRoot);
2371-
const hermesCliPath = resolveHermesCliPathSetting(reactNativeRoot);
23722322
const generatedSources = readGeneratedSourcesManifest(appRoot);
23732323
const scriptPhases = readScriptPhasesManifest(appRoot);
23742324
const flavoredFrameworks = readFlavoredFrameworksManifest(appRoot).frameworks;
@@ -2441,7 +2391,6 @@ function injectSpmIntoExistingXcodeproj(
24412391
},
24422392
reactNativePath,
24432393
remote,
2444-
hermesCliPath,
24452394
generatedSources,
24462395
flavoredFrameworks,
24472396
scriptPhases,

0 commit comments

Comments
 (0)