Skip to content

SwiftPM: move injected build settings and build scripts out of project.pbxproj - #58834

Open
chrfalch wants to merge 4 commits into
mainfrom
chrfalch/spm-xcconfig-settings
Open

chrfalch wants to merge 4 commits into
mainfrom
chrfalch/spm-xcconfig-settings

Conversation

@chrfalch

@chrfalch chrfalch commented Oct 4, 2026 •

Copy link
Copy Markdown
Collaborator

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.

Test Plan:

yarn jest --no-cache -i packages/react-native/scripts/spm

Result: 21 suites and 1055 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.

Not tested:

  • A full Xcode build of an app that uses the generated xcconfig.
  • 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.

…t.pbxproj

`spm add` and `spm update` now write React Native's build settings to
ReactNativeSPM/ReactNativeSPM.xcconfig and the two build-script bodies to
ReactNativeSPM/Scripts/. The project's build configurations use the xcconfig
as their base, and the build phases only run the script files. A version bump
then leaves project.pbxproj unchanged (local-path mode).

REACT_NATIVE_PATH is now $(SRCROOT)-relative through node_modules/react-native,
not a resolved pnpm store path.
@meta-cla meta-cla Bot added the CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. label Oct 4, 2026

@rachel11026725 rachel11026725 left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yarn jest --no-cache -i packages/react-native/scripts/spm

@chrfalch
chrfalch requested a review from cipolleschi October 4, 2026 20:05
@facebook-github-tools facebook-github-tools Bot added the Shared with Meta Applied via automation to indicate that an Issue or Pull Request has been shared with the team. label Oct 4, 2026

@cipolleschi cipolleschi left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for this, moving these values out of project.pbxproj should help a lot with merge conflicts! Given the size of the change, have we considered validating it with a real Xcode build, e.g. a CI job that builds the helloworld app in SwiftPM mode? The PR mentions that a full Xcode build with the generated xcconfig wasn't tested.

'supports SPM-only targets',
};
}
const foreign = foreignBaseConfiguration(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What happens on the next spm update after the user follows this advice and #includes the generated xcconfig from their own file? As far as I can tell, foreignBaseConfiguration still finds a base config that isn't ours and refuses again, since it doesn't check for the #include. Have we considered detecting the include (or only refusing when it's missing) so users have a way forward? A test for this case would also help.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch. The advice led nowhere. Fixed in b797547: spm now follows the #include and #include? lines of the configuration's base xcconfig. It resolves each path from the folder of the file that contains the line, follows nested includes, and stops on a cycle. When the chain reaches ReactNativeSPM.xcconfig, spm accepts the configuration and leaves its base unchanged. The $(inherited) check still applies. A file that cannot be read, or that does not include ours, is still refused. New tests cover the accepted chain, #include?, a cycle, a commented-out include, and a full add → sync → deinit cycle.

reason:
`the ${foreign.config} configuration is already based on ` +
`${foreign.file}, and React Native does not edit xcconfig files it ` +
`does not own — #include ${GENERATED_XCCONFIG_PATH} (relative to the ` +

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could "relative to the project directory" be misleading here? Xcode resolves an xcconfig #include from the folder of the file that contains it, so a file in Config/ would need ../ReactNativeSPM/ReactNativeSPM.xcconfig. Have we considered computing the exact relative path from foreign.file and printing the line to paste?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're right. Xcode resolves #include from the folder of the including file. Fixed in b797547: when the file is on disk, the message now prints the exact line to add. For example, for App.xcconfig in Config/: add #include "../ReactNativeSPM/ReactNativeSPM.xcconfig" to App.xcconfig instead. When the file cannot be found, the message says that the path is relative to the folder of that xcconfig.

configNames.flatMap(name => {
const flavor = flavorForBuildConfiguration(name);
return [
`RN_SPM_FLAVOR[config=${name}] = ${flavor}`,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What happens if a user duplicates a configuration in Xcode, e.g. Release → "Staging", without re-running spm update? The copy keeps the base config reference, but [config=Release] doesn't match "Staging", so RN_SPM_FLAVOR would be unset and the framework paths would resolve empty. Before this change, duplicating a configuration also copied the injected settings. Have we considered adding an unconditioned default (e.g. RN_SPM_FLAVOR = release) before the conditional lines? It might also help with names that contain spaces.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in b797547: the xcconfig now has RN_SPM_FLAVOR = release before the [config=…] lines. A configuration that is duplicated in Xcode, such as "Staging", now links the release frameworks and does not get empty paths. A duplicate of Debug also gets release until the next spm update adds its own [config=…] line. I did not add an unconditioned DEBUG condition, because a wrong DEBUG is worse than a release fallback.

`project directory) from ${foreign.file} instead`,
};
}
const hiding = listSettingsHidingXcconfig(text, configUuids);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Have we considered how this affects existing apps on their next spm update? Apps that base configurations on their own xcconfig (common for environment config), or that set a list setting without $(inherited), worked before and would now be refused. Should the changelog call this out as [BREAKING], or should these cases warn and fall back instead of refusing?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SwiftPM support is still a Preview feature, so I kept this as [CHANGED], not [BREAKING]. I added the effect to the changelog entry instead. Both cases now stop with a message that gives the exact fix. For a configuration based on its own xcconfig, the message gives the #include line to add, and spm accepts the configuration after that (fixed in b797547 for your first comment). For a setting without $(inherited), the message names the setting. I chose a refusal over a silent fallback so that we do not keep two ways of writing the settings. If you prefer the [BREAKING] tag anyway, I can change it.

'EmbedFlavoredFrameworks',
);
const embedScript = buildEmbedFrameworksScript(flavoredFrameworks);
const embedInputs = [

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should $(SRCROOT)/ReactNativeSPM/Scripts/embed-flavored-frameworks.sh be listed in embedInputs? Now that the phase text in the pbxproj never changes, Xcode's incremental build may not notice when only the script file changes, so the phase could be skipped until a clean build.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes. Fixed in b797547: $(SRCROOT)/ReactNativeSPM/Scripts/embed-flavored-frameworks.sh is now an input of the embed phase. The path has no version in it, so project.pbxproj still stays the same across a version bump, and the existing test checks this. The sync phase has no inputs or outputs, so Xcode runs it on every build, and it does not need this change.

SYNC_AUTOLINKING_SCRIPT_PATH,
EMBED_FRAMEWORKS_SCRIPT_PATH,
]) {
fs.rmSync(path.join(appRoot, relPath), {force: true});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: what happens if Debug is re-based on the user's own xcconfig, which #includes ours, while Release is still based on ours? ownsXcconfig would be true and deinit would delete the file that the user's xcconfig still includes. Is that case worth handling, e.g. by keeping the file whenever any configuration has been re-based?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in b797547: deinit now deletes ReactNativeSPM.xcconfig only when no configuration of the target uses a different xcconfig, and when none was recorded as including ours. Otherwise it keeps the file and logs which configurations can still include it. The scripts are still deleted.

@chrfalch
chrfalch requested a review from cipolleschi October 9, 2026 13:11

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. p: Expo Partner: Expo Partner Shared with Meta Applied via automation to indicate that an Issue or Pull Request has been shared with the team.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants