Skip to content

Fail SwiftPM sync when plugin frameworks and the Xcode project disagree - #58827

Open
chrfalch wants to merge 5 commits into
mainfrom
chrfalch/spm-sync-plugin-mismatch-check
Open

chrfalch wants to merge 5 commits into
mainfrom
chrfalch/spm-sync-plugin-mismatch-check

Conversation

@chrfalch

@chrfalch chrfalch commented Oct 4, 2026 •

Copy link
Copy Markdown
Collaborator

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/.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.

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.

@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
@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
@chrfalch
chrfalch requested a review from cipolleschi October 4, 2026 14:55
chrfalch added a commit to expo/expo that referenced this pull request Oct 4, 2026
…51033)

# Why

The committed Xcode project of the `apps/minimal-swiftpm` tester links a
precompiled ExpoFont framework. On a fresh checkout, nobody has
precompiled expo-font, so the build fails at the link step:

```
clang: error: no such file or directory: '.../ios/build/xcframeworks/debug/plugins/expo-font.xcframework/ios-arm64_x86_64-simulator/ExpoFont.framework/ExpoFont'
```

The project was generated on a machine that had expo-font precompiled,
and that state was committed. expo-font is pure Swift, so the Expo
SwiftPM plugin can build it from source. Tracked in ENG-26916 ("row 2").

# How

Remove every ExpoFont slot from `project.pbxproj` (embed phase, build
settings, search path, linker flag, Debug and Release) and from the
React Native marker `.spm-injected.json`. The removals are exactly the
ExpoFont hunks that React Native's `setup-apple-spm.js update` produces
for this precompiled set. The other changes `update` makes are not
taken: machine-specific pnpm paths for `REACT_NATIVE_PATH`, `/bin/bash`
shell paths, and the removal of the JSI macOS/tvOS slices.

The tester now needs these modules precompiled: `expo-modules-core`
(also produces ExpoModulesWorklets), `expo-modules-jsi` and
`expo-file-system`. expo-file-system stays precompiled because it mixes
Swift and Objective-C and has no `Package.swift`, so the plugin cannot
build it from source, and `expo` depends on it.

Note: with React Native's open PR react/react-native#58827, a machine
that has expo-font precompiled will get a sync mismatch error for this
project. Run `npx react-native spm` to regenerate it locally.

# Test Plan

Fresh worktree, `pnpm install --ignore-scripts`, `turbo run build
--filter='minimal-swiftpm^...'`, then precompile only core, jsi and
file-system (Debug and Release) with `node tools/bin/expotools.js
prebuild`, React Native 0.88.0-rc.3. Then `setup-apple-spm.js sync`,
`generate-spm-package.js`, and `xcodebuild` (Debug, iOS Simulator):

- Before: `** BUILD FAILED **`, link error above.
- After: `** BUILD SUCCEEDED **` twice. The first build compiles 32
`packages/expo-font/ios/*.swift` files from source, and the app's
`Frameworks/` has no `ExpoFont.framework`.

Release was not built. Its configuration has the same removal, identical
to the `update` output.

# Checklist

- [ ] No CHANGELOG entry: this changes a test app only, not a published
package.
- [x] `plutil -lint` passes on the project; the marker JSON parses.
# autolinker already printed an \`error:\` line per dep (so Xcode shows them
# and the fix). Fail the build — the developer must run
# \`npx react-native spm scaffold\` from a terminal to generate the manifest.
# Exit 2 = a problem only a terminal command fixes (e.g. a dependency

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 the only change is to project.pbxproj, e.g. after pulling a teammate's commit? As far as I can tell, the pbxproj isn't one of the stale-check inputs, so sync (and this check) wouldn't run and we'd still hit the original link error. The same might apply to a module precompiled after the last spm update, unless its output is under a plugin's watchPaths. Have we considered adding $PROJECT_FILE_PATH/project.pbxproj as a staleness input, or running this check outside the STALE gate?

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 point. In 76972bd, spm add and spm update record the linked frameworks in .spm-injected.json, and that file is now a sync input. A teammate's pull that changes what the project links starts the sync and this check on the next build. I did not make project.pbxproj an input, because Xcode rewrites it on many IDE edits (your comment on #58828). For a module that is precompiled after the last sync: the check sees it only if the plugin lists the output folder in watchPaths. Before #58828, a watch path that did not exist at the last sync was dropped from the list. #58828 fixes that.

let linkedPluginNames /*: ?Set<string> */ = null;
if (
fs.existsSync(
path.join(appRoot, 'build', 'xcframeworks', FLAVORED_FRAMEWORKS_MANIFEST),

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 build/xcframeworks/flavored-frameworks.json is missing? It looks like linkedPluginNames stays null and the reverse check ("project links a framework no plugin provides") is skipped without a warning. Have we considered recording the linked {id, frameworkName} list in .spm-injected.json at inject time? That would avoid regex-parsing inputPaths/outputPaths and wouldn't depend on the staged manifest.

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.

Agreed. In 76972bd, the check reads the recorded {id, frameworkName} list from .spm-injected.json, with no regex parsing and no dependency on the staged manifest. For a marker from an older version, it still parses the embed phase. If the manifest is also missing in that case, it now prints a warning that the reverse check was skipped and that spm update records the list.

for (const frameworkName of linkedPluginNames ?? []) {
if (!pairedNames.has(frameworkName)) {
problems.push(
`error: The Xcode project links ${frameworkName}.framework, but no autolinking plugin provides it on this machine. Precompile ${frameworkName}, or run \`npx react-native spm\` to update the project.`,

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 works for teams that commit the pbxproj? If precompiling is per-machine, running npx react-native spm on one machine could remove a framework that another teammate's run then adds back. Would it make sense to suggest precompiling first, or is the expectation that every machine precompiles the same set?

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.

The expectation is that every machine precompiles the same set: the committed project is the contract. In 76972bd, the reverse message says to precompile the framework and then run npx react-native spm sync. It also warns that spm update instead removes the framework from the committed project for the whole team. The forward message says that spm update adds it for the whole team. Projects with a different set on each machine are a known gap, and this PR does not solve that.

try {
deps.assertPluginFrameworksLinked(appRoot);
} catch (e) {
fs.rmSync(stampPath, {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.

What happens to build time while the mismatch persists? If I read it correctly, the scheme pre-action runs sync, fails, and deletes the stamp. Xcode ignores the pre-action's exit code, so the build phase then runs the full sync (codegen + autolinking) again before failing. Is it expected that every failing build runs sync twice?

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, every failing build ran the full sync twice. In 76972bd, a mismatch saves the error lines in build/generated/autolinking/.spm-plugin-mismatch and refreshes the stamp. When no sync input has changed, the next hook prints the saved errors and fails without a sync. So a failing build runs one sync, and later builds run none until an input changes. A passing check deletes the file. Shell tests run the generated script for each case.

// The per-dep `error:` lines were already printed by the autolinker.
if (
e instanceof MissingManifestError ||
e instanceof PluginFrameworkMismatchError

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: should the require.main === module entry point in sync-spm-autolinking.js also map PluginFrameworkMismatchError to exit code 2, as it does for RemoteVersionError? Right now, running that script directly would print a stack trace and exit 1, which the build phase treats as a warning.

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 76972bd: the direct entry point now maps PluginFrameworkMismatchError and MissingManifestError to exit code 2, with only the message. MissingManifestError had the same gap on main. The handler is now an exported function, so tests can call it.

An autolinking plugin can provide precompiled frameworks, but only `spm add` and `spm update` add them to the Xcode project's linker and embed settings. When the two disagree, the build failed late with an unclear link error or "no such module". The build-time sync now compares them, prints an `error:` line for each framework, and exits with code 2 so the build fails early. The fix is to run `npx react-native spm`, or to precompile the missing framework.
@chrfalch
chrfalch force-pushed the chrfalch/spm-sync-plugin-mismatch-check branch from 76972bd to ef71d3d Compare October 6, 2026 07:05

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.

2 participants