diff --git a/packages/react-native/scripts/react-native-xcode.sh b/packages/react-native/scripts/react-native-xcode.sh index fcf05ff9c3a2..4744e694740a 100755 --- a/packages/react-native/scripts/react-native-xcode.sh +++ b/packages/react-native/scripts/react-native-xcode.sh @@ -80,18 +80,18 @@ source "$REACT_NATIVE_DIR/scripts/node-binary.sh" HERMES_ENGINE_PATH="$PODS_ROOT/hermes-engine" [ -z "$HERMES_CLI_PATH" ] && HERMES_CLI_PATH="$HERMES_ENGINE_PATH/destroot/bin/hermesc" -# SwiftPM consumers have no Pods directory to provide hermesc. When there is -# no Pods installation at all and the current HERMES_CLI_PATH does not exist -# (unset, or an injector-baked path gone stale after a package-store move), -# resolve the hermes-compiler npm package THROUGH react-native's own dependency -# graph — react-native pins the hermes-compiler version, so the compiler's -# bytecode version always matches the prebuilt hermes VM artifacts (a -# mismatched pair crashes at launch with "Wrong bytecode version"). -# STRICTLY SwiftPM-scoped: in a CocoaPods build the hermes-engine pod owns both -# the VM and hermesc, and its hermes train may legitimately differ from the npm -# hermes-compiler — falling back there would compile bundles the podded VM -# rejects. -if [[ ( -z "$PODS_ROOT" || ! -d "$PODS_ROOT" ) && ! -f "$HERMES_CLI_PATH" ]]; then +# SwiftPM consumers have no hermes-engine pod to provide hermesc, so resolve the +# hermes-compiler npm package THROUGH react-native's own dependency graph — +# react-native pins the hermes-compiler version, so the compiler's bytecode +# version always matches the prebuilt hermes VM artifacts (a mismatched pair +# crashes at launch with "Wrong bytecode version"). +# Fires only when Hermes is on, no hermes-engine pod is installed, and the +# resolved path is not a file. Keyed on the pod DIRECTORY, not on PODS_ROOT: a +# SwiftPM app may keep side-by-side non-RN pods, giving it a real PODS_ROOT. +# Any app that does have the pod keeps its previous behavior exactly — including +# a partial prebuild, where the missing hermesc must stay a build failure rather +# than compile a bundle the podded VM rejects. +if [[ "$USE_HERMES" != false && ! -d "$HERMES_ENGINE_PATH" && ! -f "$HERMES_CLI_PATH" ]]; then NODE_HERMESC=$(RN_DIR="$REACT_NATIVE_DIR" "$NODE_BINARY" --print \ "require('path').join(require('path').dirname(require.resolve('hermes-compiler/package.json', {paths: [process.env.RN_DIR]})), 'hermesc', 'osx-bin', 'hermesc')" \ 2>/dev/null || true) 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..67faac0bf1ee 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 @@ -67,11 +67,6 @@ 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). -const TEST_HERMES_CLI_PATH = - '/abs/node_modules/hermes-compiler/hermesc/osx-bin/hermesc'; const TEST_FRAMEWORKS = [ { id: 'react', @@ -101,13 +96,7 @@ const TEST_FRAMEWORKS = [ }, ]; -function inject( - text, - remote = null, - hermesCliPath = TEST_HERMES_CLI_PATH, - generatedSources = [], - scriptPhases = [], -) { +function inject(text, remote = null, generatedSources = [], scriptPhases = []) { const plan = planInjection(text, {}); expect(plan.ok).toBe(true); return injectSpmIntoPbxproj( @@ -121,7 +110,6 @@ function inject( }, RN_PATH, remote, - hermesCliPath, generatedSources, TEST_FRAMEWORKS, scriptPhases, @@ -237,10 +225,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 +232,15 @@ 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('never writes HERMES_CLI_PATH into either configuration', () => { + const {text, buildSettingChanges} = inject(PLAIN); expect(text).not.toContain('HERMES_CLI_PATH'); + expect( + buildSettingChanges.flatMap(change => change.createdScalars ?? []), + ).not.toContain('HERMES_CLI_PATH'); }); // Swift's `#if DEBUG` — which AppDelegate.swift's bundleURL() uses to pick the @@ -362,9 +352,7 @@ describe('injectSpmIntoPbxproj — Tier 2 (build settings + phase)', () => { describe('injectSpmIntoPbxproj — Tier 3 (plugin generated sources)', () => { it('wires a manifest entry into the app target (ref + build file + Sources + group)', () => { - const {text, generatedSourceUuids} = inject(PLAIN, null, null, [ - PROVIDER_SOURCE, - ]); + const {text, generatedSourceUuids} = inject(PLAIN, null, [PROVIDER_SOURCE]); const [fileRefUuid, buildFileUuid] = generatedSourceUuids[PROVIDER_SOURCE.path]; expect(fileRefUuid).toMatch(/^[0-9A-F]{24}$/); @@ -398,7 +386,7 @@ describe('injectSpmIntoPbxproj — Tier 3 (plugin generated sources)', () => { `${fileRefUuid} /* ExpoModulesProvider.swift */,`, ); // File ref + build file UUIDs are tracked for deinit. - const {injectedUuids} = inject(PLAIN, null, null, [PROVIDER_SOURCE]); + const {injectedUuids} = inject(PLAIN, null, [PROVIDER_SOURCE]); expect(injectedUuids).toEqual( expect.arrayContaining([fileRefUuid, buildFileUuid]), ); @@ -406,7 +394,7 @@ describe('injectSpmIntoPbxproj — Tier 3 (plugin generated sources)', () => { }); it('is idempotent with generated sources — a second run is byte-for-byte identical', () => { - const first = inject(PLAIN, null, null, [PROVIDER_SOURCE]).text; + const first = inject(PLAIN, null, [PROVIDER_SOURCE]).text; const plan = planInjection(first, {}); const second = injectSpmIntoPbxproj( first, @@ -419,7 +407,6 @@ describe('injectSpmIntoPbxproj — Tier 3 (plugin generated sources)', () => { }, RN_PATH, null, - null, [PROVIDER_SOURCE], TEST_FRAMEWORKS, ).text; @@ -433,7 +420,7 @@ describe('injectSpmIntoPbxproj — Tier 3 (plugin generated sources)', () => { sourceTree: '""', fileType: 'sourcecode.swift', }; - const {text} = inject(PLAIN, null, null, [abs]); + const {text} = inject(PLAIN, null, [abs]); expect(text).toContain('path = /opt/generated/OtherProvider.swift;'); expect(text).toContain('sourceTree = "";'); }); @@ -459,7 +446,6 @@ describe('injectSpmIntoPbxproj — Tier 3 (plugin generated sources)', () => { }, RN_PATH, null, - null, [PROVIDER_SOURCE], TEST_FRAMEWORKS, ); @@ -496,7 +482,7 @@ describe.each(HOSTILE_SOURCE_NAMES)( }; it('normalizes all three comments and keeps the project balanced', () => { - const {text, generatedSourceUuids} = inject(PLAIN, null, null, [src]); + const {text, generatedSourceUuids} = inject(PLAIN, null, [src]); const [fileRefUuid, buildFileUuid] = generatedSourceUuids[src.path]; expect(definitionComment(text, fileRefUuid)).toBe(comment); expect(definitionComment(text, buildFileUuid)).toBe( @@ -513,14 +499,14 @@ describe.each(HOSTILE_SOURCE_NAMES)( }); it('leaves the path and name VALUES verbatim', () => { - const {text} = inject(PLAIN, null, null, [src]); + const {text} = inject(PLAIN, null, [src]); expect(text).toContain(`path = "${src.path}";`); expect(text).toContain(`name = "${fileName}";`); }); it('re-injects byte-identically', () => { - const first = inject(PLAIN, null, null, [src]).text; - expect(inject(first, null, null, [src]).text).toBe(first); + const first = inject(PLAIN, null, [src]).text; + expect(inject(first, null, [src]).text).toBe(first); }); }, ); @@ -529,9 +515,7 @@ describe('injectSpmIntoPbxproj — an ordinary generated-source name', () => { // Normalization must be invisible for every real-world filename, or every // already-injected project churns on its next sync. it('reaches all three comments byte-unchanged', () => { - const {text, generatedSourceUuids} = inject(PLAIN, null, null, [ - PROVIDER_SOURCE, - ]); + const {text, generatedSourceUuids} = inject(PLAIN, null, [PROVIDER_SOURCE]); const [fileRefUuid, buildFileUuid] = generatedSourceUuids[PROVIDER_SOURCE.path]; expect(definitionComment(text, fileRefUuid)).toBe( @@ -562,7 +546,6 @@ describe('injectSpmIntoPbxproj — Tier 4 (plugin script phases)', () => { const {text, scriptPhaseUuids} = inject( PLAIN, null, - null, [], [APP_CONFIG_PHASE], ); @@ -579,7 +562,7 @@ describe('injectSpmIntoPbxproj — Tier 4 (plugin script phases)', () => { '\t\t\t\t"$(TARGET_BUILD_DIR)/EXConstants.bundle/app.config",\n', ); // Recorded so `deinit` reverses it and `update` reconciles it. - const {injectedUuids} = inject(PLAIN, null, null, [], [APP_CONFIG_PHASE]); + const {injectedUuids} = inject(PLAIN, null, [], [APP_CONFIG_PHASE]); expect(injectedUuids).toEqual(expect.arrayContaining([uuid])); expect(isBalanced(text)).toBe(true); }); @@ -588,7 +571,6 @@ describe('injectSpmIntoPbxproj — Tier 4 (plugin script phases)', () => { const withFlag = inject( PLAIN, null, - null, [], [{...APP_CONFIG_PHASE, alwaysOutOfDate: true}], ).text; @@ -602,14 +584,14 @@ describe('injectSpmIntoPbxproj — Tier 4 (plugin script phases)', () => { APP_CONFIG_PHASE, {...APP_CONFIG_PHASE, alwaysOutOfDate: false}, ]) { - expect(inject(PLAIN, null, null, [], [phase]).text).not.toContain( + expect(inject(PLAIN, null, [], [phase]).text).not.toContain( 'alwaysOutOfDate', ); } }); it("places an 'end' phase last in buildPhases", () => { - const {text} = inject(PLAIN, null, null, [], [APP_CONFIG_PHASE]); + const {text} = inject(PLAIN, null, [], [APP_CONFIG_PHASE]); const comments = buildPhaseComments(text); expect(comments[comments.length - 1]).toBe('Bundle Expo app.config'); // NOTE: the fixture target has only Sources/Frameworks/Resources — it has @@ -622,7 +604,6 @@ describe('injectSpmIntoPbxproj — Tier 4 (plugin script phases)', () => { const {text} = inject( PLAIN, null, - null, [], [{...APP_CONFIG_PHASE, position: 'beforeCompile'}], ); @@ -644,7 +625,6 @@ describe('injectSpmIntoPbxproj — Tier 4 (plugin script phases)', () => { const {text} = inject( PLAIN, null, - null, [], [ phase('pre-a', 'beforeCompile'), @@ -668,8 +648,8 @@ describe('injectSpmIntoPbxproj — Tier 4 (plugin script phases)', () => { {...APP_CONFIG_PHASE, alwaysOutOfDate: true}, {...APP_CONFIG_PHASE, id: 'other', name: 'Other', position: 'end'}, ]; - const first = inject(PLAIN, null, null, [], phases).text; - const second = inject(first, null, null, [], phases).text; + const first = inject(PLAIN, null, [], phases).text; + const second = inject(first, null, [], phases).text; expect(second).toBe(first); }); @@ -677,37 +657,35 @@ describe('injectSpmIntoPbxproj — Tier 4 (plugin script phases)', () => { const script = 'echo "a\\b" > "$(DERIVED_FILE_DIR)/x"\nprintf \'%s\\n\' done'; const phases = [{...APP_CONFIG_PHASE, script}]; - const {text} = inject(PLAIN, null, null, [], phases); + const {text} = inject(PLAIN, null, [], phases); expect(text).toContain( 'shellScript = "echo \\"a\\\\b\\" > \\"$(DERIVED_FILE_DIR)/x\\"\\nprintf \'%s\\\\n\' done";', ); expect(isBalanced(text)).toBe(true); - expect(inject(text, null, null, [], phases).text).toBe(text); + expect(inject(text, null, [], phases).text).toBe(text); }); it('quotes a name containing a double quote in the field, dropping it from the comments', () => { const phases = [{...APP_CONFIG_PHASE, name: 'Bundle "app.config"'}]; - const {text, scriptPhaseUuids} = inject(PLAIN, null, null, [], phases); + const {text, scriptPhaseUuids} = inject(PLAIN, null, [], phases); const uuid = scriptPhaseUuids[APP_CONFIG_PHASE.id]; expect(text).toContain('name = "Bundle \\"app.config\\"";'); expect(text).toContain(`${uuid} /* Bundle app.config */ = {`); expect(buildPhaseComments(text)).toContain('Bundle app.config'); expect(isBalanced(text)).toBe(true); - expect(inject(text, null, null, [], phases).text).toBe(text); + expect(inject(text, null, [], phases).text).toBe(text); }); it('a rename refreshes the name field AND both /* … */ comments', () => { const first = inject( PLAIN, null, - null, [], [{...APP_CONFIG_PHASE, name: 'Write App Config'}], ).text; const renamed = inject( first, null, - null, [], [{...APP_CONFIG_PHASE, name: 'Write Expo Config'}], ).text; @@ -733,11 +711,11 @@ describe('injectSpmIntoPbxproj — repositioning plugin script phases', () => { }); it('moves a phase from end to beforeCompile and back again', () => { - const atEnd = inject(PLAIN, null, null, [], [phase('a', 'end')]).text; + const atEnd = inject(PLAIN, null, [], [phase('a', 'end')]).text; expect(buildPhaseComments(atEnd).slice(-1)).toEqual(['a']); const beforeCompile = [phase('a', 'beforeCompile')]; - const moved = inject(atEnd, null, null, [], beforeCompile).text; + const moved = inject(atEnd, null, [], beforeCompile).text; expect(buildPhaseComments(moved).slice(0, 3)).toEqual([ 'Sync SPM Autolinking', 'a', @@ -745,17 +723,16 @@ describe('injectSpmIntoPbxproj — repositioning plugin script phases', () => { ]); expect(isBalanced(moved)).toBe(true); // Re-syncing the now-matching declaration changes nothing. - expect(inject(moved, null, null, [], beforeCompile).text).toBe(moved); + expect(inject(moved, null, [], beforeCompile).text).toBe(moved); // And back: a move is a pure reordering of the membership lines. - expect(inject(moved, null, null, [], [phase('a', 'end')]).text).toBe(atEnd); + expect(inject(moved, null, [], [phase('a', 'end')]).text).toBe(atEnd); }); it('reorders two phases sharing a position when their declared order swaps', () => { const inOrder = inject( PLAIN, null, - null, [], [phase('b1', 'beforeCompile'), phase('b2', 'beforeCompile')], ).text; @@ -769,7 +746,6 @@ describe('injectSpmIntoPbxproj — repositioning plugin script phases', () => { const swapped = inject( inOrder, null, - null, [], [phase('b2', 'beforeCompile'), phase('b1', 'beforeCompile')], ).text; @@ -787,7 +763,7 @@ describe('injectSpmIntoPbxproj — repositioning plugin script phases', () => { phase('b', 'beforeCompile'), phase('c', 'beforeCompile'), ]; - const first = inject(PLAIN, null, null, [], declared).text; + const first = inject(PLAIN, null, [], declared).text; expect(buildPhaseComments(first).slice(0, 5)).toEqual([ 'Sync SPM Autolinking', 'a', @@ -799,7 +775,6 @@ describe('injectSpmIntoPbxproj — repositioning plugin script phases', () => { const {text} = inject( first, null, - null, [], [declared[0], {...declared[1], position: 'end'}, declared[2]], ); @@ -815,13 +790,7 @@ describe('injectSpmIntoPbxproj — repositioning plugin script phases', () => { it('moves a phase the user dragged in Xcode back to its declared position', () => { const phases = [phase('a', 'end')]; - const {text: first, scriptPhaseUuids} = inject( - PLAIN, - null, - null, - [], - phases, - ); + const {text: first, scriptPhaseUuids} = inject(PLAIN, null, [], phases); const memberLine = `\n\t\t\t\t${scriptPhaseUuids.a} /* a */,`; expect(first).toContain(memberLine); const dragged = first @@ -829,7 +798,7 @@ describe('injectSpmIntoPbxproj — repositioning plugin script phases', () => { .replace('buildPhases = (\n', `buildPhases = (${memberLine}\n`); expect(buildPhaseComments(dragged)[0]).toBe('a'); - expect(inject(dragged, null, null, [], phases).text).toBe(first); + expect(inject(dragged, null, [], phases).text).toBe(first); }); // RN never re-seats its own sync phase, so a user who drags it below Sources @@ -843,7 +812,7 @@ describe('injectSpmIntoPbxproj — repositioning plugin script phases', () => { ]); const declared = [phase('a', 'beforeCompile')]; - const {text} = inject(dragged, null, null, [], declared); + const {text} = inject(dragged, null, [], declared); expect(buildPhaseComments(text)).toEqual([ 'a', 'Sources', @@ -853,7 +822,7 @@ describe('injectSpmIntoPbxproj — repositioning plugin script phases', () => { 'Resources', ]); expect(isBalanced(text)).toBe(true); - expect(inject(text, null, null, [], declared).text).toBe(text); + expect(inject(text, null, [], declared).text).toBe(text); }); it('falls back to the sync phase as the anchor when the target has no Sources phase', () => { @@ -861,13 +830,7 @@ describe('injectSpmIntoPbxproj — repositioning plugin script phases', () => { /\/\* Begin PBXSourcesBuildPhase section \*\/[\s\S]*?\/\* End PBXSourcesBuildPhase section \*\/\n\n/, '', ); - const {text} = inject( - noSources, - null, - null, - [], - [phase('a', 'beforeCompile')], - ); + const {text} = inject(noSources, null, [], [phase('a', 'beforeCompile')]); expect(buildPhaseComments(text).slice(0, 2)).toEqual([ 'Sync SPM Autolinking', 'a', @@ -892,8 +855,8 @@ describe('injectSpmIntoPbxproj — repositioning plugin script phases', () => { ], ], ])('re-syncs %s byte-identically', (_label, phases) => { - const first = inject(PLAIN, null, null, [], phases).text; - expect(inject(first, null, null, [], phases).text).toBe(first); + const first = inject(PLAIN, null, [], phases).text; + expect(inject(first, null, [], phases).text).toBe(first); }); }); @@ -908,7 +871,6 @@ describe('buildPhaseOrder', () => { const {text} = inject( PLAIN, null, - null, [], [{...APP_CONFIG_PHASE, name: PHANTOM}], ); @@ -924,13 +886,13 @@ describe('buildPhaseOrder', () => { const phases = [ {...APP_CONFIG_PHASE, name: PHANTOM, position: 'beforeCompile'}, ]; - const first = inject(PLAIN, null, null, [], phases).text; + const first = inject(PLAIN, null, [], phases).text; expect(buildPhaseComments(first).slice(0, 3)).toEqual([ 'Sync SPM Autolinking', PHANTOM, 'Sources', ]); - expect(inject(first, null, null, [], phases).text).toBe(first); + expect(inject(first, null, [], phases).text).toBe(first); }); }); @@ -976,7 +938,7 @@ describe.each(HOSTILE_NAMES)( const phases = [{...APP_CONFIG_PHASE, name}]; it('normalizes it in both comments and keeps the project balanced', () => { - const {text, scriptPhaseUuids} = inject(PLAIN, null, null, [], phases); + const {text, scriptPhaseUuids} = inject(PLAIN, null, [], phases); const uuid = scriptPhaseUuids[APP_CONFIG_PHASE.id]; expect(definitionComment(text, uuid)).toBe(comment); expect(buildPhaseComments(text)).toContain(comment); @@ -988,8 +950,8 @@ describe.each(HOSTILE_NAMES)( }); it('re-injects byte-identically', () => { - const first = inject(PLAIN, null, null, [], phases).text; - expect(inject(first, null, null, [], phases).text).toBe(first); + const first = inject(PLAIN, null, [], phases).text; + expect(inject(first, null, [], phases).text).toBe(first); }); }, ); @@ -1002,31 +964,31 @@ describe('injectSpmIntoPbxproj — a scoped-npm-name phase id', () => { const SCOPED = {...APP_CONFIG_PHASE, id: '@expo/log-box'}; it('keys the phase UUID on the scoped id verbatim', () => { - const {scriptPhaseUuids} = inject(PLAIN, null, null, [], [SCOPED]); + const {scriptPhaseUuids} = inject(PLAIN, null, [], [SCOPED]); expect(Object.keys(scriptPhaseUuids)).toEqual(['@expo/log-box']); expect(scriptPhaseUuids['@expo/log-box']).toMatch(/^[0-9A-F]{24}$/); }); it('normalizes the id when nothing in the name survives', () => { const phases = [{...SCOPED, name: '*/*'}]; - const {text, scriptPhaseUuids} = inject(PLAIN, null, null, [], phases); + const {text, scriptPhaseUuids} = inject(PLAIN, null, [], phases); const uuid = scriptPhaseUuids['@expo/log-box']; expect(definitionComment(text, uuid)).toBe('@expo log-box'); expect(buildPhaseComments(text)).toContain('@expo log-box'); expect(text).toContain(`${uuid} /* @expo log-box */ = {`); expect(isBalanced(text)).toBe(true); - expect(inject(text, null, null, [], phases).text).toBe(text); + expect(inject(text, null, [], phases).text).toBe(text); }); it('writes no comment at all when neither the name nor the id survives', () => { const phases = [{...APP_CONFIG_PHASE, id: '//', name: '*/*'}]; - const {text, scriptPhaseUuids} = inject(PLAIN, null, null, [], phases); + const {text, scriptPhaseUuids} = inject(PLAIN, null, [], phases); const uuid = scriptPhaseUuids['//']; expect(definitionComment(text, uuid)).toBe(null); expect(text).toContain(`${uuid} = {`); expect(text).toMatch(new RegExp(`\\n\\t+${uuid},`)); expect(isBalanced(text)).toBe(true); - expect(inject(text, null, null, [], phases).text).toBe(text); + expect(inject(text, null, [], phases).text).toBe(text); }); }); @@ -1039,19 +1001,13 @@ describe('injectSpmIntoPbxproj — phase name field vs. comment', () => { ['*/*', 'name = "*/*";'], ]; for (const [name, expected] of cases) { - const {text} = inject( - PLAIN, - null, - null, - [], - [{...APP_CONFIG_PHASE, name}], - ); + const {text} = inject(PLAIN, null, [], [{...APP_CONFIG_PHASE, name}]); expect(text).toContain(expected); } }); it("leaves React Native's own two phase comments byte-identical", () => { - const {text} = inject(PLAIN, null, null, [], [APP_CONFIG_PHASE]); + const {text} = inject(PLAIN, null, [], [APP_CONFIG_PHASE]); for (const label of [ 'Sync SPM Autolinking', 'Embed React Native Flavored Frameworks', @@ -1069,13 +1025,7 @@ describe('injectSpmIntoPbxproj — phase name field vs. comment', () => { // slashes. Normalizing those would rewrite bytes Xcode itself produces, so // only untrusted labels go through commentSafe. it("preserves Xcode's comment convention for the package references", () => { - const {text} = inject( - PLAIN, - null, - null, - [PROVIDER_SOURCE], - [APP_CONFIG_PHASE], - ); + const {text} = inject(PLAIN, null, [PROVIDER_SOURCE], [APP_CONFIG_PHASE]); expect(text).toContain( '/* XCLocalSwiftPackageReference "build/generated/autolinking" */', ); @@ -1104,7 +1054,6 @@ describe('injectSpmIntoPbxproj — invariants', () => { }, RN_PATH, null, - null, [], TEST_FRAMEWORKS, ).text; @@ -1145,7 +1094,6 @@ describe('injectSpmIntoPbxproj — invariants', () => { }, RN_PATH, null, - null, [], TEST_FRAMEWORKS, ).text; 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..6d4f62554491 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 exact same lookup react-native-xcode.sh performs, through + * react-native's dependency graph, 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,11 +498,11 @@ 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 - * the hermesc that compiles the JS bundle - * always agree — a mismatched pair crashes at - * launch with "Wrong bytecode version" (#57917). + * 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). * Falls back to the 'latest-v1' npm dist-tag * (RN's CocoaPods prebuild default; see * scripts/ios-prebuild/hermes.js) only when diff --git a/packages/react-native/scripts/spm/generate-spm-xcodeproj.js b/packages/react-native/scripts/spm/generate-spm-xcodeproj.js index 5c0c5d35ce33..0e86cff7fa53 100644 --- a/packages/react-native/scripts/spm/generate-spm-xcodeproj.js +++ b/packages/react-native/scripts/spm/generate-spm-xcodeproj.js @@ -1250,7 +1250,6 @@ function injectSpmIntoPbxproj( plan /*: {rootUuid: string, targetUuid: string, configUuids: Array, frameworksPhaseUuid: string, sourcesPhaseUuid?: ?string} */, reactNativePath /*: string */, remote /*: ?RemoteCfg */, - hermesCliPath /*: ?string */ = null, generatedSources /*: ReadonlyArray */ = [], flavoredFrameworks /*: ReadonlyArray */ = [], scriptPhases /*: ReadonlyArray */ = [], @@ -1350,7 +1349,6 @@ function injectSpmIntoPbxproj( configUuid, buildConfigurationName(text, configUuid), reactNativePath, - hermesCliPath, flavoredFrameworks, ); text = merged.text; @@ -1659,48 +1657,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,29 +1679,24 @@ 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 */, configurationName /*: string */, reactNativePath /*: string */, - hermesCliPath /*: ?string */ = null, flavoredFrameworks /*: ReadonlyArray */ = [], ) /*: {text: string, change: BuildSettingChange} */ { let text = input; 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. - ...(hermesCliPath != null - ? [{key: 'HERMES_CLI_PATH', value: quoteIfNeeded(hermesCliPath)}] - : []), ]; // Re-locate the buildSettings dict before each edit (offsets shift). const dict = () => { @@ -2368,7 +2319,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 +2391,6 @@ function injectSpmIntoExistingXcodeproj( }, reactNativePath, remote, - hermesCliPath, generatedSources, flavoredFrameworks, scriptPhases,