Skip to content

[0.88] SwiftPM: move injected build settings and build scripts out of project.pbxproj - #59313

Open
chrfalch wants to merge 5 commits into
0.88-stablefrom
chrfalch/0.88-port-58834
Open

chrfalch wants to merge 5 commits into
0.88-stablefrom
chrfalch/0.88-port-58834

Conversation

@chrfalch

@chrfalch chrfalch commented Oct 21, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

Port of 6a2c7c2 (#58834, "SwiftPM: move injected build settings and build scripts out of project.pbxproj") to 0.88-stable. That commit already landed in main. This PR exists so that the 0.88 pick does not run into merge conflicts.

This is not a plain cherry-pick. The commit on main also moves the C++ stable API opt-ins from #59043 into the generated xcconfig. #59043 is not on 0.88-stable, so this port leaves those parts out. It is the reviewed version of #58834 from before it was rebased onto #59043. Everything else is the same as the commit on main.

This is PR 5 of 5 in a stack. Each PR contains the commits of the PRs before it. Merge them in order:

  1. [0.88] Encode line breaks in SwiftPM scheme pre-action scriptText #59309 (port of Encode line breaks in SwiftPM scheme pre-action scriptText #58826) Encode line breaks in SwiftPM scheme pre-action scriptText
  2. [0.88] Re-sync SwiftPM autolinking when a watched path appears #59310 (port of Re-sync SwiftPM autolinking when a watched path appears #58828) Re-sync SwiftPM autolinking when a watched path appears
  3. [0.88] Fail SwiftPM sync when plugin frameworks and the Xcode project disagree #59311 (port of Fail SwiftPM sync when plugin frameworks and the Xcode project disagree #58827) Fail SwiftPM sync when plugin frameworks and the Xcode project disagree
  4. [0.88] docs(swiftpm): correct SwiftPM docs to match current scripts #59312 (port of docs(swiftpm): correct SwiftPM docs to match current scripts #58968) docs(swiftpm): correct SwiftPM docs to match current scripts
  5. [0.88] SwiftPM: move injected build settings and build scripts out of project.pbxproj #59313 (port of SwiftPM: move injected build settings and build scripts out of project.pbxproj #58834) SwiftPM: move injected build settings and build scripts out of project.pbxproj ← this PR

Pick request: reactwg/react-native-releases#1453

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:

Summary:
The SwiftPM setup adds a "Sync SPM Autolinking" pre-action to the app's Xcode scheme. The pre-action never ran its sync step.

The script is many lines long. `escapeXmlAttribute` wrote it into the `scriptText` XML attribute with raw line breaks. An XML parser replaces each raw line break in an attribute value with a space (XML 1.0 §3.3.3). So Xcode read the script as one line. The second line of the script is a `#` comment, so everything after `set -euo pipefail` became part of that comment.

This PR makes two changes in `packages/react-native/scripts/spm/generate-spm-xcodeproj.js`:

- `escapeXmlAttribute` now also encodes `\r`, `\n` and `\t` as `&https://github.com/react/react-native/issues/13;`, `&https://github.com/react/react-native/issues/10;` and `&https://github.com/react/react-native/issues/9;`. Xcode itself writes the script this way.
- `deinit` deletes a scheme that RN created only when the scheme is still unchanged. This check now ignores the value of the script. Without this change, a scheme that an older version wrote with raw line breaks would no longer match, and `deinit` would leave it on disk. The XML decoder had no callers left after this change, so this PR removes it.

Existing projects get the fixed script when the user runs `npx react-native spm update` once. The build-time `sync` does not rewrite the scheme.

## Changelog:

[IOS] [FIXED] - SwiftPM: keep line breaks in the scheme pre-action script so that the "Sync SPM Autolinking" pre-action runs. Run `npx react-native spm update` once to rewrite an existing scheme.

Pull Request resolved: #58826

Test Plan:
New tests:

- `generate-spm-xcodeproj-test.js`: parses the generated scheme with `xmldom/xmldom` and checks that the script keeps its line breaks and tabs. A second test checks that a refresh of an older scheme with raw line breaks writes `&https://github.com/react/react-native/issues/10;`.
- `remove-spm-injection-test.js`: checks that `deinit` still deletes an RN-created scheme whose script has raw line breaks.

```
yarn jest --no-cache -i packages/react-native/scripts/spm/__tests__/generate-spm-xcodeproj-test.js packages/react-native/scripts/spm/__tests__/remove-spm-injection-test.js
```

- Before the fix: 2 failed, 124 passed (the two `generate-spm-xcodeproj` tests).
- With only the encoding change: the new `deinit` test and 4 existing `deinit` tests failed.
- With both changes: 2 suites passed, 126 tests passed.

ESLint and Prettier pass on the changed files. `flow check-contents` reports no errors for `generate-spm-xcodeproj.js`.

I did not build an app in Xcode for this PR. An Expo app had the problem: when the line breaks in its scheme were changed to `&https://github.com/react/react-native/issues/10;` by hand, the pre-action ran.

Reviewed By: christophpurrer

Differential Revision: D123382914

Pulled By: cipolleschi

fbshipit-source-id: 0c51cb7c6fe7a66567602d813a13381f6bf3704f
(cherry picked from commit b8df948)
Summary:
The SwiftPM build-time sync runs only when its stamp is older than one of the files it watches. A watched path that did not exist at the last sync was dropped from the watch list. So when that path appeared later, the sync did not run.

Examples:

- A plugin watches its precompiled-module folder. On a fresh clone, that folder does not exist yet, so later precompiles never trigger a sync.
- A dependency has no root `Package.swift` or `.react-native/` folder. When one appears later, for example after `npx react-native spm scaffold`, the dependency is detected differently, but the sync does not run.

This PR writes missing watch paths to a new file, `.spm-sync-watch-absent`. The stamp is stale when one of those paths now exists. The missing paths do not go to `.spm-sync-watch-paths`, because projects from older versions treat a missing path in that file as stale and would sync on every build. Those projects get this fix after running `npx react-native spm update` once.

The paths that can go to `.spm-sync-watch-absent`:

- a plugin `watchPaths` entry that does not exist yet
- the root `Package.swift` and `.react-native/` of each dependency, when they do not exist

The docs now tell plugins to list their precompiled-module folder in `watchPaths`, but only a folder that the Xcode build does not write to.

This PR does not watch `project.pbxproj`. Xcode rewrites that file on many edits in the IDE, so watching it would run a full sync after each edit. #58827 makes `.spm-injected.json` a watched file instead. Only `spm add` and `spm update` change that file.

This PR is independent of #58827. They can land in either order. Both change the generated sync check, so the second one to land needs a rebase.

## Changelog:

[IOS] [FIXED] - SwiftPM: the build-time sync now runs again when a watched path that did not exist at the last sync appears.

Pull Request resolved: #58828

Test Plan:
```
yarn jest --no-cache -i packages/react-native/scripts/spm/__tests__/
```

Result: 21 suites and 1010 tests pass.

- `generate-spm-autolinking-test.js`:
  - A missing watch path goes to `.spm-sync-watch-absent` only. This test failed before the change.
  - A dependency without a root `Package.swift` or `.react-native/` lists both paths as absent. The first version of this PR already did this, so the test passed at once. Removing the two paths from the code makes it fail.
  - `project.pbxproj` is not watched. This test failed before the change.
- `generate-spm-xcodeproj-test.js`: runs the shipped bash check under `/bin/bash`. The stamp is stale only when a path listed as absent now exists.

Reviewed By: andrewdacenko

Differential Revision: D125206241

Pulled By: cipolleschi

fbshipit-source-id: 2cb530615941b15adb27f8fcc4699812538e9f3d
(cherry picked from commit c89f412)
…ee (#58827)

Summary:
An autolinking plugin, such as Expo, can provide precompiled frameworks. The build-time `sync` reads the list of these frameworks from the plugin. `npx react-native spm add` and `spm update` write a second list: the frameworks that the Xcode project links. When the two lists differ, the build fails at the link step with "no such file … plugins/<pkg>.xcframework" or "no such module". That error does not say what is wrong or what to run.

This happens, for example, when a module is precompiled after the last `spm update`, or when a teammate commits a project that links a module this machine does not provide.

This PR makes `sync` compare the two lists before the build continues. For each framework that differs, it prints an Xcode `error:` line that names the framework and what to do, and it exits with code 2. Existing projects already stop the build on exit code 2.

How it works:

- `spm add` and `spm update` record the linked frameworks in `.spm-injected.json`, next to the project. The check reads them there. For a marker from an older version, the check reads the embed phase of `project.pbxproj`, as before.
- `.spm-injected.json` is now a sync input. A pull that changes what the project links starts a sync on the next build. `project.pbxproj` is not a sync input, because Xcode rewrites it on many IDE edits.
- On a mismatch, `sync` saves the error lines in `build/generated/autolinking/.spm-plugin-mismatch` and refreshes its stamp. Later builds print the saved errors and stop without running the full sync again. When any sync input changes, the next build runs the sync and the check again. When the check passes, `sync` deletes the file.
- The team must precompile the same set of frameworks on every machine, because the committed project is the contract. So the messages say:
  - Project links a framework that no plugin provides: precompile it, then run `npx react-native spm sync`. Running `spm update` instead removes it from the committed project for the whole team.
  - A plugin provides a framework that the project does not link: run `npx react-native spm update` to add it for the whole team.
- The help text of `npx react-native spm` now lists `sync`, because the error tells users to run it.
- `sync` still never writes `project.pbxproj`. Only `spm add` and `spm update` change what the project links.
- React Native's own frameworks are not compared.
- The check uses Xcode's `PROJECT_FILE_PATH` to find the project being built.

This PR is independent of #58828. They can land in either order. Both change the generated sync check, so the second one to land needs a rebase.

## Changelog:

[IOS] [ADDED] - SwiftPM: the build stops with a clear error when precompiled plugin frameworks and the Xcode project disagree, and the error says what to run.

Pull Request resolved: #58827

Test Plan:
```
yarn jest --no-cache -i packages/react-native/scripts/spm/__tests__/
```

Result: 22 suites and 1046 tests pass. The new tests failed before the change. One exception: the test that a newer `project.pbxproj` alone does not start a sync passed at once, because it guards against a regression.

- `plugin-framework-mismatch-test.js` uses a real injected project. It runs each case twice: with the list recorded in the marker, and with an older marker that has no list. It covers a framework that is missing in either list, a renamed framework, matching lists, a name with a space, a project that is not injected or has no embed phase, a missing manifest, and two injected projects. It also checks that running `spm add` or `spm update` twice gives the same `project.pbxproj`, and the warning when an older marker and a missing manifest make the check skip one direction.
- `sync-spm-autolinking-test.js` checks that a mismatch saves the errors and refreshes the stamp, that a passing check deletes the saved errors, the recovery after a precompile, and the exit codes of the script's direct entry point.
- `generate-spm-xcodeproj-test.js` runs the generated sync check under `/bin/bash`. It checks that a later build prints the saved errors without a sync, that a changed sync input runs the sync again, that a newer `.spm-injected.json` starts a sync, and that a newer `project.pbxproj` alone does not.

Not tested:

- The exit-code-2 mapping in `setup-apple-spm.js`. `main()` has no test seam. The change is one `instanceof` check.
- A full Xcode build with a precompiled plugin. The order (the sync phase runs before the link step) comes from the code and from `spm-scripts.md`.

Reviewed By: cortinico

Differential Revision: D125208952

Pulled By: cipolleschi

fbshipit-source-id: c7727e0e77932c5260237183bbd14ae7195819e3
(cherry picked from commit a920e5d)
Summary:
Several statements in the SwiftPM docs no longer match the scripts on `main`. This PR corrects them. It changes docs only.

- `scripts/spm/__docs__`: CLI options, the three causes of exit code 2, what `sync` does and when it re-runs, which command writes each file, the header products a hand-written library depends on, and remote mode. Adds a table of environment variables.
- `scripts/ios-prebuild/__docs__`: the SwiftPM statements about where dependency and Hermes headers go.

Where the code has a known gap, the docs describe the current behavior. Examples: `npx react-native spm` does not forward `--configCommand`, and `spm scaffold` also changes the `.xcodeproj`.

A second commit simplifies the same docs: it removes repeated statements and splits long sentences. No facts change. The header resolution details move to `spm-header-paths-contract.md`.

## Changelog:

[INTERNAL] [CHANGED] - Correct SwiftPM docs to match current scripts

Pull Request resolved: #58968

Test Plan:
- `npx prettier --check packages/react-native/scripts/{spm,ios-prebuild}/__docs__/*.md`: all files pass.
- Each changed statement was checked against the code on `main`. Relative links and anchors in changed lines resolve.

Reviewed By: christophpurrer

Differential Revision: D125209110

Pulled By: cipolleschi

fbshipit-source-id: faa09373ec67ed6c4f0f560428c0996716cdae5c
(cherry picked from commit 649c946)
…t.pbxproj (#58834)

Port of 6a2c7c2 (#58834) to 0.88-stable. This is not a plain cherry-pick: the commit on main also moves the C++ stable API opt-ins from #59043 into the generated xcconfig, and #59043 is not on 0.88-stable. This port is the reviewed version of #58834 from before it was rebased onto #59043. Everything else is the same as the commit on main.

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 on this 0.88-stable port branch: 22 suites and 1099 tests pass (0.88-stable before the 5 ports: 21 suites, 1005 tests). 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` (flow-bin 0.331.0, as 0.88-stable pins) on the changed files: no errors in them.
- `prettier --check` and `eslint --max-warnings 0` on the changed files: no problems.
- CI on this PR: `test_ios_spm_helloworld`, `test_ios_spm_newapp` and `test_ios_spm_rntester` (Debug and Release) run `spm add` and a full `xcodebuild` with the generated xcconfig. Not run locally.

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.

Ported from commit 6a2c7c2, without the #59043 changes.

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 Pick Request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant