Skip to content

Commit 6a2c7c2

Browse files
chrfalchmeta-codesync[bot]
authored andcommitted
SwiftPM: move injected build settings and build scripts out of project.pbxproj (#58834)
Summary: `npx react-native spm add` and `spm update` edit the app's `project.pbxproj`. Today they write React Native's build settings and the full text of two build scripts into that file. Some of these values change with every React Native version. So a routine `spm update` gives a `project.pbxproj` diff, and teams get merge conflicts in a file that is hard to read. This PR moves those values out of `project.pbxproj` into files that React Native generates next to it: - `ReactNativeSPM/ReactNativeSPM.xcconfig` holds the build settings. Each app build configuration uses it as its base configuration. - `ReactNativeSPM/Scripts/sync-autolinking.sh` and `ReactNativeSPM/Scripts/embed-flavored-frameworks.sh` hold the script bodies. The build phases in `project.pbxproj` only run these files. The result: when a version bump does not change the set of packages or plugin frameworks, `spm update` gives no `project.pbxproj` diff. The changes go to the generated files. Commit the `ReactNativeSPM/` directory with the project. Xcode reads these files before the build starts, so they must exist on a fresh clone. They are not in the ignored `build/` directory for that reason. `project.pbxproj` still holds the package references, the product dependencies, and the build phases. Xcode has no other place for them. `REACT_NATIVE_PATH` also changes. It is now `$(SRCROOT)/<path>` through `node_modules/react-native`. Before, with pnpm, it could be the resolved `.pnpm/react-native@<version>_<hash>` path. That path is specific to one machine, and it was committed. ### Limits - The "no diff" result applies only when packages are referenced by local path. In remote mode, the package version in `project.pbxproj` still changes on each bump. - When you add or remove a plugin framework, the input and output paths of the embed phase in `project.pbxproj` still change. Xcode requires these paths in the project file. - `spm add` and `spm update` now refuse a target in two new cases, with a message that tells you what to do: - A configuration already uses a different xcconfig file as its base, and that file does not `#include` the generated one. React Native does not edit xcconfig files that it does not own. The message gives the exact `#include` line to add to your file. After you add it, `spm` accepts the configuration and leaves its base unchanged. - The target sets a list setting that the generated file also sets, without `$(inherited)`. The target value would hide the React Native value. The message names the settings to fix. - Configuration names that contain spaces or `]` are not escaped in the `[config=<name>]` conditions. - A configuration that you add in Xcode after `spm update` (for example, a copy of Release named "Staging") gets the release flavor until the next `spm update` adds its own condition. ### Existing apps No manual migration is necessary. On the next `spm update`, React Native removes the build settings that it wrote before (it records them in its marker file), then writes the xcconfig file. ### Details for reviewers - `generate-spm-xcodeproj.js`: - `generateReactNativeXcconfig()` writes the settings. Flavor-specific settings (`RN_SPM_FLAVOR`, `SWIFT_ACTIVE_COMPILATION_CONDITIONS`) use Xcode's `[config=<name>]` conditions, with the real configuration names from the project. One file serves all configurations. - `mergeReactBuildSettings()` is removed. - An unconditioned `RN_SPM_FLAVOR = release` comes before the `[config=<name>]` lines, as the fallback for configurations that the file does not list. - `generatedScriptWrapper()` writes the stable build-phase text. If the script file is missing, the build fails with a message to run `npx react-native spm update`. The embed script is an input path of its phase, so Xcode runs the phase again when the script changes. - `planInjection()` follows the `#include` chain of a configuration's base xcconfig. Each path resolves from the folder of the file that contains it, and a cycle stops the walk. The configurations that include the generated file are recorded in the marker as `xcconfig.includedByConfigUuids`. - The CocoaPods template sets `REACT_NATIVE_PATH` relative to `${PODS_ROOT}`. A target value outranks an xcconfig value, so injection removes that one value. Deinit does not put it back, because the value resolves to an empty path when CocoaPods is not used. - `linkedReactNativeRoot()` finds the `node_modules/react-native` link that resolves to the real React Native directory, to build the stable path. - `injectSpmIntoPbxproj()` now takes `{fromAppRoot, fromSrcRoot}` as its third argument. - Deinit removes the base-configuration references, the file reference, and the generated files. If a configuration uses a different xcconfig that may include the generated one, deinit keeps the generated file and logs why. - `xcconfig-files.js` (new): xcconfig path resolution and `#include` parsing, moved out of `ios-deployment-target.js` so both files share it. `ios-deployment-target.js` behaves as before. - `spm-pbxproj.js`: `ensureScalarField()` takes an `{after}` option, to put `baseConfigurationReference` at the correct position. ## Changelog: [IOS] [CHANGED] - SwiftPM: `spm add` and `spm update` write React Native's build settings to `ReactNativeSPM/ReactNativeSPM.xcconfig` and its build scripts to `ReactNativeSPM/Scripts/`, so a version bump no longer changes `project.pbxproj`. A configuration that uses its own xcconfig must now `#include` the generated file, and a target setting that hides React Native's value must add `$(inherited)`. `spm` prints the exact fix in both cases. Pull Request resolved: #58834 Test Plan: ``` yarn jest --no-cache -i packages/react-native/scripts/spm ``` Result (after rebasing onto main): 22 suites and 1102 tests pass. The new tests failed before the change. New tests: - `inject-spm-xcodeproj-test.js`: a version bump (new React Native path and new framework slice data) gives a byte-identical `project.pbxproj`. Other tests check that each configuration uses the generated xcconfig, that a `${PODS_ROOT}`-based `REACT_NATIVE_PATH` is removed, that a user xcconfig is accepted when its `#include` chain reaches the generated file and refused otherwise, that the refusal gives the exact `#include` line, and that the embed script is an input of its phase. - `remove-spm-injection-test.js`: a list setting without `$(inherited)` is refused. Settings from an older injection move into the xcconfig on the next update. Deinit removes the xcconfig reference and the generated files, keeps a base configuration that the user changed, and keeps the generated xcconfig while a user xcconfig may include it. A user xcconfig that includes the generated one survives add → sync → deinit unchanged. pnpm layouts give `$(SRCROOT)/…/node_modules/react-native`. A nearer `node_modules/react-native` that is a different install is skipped. - `generate-spm-xcodeproj-test.js` and `spm-pbxproj-test.js`: the xcconfig content and the `{after}` option. Also run: - `flow focus-check` on `generate-spm-xcodeproj.js` and `spm-pbxproj.js`: 0 errors. - `prettier --check` and `eslint --max-warnings 0` on the changed files: no problems. - CI: `test_ios_spm_helloworld` and `test_ios_spm_rntester` (Debug and Release) run `spm add` and a full `xcodebuild` with the generated xcconfig. All pass on b797547. Not tested: - An Xcode project in a subdirectory of the app root (`--xcodeproj`). The generated files are written relative to the app root. This case was also not supported before this change. Reviewed By: christophpurrer Differential Revision: D125453639 Pulled By: cipolleschi fbshipit-source-id: 8cdd8cc6c8f2b957fcf19044b31b1d3a2d56c6af
1 parent 6e23d68 commit 6a2c7c2

9 files changed

Lines changed: 1924 additions & 583 deletions

‎packages/react-native/scripts/spm/__docs__/spm-scripts.md‎

Lines changed: 88 additions & 57 deletions
Large diffs are not rendered by default.

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

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ const {
1717
buildSyncAutolinkingScript,
1818
flavorForBuildConfiguration,
1919
frameworkConditionalSettings,
20+
generateReactNativeXcconfig,
2021
generateXcscheme,
2122
readScriptPhasesManifest,
2223
} = require('../generate-spm-xcodeproj');
@@ -604,6 +605,127 @@ describe('configuration-driven framework settings', () => {
604605
});
605606
});
606607

608+
describe('generateReactNativeXcconfig', () => {
609+
const DEVICE_ONLY = {...FRAMEWORK, slices: [FRAMEWORK.slices[0]]};
610+
611+
it('writes every setting the app target needs, extending inherited lists', () => {
612+
expect(
613+
generateReactNativeXcconfig(
614+
['Debug', 'Release'],
615+
'../node_modules/react-native',
616+
[DEVICE_ONLY],
617+
[],
618+
),
619+
).toBe(
620+
`// Generated by React Native on every \`spm add\` and \`spm update\`.
621+
// Do not edit: changes are overwritten.
622+
623+
CLANG_CXX_LANGUAGE_STANDARD = c++20
624+
REACT_NATIVE_PATH = $(SRCROOT)/../node_modules/react-native
625+
626+
HEADER_SEARCH_PATHS = $(inherited) "$(SRCROOT)/build/generated/autolinking/headers"
627+
OTHER_LDFLAGS = $(inherited) -ObjC "$(RN_SPM_REACT_BINARY)"
628+
FRAMEWORK_SEARCH_PATHS = $(inherited) "$(RN_SPM_REACT_SEARCH_PATH)"
629+
LD_RUNPATH_SEARCH_PATHS = $(inherited) @executable_path/Frameworks
630+
631+
RN_SPM_FLAVOR = release
632+
RN_SPM_FLAVOR[config=Debug] = debug
633+
SWIFT_ACTIVE_COMPILATION_CONDITIONS[config=Debug] = $(inherited) DEBUG
634+
RN_SPM_FLAVOR[config=Release] = release
635+
636+
RN_SPM_REACT_FRAMEWORK[sdk=iphoneos*] = $(SRCROOT)/build/xcframeworks/$(RN_SPM_FLAVOR)/React.xcframework/ios-arm64/React.framework
637+
RN_SPM_REACT_BINARY[sdk=iphoneos*] = $(SRCROOT)/build/xcframeworks/$(RN_SPM_FLAVOR)/React.xcframework/ios-arm64/React.framework/React
638+
RN_SPM_REACT_SEARCH_PATH[sdk=iphoneos*] = $(SRCROOT)/build/xcframeworks/$(RN_SPM_FLAVOR)/React.xcframework/ios-arm64
639+
`,
640+
);
641+
});
642+
643+
it('anchors REACT_NATIVE_PATH on SRCROOT itself when react-native sits there', () => {
644+
expect(
645+
generateReactNativeXcconfig(['Debug'], '', [FRAMEWORK], []),
646+
).toContain('\nREACT_NATIVE_PATH = $(SRCROOT)\n');
647+
});
648+
649+
it("conditions the flavor on the project's own configuration names", () => {
650+
const xcconfig = generateReactNativeXcconfig(
651+
['Development', 'Staging'],
652+
'../node_modules/react-native',
653+
[FRAMEWORK],
654+
[],
655+
);
656+
expect(xcconfig).toContain('RN_SPM_FLAVOR[config=Development] = debug\n');
657+
expect(xcconfig).toContain(
658+
'SWIFT_ACTIVE_COMPILATION_CONDITIONS[config=Development] = $(inherited) DEBUG\n',
659+
);
660+
expect(xcconfig).toContain('RN_SPM_FLAVOR[config=Staging] = release\n');
661+
expect(xcconfig).not.toContain(
662+
'SWIFT_ACTIVE_COMPILATION_CONDITIONS[config=Staging]',
663+
);
664+
expect(xcconfig).not.toMatch(/\[config=(Debug|Release)\]/);
665+
});
666+
667+
// A configuration duplicated in Xcode keeps the base reference but has no
668+
// line of its own until the next sync, and an unset flavor would resolve
669+
// every framework path to nothing.
670+
it('falls back to the release flavor for a configuration it does not list', () => {
671+
const lines = generateReactNativeXcconfig(
672+
['Debug', 'Release'],
673+
'../node_modules/react-native',
674+
[FRAMEWORK],
675+
[],
676+
).split('\n');
677+
const fallback = lines.indexOf('RN_SPM_FLAVOR = release');
678+
expect(fallback).toBeGreaterThan(-1);
679+
expect(fallback).toBeLessThan(
680+
lines.findIndex(line => line.startsWith('RN_SPM_FLAVOR[config=')),
681+
);
682+
expect(
683+
lines.some(line =>
684+
line.startsWith('SWIFT_ACTIVE_COMPILATION_CONDITIONS ='),
685+
),
686+
).toBe(false);
687+
});
688+
689+
it('writes no plist quoting around conditional keys or path values', () => {
690+
const xcconfig = generateReactNativeXcconfig(
691+
['Debug'],
692+
'../node_modules/react-native',
693+
[FRAMEWORK],
694+
[],
695+
);
696+
expect(xcconfig).toContain(
697+
'\nRN_SPM_REACT_BINARY[sdk=iphonesimulator*] = $(SRCROOT)/',
698+
);
699+
expect(xcconfig).toContain('\nRN_SPM_REACT_SEARCH_PATH[sdk=macosx*] = ');
700+
expect(xcconfig).not.toMatch(/^"/m);
701+
expect(xcconfig).not.toContain('= "$(SRCROOT)/build/xcframeworks');
702+
});
703+
704+
it('defines the opted-in C++ stable API macros for every configuration', () => {
705+
const xcconfig = generateReactNativeXcconfig(
706+
['Debug', 'Release'],
707+
'../node_modules/react-native',
708+
[FRAMEWORK],
709+
['RN_STRICT_API', 'RN_ALLOW_FRAMEWORKS'],
710+
);
711+
expect(xcconfig).toContain(
712+
'\nGCC_PREPROCESSOR_DEFINITIONS = $(inherited) RN_STRICT_API RN_ALLOW_FRAMEWORKS\n',
713+
);
714+
expect(xcconfig).not.toContain('GCC_PREPROCESSOR_DEFINITIONS[');
715+
});
716+
717+
it('writes no GCC_PREPROCESSOR_DEFINITIONS without an opted-in macro', () => {
718+
expect(
719+
generateReactNativeXcconfig(
720+
['Debug', 'Release'],
721+
'../node_modules/react-native',
722+
[FRAMEWORK],
723+
[],
724+
),
725+
).not.toContain('GCC_PREPROCESSOR_DEFINITIONS');
726+
});
727+
});
728+
607729
describe('embed framework phase script', () => {
608730
const script = buildEmbedFrameworksScript([FRAMEWORK]);
609731

0 commit comments

Comments
 (0)