From 1d69b9084d8a46ba35006f37159ac4c0f9dafc16 Mon Sep 17 00:00:00 2001 From: Christian Falch Date: Mon, 5 Oct 2026 23:07:26 -0700 Subject: [PATCH 1/5] Encode line breaks in SwiftPM scheme pre-action scriptText (#58826) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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: https://github.com/react/react-native/pull/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 b8df948fd4d22b3832c3e6319288232103cce700) --- .../__tests__/generate-spm-xcodeproj-test.js | 35 +++++++++++++++++ .../__tests__/remove-spm-injection-test.js | 15 +++++++ .../scripts/spm/generate-spm-xcodeproj.js | 39 +++++++------------ 3 files changed, 65 insertions(+), 24 deletions(-) diff --git a/packages/react-native/scripts/spm/__tests__/generate-spm-xcodeproj-test.js b/packages/react-native/scripts/spm/__tests__/generate-spm-xcodeproj-test.js index f5b1145edf3a..efff9aaad6b5 100644 --- a/packages/react-native/scripts/spm/__tests__/generate-spm-xcodeproj-test.js +++ b/packages/react-native/scripts/spm/__tests__/generate-spm-xcodeproj-test.js @@ -20,6 +20,7 @@ const { generateXcscheme, readScriptPhasesManifest, } = require('../generate-spm-xcodeproj'); +const {DOMParser} = require('@xmldom/xmldom'); const {execFileSync} = require('node:child_process'); const fs = require('node:fs'); const os = require('node:os'); @@ -137,6 +138,40 @@ describe('scheme pre-action', () => { expect(result).toContain('<'); }); + function parsedSyncScript(xml) { + return new DOMParser() + .parseFromString(xml, 'text/xml') + .getElementsByTagName('ActionContent')[0] + .getAttribute('scriptText'); + } + + const MULTILINE_SCRIPT = 'set -e\n\tif [ "$A" ]; then\r\n a && b < c\nfi'; + + it('keeps line breaks and tabs when an XML parser reads the script', () => { + const result = generateXcscheme( + 'MyApp', + 'TARGET_UUID', + 'MyApp', + MULTILINE_SCRIPT, + ); + expect(parsedSyncScript(result)).toBe(MULTILINE_SCRIPT); + }); + + it('encodes line breaks when it refreshes a script written with raw ones', () => { + const legacy = generateXcscheme( + 'MyApp', + 'TARGET_UUID', + 'MyApp', + 'OLD', + ).replace('scriptText = "OLD"', 'scriptText = "old\n# line 2"'); + const updated = addPreActionToScheme( + legacy, + 'TARGET_UUID', + MULTILINE_SCRIPT, + ); + expect(parsedSyncScript(updated)).toBe(MULTILINE_SCRIPT); + }); + it('refreshes stale script text and is idempotent', () => { const first = generateXcscheme( 'MyApp', 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 2974e310af4e..d039a55eb3fd 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 @@ -499,6 +499,21 @@ describe('deinit — scheme ownership', () => { expect(deinit().status).toBe('removed'); expect(fs.existsSync(schemePath)).toBe(false); }); + + it('deletes its own scheme written with raw line breaks in the script', () => { + const {sync, deinit, schemePath, readScheme} = setUp(); + sync(); + fs.writeFileSync( + schemePath, + readScheme().replace(/&#(9|10|13);/g, (_, c) => + String.fromCharCode(Number(c)), + ), + 'utf8', + ); + + expect(deinit().status).toBe('removed'); + expect(fs.existsSync(schemePath)).toBe(false); + }); }); // --------------------------------------------------------------------------- diff --git a/packages/react-native/scripts/spm/generate-spm-xcodeproj.js b/packages/react-native/scripts/spm/generate-spm-xcodeproj.js index 349f366b2fa1..66755221a888 100644 --- a/packages/react-native/scripts/spm/generate-spm-xcodeproj.js +++ b/packages/react-native/scripts/spm/generate-spm-xcodeproj.js @@ -862,28 +862,21 @@ function buildSyncAutolinkingScript( ${syncStaleCheckAndDispatch()} `; } -// XML-attribute escape (the five named entities). The sync script uses `>` -// and `&` for redirection and bg/and chains, plus `<` for heredocs and -// comparisons — all of which break Xcode's scheme parser if left raw. +// XML-attribute escape. The sync script uses `>` and `&` for redirection and +// bg/and chains, plus `<` for heredocs and comparisons — all of which break +// Xcode's scheme parser if left raw. Raw line breaks and tabs are legal but an +// XML parser turns them into spaces (XML 1.0 §3.3.3), collapsing the script +// onto one line, so they are written as character references, as Xcode does. function escapeXmlAttribute(s /*: string */) /*: string */ { return s .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') - .replace(/'/g, '''); -} - -// The inverse of escapeXmlAttribute. `&` is expanded LAST so an entity that -// was itself escaped (`<` → `&lt;`) round-trips back to its own text -// rather than to `<`. -function unescapeXmlAttribute(s /*: string */) /*: string */ { - return s - .replace(/</g, '<') - .replace(/>/g, '>') - .replace(/"/g, '"') - .replace(/'/g, "'") - .replace(/&/g, '&'); + .replace(/'/g, ''') + .replace(/\r/g, ' ') + .replace(/\n/g, ' ') + .replace(/\t/g, ' '); } function generateXcscheme( @@ -2525,8 +2518,9 @@ function injectSpmIntoExistingXcodeproj( return {status: 'injected', target: plan.target.name}; } -/** The sync pre-action's script, unescaped, or null when the scheme has none. */ -function schemePreActionScript(xml /*: string */) /*: ?string */ { +// The scheme with the sync pre-action's scriptText emptied, so the compare +// ignores how the script was encoded; null when there is no pre-action. +function withoutPreActionScript(xml /*: string */) /*: ?string */ { const titleIdx = xml.indexOf('title = "Sync SPM Autolinking"'); if (titleIdx < 0) { return null; @@ -2540,9 +2534,7 @@ function schemePreActionScript(xml /*: string */) /*: ?string */ { // escapeXmlAttribute maps a literal `"` to `"`, so the next `"` is always // the closing delimiter. const valueEnd = xml.indexOf('"', valueStart); - return valueEnd < 0 - ? null - : unescapeXmlAttribute(xml.slice(valueStart, valueEnd)); + return valueEnd < 0 ? null : xml.slice(0, valueStart) + xml.slice(valueEnd); } /** @@ -2564,10 +2556,9 @@ function isGeneratedScheme( targetUuid /*: string */, projName /*: string */, ) /*: boolean */ { - const script = schemePreActionScript(xml); return ( - script != null && - xml === generateXcscheme(appName, targetUuid, projName, script) + withoutPreActionScript(xml) === + generateXcscheme(appName, targetUuid, projName, '') ); } From 8c5d3879a287d61aa7c86382582b2a7394a03c6f Mon Sep 17 00:00:00 2001 From: Christian Falch Date: Wed, 14 Oct 2026 10:43:09 -0700 Subject: [PATCH 2/5] Re-sync SwiftPM autolinking when a watched path appears (#58828) 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. https://github.com/react/react-native/issues/58827 makes `.spm-injected.json` a watched file instead. Only `spm add` and `spm update` change that file. This PR is independent of https://github.com/react/react-native/issues/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: https://github.com/react/react-native/pull/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 c89f4120ac78f9808d2e15b9f46120636d59ded7) --- .../spm/__docs__/spm-autolinking-plugins.md | 11 ++- .../scripts/spm/__docs__/spm-scripts.md | 7 +- .../generate-spm-autolinking-test.js | 67 +++++++++++++++++-- .../__tests__/generate-spm-xcodeproj-test.js | 36 ++++++++++ .../scripts/spm/autolinking-plugins.js | 3 +- .../scripts/spm/generate-spm-autolinking.js | 24 +++++-- .../scripts/spm/generate-spm-xcodeproj.js | 13 ++++ .../react-native/scripts/spm/spm-types.js | 3 +- 8 files changed, 149 insertions(+), 15 deletions(-) diff --git a/packages/react-native/scripts/spm/__docs__/spm-autolinking-plugins.md b/packages/react-native/scripts/spm/__docs__/spm-autolinking-plugins.md index be999741f8cd..3dc2e5315b04 100644 --- a/packages/react-native/scripts/spm/__docs__/spm-autolinking-plugins.md +++ b/packages/react-native/scripts/spm/__docs__/spm-autolinking-plugins.md @@ -144,11 +144,18 @@ manifests. On the next build the phase re-syncs when a watched **file** is newer than the last sync, a watched **dir** has a newer child, or a watched path has **vanished** (a rename forces a re-sync so the config error surfaces). +A watched path that does not exist yet is watched for **appearing**. When it is +created, the next build re-syncs. So a plugin that builds precompiled modules +should list their output folder, even when a fresh clone does not have it yet. +List only folders that the Xcode build itself does not write to, or every build +re-syncs. + Unlike `flavoredFrameworks`, watch paths are best-effort: a non-array is ignored with a warning (never fatal), and each non-string / empty / **relative** entry is dropped with a warning. Absolute-only, because the generated phase tests -these paths with no cwd context. The kept paths are folded into -`/.spm-sync-watch-paths` alongside RN's own, then deduped and sorted. +these paths with no cwd context. The kept paths are folded in with RN's own, +deduped, and sorted: paths that exist go to `/.spm-sync-watch-paths`, +and paths that do not exist yet go to `/.spm-sync-watch-absent`. ### `scriptPhases` — build-time shell phases on the app target diff --git a/packages/react-native/scripts/spm/__docs__/spm-scripts.md b/packages/react-native/scripts/spm/__docs__/spm-scripts.md index 47059cb79da2..fb1b1565f3f0 100644 --- a/packages/react-native/scripts/spm/__docs__/spm-scripts.md +++ b/packages/react-native/scripts/spm/__docs__/spm-scripts.md @@ -258,7 +258,7 @@ Because `deinit` drops the marker, it drops both. | Path | Written by | Contents | | ------------------------------ | ----------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `build/generated/ios/` | `add`, `update`, `sync`, `codegen` | Codegen output plus the SwiftPM codegen manifest (the `React-GeneratedCode` package). | -| `build/generated/autolinking/` | `add`, `update`, `sync` | `Package.swift`, `autolinking.json`, `packages/`, `libs/`, `headers/`, the `.spm-sync-stamp`, `.spm-sync-watch-paths`, and any `.spm-plugin-*.json` plugin manifests. | +| `build/generated/autolinking/` | `add`, `update`, `sync` | `Package.swift`, `autolinking.json`, `packages/`, `libs/`, `headers/`, the `.spm-sync-stamp`, `.spm-sync-watch-paths`, `.spm-sync-watch-absent`, and any `.spm-plugin-*.json` manifests. | | `build/xcframeworks/` | `add`, `update`, `sync`, `download` | The `debug/` and `release/` flavor slots (symlinks into the cache), `ReactHeadersTarget/`, the headers-only xcframeworks, `Package.swift`, `flavored-frameworks.json`, `.artifact-stamp`. | | `.build/`, `Package.resolved` | Xcode / SwiftPM | SwiftPM's own build directory and resolution file. Machine-specific. | @@ -711,6 +711,11 @@ _existing_ set of generated packages current; they do not create the first one. [plugin](./spm-autolinking-plugins.md#watchpaths--plugin-staleness-inputs) `watchPaths`; a watched file that is newer, a watched dir with a newer child, or a watched path that has **vanished** all mark stale + - every path in `.spm-sync-watch-absent` — watched paths that did not exist + at the last sync; one that **appears** marks stale. This includes each + dependency's root `Package.swift` and `.react-native/`: one that appears + later (e.g. after `spm scaffold`) changes self-managed detection. Projects + get this check after running `npx react-native spm update` once 2. If any input is newer (or the stamp is missing): runs `npx react-native spm sync`, which re-executes autolinking + package generation (downloading artifacts if the cache slot is incomplete) and writes diff --git a/packages/react-native/scripts/spm/__tests__/generate-spm-autolinking-test.js b/packages/react-native/scripts/spm/__tests__/generate-spm-autolinking-test.js index aa2d2170ec73..245b51a800e3 100644 --- a/packages/react-native/scripts/spm/__tests__/generate-spm-autolinking-test.js +++ b/packages/react-native/scripts/spm/__tests__/generate-spm-autolinking-test.js @@ -1879,7 +1879,7 @@ describe('main() — scriptPhases sidecar', () => { // and deduped/sorted: (1) each module's source DIR; (2) each npm dep's // checked-in root Package.swift (FILE) and .react-native/ (DIR) — threaded from // the autolinking model's dep root, not derived by walking up; (3) plugin -// watchPaths. Nonexistent paths are filtered at emission time. +// watchPaths. Nonexistent paths go to .spm-sync-watch-absent instead. // --------------------------------------------------------------------------- describe('main() — .spm-sync-watch-paths emission', () => { @@ -1920,8 +1920,8 @@ describe('main() — .spm-sync-watch-paths emission', () => { fs.writeFileSync(path.join(fooDir, 'Foo.swift'), '// src\n'); // (C) A plugin-host dep contributing watchPaths: one existing absolute path - // (kept), one absent absolute path (filtered at emission), one relative - // path (dropped by invokePlugins). + // (kept), one absent absolute path (listed as absent), one relative path + // (dropped by invokePlugins). const expoDir = path.join(appRoot, 'node_modules', 'expo'); fs.mkdirSync(path.join(expoDir, 'ios'), {recursive: true}); fs.writeFileSync(path.join(expoDir, 'ios', 'Expo.mm'), '// native\n'); @@ -1940,7 +1940,7 @@ describe('main() — .spm-sync-watch-paths emission', () => { " productDependencies: [{name: 'ExpoModulesCore', package: 'ExpoModulesCore'}],", ' watchPaths: [', " path.join(__dirname, 'Package.swift'),", // exists → kept - " path.join(__dirname, 'MISSING.swift'),", // absent → filtered at emission + " path.join(__dirname, 'MISSING.swift'),", // absent → listed as absent " 'rel/manifest',", // relative → dropped by invokePlugins ' ],', ' };', @@ -1948,6 +1948,14 @@ describe('main() — .spm-sync-watch-paths emission', () => { ].join('\n') + '\n', ); + // (D) The app's project file: Xcode rewrites it on every IDE edit, so + // watching it would re-sync after each one. + fs.mkdirSync(path.join(appRoot, 'App.xcodeproj')); + fs.writeFileSync( + path.join(appRoot, 'App.xcodeproj', 'project.pbxproj'), + '\n', + ); + const autolinkDir = path.join(appRoot, 'build', 'generated', 'autolinking'); fs.mkdirSync(autolinkDir, {recursive: true}); fs.writeFileSync( @@ -1972,6 +1980,9 @@ describe('main() — .spm-sync-watch-paths emission', () => { // (C) plugin's existing absolute watchPath is kept. expect(lines).toContain(path.join(expoDir, 'Package.swift')); + // (D) the app's project file is not watched. + expect(lines.some(l => l.includes('project.pbxproj'))).toBe(false); + // Filtered / dropped entries never reach the file. expect(lines.some(l => l.includes('MISSING.swift'))).toBe(false); expect(lines.some(l => l.includes('rel/manifest'))).toBe(false); @@ -1979,6 +1990,54 @@ describe('main() — .spm-sync-watch-paths emission', () => { // Deduped and sorted (stable, deterministic output). expect(new Set(lines).size).toBe(lines.length); expect([...lines].sort()).toEqual(lines); + + expect( + fs.readFileSync(path.join(autolinkDir, '.spm-sync-watch-absent'), 'utf8'), + ).toBe(path.join(expoDir, 'MISSING.swift') + '\n'); + }); + + it('lists a dep root without Package.swift or .react-native/ as absent', () => { + const appRoot = fs.realpathSync( + fs.mkdtempSync(path.join(os.tmpdir(), 'spm-watch-absent-')), + ); + created.push(appRoot); + const rnRoot = path.join(appRoot, 'rn'); + fs.mkdirSync(rnRoot, {recursive: true}); + fs.writeFileSync( + path.join(appRoot, 'package.json'), + JSON.stringify({name: 'app'}), + ); + + // Self-managed through ios/Package.swift, so nothing exists at the root. + const barDir = path.join(appRoot, 'node_modules', 'react-native-bar'); + fs.mkdirSync(path.join(barDir, 'ios'), {recursive: true}); + fs.writeFileSync( + path.join(barDir, 'ios', 'Package.swift'), + '// swift-tools-version:5.9\n// hand-authored\n', + ); + fs.writeFileSync(path.join(barDir, 'ios', 'Bar.swift'), '// src\n'); + + const autolinkDir = path.join(appRoot, 'build', 'generated', 'autolinking'); + fs.mkdirSync(autolinkDir, {recursive: true}); + fs.writeFileSync( + path.join(autolinkDir, 'autolinking.json'), + JSON.stringify({ + dependencies: { + 'react-native-bar': {root: barDir, platforms: {ios: {}}}, + }, + }), + ); + + main(['--app-root', appRoot, '--react-native-root', rnRoot]); + + expect( + fs.readFileSync(path.join(autolinkDir, '.spm-sync-watch-absent'), 'utf8'), + ).toBe( + [ + path.join(barDir, '.react-native'), + path.join(barDir, 'Package.swift'), + ].join('\n') + '\n', + ); }); }); diff --git a/packages/react-native/scripts/spm/__tests__/generate-spm-xcodeproj-test.js b/packages/react-native/scripts/spm/__tests__/generate-spm-xcodeproj-test.js index efff9aaad6b5..c98c52878746 100644 --- a/packages/react-native/scripts/spm/__tests__/generate-spm-xcodeproj-test.js +++ b/packages/react-native/scripts/spm/__tests__/generate-spm-xcodeproj-test.js @@ -333,6 +333,42 @@ describe('sync scripts', () => { }); }); + it('re-syncs when a path listed as absent appears', () => { + const loop = /^ABSENT_FILE=[\s\S]*?^fi$/m.exec(script)?.[0]; + expect(loop).toBeDefined(); + const probe = srcRoot => + execFileSync( + '/bin/bash', + [ + '-c', + `set -euo pipefail\nSRCROOT="$1"\nSTALE=0\n${String(loop)}\necho "$STALE"\n`, + 'probe', + srcRoot, + ], + {encoding: 'utf8'}, + ).trim(); + + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'spm-sync-absent-')); + try { + const autolinkDir = path.join(root, 'build/generated/autolinking'); + fs.mkdirSync(autolinkDir, {recursive: true}); + const watched = path.join(root, 'precompiled'); + fs.mkdirSync(watched); + expect(probe(root)).toBe('0'); + + fs.writeFileSync( + path.join(autolinkDir, '.spm-sync-watch-absent'), + `${watched}\n`, + ); + expect(probe(root)).toBe('1'); + + fs.rmdirSync(watched); + expect(probe(root)).toBe('0'); + } finally { + fs.rmSync(root, {recursive: true, force: true}); + } + }); + it('is deterministic, shared with the pre-action, and valid POSIX shell', () => { expect(buildSyncAutolinkingScript(baked)).toBe(script); expect(buildSchemePreActionScript(baked)).toBe(script); diff --git a/packages/react-native/scripts/spm/autolinking-plugins.js b/packages/react-native/scripts/spm/autolinking-plugins.js index 1063267e0d5c..23dfc48d08c8 100644 --- a/packages/react-native/scripts/spm/autolinking-plugins.js +++ b/packages/react-native/scripts/spm/autolinking-plugins.js @@ -65,7 +65,8 @@ * // Absolute paths (dirs OR files) the Xcode auto-sync build phase * // should watch for staleness — e.g. the plugin dep's own * // `Package.swift`, `expo-module.config.json`, and per-module - * // manifests. Folded into `.spm-sync-watch-paths`; a file edit or a + * // manifests. Folded into `.spm-sync-watch-paths` (or + * // `.spm-sync-watch-absent` while missing); a file edit or a * // dir add/remove there re-triggers the sync. Relative/empty/non-string * // entries are dropped with a warning; a non-array is ignored. * watchPaths: ['/abs/path/Package.swift', '/abs/dir'], diff --git a/packages/react-native/scripts/spm/generate-spm-autolinking.js b/packages/react-native/scripts/spm/generate-spm-autolinking.js index 17ecbbe7bef9..24ebbca34921 100644 --- a/packages/react-native/scripts/spm/generate-spm-autolinking.js +++ b/packages/react-native/scripts/spm/generate-spm-autolinking.js @@ -1920,12 +1920,15 @@ function main(argv /*:: ?: Array */) /*: void */ { // stale until an unrelated install triggers a sync. The dep ROOT is // threaded through the entry (entry.root, from the autolinking model), // not derived by walking up from the (possibly nested) source dir. + // Both are listed even when absent: one that appears later (e.g. after + // `spm scaffold`) changes self-managed detection and needs a re-sync. // 3. Plugin-contributed paths (e.g. Expo's own Package.swift / per-module // manifests) — already validated absolute in invokePlugins. // The phase distinguishes dir vs file with `-d`/`-f` at build time (no - // markers). The existsSync filter here is safe: these paths come from a - // successful sync so they exist now; a path that later VANISHES is caught at - // phase time against this file and forces a re-sync. + // markers). A listed path that later VANISHES forces a re-sync. Paths that do + // not exist yet go to .spm-sync-watch-absent, where one that APPEARS forces a + // re-sync; they stay out of this file because phases from older versions + // treat a missing listed path as stale. const watchCandidates /*: Array */ = [...entryAbsDirs.values()]; for (const entry of entries) { const root = entry.root; @@ -1936,14 +1939,23 @@ function main(argv /*:: ?: Array */) /*: void */ { watchCandidates.push(path.join(root, '.react-native')); } watchCandidates.push(...pluginWatchPaths); - const watchPaths = Array.from(new Set(watchCandidates)) - .filter(p => p.length > 0 && fs.existsSync(p)) - .sort(); + const watchPaths /*: Array */ = []; + const absentWatchPaths /*: Array */ = []; + for (const p of Array.from(new Set(watchCandidates)).sort()) { + if (p.length > 0) { + (fs.existsSync(p) ? watchPaths : absentWatchPaths).push(p); + } + } fs.writeFileSync( path.join(outputDir, '.spm-sync-watch-paths'), watchPaths.join('\n') + (watchPaths.length > 0 ? '\n' : ''), 'utf8', ); + fs.writeFileSync( + path.join(outputDir, '.spm-sync-watch-absent'), + absentWatchPaths.join('\n') + (absentWatchPaths.length > 0 ? '\n' : ''), + 'utf8', + ); // AutolinkedAggregate is glue; needs at least one source file (Swift, so we // sidestep the Obj-C public-headers-dir requirement). diff --git a/packages/react-native/scripts/spm/generate-spm-xcodeproj.js b/packages/react-native/scripts/spm/generate-spm-xcodeproj.js index 66755221a888..fa2439c6f433 100644 --- a/packages/react-native/scripts/spm/generate-spm-xcodeproj.js +++ b/packages/react-native/scripts/spm/generate-spm-xcodeproj.js @@ -785,6 +785,19 @@ if [ "$STALE" -eq 0 ] && [ -f "$WATCH_FILE" ]; then done < "$WATCH_FILE" fi +# Watched paths that did not exist at the last sync. One that APPEARS (e.g. a +# plugin's precompiled output folder) forces a re-sync. +ABSENT_FILE="$SRCROOT/build/generated/autolinking/.spm-sync-watch-absent" +if [ "$STALE" -eq 0 ] && [ -f "$ABSENT_FILE" ]; then + while IFS= read -r P; do + [ -z "$P" ] && continue + if [ -e "$P" ]; then + STALE=1 + break + fi + done < "$ABSENT_FILE" +fi + # Check 2: codegen spec files changed via git (covers monorepo after git pull) if [ "$STALE" -eq 0 ] && [ -f "$STAMP" ]; then STAMP_TIME=$(stat -f %m "$STAMP" 2>/dev/null || stat -c %Y "$STAMP" 2>/dev/null || echo 0) diff --git a/packages/react-native/scripts/spm/spm-types.js b/packages/react-native/scripts/spm/spm-types.js index a89142f6ada0..c868c0043d36 100644 --- a/packages/react-native/scripts/spm/spm-types.js +++ b/packages/react-native/scripts/spm/spm-types.js @@ -324,7 +324,8 @@ export type PluginResult = { flavoredFrameworks: Array, // Absolute paths (dirs or files) the Xcode auto-sync build phase should watch // for staleness, e.g. the plugin dep's own `Package.swift` and per-module - // manifests. Folded into `.spm-sync-watch-paths` by main(). + // manifests. Folded into `.spm-sync-watch-paths` (or `.spm-sync-watch-absent` + // while missing) by main(). watchPaths: Array, // Build-time shell phases for the app target, recorded to // `.spm-plugin-script-phases.json` by main() and injected by `spm add`/ From 50c6e9726d5843f10a239a496b1433b4d42780ff Mon Sep 17 00:00:00 2001 From: Christian Falch Date: Thu, 15 Oct 2026 05:55:36 -0700 Subject: [PATCH 3/5] Fail SwiftPM sync when plugin frameworks and the Xcode project disagree (#58827) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 https://github.com/react/react-native/issues/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: https://github.com/react/react-native/pull/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 a920e5db46876daa4defc3a75fbb11f636c1f0ce) --- .../community-cli-plugin/src/commands/spm.js | 4 +- .../react-native/scripts/setup-apple-spm.js | 15 +- .../spm/__docs__/spm-autolinking-plugins.md | 4 +- .../scripts/spm/__docs__/spm-scripts.md | 64 ++- .../__tests__/generate-spm-xcodeproj-test.js | 177 ++++++- .../plugin-framework-mismatch-test.js | 439 ++++++++++++++++++ .../__tests__/sync-spm-autolinking-test.js | 96 +++- .../scripts/spm/flavored-frameworks.js | 1 + .../scripts/spm/generate-spm-xcodeproj.js | 220 ++++++++- .../scripts/spm/sync-spm-autolinking.js | 80 +++- 10 files changed, 1035 insertions(+), 65 deletions(-) create mode 100644 packages/react-native/scripts/spm/__tests__/plugin-framework-mismatch-test.js diff --git a/packages/community-cli-plugin/src/commands/spm.js b/packages/community-cli-plugin/src/commands/spm.js index 181ad4ff812e..b0579adf3589 100644 --- a/packages/community-cli-plugin/src/commands/spm.js +++ b/packages/community-cli-plugin/src/commands/spm.js @@ -25,8 +25,8 @@ const spmCommand: Command = { name: 'spm [action]', description: 'Set up or maintain Swift Package Manager support for the iOS/macOS app. ' + - 'Actions: add, update, deinit, scaffold. With no action: add (or update ' + - 'if SPM is already set up).', + 'Actions: add, update, deinit, scaffold, sync. With no action: add (or ' + + 'update if SPM is already set up).', options: [ { name: '--version ', diff --git a/packages/react-native/scripts/setup-apple-spm.js b/packages/react-native/scripts/setup-apple-spm.js index 0ace9683a03a..b37a872d768f 100644 --- a/packages/react-native/scripts/setup-apple-spm.js +++ b/packages/react-native/scripts/setup-apple-spm.js @@ -34,9 +34,10 @@ * .spm-injected.json) and drop the marker. * scaffold Generate Package.swift for community deps that * lack SPM support. - * sync / codegen / download Advanced/internal: `sync` is invoked by the - * generated Xcode build phase; `codegen` and - * `download` run a single pipeline step. + * sync Re-run the build-time autolinking sync and its + * checks. The generated Xcode build phase runs + * it; run it by hand after precompiling a module. + * codegen / download Advanced/internal: run a single pipeline step. * * Zero-arg `npx react-native spm` auto-detects: a freshly-scaffolded CocoaPods * project (clean tree, stock Podfile) → `add --deintegrate`; an injected @@ -93,6 +94,7 @@ const { const {main: generatePackage} = require('./spm/generate-spm-package'); const {findSourcePath} = require('./spm/generate-spm-package'); const { + PluginFrameworkMismatchError, SPM_INJECTED_MARKER, cleanupDanglingJavaScriptCoreRef, cleanupLeftoverPodsGroup, @@ -1200,8 +1202,11 @@ async function main(argv /*:: ?: Array */) /*: Promise */ { iosDeploymentTarget, ]); } catch (e) { - if (e instanceof MissingManifestError) { - // The per-dep `error:` lines were already printed by the autolinker. + if ( + e instanceof MissingManifestError || + e instanceof PluginFrameworkMismatchError + ) { + // The `error:` lines were already printed. // Exit 2 (distinct from generic failure) so the Xcode build phase can // turn this into a hard build error while staying lenient on transient // sync failures. diff --git a/packages/react-native/scripts/spm/__docs__/spm-autolinking-plugins.md b/packages/react-native/scripts/spm/__docs__/spm-autolinking-plugins.md index 3dc2e5315b04..1b9cb7a7b77d 100644 --- a/packages/react-native/scripts/spm/__docs__/spm-autolinking-plugins.md +++ b/packages/react-native/scripts/spm/__docs__/spm-autolinking-plugins.md @@ -131,7 +131,9 @@ The declarations are recorded to immutable app-local slots as React Native, and added to Xcode's exact linker and embed settings. They are not emitted as SwiftPM product dependencies. Adding or removing one requires `spm update`; the build-time `spm sync` intentionally does -not mutate runtime framework settings. +not mutate runtime framework settings. When the frameworks that plugins provide +and the frameworks that the project links disagree, sync fails the build. An +`error:` line names each framework and the command to run. ### `watchPaths` — plugin staleness inputs diff --git a/packages/react-native/scripts/spm/__docs__/spm-scripts.md b/packages/react-native/scripts/spm/__docs__/spm-scripts.md index fb1b1565f3f0..d6e9ae9ae580 100644 --- a/packages/react-native/scripts/spm/__docs__/spm-scripts.md +++ b/packages/react-native/scripts/spm/__docs__/spm-scripts.md @@ -239,14 +239,14 @@ Paths are relative to the Xcode project directory (`ios/`) unless noted. ### In your repo — committed -| Path | Written by | What happens | Undone by `deinit`? | -| --------------------------------------------------- | ------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------ | -| `MyApp.xcodeproj/project.pbxproj` | `add`, `update` | SwiftPM package refs, the React build settings, the Sync build phase, and the flavored-framework embed phase are added. Purely additive; a re-run is a no-op. | Yes — exactly what was injected, per the marker (one exception below) | -| `MyApp.xcodeproj/.spm-injected.json` | `add`, `update` | Created. Two roles: it records every edit made — including the pre-injection value of any build setting rewritten — so removal is surgical and re-runs stay idempotent; and it **pins configuration** later runs and Xcode builds must reuse (see the two pins below). | Yes — deleted, and the pins go with it | -| `MyApp.xcodeproj/xcshareddata/xcschemes/*.xcscheme` | `add`, `update` | The sync pre-action is added to the scheme that builds your target; a shared scheme is created if there is none. Commit this or teammates lose the pre-action. | Yes — the scheme is deleted if `add` created it, otherwise only the pre-action is stripped | -| `.gitignore` | `add` only | Created if absent, else appended: a `# SPM – auto-generated at build time` block adding `Package.resolved`, `build/generated/`, `build/xcframeworks/`, `.build/`. | **No** — the block is left behind | -| `Podfile` | `add --deintegrate` | Only the React Native directives (`use_react_native!`, `use_native_modules!`, `prepare_react_native_project!`) are stripped. Your own `pod '…'` lines are preserved. | **No** — re-add the directives yourself to go back to CocoaPods | -| `Pods/`, `Pods-*.xcconfig`, `[CP]` phases | `add --deintegrate` | Removed by `pod deintegrate`. The `.xcworkspace` referencing them is left on disk. | **No** — run `pod install` to restore | +| Path | Written by | What happens | Undone by `deinit`? | +| --------------------------------------------------- | ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------ | +| `MyApp.xcodeproj/project.pbxproj` | `add`, `update` | SwiftPM package refs, the React build settings, the Sync build phase, and the flavored-framework embed phase are added. Purely additive; a re-run is a no-op. | Yes — exactly what was injected, per the marker (one exception below) | +| `MyApp.xcodeproj/.spm-injected.json` | `add`, `update` | Created. Three roles: it records every edit made — including the pre-injection value of any build setting rewritten — so removal is surgical and re-runs stay idempotent; it records the frameworks the project links (`linkedFrameworks`), which the build-time sync compares with the frameworks plugins provide; and it **pins configuration** later runs and Xcode builds must reuse (see the two pins below). | Yes — deleted, and the pins go with it | +| `MyApp.xcodeproj/xcshareddata/xcschemes/*.xcscheme` | `add`, `update` | The sync pre-action is added to the scheme that builds your target; a shared scheme is created if there is none. Commit this or teammates lose the pre-action. | Yes — the scheme is deleted if `add` created it, otherwise only the pre-action is stripped | +| `.gitignore` | `add` only | Created if absent, else appended: a `# SPM – auto-generated at build time` block adding `Package.resolved`, `build/generated/`, `build/xcframeworks/`, `.build/`. | **No** — the block is left behind | +| `Podfile` | `add --deintegrate` | Only the React Native directives (`use_react_native!`, `use_native_modules!`, `prepare_react_native_project!`) are stripped. Your own `pod '…'` lines are preserved. | **No** — re-add the directives yourself to go back to CocoaPods | +| `Pods/`, `Pods-*.xcconfig`, `[CP]` phases | `add --deintegrate` | Removed by `pod deintegrate`. The `.xcworkspace` referencing them is left on disk. | **No** — run `pod install` to restore | The two pinned settings are the `--version` pin (`artifactsVersionOverride`, see [Pinning the React Native version](#pinning-the-react-native-version)) and the @@ -255,12 +255,12 @@ Because `deinit` drops the marker, it drops both. ### In your repo — generated, gitignored -| Path | Written by | Contents | -| ------------------------------ | ----------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `build/generated/ios/` | `add`, `update`, `sync`, `codegen` | Codegen output plus the SwiftPM codegen manifest (the `React-GeneratedCode` package). | -| `build/generated/autolinking/` | `add`, `update`, `sync` | `Package.swift`, `autolinking.json`, `packages/`, `libs/`, `headers/`, the `.spm-sync-stamp`, `.spm-sync-watch-paths`, `.spm-sync-watch-absent`, and any `.spm-plugin-*.json` manifests. | -| `build/xcframeworks/` | `add`, `update`, `sync`, `download` | The `debug/` and `release/` flavor slots (symlinks into the cache), `ReactHeadersTarget/`, the headers-only xcframeworks, `Package.swift`, `flavored-frameworks.json`, `.artifact-stamp`. | -| `.build/`, `Package.resolved` | Xcode / SwiftPM | SwiftPM's own build directory and resolution file. Machine-specific. | +| Path | Written by | Contents | +| ------------------------------ | ----------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `build/generated/ios/` | `add`, `update`, `sync`, `codegen` | Codegen output plus the SwiftPM codegen manifest (the `React-GeneratedCode` package). | +| `build/generated/autolinking/` | `add`, `update`, `sync` | `Package.swift`, `autolinking.json`, `packages/`, `libs/`, `headers/`, the `.spm-sync-stamp`, `.spm-sync-watch-paths`, `.spm-sync-watch-absent`, `.spm-plugin-mismatch` (the errors of a failed plugin framework check), and any `.spm-plugin-*.json` plugin manifests. | +| `build/xcframeworks/` | `add`, `update`, `sync`, `download` | The `debug/` and `release/` flavor slots (symlinks into the cache), `ReactHeadersTarget/`, the headers-only xcframeworks, `Package.swift`, `flavored-frameworks.json`, `.artifact-stamp`. | +| `.build/`, `Package.resolved` | Xcode / SwiftPM | SwiftPM's own build directory and resolution file. Machine-specific. | `deinit` leaves all of the above in place — it is regenerable, and removing it is `rm -rf build/ .build/` (see [Removing / resetting](#removing--resetting)). @@ -595,6 +595,8 @@ across apps; refresh it with `react-native spm update --download force`. | `spm add` fails: "no .xcodeproj found" | Create an app first (`npx @react-native-community/cli init`) or make a project in Xcode, then `spm add`. | | `spm add` fails: "multiple .xcodeproj found" | Pass `--xcodeproj ` (and `--product-name ` if multiple app targets). | | `Package.swift is missing for library ""` (exit 2) | The dep ships no SwiftPM support. `npx react-native spm scaffold`, then re-run setup; persist with `patch-package`. See [Community packages without a Package.swift](#community-packages-without-a-packageswift) | +| ` () is a precompiled framework from an autolinking plugin, but the Xcode project does not link it` (exit 2) | A plugin provides a framework on this machine that the committed project does not link. Run `npx react-native spm update` and commit the project: every machine on the team then has to precompile the framework. | +| `The Xcode project links .framework, but no autolinking plugin provides it on this machine` (exit 2) | The committed project links a framework that this machine has not precompiled. Running `npx react-native spm update` removes the framework from the committed project for the whole team. Instead, precompile it, then run `npx react-native spm sync`. | | `SPM Swift name collision` | Two libraries resolved to one Swift name, or one took a name React Native reserves. Set `swiftpmConfig.name` in the library's package.json — see [Library names](#library-names) | | Missing headers | Re-run `react-native spm` | | "not contained in target" | Re-run setup (regenerates file-level symlinks) | @@ -703,6 +705,8 @@ _existing_ set of generated packages current; they do not create the first one. `build/generated/autolinking/.spm-sync-stamp`: - `package.json` — dependency declarations - `react-native.config.js` — autolinking config + - `MyApp.xcodeproj/.spm-injected.json` — the frameworks the project links, so + a pull that changes them re-checks them - `node_modules/` directory mtime — updated by any package manager (npm, yarn, pnpm, bun); also checks parent `node_modules` for monorepo setups - a missing `build/xcframeworks/` (e.g. after a manual clean) also marks @@ -716,11 +720,15 @@ _existing_ set of generated packages current; they do not create the first one. dependency's root `Package.swift` and `.react-native/`: one that appears later (e.g. after `spm scaffold`) changes self-managed detection. Projects get this check after running `npx react-native spm update` once -2. If any input is newer (or the stamp is missing): runs +2. If the last sync recorded a plugin framework mismatch in + `build/generated/autolinking/.spm-plugin-mismatch` and no input changed since + (the marker and the plugin manifest included): prints the recorded `error:` + lines and fails the build without re-running sync. +3. If any input is newer (or the stamp is missing): runs `npx react-native spm sync`, which re-executes autolinking + package generation (downloading artifacts if the cache slot is incomplete) and writes the stamp file. -3. If all inputs are fresh: exits immediately (~1ms). +4. If all inputs are fresh: exits immediately (~1ms). **Ordering.** As observed in an `xcodebuild -scheme … build` log on Xcode 26.6: @@ -743,15 +751,25 @@ clean checkout; it is not something either hook can work around. A sync failure is lenient by default but **not unconditionally**. The generated script branches on the exit code: -- **Exit 2** — an autolinked dependency ships no `Package.swift`. This **fails - the build** (`exit 1`), deliberately: the autolinker has already printed an - `error:` line per dep, and the fix needs a terminal (see - [Community packages without a Package.swift](#community-packages-without-a-packageswift)). +- **Exit 2** — a problem that only a terminal command fixes. This **fails the + build** (`exit 1`), deliberately: sync has already printed `error:` lines that + name the command. Two cases use it: + - An autolinked dependency ships no `Package.swift` (see + [Community packages without a Package.swift](#community-packages-without-a-packageswift)). + - The precompiled frameworks that autolinking plugins provide differ from the + frameworks that the Xcode project links (see + [Troubleshooting](#troubleshooting)). Sync writes the stamp and records the + `error:` lines in `.spm-plugin-mismatch`. When the scheme pre-action ran + that sync, the build phase prints the recorded lines instead of running sync + again. Later builds also print them without a sync, until an input changes. + `spm update` changes inputs, so the next build syncs. Precompiling a + framework changes none, so run `npx react-native spm sync` after it. A sync + that passes the check deletes the file. - **Any other non-zero exit** — emits `warning: SPM sync failed — build may use stale codegen/autolinking` and lets the build continue, so an already-generated package graph can still produce a successful build. -That split is the whole reason the missing-manifest case has its own exit code: -a transient sync hiccup should not break a build that could still succeed, while -a genuinely missing manifest should not pass silently. +That split is the whole reason these cases have their own exit code: a transient +sync hiccup should not break a build that could still succeed, while a missing +manifest or an out-of-date project should not pass silently. diff --git a/packages/react-native/scripts/spm/__tests__/generate-spm-xcodeproj-test.js b/packages/react-native/scripts/spm/__tests__/generate-spm-xcodeproj-test.js index c98c52878746..9a7dda111218 100644 --- a/packages/react-native/scripts/spm/__tests__/generate-spm-xcodeproj-test.js +++ b/packages/react-native/scripts/spm/__tests__/generate-spm-xcodeproj-test.js @@ -21,7 +21,7 @@ const { readScriptPhasesManifest, } = require('../generate-spm-xcodeproj'); const {DOMParser} = require('@xmldom/xmldom'); -const {execFileSync} = require('node:child_process'); +const {execFileSync, spawnSync} = require('node:child_process'); const fs = require('node:fs'); const os = require('node:os'); const path = require('node:path'); @@ -369,6 +369,181 @@ describe('sync scripts', () => { } }); + describe('stale inputs and a recorded plugin framework mismatch', () => { + const MISMATCH = + 'error: The Xcode project links ExpoCore.framework, but no autolinking plugin provides it on this machine.'; + + let root; + let srcRoot; + let xcodeprojPath; + let generatedDir; + let fakeNode; + let rnDir; + let syncLog; + + // Does to the generated dir what a real sync does: on a mismatch it prints + // the errors, records them, refreshes the stamp, and exits 2. + const FAKE_NODE = `#!/bin/bash +if [ "$1" = "--print" ]; then + echo "$FAKE_RN_DIR" + exit 0 +fi +echo sync >> "$FAKE_SYNC_LOG" +DIR="$SRCROOT/build/generated/autolinking" +date > "$DIR/.spm-sync-stamp" +if [ -n "$FAKE_MISMATCH" ]; then + echo "$FAKE_MISMATCH" >&2 + echo "$FAKE_MISMATCH" > "$DIR/.spm-plugin-mismatch" + exit 2 +fi +rm -f "$DIR/.spm-plugin-mismatch" +`; + + const setTime = (file, secondsFromNow) => { + const time = Date.now() / 1000 + secondsFromNow; + fs.utimesSync(file, time, time); + }; + + beforeEach(() => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'spm-sync-mismatch-')); + srcRoot = path.join(root, 'app'); + xcodeprojPath = path.join(srcRoot, 'MyApp.xcodeproj'); + generatedDir = path.join(srcRoot, 'build', 'generated', 'autolinking'); + rnDir = path.join(root, 'react-native'); + fs.mkdirSync(xcodeprojPath, {recursive: true}); + fs.mkdirSync(generatedDir, {recursive: true}); + fs.mkdirSync(path.join(rnDir, 'scripts'), {recursive: true}); + fs.writeFileSync(path.join(rnDir, 'scripts', 'setup-apple-spm.js'), ''); + fs.writeFileSync(path.join(srcRoot, 'package.json'), '{}\n'); + fs.writeFileSync(path.join(xcodeprojPath, '.spm-injected.json'), '{}\n'); + fs.writeFileSync( + path.join(generatedDir, '.spm-plugin-flavored-frameworks.json'), + '[]\n', + ); + for (const file of [ + path.join(srcRoot, 'package.json'), + path.join(xcodeprojPath, '.spm-injected.json'), + path.join(generatedDir, '.spm-plugin-flavored-frameworks.json'), + ]) { + setTime(file, -100); + } + fakeNode = path.join(root, 'node'); + fs.writeFileSync(fakeNode, FAKE_NODE, {mode: 0o755}); + syncLog = path.join(root, 'syncs.log'); + }); + + afterEach(() => { + fs.rmSync(root, {recursive: true, force: true}); + }); + + function build({mismatch}) { + fs.writeFileSync(syncLog, ''); + const result = spawnSync('/bin/bash', ['-c', script], { + encoding: 'utf8', + env: { + PATH: process.env.PATH, + SRCROOT: srcRoot, + PROJECT_FILE_PATH: xcodeprojPath, + NODE_BINARY: fakeNode, + FAKE_RN_DIR: rnDir, + FAKE_SYNC_LOG: syncLog, + FAKE_MISMATCH: mismatch ? MISMATCH : '', + }, + }); + const output = result.stdout + result.stderr; + return { + status: result.status, + errors: output.split(MISMATCH).length - 1, + syncs: fs.readFileSync(syncLog, 'utf8').split('sync').length - 1, + }; + } + + function writeStamp(secondsFromNow) { + const stamp = path.join(generatedDir, '.spm-sync-stamp'); + fs.writeFileSync(stamp, 'earlier sync\n'); + setTime(stamp, secondsFromNow); + } + + it('syncs when the marker is newer than the stamp', () => { + writeStamp(-50); + setTime(path.join(xcodeprojPath, '.spm-injected.json'), -10); + expect(build({mismatch: false})).toEqual({ + status: 0, + errors: 0, + syncs: 1, + }); + }); + + it('does not sync when only project.pbxproj is newer than the stamp', () => { + writeStamp(-50); + const pbxprojPath = path.join(xcodeprojPath, 'project.pbxproj'); + fs.writeFileSync(pbxprojPath, '// !$*UTF8*$!\n'); + setTime(pbxprojPath, -10); + expect(build({mismatch: false})).toEqual({ + status: 0, + errors: 0, + syncs: 0, + }); + }); + + it('passes the next build after the recovery sync clears the mismatch', () => { + build({mismatch: true}); + // `npx react-native spm sync`, run after precompiling the framework. + spawnSync(fakeNode, ['setup-apple-spm.js', 'sync'], { + env: {SRCROOT: srcRoot, FAKE_SYNC_LOG: syncLog, FAKE_MISMATCH: ''}, + }); + expect(build({mismatch: false})).toEqual({ + status: 0, + errors: 0, + syncs: 0, + }); + }); + + it('fails the first build and prints the errors once', () => { + expect(build({mismatch: true})).toEqual({ + status: 1, + errors: 1, + syncs: 1, + }); + }); + + it('fails later builds with the recorded errors without re-running sync', () => { + build({mismatch: true}); + expect(build({mismatch: true})).toEqual({ + status: 1, + errors: 1, + syncs: 0, + }); + }); + + it.each([ + [ + 'the marker changes', + () => path.join(xcodeprojPath, '.spm-injected.json'), + ], + [ + 'spm update rewrites the plugin manifest', + () => path.join(generatedDir, '.spm-plugin-flavored-frameworks.json'), + ], + ['a dependency input changes', () => path.join(srcRoot, 'package.json')], + ])('re-runs sync when %s', (_, changedFile) => { + build({mismatch: true}); + setTime(path.join(generatedDir, '.spm-sync-stamp'), -50); + setTime(path.join(generatedDir, '.spm-plugin-mismatch'), -50); + setTime(changedFile(), -10); + expect(build({mismatch: false})).toEqual({ + status: 0, + errors: 0, + syncs: 1, + }); + expect(build({mismatch: false})).toEqual({ + status: 0, + errors: 0, + syncs: 0, + }); + }); + }); + it('is deterministic, shared with the pre-action, and valid POSIX shell', () => { expect(buildSyncAutolinkingScript(baked)).toBe(script); expect(buildSchemePreActionScript(baked)).toBe(script); diff --git a/packages/react-native/scripts/spm/__tests__/plugin-framework-mismatch-test.js b/packages/react-native/scripts/spm/__tests__/plugin-framework-mismatch-test.js new file mode 100644 index 000000000000..0cd7897e788e --- /dev/null +++ b/packages/react-native/scripts/spm/__tests__/plugin-framework-mismatch-test.js @@ -0,0 +1,439 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @format + * @noflow + */ + +'use strict'; + +const {PLUGIN_FRAMEWORKS_MANIFEST} = require('../flavored-frameworks'); +const { + PluginFrameworkMismatchError, + SPM_INJECTED_MARKER, + assertPluginFrameworksLinked, + injectSpmIntoExistingXcodeproj, +} = require('../generate-spm-xcodeproj'); +const {main: syncAutolinking} = require('../sync-spm-autolinking'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); + +const PLAIN = fs.readFileSync( + path.join(__dirname, '__fixtures__', 'plain-app.pbxproj'), + 'utf8', +); + +function frameworkEntry(id, frameworkName, artifactRelativePath) { + return { + id, + frameworkName, + executableName: frameworkName, + linkage: 'dynamic', + artifactRelativePath, + slices: [ + { + sdk: 'iphoneos*', + platform: 'ios', + variant: null, + architectures: ['arm64'], + libraryIdentifier: 'ios-arm64', + libraryPath: `${frameworkName}.framework`, + binaryPath: `${frameworkName}.framework/${frameworkName}`, + }, + ], + }; +} + +const REACT = frameworkEntry('react', 'React', 'React.xcframework'); +const HERMES = frameworkEntry('hermes', 'hermes', 'hermes-engine.xcframework'); +const EXPO = frameworkEntry( + 'expo-core', + 'ExpoCore', + 'plugins/expo-core.xcframework', +); + +let appRoots = []; +let errorSpy; +let warnSpy; + +beforeEach(() => { + errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); + jest.spyOn(console, 'log').mockImplementation(() => {}); +}); + +afterEach(() => { + jest.restoreAllMocks(); + for (const appRoot of appRoots) { + fs.rmSync(appRoot, {recursive: true, force: true}); + } + appRoots = []; +}); + +function scaffoldApp() { + const appRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'spm-plugin-check-')); + appRoots.push(appRoot); + const xcodeprojPath = path.join(appRoot, 'MyApp.xcodeproj'); + fs.mkdirSync(xcodeprojPath, {recursive: true}); + fs.writeFileSync(path.join(xcodeprojPath, 'project.pbxproj'), PLAIN); + const rnRoot = path.join(appRoot, 'node_modules', 'react-native'); + fs.mkdirSync(rnRoot, {recursive: true}); + const artifactRoot = path.join(appRoot, 'build', 'xcframeworks'); + fs.mkdirSync(artifactRoot, {recursive: true}); + fs.writeFileSync(path.join(artifactRoot, '.artifact-stamp'), 'test\n'); + return {appRoot, xcodeprojPath, rnRoot}; +} + +// What `spm add` / `spm update` would have linked. +function inject({appRoot, xcodeprojPath, rnRoot}, frameworks) { + fs.writeFileSync( + path.join(appRoot, 'build', 'xcframeworks', 'flavored-frameworks.json'), + JSON.stringify({version: 1, frameworks}), + ); + injectSpmIntoExistingXcodeproj({ + appRoot, + reactNativeRoot: rnRoot, + xcodeprojPath, + }); +} + +// What the build-time sync's plugins pair on this machine. +function writeSidecar(appRoot, frameworks) { + const sidecarPath = path.join(appRoot, PLUGIN_FRAMEWORKS_MANIFEST); + fs.mkdirSync(path.dirname(sidecarPath), {recursive: true}); + fs.writeFileSync( + sidecarPath, + JSON.stringify( + frameworks.map(({id, frameworkName}) => ({ + id, + frameworkName, + linkage: 'dynamic', + flavors: { + debug: `/plugins/debug/${frameworkName}.xcframework`, + release: `/plugins/release/${frameworkName}.xcframework`, + }, + })), + ), + ); +} + +function projectFiles(xcodeprojPath) { + return ['project.pbxproj', SPM_INJECTED_MARKER].map(name => { + const file = path.join(xcodeprojPath, name); + return fs.existsSync(file) ? fs.readFileSync(file, 'utf8') : null; + }); +} + +// Runs the check and proves it wrote neither the pbxproj nor the marker. +// Returns the printed lines and the thrown error, if any. +function check(app, env = {}) { + const before = projectFiles(app.xcodeprojPath); + let error = null; + try { + assertPluginFrameworksLinked(app.appRoot, env); + } catch (e) { + error = e; + } + expect(projectFiles(app.xcodeprojPath)).toEqual(before); + return {error, lines: errorSpy.mock.calls.map(call => call.join(' '))}; +} + +function readLinkedFrameworks({xcodeprojPath}) { + return JSON.parse( + fs.readFileSync(path.join(xcodeprojPath, SPM_INJECTED_MARKER), 'utf8'), + ).linkedFrameworks; +} + +// What a marker written before `linkedFrameworks` was recorded looks like. +function injectWithOlderMarker(app, frameworks) { + inject(app, frameworks); + const markerPath = path.join(app.xcodeprojPath, SPM_INJECTED_MARKER); + const {linkedFrameworks, ...olderMarker} = JSON.parse( + fs.readFileSync(markerPath, 'utf8'), + ); + expect(linkedFrameworks).toBeDefined(); + fs.writeFileSync(markerPath, JSON.stringify(olderMarker, null, 2) + '\n'); +} + +const EXPO_NOT_LINKED = + 'error: ExpoCore (expo-core) is a precompiled framework from an autolinking plugin, but the Xcode project does not link it. Run `npx react-native spm update` to add ExpoCore to the committed project for the whole team.'; +const EXPO_NOT_PROVIDED = + 'error: The Xcode project links ExpoCore.framework, but no autolinking plugin provides it on this machine. Running `npx react-native spm update` removes ExpoCore from the committed project for the whole team. Instead, precompile ExpoCore, then run `npx react-native spm sync`.'; +const LINKED_CHECK_SKIPPED = + 'warning: Skipped the check for frameworks that the Xcode project links but no plugin provides. The project does not record them in .spm-injected.json, and build/xcframeworks/flavored-frameworks.json is missing. Run `npx react-native spm update` to record them.'; + +describe.each([ + ['the marker', inject], + ['the project, for an older marker', injectWithOlderMarker], +])('assertPluginFrameworksLinked, linked frameworks from %s', (_, link) => { + it('fails when a plugin pairs a framework the project does not link', () => { + const app = scaffoldApp(); + link(app, [REACT]); + writeSidecar(app.appRoot, [EXPO]); + + const {error, lines} = check(app); + + expect(error).toBeInstanceOf(PluginFrameworkMismatchError); + expect(lines).toEqual([EXPO_NOT_LINKED]); + expect(error.problems).toEqual(lines); + }); + + it('fails when the project links a plugin framework no plugin provides', () => { + const app = scaffoldApp(); + link(app, [REACT, EXPO]); + writeSidecar(app.appRoot, []); + + const {error, lines} = check(app); + + expect(error).toBeInstanceOf(PluginFrameworkMismatchError); + expect(lines).toEqual([EXPO_NOT_PROVIDED]); + }); + + it('fails when a plugin renames its framework but keeps its id', () => { + const app = scaffoldApp(); + link(app, [REACT, EXPO]); + writeSidecar(app.appRoot, [{id: 'expo-core', frameworkName: 'NewCore'}]); + + const {error, lines} = check(app); + + expect(error).toBeInstanceOf(PluginFrameworkMismatchError); + expect(lines).toEqual([ + 'error: NewCore (expo-core) is a precompiled framework from an autolinking plugin, but the Xcode project does not link it. Run `npx react-native spm update` to add NewCore to the committed project for the whole team.', + EXPO_NOT_PROVIDED, + ]); + }); + + it('checks the project Xcode is building, not the first injected one', () => { + const app = scaffoldApp(); + const stale = { + ...app, + xcodeprojPath: path.join(app.appRoot, 'A.xcodeproj'), + }; + fs.mkdirSync(stale.xcodeprojPath); + fs.writeFileSync(path.join(stale.xcodeprojPath, 'project.pbxproj'), PLAIN); + link(stale, [REACT]); + link(app, [REACT, EXPO]); + writeSidecar(app.appRoot, [EXPO]); + + expect(check(app, {PROJECT_FILE_PATH: app.xcodeprojPath})).toEqual({ + error: null, + lines: [], + }); + }); + + it('passes when the project links exactly the paired plugin frameworks', () => { + const app = scaffoldApp(); + link(app, [REACT, HERMES, EXPO]); + writeSidecar(app.appRoot, [EXPO]); + + expect(check(app)).toEqual({error: null, lines: []}); + }); + + it('reads framework names that contain a space', () => { + const app = scaffoldApp(); + const spaced = frameworkEntry('my-fw', 'My Fw', 'My Fw.xcframework'); + link(app, [REACT, HERMES, spaced]); + writeSidecar(app.appRoot, [spaced]); + + expect(check(app)).toEqual({error: null, lines: []}); + }); + + it('skips a project that was never injected', () => { + const app = scaffoldApp(); + writeSidecar(app.appRoot, [EXPO]); + + expect(check(app)).toEqual({error: null, lines: []}); + }); +}); + +describe('assertPluginFrameworksLinked, linked frameworks from the marker', () => { + it('records every framework the embed phase links', () => { + const app = scaffoldApp(); + inject(app, [REACT, HERMES, EXPO]); + + expect(readLinkedFrameworks(app)).toEqual([ + {id: 'react', frameworkName: 'React'}, + {id: 'hermes', frameworkName: 'hermes'}, + {id: 'expo-core', frameworkName: 'ExpoCore'}, + ]); + }); + + it('reports an unprovided framework when the frameworks manifest is missing', () => { + const app = scaffoldApp(); + inject(app, [REACT, HERMES, EXPO]); + writeSidecar(app.appRoot, []); + fs.rmSync( + path.join(app.appRoot, 'build/xcframeworks/flavored-frameworks.json'), + ); + + expect(check(app).lines).toEqual([EXPO_NOT_PROVIDED]); + }); + + it('reads the marker, not the project', () => { + const app = scaffoldApp(); + inject(app, [REACT, EXPO]); + writeSidecar(app.appRoot, []); + fs.writeFileSync(path.join(app.xcodeprojPath, 'project.pbxproj'), PLAIN); + + expect(check(app).lines).toEqual([EXPO_NOT_PROVIDED]); + }); + + it('fails when plugins swap the ids of two linked frameworks', () => { + const app = scaffoldApp(); + inject(app, [ + REACT, + frameworkEntry('a', 'Foo', 'plugins/a.xcframework'), + frameworkEntry('b', 'Bar', 'plugins/b.xcframework'), + ]); + writeSidecar(app.appRoot, [ + {id: 'a', frameworkName: 'Bar'}, + {id: 'b', frameworkName: 'Foo'}, + ]); + + const {error, lines} = check(app); + + expect(error).toBeInstanceOf(PluginFrameworkMismatchError); + expect(lines).toEqual([ + 'error: The Xcode project links Foo.framework (a), but the autolinking plugin that provides Foo gives it a different id. Run `npx react-native spm update` to relink Foo in the committed project for the whole team.', + 'error: The Xcode project links Bar.framework (b), but the autolinking plugin that provides Bar gives it a different id. Run `npx react-native spm update` to relink Bar in the committed project for the whole team.', + ]); + }); + + it('fails once when a plugin changes the id of a linked framework', () => { + const app = scaffoldApp(); + inject(app, [REACT, frameworkEntry('a', 'Foo', 'plugins/a.xcframework')]); + writeSidecar(app.appRoot, [{id: 'c', frameworkName: 'Foo'}]); + + expect(check(app).lines).toEqual([ + 'error: The Xcode project links Foo.framework (a), but the autolinking plugin that provides Foo gives it a different id. Run `npx react-native spm update` to relink Foo in the committed project for the whole team.', + ]); + }); + + it('reports a new framework and swapped ids once each', () => { + const app = scaffoldApp(); + inject(app, [ + REACT, + frameworkEntry('a', 'Foo', 'plugins/a.xcframework'), + frameworkEntry('b', 'Bar', 'plugins/b.xcframework'), + ]); + writeSidecar(app.appRoot, [ + {id: 'a', frameworkName: 'Bar'}, + {id: 'b', frameworkName: 'Foo'}, + {id: 'c', frameworkName: 'Baz'}, + ]); + + expect(check(app).lines).toEqual([ + 'error: Baz (c) is a precompiled framework from an autolinking plugin, but the Xcode project does not link it. Run `npx react-native spm update` to add Baz to the committed project for the whole team.', + 'error: The Xcode project links Foo.framework (a), but the autolinking plugin that provides Foo gives it a different id. Run `npx react-native spm update` to relink Foo in the committed project for the whole team.', + 'error: The Xcode project links Bar.framework (b), but the autolinking plugin that provides Bar gives it a different id. Run `npx react-native spm update` to relink Bar in the committed project for the whole team.', + ]); + }); +}); + +describe('assertPluginFrameworksLinked, linked frameworks from the project, for an older marker', () => { + it('compares setting prefixes and names separately', () => { + const app = scaffoldApp(); + injectWithOlderMarker(app, [ + REACT, + frameworkEntry('a', 'Foo', 'plugins/a.xcframework'), + frameworkEntry('b', 'Bar', 'plugins/b.xcframework'), + ]); + writeSidecar(app.appRoot, [ + {id: 'a', frameworkName: 'Bar'}, + {id: 'b', frameworkName: 'Foo'}, + ]); + + expect(check(app)).toEqual({error: null, lines: []}); + }); + + it('compares only setting prefixes when the frameworks manifest is missing', () => { + const app = scaffoldApp(); + injectWithOlderMarker(app, [REACT, HERMES, EXPO]); + writeSidecar(app.appRoot, [EXPO]); + fs.rmSync( + path.join(app.appRoot, 'build/xcframeworks/flavored-frameworks.json'), + ); + + expect(check(app)).toEqual({error: null, lines: []}); + }); + + it('warns that it skipped the unprovided check when the frameworks manifest is missing', () => { + const app = scaffoldApp(); + injectWithOlderMarker(app, [REACT, HERMES, EXPO]); + writeSidecar(app.appRoot, []); + fs.rmSync( + path.join(app.appRoot, 'build/xcframeworks/flavored-frameworks.json'), + ); + + expect(check(app)).toEqual({error: null, lines: []}); + expect(warnSpy.mock.calls).toEqual([[LINKED_CHECK_SKIPPED]]); + }); + + it('does not warn when the frameworks manifest is present', () => { + const app = scaffoldApp(); + injectWithOlderMarker(app, [REACT, HERMES, EXPO]); + writeSidecar(app.appRoot, [EXPO]); + + expect(check(app)).toEqual({error: null, lines: []}); + expect(warnSpy).not.toHaveBeenCalled(); + }); + + it('skips a project without the embed phase', () => { + const app = scaffoldApp(); + injectWithOlderMarker(app, [REACT]); + writeSidecar(app.appRoot, [EXPO]); + fs.writeFileSync(path.join(app.xcodeprojPath, 'project.pbxproj'), PLAIN); + + expect(check(app)).toEqual({error: null, lines: []}); + }); +}); + +describe('recovering from an unprovided framework', () => { + // A build-time sync whose plugins provide `provided` on this machine. + function sync(app, provided) { + return syncAutolinking( + ['--app-root', app.appRoot, '--react-native-root', app.rnRoot], + { + runCodegenAndInstallTemplate: jest.fn(), + installSpmCodegenTemplate: jest.fn(), + buildPerAppHeaderTree: jest.fn(), + findProjectRoot: () => app.appRoot, + generateAutolinking: () => writeSidecar(app.appRoot, provided), + }, + ); + } + + const mismatchPath = app => + path.join(app.appRoot, 'build/generated/autolinking/.spm-plugin-mismatch'); + + it('passes the sync the error names once the framework is precompiled', async () => { + const app = scaffoldApp(); + inject(app, [REACT, EXPO]); + await expect(sync(app, [])).rejects.toBeInstanceOf( + PluginFrameworkMismatchError, + ); + const recorded = fs.readFileSync(mismatchPath(app), 'utf8'); + expect(recorded).toBe(EXPO_NOT_PROVIDED + '\n'); + expect(recorded.trim()).toMatch(/run `npx react-native spm sync`\.$/); + + // The developer precompiles ExpoCore, then runs `npx react-native spm sync`. + await sync(app, [EXPO]); + + expect(fs.existsSync(mismatchPath(app))).toBe(false); + }); +}); + +it('injects a plugin framework idempotently', () => { + const app = scaffoldApp(); + inject(app, [REACT, EXPO]); + const pbxprojPath = path.join(app.xcodeprojPath, 'project.pbxproj'); + const once = fs.readFileSync(pbxprojPath, 'utf8'); + + inject(app, [REACT, EXPO]); + + expect(fs.readFileSync(pbxprojPath, 'utf8')).toBe(once); +}); diff --git a/packages/react-native/scripts/spm/__tests__/sync-spm-autolinking-test.js b/packages/react-native/scripts/spm/__tests__/sync-spm-autolinking-test.js index b2e81d23e5d0..1f12073df3ed 100644 --- a/packages/react-native/scripts/spm/__tests__/sync-spm-autolinking-test.js +++ b/packages/react-native/scripts/spm/__tests__/sync-spm-autolinking-test.js @@ -10,7 +10,10 @@ 'use strict'; -const {main} = require('../sync-spm-autolinking'); +const {MissingManifestError} = require('../generate-spm-autolinking'); +const {PluginFrameworkMismatchError} = require('../generate-spm-xcodeproj'); +const {RemoteVersionError} = require('../spm-utils'); +const {main, reportFailure} = require('../sync-spm-autolinking'); const fs = require('node:fs'); const os = require('node:os'); const path = require('node:path'); @@ -38,6 +41,7 @@ describe('sync-spm-autolinking main', () => { generateAutolinking: jest.fn(), installSpmCodegenTemplate: jest.fn(), buildPerAppHeaderTree: jest.fn(), + assertPluginFrameworksLinked: jest.fn(), findProjectRoot: jest.fn(() => appRoot), // Obsolete collaborators are deliberately supplied to prove sync does // not download artifacts or regenerate the runtime package graph. @@ -91,6 +95,7 @@ describe('sync-spm-autolinking main', () => { appRoot, expect.any(Object), ); + expect(deps.assertPluginFrameworksLinked).toHaveBeenCalledWith(appRoot); expect(deps.downloadArtifacts).not.toHaveBeenCalled(); expect(deps.generatePackage).not.toHaveBeenCalled(); expect(fs.existsSync(stampPath())).toBe(true); @@ -133,4 +138,93 @@ describe('sync-spm-autolinking main', () => { ).toBeLessThan(deps.generateAutolinking.mock.invocationCallOrder[0]); expect(fs.existsSync(stampPath())).toBe(false); }); + + function mismatchPath() { + return path.join( + appRoot, + 'build', + 'generated', + 'autolinking', + '.spm-plugin-mismatch', + ); + } + + it('records the errors and refreshes the stamp when plugin frameworks and the project disagree', async () => { + fs.mkdirSync(path.dirname(stampPath()), {recursive: true}); + fs.writeFileSync(stampPath(), 'earlier sync\n'); + const mismatch = new PluginFrameworkMismatchError([ + 'error: first problem', + 'error: second problem', + ]); + const deps = makeDeps({ + assertPluginFrameworksLinked: jest.fn(() => { + throw mismatch; + }), + }); + await expect(run(deps)).rejects.toBe(mismatch); + expect(deps.generateAutolinking.mock.invocationCallOrder[0]).toBeLessThan( + deps.assertPluginFrameworksLinked.mock.invocationCallOrder[0], + ); + expect(fs.readFileSync(mismatchPath(), 'utf8')).toBe( + 'error: first problem\nerror: second problem\n', + ); + expect(fs.readFileSync(stampPath(), 'utf8')).not.toBe('earlier sync\n'); + }); + + it('removes the recorded errors when the check passes', async () => { + fs.mkdirSync(path.dirname(mismatchPath()), {recursive: true}); + fs.writeFileSync(mismatchPath(), 'error: earlier problem\n'); + await run(makeDeps()); + expect(fs.existsSync(mismatchPath())).toBe(false); + }); + + it('leaves the stamp alone when the check fails for another reason', async () => { + const deps = makeDeps({ + assertPluginFrameworksLinked: jest.fn(() => { + throw new Error('unreadable marker'); + }), + }); + await expect(run(deps)).rejects.toThrow(/unreadable marker/); + expect(fs.existsSync(stampPath())).toBe(false); + expect(fs.existsSync(mismatchPath())).toBe(false); + }); +}); + +describe('sync-spm-autolinking reportFailure', () => { + let logSpy; + let errorSpy; + + beforeEach(() => { + logSpy = jest.spyOn(console, 'log').mockImplementation(() => {}); + errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + }); + + afterEach(() => { + jest.restoreAllMocks(); + process.exitCode = undefined; + }); + + it.each([ + ['a remote version error', new RemoteVersionError('bad remote version')], + ['a missing manifest', new MissingManifestError([])], + [ + 'a plugin framework mismatch', + new PluginFrameworkMismatchError(['error: a problem']), + ], + ])('exits 2 with only the message for %s', (_, error) => { + reportFailure(error); + expect(process.exitCode).toBe(2); + expect(errorSpy).not.toHaveBeenCalled(); + expect(logSpy.mock.calls.map(call => call.join(' '))).toEqual([ + expect.stringContaining(error.message), + ]); + expect(logSpy.mock.calls[0].join(' ')).not.toContain(' at '); + }); + + it('exits 1 with the stack trace for any other error', () => { + const error = new Error('unexpected'); + reportFailure(error); + expect(process.exitCode).toBe(1); + expect(errorSpy).toHaveBeenCalledWith(error); + }); }); diff --git a/packages/react-native/scripts/spm/flavored-frameworks.js b/packages/react-native/scripts/spm/flavored-frameworks.js index 5e550a75dea6..5065cddb03a6 100644 --- a/packages/react-native/scripts/spm/flavored-frameworks.js +++ b/packages/react-native/scripts/spm/flavored-frameworks.js @@ -1002,6 +1002,7 @@ module.exports = { finalizeArtifactPublication, prepareFlavoredFrameworks, readFlavoredFrameworksManifest, + readPluginFrameworks, sdkConditionForSlice, transformReactModuleMap, validatePair, diff --git a/packages/react-native/scripts/spm/generate-spm-xcodeproj.js b/packages/react-native/scripts/spm/generate-spm-xcodeproj.js index fa2439c6f433..3be29aa47f4a 100644 --- a/packages/react-native/scripts/spm/generate-spm-xcodeproj.js +++ b/packages/react-native/scripts/spm/generate-spm-xcodeproj.js @@ -21,7 +21,12 @@ * by `spm deinit`). Consumed as a library by setup-apple-spm.js; not a CLI. */ -const {readFlavoredFrameworksManifest} = require('./flavored-frameworks'); +const { + BUILTIN_FRAMEWORKS, + FLAVORED_FRAMEWORKS_MANIFEST, + readFlavoredFrameworksManifest, + readPluginFrameworks, +} = require('./flavored-frameworks'); const {parseConfigCommandJson} = require('./generate-spm-autolinking-config'); const { addArrayMembers, @@ -687,6 +692,9 @@ fi`; // Runtime framework slots are never touched here; add/update owns them. function syncStaleCheckAndDispatch() /*: string */ { return `STAMP="$SRCROOT/build/generated/autolinking/.spm-sync-stamp" +PLUGIN_MISMATCH="$SRCROOT/build/generated/autolinking/.spm-plugin-mismatch" +PLUGIN_FRAMEWORKS="$SRCROOT/build/generated/autolinking/.spm-plugin-flavored-frameworks.json" +MARKER="\${PROJECT_FILE_PATH:-}/${SPM_INJECTED_MARKER}" STALE=0 # Find project root (where package.json lives — may be an ancestor of SRCROOT) @@ -698,10 +706,12 @@ if [ ! -f "$PROJECT_ROOT/package.json" ]; then PROJECT_ROOT="$SRCROOT" fi -# Check 1: dependency inputs (covers app projects after any package manager install) +# Check 1: dependency inputs (covers app projects after any package manager +# install), and the marker, which records the frameworks the project links. for INPUT in \\ "$PROJECT_ROOT/package.json" \\ - "$PROJECT_ROOT/react-native.config.js"; do + "$PROJECT_ROOT/react-native.config.js" \\ + "$MARKER"; do if [ -f "$INPUT" ] && [ "$INPUT" -nt "$STAMP" ]; then STALE=1 break @@ -811,6 +821,20 @@ if [ ! -f "$STAMP" ]; then STALE=1 fi +# The last sync found a plugin framework mismatch and kept its errors. Until an +# input changes, report them again instead of re-running sync: the scheme +# pre-action and this build phase would otherwise both run it. spm add/update +# rewrite the plugin manifest without a sync, so that counts as a change too. +if [ -f "$PLUGIN_MISMATCH" ]; then + if [ "$STALE" -eq 0 ] && \\ + [ ! "$MARKER" -nt "$PLUGIN_MISMATCH" ] && \\ + [ ! "$PLUGIN_FRAMEWORKS" -nt "$PLUGIN_MISMATCH" ]; then + cat "$PLUGIN_MISMATCH" >&2 + exit 1 + fi + STALE=1 +fi + # Re-sync codegen + autolinking when a dependency input changed. Runtime # framework slots and Xcode linker settings are only changed by spm update. if [ "$STALE" -eq 1 ]; then @@ -830,7 +854,7 @@ if [ "$STALE" -eq 1 ]; then cd "$SRCROOT" # \`|| RC=$?\` so a non-zero exit is CAPTURED rather than aborting the phase # under \`set -e\` — the whole point is to branch on the code below (2 = fail - # the build with a scaffold hint; other non-zero = warn but don't break). + # the build; other non-zero = warn but don't break). RC=0 if [ -n "$NODE_BINARY" ] && [ -f "$RN_DIR/scripts/setup-apple-spm.js" ]; then # Direct, dependency-free dispatch (no \`npx react-native\`, which needs @@ -842,10 +866,10 @@ if [ "$STALE" -eq 1 ]; then echo "warning: node/npx not found — skipping SPM sync" fi if [ "$RC" -eq 2 ]; then - # Exit 2 = an autolinked community dependency has no Package.swift. The - # 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 + # without Package.swift, or plugin frameworks the project does not link). + # Sync already printed \`error:\` lines that name the command, so Xcode + # shows them. Fail the build. exit 1 elif [ "$RC" -ne 0 ]; then echo "warning: SPM sync failed — build may use stale codegen/autolinking" @@ -2232,7 +2256,7 @@ function readScriptPhasesManifest( */ function readMarker( xcodeprojPath /*: string */, -) /*: ?{targetUuid?: ?string, generatedSources?: {[string]: Array}, scriptPhases?: {[string]: string}, artifactsVersionOverride?: ?string, configCommand?: ?Array, buildSettingChanges?: Array, createdArrayFields?: Array, scheme?: {file?: ?string, created?: ?boolean}, ...} */ { +) /*: ?{targetUuid?: ?string, generatedSources?: {[string]: Array}, scriptPhases?: {[string]: string}, linkedFrameworks?: Array<{id: string, frameworkName: string}>, artifactsVersionOverride?: ?string, configCommand?: ?Array, buildSettingChanges?: Array, createdArrayFields?: Array, scheme?: {file?: ?string, created?: ?boolean}, ...} */ { const markerPath = path.join(xcodeprojPath, SPM_INJECTED_MARKER); try { // $FlowFixMe[incompatible-return] JSON.parse returns any @@ -2268,6 +2292,176 @@ function findInjectedXcodeproj(appRoot /*: string */) /*: string | null */ { return null; } +class PluginFrameworkMismatchError extends Error { + /*:: problems: Array; */ + constructor(problems /*: Array */) { + super( + 'The precompiled frameworks that autolinking plugins provide differ ' + + 'from the frameworks that the Xcode project links.', + ); + this.name = 'PluginFrameworkMismatchError'; + this.problems = problems; + } +} + +/** + * The frameworks the project links: the setting prefix of every one, and the + * names of the plugin ones. A marker that records them also pairs each plugin + * framework's id with its name. For an older marker they come from the embed + * phase, which cannot pair them: null without one, and null names when the + * frameworks manifest that tells plugin names from built-in ones is missing. + */ +function readLinkedFrameworks( + appRoot /*: string */, + xcodeprojPath /*: string */, +) /*: ?{ + settingPrefixes: Set, + pluginNames: ?Set, + pluginFrameworks?: Array<{id: string, frameworkName: string}>, +} */ { + const builtinIds = new Set(BUILTIN_FRAMEWORKS.map(({id}) => id)); + const recorded = readMarker(xcodeprojPath)?.linkedFrameworks; + if (recorded != null) { + const pluginFrameworks = recorded.filter( + framework => !builtinIds.has(framework.id), + ); + return { + settingPrefixes: new Set( + recorded.map(framework => frameworkSettingPrefix(framework.id)), + ), + pluginNames: new Set( + pluginFrameworks.map(framework => framework.frameworkName), + ), + pluginFrameworks, + }; + } + + // Markers written before `linkedFrameworks` existed: read the embed phase. + const text = fs.readFileSync( + path.join(xcodeprojPath, 'project.pbxproj'), + 'utf8', + ); + const rootUuid = findProjectObject(text)?.uuid; + if (rootUuid == null) { + return null; + } + const embedPhase = findObjectByUuid( + text, + namespacedUUID( + rootUuid, + 'PBXShellScriptBuildPhase', + 'EmbedFlavoredFrameworks', + ), + ); + if (embedPhase == null) { + return null; + } + + const inputs = findField(text, embedPhase, 'inputPaths')?.value ?? ''; + const outputs = findField(text, embedPhase, 'outputPaths')?.value ?? ''; + const settingPrefixes = new Set( + Array.from(inputs.matchAll(/\$\((RN_SPM_\w+)_FRAMEWORK\)/g), m => m[1]), + ); + // Built-in framework names come from the staged manifest. Without it, only + // setting prefixes are compared: a missing manifest is not a plugin mismatch. + if ( + !fs.existsSync( + path.join(appRoot, 'build', 'xcframeworks', FLAVORED_FRAMEWORKS_MANIFEST), + ) + ) { + return {settingPrefixes, pluginNames: null}; + } + const builtinNames = new Set( + readFlavoredFrameworksManifest(appRoot) + .frameworks.filter(framework => builtinIds.has(framework.id)) + .map(framework => framework.frameworkName), + ); + const pluginNames = new Set( + Array.from(outputs.matchAll(/([^/"\n]+)\.framework\b/g), m => m[1]).filter( + name => !builtinNames.has(name), + ), + ); + return {settingPrefixes, pluginNames}; +} + +/** + * Only `spm add` / `spm update` change what the project links, so the + * build-time sync reports a plugin framework mismatch instead of fixing it. + * The committed project is the team's contract: every machine precompiles the + * frameworks it links. + */ +function assertPluginFrameworksLinked( + appRoot /*: string */, + env /*: {readonly [string]: ?string} */ = process.env, +) /*: void */ { + const builtProjectPath = env.PROJECT_FILE_PATH; + const xcodeprojPath = + builtProjectPath != null && + builtProjectPath.endsWith('.xcodeproj') && + fs.existsSync(path.join(builtProjectPath, SPM_INJECTED_MARKER)) + ? builtProjectPath + : findInjectedXcodeproj(appRoot); + if (xcodeprojPath == null) { + return; + } + const linked = readLinkedFrameworks(appRoot, xcodeprojPath); + if (linked == null) { + return; + } + + const pluginFrameworks = readPluginFrameworks(appRoot); + const pairedNames = new Set( + pluginFrameworks.map(framework => framework.frameworkName), + ); + const pairKey = ( + {id, frameworkName} /*: {id: string, frameworkName: string, ...} */, + ) => JSON.stringify([id, frameworkName]); + const pairedPairs = new Set(pluginFrameworks.map(pairKey)); + const problems /*: Array */ = []; + for (const {id, frameworkName} of pluginFrameworks) { + // A name linked under another id is reported once, from the linked side. + const notLinked = + linked.pluginFrameworks != null + ? linked.pluginNames?.has(frameworkName) === false + : !linked.settingPrefixes.has(frameworkSettingPrefix(id)) || + linked.pluginNames?.has(frameworkName) === false; + if (notLinked) { + problems.push( + `error: ${frameworkName} (${id}) is a precompiled framework from an autolinking plugin, but the Xcode project does not link it. Run \`npx react-native spm update\` to add ${frameworkName} to the committed project for the whole team.`, + ); + } + } + if (linked.pluginNames == null) { + console.warn( + 'warning: Skipped the check for frameworks that the Xcode project links but no plugin provides. The project does not record them in .spm-injected.json, and build/xcframeworks/flavored-frameworks.json is missing. Run `npx react-native spm update` to record them.', + ); + } + for (const frameworkName of linked.pluginNames ?? []) { + if (!pairedNames.has(frameworkName)) { + // Precompiling changes no input the build checks, so the recorded + // error stays until sync runs again. + problems.push( + `error: The Xcode project links ${frameworkName}.framework, but no autolinking plugin provides it on this machine. Running \`npx react-native spm update\` removes ${frameworkName} from the committed project for the whole team. Instead, precompile ${frameworkName}, then run \`npx react-native spm sync\`.`, + ); + } + } + for (const framework of linked.pluginFrameworks ?? []) { + const {id, frameworkName} = framework; + if ( + pairedNames.has(frameworkName) && + !pairedPairs.has(pairKey(framework)) + ) { + problems.push( + `error: The Xcode project links ${frameworkName}.framework (${id}), but the autolinking plugin that provides ${frameworkName} gives it a different id. Run \`npx react-native spm update\` to relink ${frameworkName} in the committed project for the whole team.`, + ); + } + } + if (problems.length > 0) { + problems.forEach(problem => console.error(problem)); + throw new PluginFrameworkMismatchError(problems); + } +} + /** * Read the `artifactsVersionOverride` a previous `spm add --version` / `spm * update --version` pinned into the injected xcodeproj's `.spm-injected.json` @@ -2511,6 +2705,12 @@ function injectSpmIntoExistingXcodeproj( // Plugin phase id → its PBXShellScriptBuildPhase UUID, reconciled the // same way. scriptPhases: scriptPhaseUuids, + // What the embed phase links. The build-time sync compares it with + // the frameworks plugins provide, and re-runs when it changes. + linkedFrameworks: flavoredFrameworks.map(({id, frameworkName}) => ({ + id, + frameworkName, + })), artifactsVersionOverride, configCommand, scheme: { @@ -2786,5 +2986,7 @@ module.exports = { readArtifactsVersionOverride, readPinnedConfigCommand, readScriptPhasesManifest, + assertPluginFrameworksLinked, + PluginFrameworkMismatchError, SPM_INJECTED_MARKER, }; diff --git a/packages/react-native/scripts/spm/sync-spm-autolinking.js b/packages/react-native/scripts/spm/sync-spm-autolinking.js index 78e1fcbd43a0..458c77ec0b88 100644 --- a/packages/react-native/scripts/spm/sync-spm-autolinking.js +++ b/packages/react-native/scripts/spm/sync-spm-autolinking.js @@ -31,14 +31,24 @@ * call site) * 2. Calls generate-spm-autolinking.js → build/generated/autolinking/Package.swift * 3. Rebuilds the generated-headers farm - * 4. Writes build/generated/autolinking/.spm-sync-stamp + * 4. Fails when the Xcode project does not link exactly the precompiled + * frameworks autolinking plugins provide, and records the errors in + * build/generated/autolinking/.spm-plugin-mismatch for later builds + * 5. Writes build/generated/autolinking/.spm-sync-stamp * * Runtime frameworks are deliberately not downloaded or regenerated from an * Xcode build. `spm add` / `spm update` own the immutable dual-flavor slots and * the app target's linker/embed configuration. */ -const {main: generateAutolinking} = require('./generate-spm-autolinking'); +const { + MissingManifestError, + main: generateAutolinking, +} = require('./generate-spm-autolinking'); +const { + PluginFrameworkMismatchError, + assertPluginFrameworksLinked, +} = require('./generate-spm-xcodeproj'); const { RemoteVersionError, buildPerAppHeaderTree, @@ -62,6 +72,7 @@ const defaultDeps = { generateAutolinking, installSpmCodegenTemplate, buildPerAppHeaderTree, + assertPluginFrameworksLinked, findProjectRoot, }; @@ -130,6 +141,27 @@ async function main( autolinkingArgv.push('--ios-deployment-target', iosDeploymentTarget); } deps.generateAutolinking(autolinkingArgv); + const autolinkingDir = path.join( + appRoot, + 'build', + 'generated', + 'autolinking', + ); + const stampPath = path.join(autolinkingDir, '.spm-sync-stamp'); + const mismatchPath = path.join(autolinkingDir, '.spm-plugin-mismatch'); + try { + deps.assertPluginFrameworksLinked(appRoot); + } catch (e) { + if (e instanceof PluginFrameworkMismatchError) { + // The build phase prints the recorded errors and fails until an input + // changes, so these inputs count as synced. + fs.mkdirSync(autolinkingDir, {recursive: true}); + fs.writeFileSync(mismatchPath, e.problems.join('\n') + '\n', 'utf8'); + writeStamp(stampPath); + } + throw e; + } + fs.rmSync(mismatchPath, {force: true}); // Rebuild the per-app generated-headers farm (vended as the ReactAppHeaders // SPM target inside the codegen package). React core headers need no trees @@ -139,31 +171,33 @@ async function main( // so no path-locator JSON is written. deps.buildPerAppHeaderTree(appRoot, {log}); - const stampPath = path.join( - appRoot, - 'build', - 'generated', - 'autolinking', - '.spm-sync-stamp', - ); + writeStamp(stampPath); + log('SPM autolinking sync complete.'); +} + +function writeStamp(stampPath /*: string */) /*: void */ { fs.mkdirSync(path.dirname(stampPath), {recursive: true}); fs.writeFileSync(stampPath, new Date().toISOString() + '\n', 'utf8'); - log('SPM autolinking sync complete.'); +} + +function reportFailure(e /*: unknown */) /*: void */ { + if ( + e instanceof RemoteVersionError || + e instanceof MissingManifestError || + e instanceof PluginFrameworkMismatchError + ) { + // Clean message + exit 2 (the build phase hard-fails on it) instead of a + // stack trace, matching how setup-apple-spm.js surfaces these errors. + log(e.message); + process.exitCode = 2; + return; + } + console.error(e); + process.exitCode = 1; } if (require.main === module) { - main().catch(e => { - if (e instanceof RemoteVersionError) { - // Clean message + exit 2 (the build phase hard-fails on it) instead of a - // stack trace, matching how setup-apple-spm.js surfaces remote-mode - // version errors. - log(e.message); - process.exitCode = 2; - return; - } - console.error(e); - process.exitCode = 1; - }); + main().catch(reportFailure); } -module.exports = {main}; +module.exports = {main, reportFailure}; From 0f95088438c95082a0c023b9d18503371dfa2b2e Mon Sep 17 00:00:00 2001 From: Christian Falch Date: Thu, 15 Oct 2026 05:55:36 -0700 Subject: [PATCH 4/5] docs(swiftpm): correct SwiftPM docs to match current scripts (#58968) 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: https://github.com/react/react-native/pull/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 649c946516a8ba5e5e80ef74047d3e6ef67b8d87) --- .../scripts/ios-prebuild/__docs__/README.md | 31 +- .../ios-prebuild/__docs__/headers-rules.md | 39 +- .../scripts/spm/__docs__/README.md | 50 +- .../spm/__docs__/spm-autolinking-plugins.md | 327 ++++---- .../spm/__docs__/spm-header-paths-contract.md | 167 ++-- .../scripts/spm/__docs__/spm-scripts.md | 754 ++++++++++-------- 6 files changed, 743 insertions(+), 625 deletions(-) diff --git a/packages/react-native/scripts/ios-prebuild/__docs__/README.md b/packages/react-native/scripts/ios-prebuild/__docs__/README.md index 70411582212b..0e1431f876ba 100644 --- a/packages/react-native/scripts/ios-prebuild/__docs__/README.md +++ b/packages/react-native/scripts/ios-prebuild/__docs__/README.md @@ -126,11 +126,14 @@ overlay**. The layout contract is defined and validated in code: the `React/` and bare-aliased headers into every slice's `React.framework/Headers`, and `buildReactNativeHeadersXcframework()` assembles the headers-only `ReactNativeHeaders.xcframework` carrying every - other namespace (incl. `react/`) plus the third-party dependency namespaces - (`folly`, `glog`, `boost`, `fmt`, `double-conversion`, `fast_float`). The - Hermes public headers (``) are folded in only on the SwiftPM - consumer side (`ensureHeadersLayout`); the published prebuild artifact does - not yet carry them (TODO in `xcframework.js`). + other React Native namespace (incl. `react/`). The third-party dependency + namespaces (`folly`, `glog`, `boost`, `fmt`, `double-conversion`, + `fast_float`, `SocketRocket`) ship in the separate headers-only + `ReactNativeDependenciesHeaders.xcframework` sidecar, built by the + dependencies prebuild. The Hermes public headers (``) are folded + into the published `ReactNativeHeaders` when the hermes-ios headers are + staged; without them the compose ships no `hermes/`, unless `--require-hermes` + is set, which makes it fail closed. ### Artifacts @@ -139,15 +142,15 @@ The prebuild (`xcframework.js`) always produces: - `React.xcframework` — the compiled React core. Each slice's `React.framework` carries the headers-spec layout (every `` header + the framework module map). CocoaPods consumes that layout directly, through - `FRAMEWORK_SEARCH_PATHS`. SwiftPM consumes the same headers indirectly: the - XCFramework is not a member of the Swift package graph, so the consumer side - stages a copy of `React.framework/Headers` into - `ReactHeadersTarget/include/React` and rewrites `framework module React` to a - plain `module React`, vended as the `ReactHeaders` target (see - `spm-header-paths-contract.md` in the SwiftPM docs). -- `ReactNativeHeaders.xcframework` — headers-only; carries every other - namespace. Consumed by SwiftPM as a `binaryTarget` and by CocoaPods via the - `React-Core-prebuilt` pod (headers flattened onto the header search path). + `FRAMEWORK_SEARCH_PATHS`. SwiftPM consumes the same headers indirectly, + because the XCFramework is not a member of the Swift package graph. The + consumer side stages a copy of `React.framework/Headers` into + `ReactHeadersTarget/include/React`, rewrites `framework module React` to a + plain `module React`, and vends it as the `ReactHeaders` target (see + [spm-header-paths-contract.md](../../spm/__docs__/spm-header-paths-contract.md)). +- `ReactNativeHeaders.xcframework` — headers-only; carries every other React + Native namespace. Consumed by SwiftPM as a `binaryTarget` and by CocoaPods via + the `React-Core-prebuilt` pod (headers flattened onto the header search path). ### CocoaPods consumption diff --git a/packages/react-native/scripts/ios-prebuild/__docs__/headers-rules.md b/packages/react-native/scripts/ios-prebuild/__docs__/headers-rules.md index 3c4de65e246a..950c8dee386a 100644 --- a/packages/react-native/scripts/ios-prebuild/__docs__/headers-rules.md +++ b/packages/react-native/scripts/ios-prebuild/__docs__/headers-rules.md @@ -120,13 +120,14 @@ resolving `` through `React.framework` requires case-folding `react.framework` → `React.framework`, which only works on case-insensitive filesystems. The header-search-path route (R2) is exact everywhere. -**R2 — every other namespace ships in ONE headers-only xcframework, +**R2 — every other React Native namespace ships in ONE headers-only xcframework, `ReactNativeHeaders`.** Namespace dirs at its `Headers/` root: `react/`, -`yoga/`, `jsi/`, `cxxreact/`, `React_RCTAppDelegate/`, … **including the -third-party deps namespaces** (folly/glog/boost/fmt/double-conversion/ -fast_float, copied out of the ReactNativeDependencies artifact — which thereby -becomes binary-only). SPM/Xcode auto-serve a binaryTarget's `Headers/` on the -consumer's search path, so everything resolves with zero flags. +`yoga/`, `jsi/`, `cxxreact/`, `React_RCTAppDelegate/`, … The third-party deps +namespaces (folly, glog, boost, fmt, double-conversion, fast_float, +SocketRocket) are **not** here: they ship in the headers-only +`ReactNativeDependenciesHeaders` sidecar built by the deps prebuild. SPM/Xcode +auto-serve a binaryTarget's `Headers/` on the consumer's search path, so +everything resolves with zero flags. **R3 — NO include rewriting, anywhere.** Shipped headers are byte-identical to the repo. If a header's includes don't work in the packaged layout, the fix is @@ -249,11 +250,11 @@ declarations stay single-owned. ### `buildReactNativeHeadersXcframework` -1. Stage all R2 entries, then copy the six deps namespaces from - `third-party/ReactNativeDependencies.xcframework/Headers`. A declared deps - namespace that is missing is a **hard error** — previously a warn-and-ship, - which once produced a silently deps-less artifact (1.6 MB instead of 11 MB). -2. Optionally fold in the `hermes/` public headers (consumer-side compose path). +1. Stage all R2 entries. The deps namespaces are not copied here; they ship in + the `ReactNativeDependenciesHeaders` sidecar, whose namespace guards live in + `buildDepsHeadersXcframework` (`headers-xcframework.js`). +2. Fold in the `hermes/` public headers when they are staged (both the prebuild + compose and the consumer-side compose path). 3. Write the R10 umbrella files, then the R5 module map. 4. Compile a stub static archive per slice (headers-only artifacts still need a library for `xcodebuild -create-xcframework`) and compose the xcframework. @@ -274,13 +275,17 @@ mtime + hermes presence). | `#import ` | same, **textual** via the R9 entry | R9 | | `#import ` (C++) | `ReactNativeHeaders/Headers` search path, textual | R2 | | `#import ` | same, **modular** via the yoga R5 module | R2, R5 | -| `#import ` | same (deps namespaces relocated here) | R2 | +| `#import ` | SwiftPM: the `ReactNativeDependenciesHeaders` sidecar's `Headers/` (deps namespaces are not in `ReactNativeHeaders`) | R2 | | `` | same, modular | R10 | | `#import "RCTFabricComponentsPlugins.h"` (quoted, community Fabric pods) | CocoaPods only: re-vended by the `React-RCTFabric` facade into the pod header map (`rncore_facades.rb`, `FACADE_REEXPOSED_HEADERS`) | — | -- **SwiftPM**: both xcframeworks are plain `.binaryTarget`s; Xcode auto-serves - `React.framework`'s `Headers/`+`Modules/` and `ReactNativeHeaders`' `Headers/` - (incl. its `module.modulemap`) to dependents. Zero flags. +- **SwiftPM**: `React.xcframework` is not in the package graph. Its headers are + copied into `ReactHeadersTarget/include/React` with the module map rewritten + to a plain `module React`, vended as the `ReactHeaders` target. The only + `.binaryTarget`s are `ReactNativeHeaders` and + `ReactNativeDependenciesHeaders`; Xcode auto-serves their `Headers/` (incl. + `module.modulemap`) to dependents. Zero flags. See + [spm-header-paths-contract.md](../../spm/__docs__/spm-header-paths-contract.md). - **CocoaPods**: `React-Core-prebuilt`'s `prepare_command` flattens `ReactNativeHeaders`' Headers (incl. the module map) into the pod, and the React-core pods are installed as dependency-only **facades** @@ -294,8 +299,8 @@ mtime + hermes presence). | R9 allowlist validation | private header removed/renamed, or bucket drifted | `validatePrivateReactHeaders` | | R10 umbrella check | a probed namespace loses all modular headers | `planFromInventory` | | R5 exemption assert | an invalid-module-identifier namespace gains a modular-candidate header | `planFromInventory` | -| Deps namespace guard (missing) | folly/glog/… not staged at compose time | `buildReactNativeHeadersXcframework` | -| Deps namespace guard (undeclared) | the deps artifact ships a namespace not in `DEPS_NAMESPACES` (new third-party dep) | `buildReactNativeHeadersXcframework` | +| Deps namespace guard (missing) | folly/glog/… not staged at compose time | `buildDepsHeadersXcframework` | +| Deps namespace guard (undeclared) | the deps artifact ships a namespace not in `DEPS_NAMESPACES` (new third-party dep) | `buildDepsHeadersXcframework` | | Include-health ratchet | a shipped header gains a `notShipped`/`unresolved`/quoted-unresolvable include not in the committed baseline | `headers-verify.js` | | Structural gate | composed module maps/umbrellas differ from the spec render; R9 headers or deps dirs absent | `headers-verify.js` | | Compile gates | the React module, any R5 namespace module, the R10 umbrella, the R9 textual (Expo-shape) surface, or Swift `RCTBridge.moduleRegistry` fails to compile | `headers-verify.js` (CI: prebuild compose job) | diff --git a/packages/react-native/scripts/spm/__docs__/README.md b/packages/react-native/scripts/spm/__docs__/README.md index caeb710be67a..b04da44fc81f 100644 --- a/packages/react-native/scripts/spm/__docs__/README.md +++ b/packages/react-native/scripts/spm/__docs__/README.md @@ -2,10 +2,9 @@ [🏠 Home](../../../../../__docs__/README.md) -> **Preview.** SwiftPM support is an early preview: the commands, flags, -> generated layout, and distribution model may change in future releases, and it -> is not yet recommended for production. CocoaPods remains the supported -> default. +> **Preview.** SwiftPM support is an early preview. The commands, flags, +> generated layout, and distribution model may change in future releases. It is +> not yet recommended for production. CocoaPods remains the supported default. The scripts in `scripts/spm/` let a React Native iOS app consume React Native through **Swift Package Manager** instead of CocoaPods, using prebuilt @@ -13,9 +12,9 @@ XCFrameworks. Support is opt-in and additive: `npx react-native spm` injects package references into the app's existing `.xcodeproj` in place, and `deinit` reverses exactly what it injected. -The motivation, staged migration plan, and open questions live in -[RFC0994](https://github.com/react-native-community/discussions-and-proposals/blob/main/proposals/0994-swift-package-manager-support-for-react-native-ios-projects.md). -The documents here describe how the implementation actually works. +[RFC0994](https://github.com/react-native-community/discussions-and-proposals/blob/main/proposals/0994-swift-package-manager-support-for-react-native-ios-projects.md) +holds the motivation, the staged migration plan, and the open questions. The +documents here describe how the implementation works. ## 🚀 Usage @@ -24,21 +23,10 @@ cd ios npx react-native spm # add on first run, update thereafter ``` -**If any autolinked dependency ships no `Package.swift`, this stops with -`error: Package.swift is missing for library ""` and exit code 2.** That -is deliberate — `add` and `update` never scaffold silently, so a missing -manifest is visible and fixed on purpose. Generate the manifests first, then -re-run setup: - -```bash -npx react-native spm scaffold # writes Package.swift into node_modules// -npx react-native spm # then inject as usual -``` - -Because `node_modules` isn't committed, persist each scaffolded manifest with -`npx patch-package ` and commit the patch — otherwise the same error -returns on every fresh install and in CI. Better still, contribute the manifest -upstream. See +**If an autolinked dependency ships no `Package.swift`, setup stops with +`error: Package.swift is missing for library ""` and exit code 2.** Run +`npx react-native spm scaffold`, persist the manifest with `patch-package`, and +re-run setup. See [Community packages without a Package.swift](./spm-scripts.md#community-packages-without-a-packageswift). See **[spm-scripts.md](./spm-scripts.md)** for the CLI actions and flags, @@ -52,20 +40,20 @@ Three documents cover the design, each owning one area: | Document | Covers | | -------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | [spm-scripts.md](./spm-scripts.md) | The tool itself: CLI surface, the six-step pipeline, [every file it creates or modifies](./spm-scripts.md#files-the-tool-touches), the two auto-sync hooks, and how Debug/Release flavor selection works. | -| [spm-header-paths-contract.md](./spm-header-paths-contract.md) | How headers and package references resolve. The contract is **zero-`-I`**: no header search paths and no `unsafeFlags` in any generated manifest. Also covers remote mode. | +| [spm-header-paths-contract.md](./spm-header-paths-contract.md) | How headers and package references resolve. The contract is **zero-`-I`**: no generated manifest carries a search path into React Native's own headers. Also covers remote mode. | | [spm-autolinking-plugins.md](./spm-autolinking-plugins.md) | The extension seam for frameworks with their own module system (Expo is the first consumer): discovery, the full context/return contract including `flavoredFrameworks`, `watchPaths` and `scriptPhases`, and failure behavior. | Two ideas explain most of the architecture: - **Headers go through SwiftPM; runtime binaries do not.** A `binaryTarget` - cannot vary by build configuration, but React Native ships flavored binaries - (a debug `React.framework` carries the dev menu and assertions; release strips - them). So the package graph vends headers only, and the flavored frameworks - are linked and embedded through generated Xcode build settings instead. + cannot vary by build configuration, but React Native ships flavored binaries. + So the package graph vends headers only, and generated Xcode build settings + link and embed the flavored frameworks. See + [Debug/Release flavor is automatic](./spm-scripts.md#debugrelease-flavor-is-automatic). - **Generated state is regenerable, and the injection is reversible.** Everything under `build/` is gitignored and rebuilt from the app's - `package.json`; everything written into the `.xcodeproj` is recorded in a - `.spm-injected.json` marker so `deinit` can undo precisely that. + `package.json`. Every edit to the `.xcodeproj` is recorded in a + `.spm-injected.json` marker, so `deinit` can undo exactly that. ## 🔗 Relationship with other systems @@ -83,7 +71,9 @@ Two ideas explain most of the architecture: local `React-GeneratedCode` package rather than a Pod. - **`@react-native-community/cli config`** — supplies the autolinking metadata (`autolinking.json`) that the SwiftPM autolinker turns into a `Package.swift`. - Overridable via `--configCommand`. + Override it with the `RCT_SPM_AUTOLINKING_CONFIG_COMMAND` environment + variable, or with `--config-command` when you run `setup-apple-spm.js` + directly. ### Uses this diff --git a/packages/react-native/scripts/spm/__docs__/spm-autolinking-plugins.md b/packages/react-native/scripts/spm/__docs__/spm-autolinking-plugins.md index 1b9cb7a7b77d..878d738f2804 100644 --- a/packages/react-native/scripts/spm/__docs__/spm-autolinking-plugins.md +++ b/packages/react-native/scripts/spm/__docs__/spm-autolinking-plugins.md @@ -22,18 +22,18 @@ The documented extension points don't cover a framework: re-run autolinking on every dependency change. A framework's contribution must run _whenever autolinking runs_. -A plugin is exactly that. It is invoked from `generate-spm-autolinking.js`'s -`main()` — the single function that both `add` / `update` **and** the build-time -`sync` call — so the contribution is regenerated on every build and never goes -stale. +A plugin does exactly that. `generate-spm-autolinking.js`'s `main()` invokes it. +`add` / `update` **and** the build-time `sync` all call that one function. So +the contribution is regenerated on every build and never goes stale, and the +build-time path needs no separate hook. -(This is the SwiftPM analog of the seams CocoaPods gave Expo: the Podfile, -`use_expo_modules!`, and `react_native_post_install` hooks.) +This is the SwiftPM analog of the seams CocoaPods gave Expo: the Podfile, +`use_expo_modules!`, and `react_native_post_install` hooks. ## Discovery — transitive, zero app config A dependency opts in from its **own** package.json, so installing the framework -is enough (mirrors how CocoaPods pulls in `use_expo_modules!` transitively): +is enough. This mirrors how CocoaPods pulls in `use_expo_modules!` transitively: ```json // node_modules/expo/package.json @@ -42,12 +42,16 @@ is enough (mirrors how CocoaPods pulls in `use_expo_modules!` transitively): } ``` -The autolinker reads every dependency's SwiftPM settings; any that declares -`autolinkingPlugin` is `require`d and invoked. No app-level registration or +The autolinker reads every dependency's SwiftPM settings. It `require`s and +invokes each one that declares `autolinkingPlugin`. No app-level registration or allowlist is required. The deprecated `spm.autolinkingPlugin` in `react-native.config.js` is still read — see [Migrating from react-native.config.js](spm-scripts.md#where-swiftpm-settings-live). +A dependency that declares a plugin owns its native contribution. React Native +does not build it as an autolinked target, so it needs no `Package.swift` of its +own. + **Opt-out escape hatch.** An app can exclude a plugin from its own package.json: ```json @@ -117,6 +121,29 @@ module.exports = function plugin(context) { }; ``` +### `generatedSources` — sources wired into the app target + +The merge writes `.spm-plugin-generated-sources.json`. The `spm add`/`update` +xcodeproj injector (`generate-spm-xcodeproj.js`) reads it and wires each source +**into the app target**: a `PBXFileReference`, a `PBXBuildFile`, and a +Sources-build-phase entry, all under one "SPM Generated Sources" navigator +group. + +This is what makes an `@objc` class (e.g. Expo's `ExpoModulesProvider`) reach +the ObjC classlist. A class inside the static Autolinked aggregate never does, +so `NSClassFromString` discovery would fail. + +- Paths are stored SRCROOT-relative when they are under the app root (the usual + `build/generated/…` case), else absolute (`sourceTree = ""`). +- All UUIDs are namespaced on the normalized path, so injection is deterministic + and idempotent. They are recorded in the `.spm-injected.json` marker's + `generatedSources` map. `deinit` reverts them, and `update` reconciles entries + that left the manifest. +- A target without a Sources phase logs loudly and skips the wiring. The rest of + the injection succeeds. +- v1 targets only the injected app target and assumes `.swift` in practice. + `.m`/`.mm` are mapped as future-proofing. + ### `flavoredFrameworks` — per-configuration precompiled frameworks Each entry is @@ -127,24 +154,29 @@ agree across flavors. Static binaries, nested frameworks, duplicate IDs, and duplicate embedded framework names are fatal. The declarations are recorded to -`/.spm-plugin-flavored-frameworks.json`, normalized into the same -immutable app-local slots as React Native, and added to Xcode's exact linker and -embed settings. They are not emitted as SwiftPM product dependencies. Adding or -removing one requires `spm update`; the build-time `spm sync` intentionally does -not mutate runtime framework settings. When the frameworks that plugins provide -and the frameworks that the project links disagree, sync fails the build. An -`error:` line names each framework and the command to run. +`/.spm-plugin-flavored-frameworks.json`. They are normalized into the +same immutable app-local slots as React Native and added to Xcode's exact linker +and embed settings. They are not emitted as SwiftPM product dependencies. Adding +or removing one requires `spm update`: the build-time `spm sync` intentionally +does not mutate runtime framework settings. When the frameworks that plugins +provide and the frameworks that the project links disagree, sync fails the +build. An `error:` line names each framework and the command to run. ### `watchPaths` — plugin staleness inputs -`watchPaths` is an array of **absolute** paths (dirs **or** files) the Xcode -auto-sync hooks watch to decide whether they must re-sync. RN already watches -each module's source dir plus every npm dep's checked-in `Package.swift` and -`.react-native/` dir; a plugin adds the inputs only it knows about — e.g. -`packages/expo/Package.swift`, `expo-module.config.json`, and per-module -manifests. On the next build the phase re-syncs when a watched **file** is newer -than the last sync, a watched **dir** has a newer child, or a watched path has -**vanished** (a rename forces a re-sync so the config error surfaces). +`watchPaths` is an array of **absolute** paths (dirs **or** files). The Xcode +auto-sync hooks watch them to decide whether they must re-sync. RN already +watches each module's source dir, plus every npm dep's checked-in +`Package.swift` and `.react-native/` dir. A plugin adds the inputs only it knows +about, e.g. `packages/expo/Package.swift`, `expo-module.config.json`, and +per-module manifests. + +On the next build, the phase re-syncs when: + +- a watched **file** is newer than the last sync, +- a watched **dir** has a newer child, or +- a watched path has **vanished**. A rename forces a re-sync, so the config + error surfaces. A watched path that does not exist yet is watched for **appearing**. When it is created, the next build re-syncs. So a plugin that builds precompiled modules @@ -152,21 +184,26 @@ should list their output folder, even when a fresh clone does not have it yet. List only folders that the Xcode build itself does not write to, or every build re-syncs. -Unlike `flavoredFrameworks`, watch paths are best-effort: a non-array is ignored -with a warning (never fatal), and each non-string / empty / **relative** entry -is dropped with a warning. Absolute-only, because the generated phase tests -these paths with no cwd context. The kept paths are folded in with RN's own, -deduped, and sorted: paths that exist go to `/.spm-sync-watch-paths`, -and paths that do not exist yet go to `/.spm-sync-watch-absent`. +Unlike `flavoredFrameworks`, watch paths are best-effort. A non-array is ignored +with a warning (never fatal). Each non-string, empty, or **relative** entry is +dropped with a warning. Paths must be absolute because the generated phase tests +them with no cwd context. The kept paths are folded in with RN's own, deduped, +and sorted: paths that exist go to `/.spm-sync-watch-paths`, and +paths that do not exist yet go to `/.spm-sync-watch-absent`. Only +paths that exist when the sync runs are written; a missing path is dropped +silently. So the **vanished** check applies only to paths that existed at the +last sync. ### `scriptPhases` — build-time shell phases on the app target -SwiftPM has no equivalent of CocoaPods' `script_phase`, so a framework that must -run a script during the app's build — `expo-constants` writing -`EXConstants.bundle/app.config` is the first consumer — declares it here. Each -entry is recorded to `/.spm-plugin-script-phases.json`, which -`spm add` / `spm update` reads to emit one `PBXShellScriptBuildPhase` per entry -on the injected app target: +SwiftPM has no equivalent of CocoaPods' `script_phase`. A framework that must +run a script during the app's build declares it here. The first consumer is +`expo-constants`, which writes `EXConstants.bundle/app.config`. + +The merge always rewrites `/.spm-plugin-script-phases.json` — `[]` +when no plugin declares any, so removing a plugin clears stale entries. +`spm add` / `spm update` read that sidecar and emit one +`PBXShellScriptBuildPhase` per entry on the injected app target: | Key | Meaning | | ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | @@ -177,69 +214,80 @@ on the injected app target: | `inputPaths` / `outputPaths` | Optional Xcode input/output file lists, which is what lets Xcode skip an up-to-date phase. | | `alwaysOutOfDate` | Optional; when `true` the phase runs on every build regardless of its file lists. | -**Placement.** `'end'` appends at the true end of the target's `buildPhases` — -after the app's own JS-bundle phase. `'beforeCompile'` lands directly after the -"Sync SPM Autolinking" phase, which stays first because it regenerates the -content everything else reads, and always **before Sources**: React Native never -re-seats its own sync phase, so if you have dragged that below Sources your -`beforeCompile` phases are seated ahead of Sources instead of following it. -Phases sharing a position keep their declared order. +**Placement.** + +- `'end'` appends at the true end of the target's `buildPhases`, after the app's + own JS-bundle phase. +- `'beforeCompile'` lands directly after the "Sync SPM Autolinking" phase, and + always **before Sources**. The sync phase stays first because it regenerates + the content everything else reads. React Native never re-seats its own sync + phase. If you have dragged it below Sources, your `beforeCompile` phases sit + ahead of Sources instead of following it. + +Phases that share a position keep their declared order. **Position is enforced on every sync.** `add`/`update` compares where the plugin -phases actually sit in `buildPhases` against the declared placement and, **only -when the two differ**, lifts their membership lines and re-seats them in -declared order. So changing `position` — or swapping two phases that share one — -takes effect on the next `spm add`/`update`, with no remove + re-add. When they -agree nothing is rewritten, which is what keeps an unchanged declaration -re-syncing to a byte-identical project. The consequence worth knowing: a phase -you **drag somewhere else in Xcode is moved back** to its declared position on -the next sync, because the plugin's declaration is the source of truth. Only the -`id` behaves differently — it is a key, not a label, so renaming it is a remove - -- add. +phases sit in `buildPhases` with the declared placement. **Only when the two +differ** does it lift their membership lines and re-seat them in declared order. +So a change to `position`, or a swap of two phases that share one, takes effect +on the next `spm add`/`update`, with no remove + re-add. When they agree nothing +is rewritten, so an unchanged declaration re-syncs to a byte-identical project. +The plugin's declaration is the source of truth: a phase you **drag somewhere +else in Xcode is moved back** to its declared position on the next sync. Phases are injected by `spm add` / `spm update` **only**. The build-time `sync` -rewrites the sidecar but never touches the `.xcodeproj`, so a newly declared +rewrites the sidecar but never touches the `.xcodeproj`. So a newly declared phase appears on the next `add`/`update`, not on the next build. Each phase's UUID is derived from its `id` and recorded in the `.spm-injected.json` marker's -`scriptPhases` map, so a re-run refreshes the phase's `name`, `script`, path -lists, `alwaysOutOfDate` and placement in place, `update` removes phases that +`scriptPhases` map. So a re-run refreshes the phase's `name`, `script`, path +lists, `alwaysOutOfDate` and placement in place. `update` removes phases that left the sidecar, and `deinit` reverts all of them. -Validation is **fatal**, like `flavoredFrameworks` and unlike `watchPaths`: a -non-array `scriptPhases`, a malformed entry, or a duplicate `id` (within one -plugin or across plugins) aborts the run. A silently dropped phase would produce -a green build whose generated content was never written — a runtime failure with -no build-time signal — and two phases sharing an `id` would collapse onto one -ledger key. `__proto__`, `constructor`, and `prototype` are rejected as ids even -though the charset admits them: as keys of that ledger they never become own -properties, so the phase would look recorded, disappear when the marker is -serialized, and be unremovable by `deinit`. - -**Hostile names.** A `name` reaches the project file twice. In the `name` field -— what Xcode displays — it lands verbatim, escaped as an OpenStep string, so any -single-line string is expressible. Beside the phase's UUID, on the object's -definition line and on its `buildPhases` member line, it also becomes a -`/* … */` comment; those comments are cosmetic (Xcode regenerates them from the -`name` field) but the text around them is scanned by delimiter, so the name is -**normalized** there: `{}(),;="*/`, tabs and whitespace runs collapse to single -spaces (`spm-pbxproj.js`'s `commentSafe`), falling back to the phase `id` — -normalized the same way — and then to no comment at all if nothing survives -either. Without that, a `{` in a comment would make the injector read the next -object's body as this one's, and a `,` would make `deinit` delete the wrong line -— corruption with no error. Only a line break is therefore rejected outright; a -name is a display name, and no Xcode phase name spans lines. - -The injector's read of the sidecar is deliberately lenient — the file does not +Validation is **fatal**, like `flavoredFrameworks` and unlike `watchPaths`. +`invokePlugins` checks the `id` charset, a single-line `name`, a required +`script`, the `position` enum, and the optional path lists and +`alwaysOutOfDate`. A non-array `scriptPhases`, a malformed entry, or a duplicate +`id` (within one plugin or across plugins) aborts the run. + +- A silently dropped phase would produce a green build whose generated content + was never written — a runtime failure with no build-time signal. +- Two phases that share an `id` would collapse onto one ledger key. +- `__proto__`, `constructor`, and `prototype` are rejected as ids even though + the charset admits them. As keys of that ledger they never become own + properties. The phase would look recorded, disappear when the marker is + serialized, and be unremovable by `deinit`. + +**Hostile names.** A `name` reaches the project file twice: + +- In the `name` field, which Xcode displays, it lands verbatim, escaped as an + OpenStep string. Any single-line string is expressible there. +- Beside the phase's UUID, on the object's definition line and on its + `buildPhases` member line, it becomes a `/* … */` comment. Xcode regenerates + these comments from the `name` field, so they are cosmetic. But the injector + scans the text around them by delimiter, so the name is **normalized** there. + `{}(),;="*/`, tabs and whitespace runs collapse to single spaces + (`spm-pbxproj.js`'s `commentSafe`). If nothing survives, the comment falls + back to the phase `id`, normalized the same way, and then to no comment at + all. + +Without that normalization, a `{` in a comment would make the injector read the +next object's body as this one's. A `,` would make `deinit` delete the wrong +line. Both are corruption with no error. So only a line break is rejected +outright: a name is a display name, and no Xcode phase name spans lines. + +The injector's read of the sidecar is deliberately lenient. The file does not exist yet on a first `spm add`, and a stale or hand-edited copy must not break -injection. An absent file yields no phases silently, an unparseable one warns, -and a single entry failing the same checks (bad or reserved `id`, empty or -multi-line `name`, missing `script`, unknown `position`, duplicate `id`) is -**skipped, never coerced** — the sidecar is the only gate on a hand edit, so it -enforces exactly the rules the plugin contract does. +injection. + +- An absent file yields no phases, silently. +- An unparseable file warns. +- An entry that fails the same checks (bad or reserved `id`, empty or multi-line + `name`, missing `script`, unknown `position`, duplicate `id`) is **skipped, + never coerced**. The sidecar is the only gate on a hand edit, so it enforces + exactly the rules the plugin contract does. **Gating is the script's job.** The phase runs for every configuration and -platform the target builds; if it should be a no-op for some of them (Release +platform the target builds. If it should be a no-op for some of them (Release only, simulator only, …), the script must check `$CONFIGURATION` / `$PLATFORM_NAME` and exit early. @@ -257,9 +305,9 @@ only, simulator only, …), the script must check `$CONFIGURATION` / #### `context.react` — depending on React A plugin that emits its own `Package.swift` must declare React as a dependency. -Rather than re-deriving React Native's package path, identity, and product names -— which differ between local and remote mode and **move as RN repackages** — -take them from `context.react`: +React Native's package path, identity, and product names differ between local +and remote mode, and they **move as RN repackages**. So take them from +`context.react` rather than re-deriving them: ```js react: { @@ -275,19 +323,21 @@ react: { } ``` -Local vs remote is signalled by which `packageRef` keys are present (`path` xor -`url`+`version`). `packageRef.path` is **absolute** — always correct no matter -which subdirectory of `outputDir` the plugin writes its own manifest into (the -generated manifests are gitignored and regenerated every sync, so there's no -portability cost); `relPath` (relative to `outputDir`) is provided as a -convenience. `products` is the set React Native wires into **its own** -autolinked targets (so a plugin's target compiles against exactly RN's React -surface), filtered to those resolvable this run — every listed product is safe -to reference without guarding. Note the fourth entry: `ReactAppHeaders` lives in -the separate `React-GeneratedCode` package (per-app codegen), which a -hand-rolled plugin would miss, and which is omitted when that package is absent. -Because RN derives this list from one source of truth alongside its own product -wiring, it stays correct across repackaging. +- The `packageRef` keys signal local or remote mode: `path` xor `url`+`version`. +- `packageRef.path` is **absolute**, so it is correct in any subdirectory of + `outputDir` the plugin writes its manifest into. The generated manifests are + gitignored and regenerated every sync, so this has no portability cost. + `relPath` (relative to `outputDir`) is a convenience. +- `products` is the set React Native wires into **its own** autolinked targets, + so a plugin's target compiles against exactly RN's React surface. It is + filtered to the products resolvable this run, so every listed product is safe + to reference without a guard. +- The fourth entry, `ReactAppHeaders`, lives in the separate + `React-GeneratedCode` package (per-app codegen). A hand-rolled plugin would + miss it. It is omitted when that package is absent. + +RN derives this list from one source of truth alongside its own product wiring, +so it stays correct across repackaging. ### Return (contributions, all optional) @@ -298,11 +348,13 @@ wiring, it stays correct across repackaging. | `generatedSources` | Recorded for the codegen step to register (e.g. a module-registry `.swift`). | | `flavoredFrameworks` | Mandatory Debug/Release dynamic XCFramework pairs normalized outside SwiftPM. Malformed or incomplete entries are fatal. | | `scriptPhases` | Recorded for `spm add` / `update` to emit one `PBXShellScriptBuildPhase` per entry on the app target. Malformed entries and duplicate `id`s are fatal. | +| `watchPaths` | The auto-sync staleness inputs (see [`watchPaths`](#watchpaths--plugin-staleness-inputs)). Invalid entries are dropped with a warning, not fatal. | -The plugin returns **data** — it never writes into React Native's generated -tree. RN owns the merge, so a re-sync reproduces the same `Package.swift` -byte-for-byte (idempotent). Package and product contributions are **deduped by -name** across plugins. +The plugin returns **data**. It never writes into React Native's generated tree. +RN owns the merge, so a re-sync reproduces the same `Package.swift` +byte-for-byte (idempotent). Across plugins, package contributions are **deduped +by name** and product contributions by **package and name**. The first +contribution wins. ## Lifecycle @@ -316,51 +368,32 @@ Xcode "Sync SPM Autolinking" ──┘ │ └─ 4. merge results → aggregator Package.swift ``` -Because steps 1–4 run in the one `main()`, everything above shares the same seam -— there is no separate hook to wire for the build-time path. - ## Failure behavior -Fail-closed and **named**: a plugin that fails to load, doesn't export a -function, throws, or returns a malformed contribution aborts the run with a -message identifying the framework. A framework silently dropping its modules (a -green build missing native code) is worse than a loud stop. +Failures are fail-closed and **named**. A plugin that fails to load, doesn't +export a function, throws, or returns a malformed contribution aborts the run. +The message identifies the framework. A framework that silently drops its +modules (a green build missing native code) is worse than a loud stop. + +A plugin's host dependency is also a hard error when another library lists it in +`swiftpmConfig.dependencies` and that library has no `Package.swift` of its own +(shipped or scaffolded). React Native builds no target for the host, so there is +nothing to depend on. Remove the entry: the plugin already links its products +into the app. ## Status & open items (Preview) - **Implemented & tested:** discovery (transitive + deny-list), invocation, - package + product merge, fail-closed validation, and dual-flavor framework - normalization/link/embed outside SwiftPM. -- **Implemented & tested:** `generatedSources` **app-target wiring**. The merge - writes `.spm-plugin-generated-sources.json`; the `spm add`/`update` xcodeproj - injector (generate-spm-xcodeproj.js) reads it and wires each source **into the - app target** — a `PBXFileReference` + `PBXBuildFile` + a Sources-build-phase - entry, parented under one "SPM Generated Sources" navigator group. This is - what makes an `@objc` class (e.g. Expo's `ExpoModulesProvider`) reach the ObjC - classlist: a class inside the static Autolinked aggregate never does, so - `NSClassFromString` discovery would fail. Paths are stored SRCROOT-relative - when under the app root (the usual `build/generated/…` case), else absolute - (`sourceTree = ""`). All UUIDs are namespaced on the normalized path - (deterministic/idempotent) and recorded in the `.spm-injected.json` marker's - `generatedSources` map, so `deinit` reverts them and `update` reconciles - entries that left the manifest. A target without a Sources phase logs loudly - and skips the wiring (injection otherwise succeeds). v1 targets only the - injected app target and assumes `.swift` in practice (`.m`/`.mm` are mapped as - future-proofing). -- **Implemented & tested:** `scriptPhases`, contract through injection. - `invokePlugins` validates every entry fatally (`id` charset plus the reserved - `__proto__`/`constructor`/`prototype` names, a single-line `name`, a required - `script`, the `position` enum, optional path lists and `alwaysOutOfDate`, plus - duplicate `id`s), and the merge always rewrites - `.spm-plugin-script-phases.json` — `[]` when no plugin declares any, so - removing a plugin clears stale entries. The `spm add`/`update` xcodeproj - injector reads that sidecar and emits one `PBXShellScriptBuildPhase` per entry - on the app target at the requested position, recording the id→UUID map in the - `.spm-injected.json` marker so a re-run refreshes each phase's content in - place, re-seats it when its declared position or order changed, `update` - removes phases that left the sidecar, and `deinit` reverts them. Like - `flavoredFrameworks`, the build-time `sync` only rewrites the sidecar; it - never mutates the project. + package + product merge, and fail-closed validation. +- **Implemented & tested:** dual-flavor framework normalization/link/embed + outside SwiftPM + ([`flavoredFrameworks`](#flavoredframeworks--per-configuration-precompiled-frameworks)). +- **Implemented & tested:** + [`generatedSources`](#generatedsources--sources-wired-into-the-app-target) + **app-target wiring**. +- **Implemented & tested:** + [`scriptPhases`](#scriptphases--build-time-shell-phases-on-the-app-target), + from the contract through injection. - **Co-design with Expo (not final):** codegen **provider ordering** — codegen must consume the same discovered module set the plugin contributes — is intentionally left for the first real plugin to drive to a stable shape. diff --git a/packages/react-native/scripts/spm/__docs__/spm-header-paths-contract.md b/packages/react-native/scripts/spm/__docs__/spm-header-paths-contract.md index ff467c249b92..4055fed9f182 100644 --- a/packages/react-native/scripts/spm/__docs__/spm-header-paths-contract.md +++ b/packages/react-native/scripts/spm/__docs__/spm-header-paths-contract.md @@ -1,100 +1,131 @@ # SPM headers & package references — how they resolve -React Native's SPM consumption is **zero-I**: no `-I` / `-F` header search paths -and no `unsafeFlags` in any generated manifest. Headers are served by SPM -products/binary targets, and every generated `Package.swift` references the -React Native + codegen packages with plain, fixed-relative paths computed at -generation time (no runtime discovery). This document is the single source of -truth for how that resolves. - -> History: earlier iterations materialized two header trees and fed them to -> consumers as `-I` flags read from `spm-paths.json` / -> `.react-native/paths.json` via an inlined Swift loader. That whole mechanism -> (the loader `renderRNPathsLoader`, the `writeAppPathsJson` / -> `writeSharedPathsJson` writers, and both JSON files) has been **deleted** — -> manifests are now declarative. If you find a reference to those files, it is -> stale. +React Native's SPM consumption is **zero-I**: no generated manifest carries a +`-I` / `-F` header search path into React Native's own headers. SPM products and +binary targets serve those headers, with no clang VFS overlay. Every generated +`Package.swift` references the React Native and codegen packages by plain, +fixed-relative paths. This document is the single source of truth for how that +resolves. + +Generated manifests may still reach into their own tree: + +- Scaffolded manifests, local-module wrappers, and the codegen package use + `.headerSearchPath` into their own tree. +- Scaffolded manifests may use `.unsafeFlags` for podspec compiler flags and a + prefix header. + +Manifests are declarative. The files `spm-paths.json` and +`.react-native/paths.json`, their writers (`writeAppPathsJson`, +`writeSharedPathsJson`), and the Swift loader `renderRNPathsLoader` no longer +exist. A reference to any of them is stale. ## How headers resolve (no search paths) +React Native uses CocoaPods-style imports (`#import `) that +SwiftPM does not natively support. These products serve them: + | Namespace | Served by | Mechanism | | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | | Objective-C `` / Swift `import React` | `ReactHeaders` Clang source target | Canonical Debug/Release-identical React headers staged under `ReactHeadersTarget/include/React`, with a plain `module React` module map. | | Lowercase C++ `` and everything else: ``, ``, ``, ``, folly/glog/boost/fmt/double-conversion | `ReactNativeHeaders.xcframework` plus `ReactNativeDependenciesHeaders.xcframework` | Header-only invariant binary targets keep lowercase `react` separate from Objective-C `React` and propagate their search paths through product dependencies. | | ``, `ReactAppDependencyProvider`, this app's generated specs | `ReactAppHeaders` SPM target in the codegen package | SPM `publicHeadersPath` propagation — a real target dependency, not a flag. | +`ReactHeaders` stages its copy only after it proves that Debug and Release +expose identical public headers; its module map uses `React/`-prefixed paths. +`ReactNativeHeaders` is a headers-only (LIBRARY-type) binaryTarget, and the deps +sidecar uses the same mechanism: SwiftPM serves each slice's `Headers/` to +dependents. The deps sidecar also carries `fast_float/` and `SocketRocket/`. It +exists because the binary `ReactNativeDependencies.xcframework` is +framework-type and cannot expose those headers to SwiftPM. Targets that compile +against React take all four products in the table as product dependencies. + The one remaining materialized header tree is the per-app farm at -`/build/generated/ios/ReactAppHeaders` (built by -`buildPerAppHeaderTree` in `spm-utils.js`, called from the orchestrators). It is -vended as the `ReactAppHeaders` SPM target — consumers reach it through a -product dependency, never through `-I`. +`/build/generated/ios/ReactAppHeaders`. `buildPerAppHeaderTree` in +`spm-utils.js` builds it, called from the orchestrators. It is vended as the +`ReactAppHeaders` SPM target, so consumers reach it through a product +dependency, never through `-I`. `autolinking.json` (the `@react-native-community/cli config` output) is an INPUT -used to generate the manifests; it is never read by a manifest. +used to generate the manifests. No manifest reads it. ## How each manifest references the React + codegen packages Every generated manifest sits at a known depth inside the app and is regenerated -on every `react-native spm` run, so package references are plain fixed-relative -paths — no walk-up, no JSON, no `import Foundation`. - -| Manifest | Location | How it references the React + codegen packages | -| ------------------------ | ---------------------------------------------- | -------------------------------------------------------------------------------------------------------------------- | -| Autolinked aggregator | `build/generated/autolinking/Package.swift` | `.package(path: "../../xcframeworks")` + `"../ios"` (only when it has inline `spmModule` targets) | -| Per-dep synth wrapper | `build/generated/autolinking/packages//` | `.package(path: "../../../../xcframeworks")` + `"../../../ios"` | -| Codegen template | `build/generated/ios/Package.swift` | `.package(path: "../../xcframeworks")` (or the remote url) | -| App target (pbxproj) | `.xcodeproj` | local `XCLocalSwiftPackageReference` (or `XCRemoteSwiftPackageReference` in remote mode) | -| Scaffolded community lib | `node_modules//Package.swift` | scaffold-time relative paths to the app's xcframeworks + codegen packages (or `.package(url:exact:)` in remote mode) | +on every `react-native spm` run. So package references are computed at +generation time as plain fixed-relative paths — no runtime discovery, no +walk-up, no JSON, no `import Foundation`. + +| Manifest | Location | How it references the React + codegen packages | +| ------------------------ | ---------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- | +| Autolinked aggregator | `build/generated/autolinking/Package.swift` | Neither: it has no inline targets, and references only its dependencies' packages (`packages/`, `libs/`, plugin packages) | +| Local-module wrapper | `build/generated/autolinking/packages//` | `.package(path: "../../../../xcframeworks")` + `"../../../ios"` (only for `swiftpmConfig.modules` entries) | +| Codegen template | `build/generated/ios/Package.swift` | `.package(path: "../../xcframeworks")` (or the remote url) | +| App target (pbxproj) | `.xcodeproj` | local `XCLocalSwiftPackageReference` (or `XCRemoteSwiftPackageReference` in remote mode) | +| Scaffolded community lib | `node_modules//Package.swift` | scaffold-time relative paths to the app's xcframeworks + codegen packages (or `.package(url:exact:)` in remote mode) | ## Remote-package mode -Remote mode is gated by a **URL alone** — `RN_SPM_REMOTE_URL` (or the persisted -`url`). When set, the whole app graph flips to a single remote React Native -package identity: `.package(path: build/xcframeworks)` becomes -`.package(url:exact:)` everywhere (aggregator/synth/codegen template/pbxproj), -and the local artifact download + compose is skipped. SPM's -one-version-per-package rule then unifies app + every library on one resolved -React Native. The package identity is derived from the URL tail (swift-tools 6 -dropped `.package(name:url:)`) — nothing hardcodes a repo name. - -**Version is derived from npm, not pinned by hand.** The SPM-pinned RN version -is not a free parameter: the SPM graph must compile against the same React -Native the JS/native code uses, so the app (graph root) pins EXACT to the -_installed_ RN version, read from `node_modules/react-native/package.json`. +A **URL alone** turns remote mode on: `RN_SPM_REMOTE_URL`, or the persisted +`url`. The whole app graph then uses a single remote React Native package +identity. `.package(path: build/xcframeworks)` becomes `.package(url:exact:)` +everywhere it appears (synth, codegen template, pbxproj). The local artifact +download and the compose of `build/xcframeworks` still run. SPM's +one-version-per-package rule then unifies the app and every library on one +resolved React Native. + +The package identity comes from the URL tail, because swift-tools 6 dropped +`.package(name:url:)`. Nothing hardcodes a repo name. + +**Version is derived from npm, not pinned by hand.** The SPM graph must compile +against the same React Native that the JS and native code use. So the app (the +graph root) pins EXACT to the _installed_ React Native version, read from +`node_modules/react-native/package.json`. + `RN_SPM_REMOTE_VERSION` and the persisted `versionOverride` are **overrides**, -not the source of truth — they're only needed when the installed version isn't -publishable (e.g. the monorepo `1000.0.0` dev placeholder, which has no remote -tag). A _derived_ version is never persisted, so an `npm install` that upgrades -RN auto-re-pins the SPM graph on the next `spm` run; an _override_ is persisted -as `versionOverride` so it survives Xcode-phase re-syncs without the env. - -Persisted schema is `{url, versionOverride?}`. Legacy `{url, version}` is still -read, with `version` honored as an override (back-compat). If remote mode is on -but no usable version can be resolved — react-native isn't installed, or it's a -non-publishable dev placeholder and no override is set — the tooling errors -(exit 2, a hard Xcode build error) directing you to set `RN_SPM_REMOTE_VERSION` -or install a released react-native, rather than silently pinning an unpublished +not the source of truth. You need them only when the installed version is not +publishable, e.g. the monorepo `1000.0.0` dev placeholder, which has no remote tag. +- A _derived_ version is never persisted. An `npm install` that upgrades React + Native re-pins the SPM graph on the next `spm` run. +- An _override_ is persisted as `versionOverride`, so it survives Xcode-phase + re-syncs without the environment variable. + +The settings persist in `build/generated/autolinking/spm-remote.json`. The file +is written only when the settings come from the environment variables. It lives +under `build/`, so deleting `build/` turns remote mode off until you set the +variables again. The persisted schema is `{url, versionOverride?}`. The legacy +`{url, version}` is still read, with `version` honored as an override. + +If remote mode is on but no usable version resolves, the tooling stops with exit +2, a hard Xcode build error. This happens when react-native is not installed, or +when it is a non-publishable dev placeholder and no override is set. The error +tells you to set `RN_SPM_REMOTE_VERSION` or install a released react-native. The +tooling never silently pins an unpublished tag. + ## Hand-authored community library contract -A library that ships its own `Package.swift` (no scaffolder/autolinker marker) -is left untouched by the tooling. It needs only two things, and **no discovery -code**: +The tooling leaves untouched a library that ships its own `Package.swift` (no +scaffolder or autolinker marker). That library needs only two things, and **no +discovery code**: 1. Depend on the React Native SPM package and its products — in remote mode `.package(url: "", exact: "")` + - `.product(name: "ReactNative", …)` and - `.product(name: "ReactNativeHeaders", …)`. (Libraries should declare a - version RANGE in production; the consuming app pins EXACT.) + `.product(name: "ReactHeaders", package: "")`, + `.product(name: "ReactNativeHeaders", package: "")`, and + `.product(name: "ReactNativeDependenciesHeaders", package: "")`, + where `` is the URL tail, lowercased, without `.git`. A scaffolded + manifest depends on the same React Native products. Libraries should declare + a version RANGE in production; the consuming app pins EXACT. 2. Ship its own generated code: set `codegenConfig.includesGeneratedCode: true` and generate with `generate-codegen-artifacts.js --path . --targetPlatform ios --source library`. - Output lands at `/build/generated/ios/ReactCodegen/`, reachable - from the manifest with one safe `.headerSearchPath(...)` into the library's - own tree. The app-side codegen then skips the lib's spec (no duplicate - symbols). - -This makes the library self-contained — it carries no app-layout knowledge and -needs no per-app codegen headers from the consuming app. Proven with -`@chrfalch/react-native-calculator` (a hand-authored Fabric/TurboModule lib). + The output lands at `/build/generated/ios/ReactCodegen/`. The + manifest reaches it with one safe `.headerSearchPath(...)` into the library's + own tree. The app-side codegen then skips the library's spec, so there are no + duplicate symbols. + +The library is then self-contained. It carries no app-layout knowledge and needs +no per-app codegen headers from the consuming app. +`@chrfalch/react-native-calculator` (a hand-authored Fabric/TurboModule library) +proves this. diff --git a/packages/react-native/scripts/spm/__docs__/spm-scripts.md b/packages/react-native/scripts/spm/__docs__/spm-scripts.md index d6e9ae9ae580..e2a2e1ef858c 100644 --- a/packages/react-native/scripts/spm/__docs__/spm-scripts.md +++ b/packages/react-native/scripts/spm/__docs__/spm-scripts.md @@ -1,14 +1,13 @@ # SwiftPM Scripts – React Native iOS via Swift Package Manager (Preview) -> **Preview.** SwiftPM support is an early preview: the commands, flags, -> generated layout, and distribution model may change in future releases, and it -> is not yet recommended for production. Feedback is welcome. CocoaPods remains -> the supported default. +> **Preview.** SwiftPM support is an early preview. The commands, flags, +> generated layout, and distribution model may change in future releases. It is +> not yet recommended for production. Feedback is welcome. CocoaPods remains the +> supported default. Build React Native iOS apps using **Swift Package Manager** with prebuilt -XCFrameworks, as an alternative to CocoaPods. It is **opt-in and additive** — -CocoaPods remains the default; `spm` injects into your existing `.xcodeproj` in -place and is fully reversible. +XCFrameworks, as an alternative to CocoaPods. It is **opt-in and additive**: +`spm` injects into your existing `.xcodeproj` in place and is fully reversible. ## Quick Start @@ -25,10 +24,10 @@ npx react-native spm add --deintegrate open MyApp.xcodeproj ``` -After the initial run, the project carries **auto-sync hooks** that detect +After the initial run, the project carries **auto-sync hooks**. They detect dependency changes and re-run autolinking before compilation (see -[Auto-Sync](#auto-sync)) — you don't re-invoke `react-native spm` manually for -day-to-day dependency changes. **On a fresh clone or CI checkout, run +[Auto-Sync](#auto-sync)), so you don't re-run `react-native spm` for day-to-day +dependency changes. **On a fresh clone or CI checkout, run `npx react-native spm` once before building** (see [Fresh clones & CI](#fresh-clones--ci)). @@ -42,35 +41,39 @@ day-to-day dependency changes. **On a fresh clone or CI checkout, run `spm add` injects into a project that is **not** CocoaPods-integrated. On a CocoaPods app it fails loud and points you at `--deintegrate`, which: -1. runs `pod deintegrate` — removes CocoaPods integration from the `.xcodeproj` - (Pods references, `[CP]` build phases, xcconfig links). Your `Podfile` is - left on disk. +1. runs `pod deintegrate`. This removes CocoaPods integration from the + `.xcodeproj` (Pods references, `[CP]` build phases, xcconfig links). Your + `Podfile` is left on disk. 2. strips **only** the React Native directives (`use_react_native!`, - `use_native_modules!`, `prepare_react_native_project!`) from the Podfile — - every other line, **including your own `pod '…'` entries, is preserved**. + `use_native_modules!`, `prepare_react_native_project!`) from the Podfile. + Every other line, **including your own `pod '…'` entries, is preserved**. The + strip is line-based: it deletes each line that contains one of those names, + and nothing else. The argument lines of a multi-line `use_react_native!(` + call and any `react_native_post_install(...)` call remain, so remove them by + hand before you run `pod install`. 3. injects SwiftPM into the `.xcodeproj`. -React Native now comes from SwiftPM; no pods are linked yet (deintegrate removed -the integration). +React Native now comes from SwiftPM. No pods are linked yet, because deintegrate +removed the integration. ### Keeping non-RN pods -Non-RN pods can stay side-by-side. After `spm add --deintegrate` your Podfile -still lists them (only the RN directives were removed) — re-integrate them with +Non-RN pods can stay side-by-side. Your Podfile still lists them. Remove the +leftover React Native lines (see step 2 above), then re-integrate the pods with a normal install: ```bash pod install # re-integrates the remaining (non-RN) pods; (re)creates the .xcworkspace ``` -Then **open the `.xcworkspace`** (not the `.xcodeproj`): the workspace includes +Then **open the `.xcworkspace`** (not the `.xcodeproj`). The workspace includes the SwiftPM-injected project, so React Native resolves through SwiftPM and your -other pods through CocoaPods, together. +other pods through CocoaPods. -> **Do not re-add `use_react_native!`.** React Native must be provided by -> _either_ SwiftPM _or_ CocoaPods, never both — they share `build/generated/`, -> so a dual-managed RN does not build. `spm add` refuses to run while the -> Podfile still declares `use_react_native!`. +> **Do not re-add `use_react_native!`.** React Native must come from _either_ +> SwiftPM _or_ CocoaPods, never both. They share `build/generated/`, so a +> dual-managed RN does not build. If only the Podfile still declares +> `use_react_native!`, `spm add` prints a warning and continues. The migration is fully reversible — see [Removing / resetting](#removing--resetting). @@ -78,23 +81,22 @@ The migration is fully reversible — see ## Brownfield apps `spm add` injects into your existing `.xcodeproj` in place, so an app that -embeds React Native works the same way — point it at the right project and +embeds React Native works the same way. Point it at the right project and target: ```bash npx react-native spm add --xcodeproj MyApp.xcodeproj --productName MyApp ``` -**Requirement:** the `.xcodeproj` must live **inside the React Native JS tree** -— i.e. the app's `package.json` is a parent directory of the project. Both setup -and the build-time sync locate React Native by walking up from the project to -the nearest `package.json`. The common "native project at the repo root with the -RN JS in a sibling/child subfolder" layout is **not supported yet** — there is -no way to point at a JS root outside the project's ancestors. +**Requirement:** the `.xcodeproj` must live **inside the React Native JS tree**. +The app's `package.json` must be in a parent directory of the project. Setup and +the build-time sync both find React Native by walking up from the project to the +nearest `package.json`. The common "native project at the repo root with the RN +JS in a sibling/child subfolder" layout is **not supported yet**. You cannot +point at a JS root outside the project's ancestors. -Brownfield apps that keep CocoaPods for their other native dependencies follow -the [coexistence rules above](#keeping-non-rn-pods): React Native from SwiftPM, -everything else from CocoaPods, and no `use_react_native!` in the Podfile. +Brownfield apps that keep CocoaPods for other native dependencies follow the +[coexistence rules above](#keeping-non-rn-pods). ## CLI Actions @@ -104,18 +106,18 @@ react-native spm [action] [options] With no action, the command **auto-resolves**: if SwiftPM has been injected (`.spm-injected.json` marker present) it routes to `update`; otherwise `add`. On -a freshly-scaffolded CocoaPods project (clean git tree, stock Podfile) the -zero-arg path additionally implies `--deintegrate` (the safe-gate), so +a freshly-scaffolded CocoaPods project (clean git tree, stock Podfile), the +zero-arg path also implies `--deintegrate` (the safe-gate). So `npx react-native spm` converts a brand-new app to SwiftPM in one command. -When invoked from the JS root of a standard RN app (sibling `ios/` subdir), the -command auto-redirects into `ios/` with a banner. +From the JS root of a standard RN app (sibling `ios/` subdir), the command +redirects into `ios/` and prints a banner. | Action | Description | | --------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `add` | Inject SwiftPM packages (package refs, build settings, the Sync build phase) into the existing `.xcodeproj`, in place. Idempotent. Default on first run. `--deintegrate` first runs `pod deintegrate` + strips React Native from the Podfile. | | `update` | Re-run the pipeline and refresh the existing injection. Default once a project is injected. | -| `deinit` | The inverse of `add`: surgically remove only what `add` injected (recorded in `.spm-injected.json`) and drop the marker. Git-recoverable; no prompt. Three things it does not undo — see [Files the tool touches](#files-the-tool-touches). | +| `deinit` | The inverse of `add`: surgically remove only what `add` injected (recorded in `.spm-injected.json`) and drop the marker. Git-recoverable; no prompt. Some edits it does not undo — see [Files the tool touches](#files-the-tool-touches). | | `scaffold` | Generate `Package.swift` into `node_modules//` for community RN libraries that ship only a podspec. | | `sync` (advanced) | Lightweight resync invoked by the Xcode auto-sync hooks. Regenerates invariant codegen and autolinking output only. Not for humans. | | `codegen` (advanced) | Run codegen and install the SwiftPM codegen template only. | @@ -123,115 +125,134 @@ command auto-redirects into `ios/` with a banner. ## CLI Options -Flags below use the `react-native spm` (camelCase) form. The raw script accepts -kebab-case equivalents (e.g. `--skip-codegen`). - -| Option | Description | -| -------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `--version ` | RN version. Resolved in this order: this flag, then the version a previous `--version` pinned into `.spm-injected.json`, then `node_modules/react-native/package.json`. Pass it once — later runs reuse the pin (see [Pinning the React Native version](#pinning-the-react-native-version)) | -| `--yes` | Skip the dirty-pbxproj confirmation prompt | -| `--xcodeproj ` | [add] Which `.xcodeproj` to inject into (when several exist) | -| `--productName ` | [add] Which app target to inject into (when several exist) | -| `--deintegrate` | [add] Run `pod deintegrate` + strip React Native from the Podfile before injecting | -| `--artifacts ` | [advanced] Local artifact root containing complete `debug/` and `release/` cache slots | -| `--download ` | [advanced] Artifact download policy (default: auto) | -| `--skipCodegen` | [advanced] Skip the codegen step | -| `--configCommand ` | [advanced] JSON array of the argv used to generate `autolinking.json`, overriding the default `@react-native-community/cli config` command. Also settable via the `RCT_SPM_AUTOLINKING_CONFIG_COMMAND` env var. Either way the value is remembered, so you pass it once. Example: `'["npx","expo-modules-autolinking","react-native-config","--json","--platform","ios"]'` | +Flags below use the `react-native spm` (camelCase) form. + +| Option | Description | +| -------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `--version ` | RN version. Resolved in this order: this flag, then the version a previous `--version` pinned into `.spm-injected.json`, then `node_modules/react-native/package.json`. Pass it once — later runs reuse the pin (see [Pinning the React Native version](#pinning-the-react-native-version)) | +| `--yes` | Skip the dirty-pbxproj confirmation prompt | +| `--xcodeproj ` | [add, update, scaffold, deinit] Which `.xcodeproj` to inject into or remove from (when several exist) | +| `--productName ` | [add, update, scaffold] Which app target to inject into (when several exist) | +| `--deintegrate` | [add] Run `pod deintegrate` + strip React Native from the Podfile before injecting | +| `--artifacts ` | [advanced] Local artifact root containing complete `debug/` and `release/` cache slots | +| `--download ` | [advanced] Artifact download policy (default: auto) | +| `--skipCodegen` | [advanced] Skip the codegen step | +| `--config-command ` | [advanced] **Raw script only** — `npx react-native spm` does not forward it; use the `RCT_SPM_AUTOLINKING_CONFIG_COMMAND` env var there. JSON array of the argv used to generate `autolinking.json`, overriding the default `@react-native-community/cli config` command. Either way the value is remembered, so you pass it once. Example: `'["npx","expo-modules-autolinking","react-native-config","--json","--platform","ios"]'` | ### The autolinking config command is remembered An app that replaces `@react-native-community/cli` autolinking (an Expo app, for -example) has to tell `spm` how to produce `autolinking.json`. Pass the command -once, on `add` or `update`: +example) must tell `spm` how to produce `autolinking.json`. Pass the command +once, on `add` or `update`. `npx react-native spm` does not forward a +`--configCommand` flag, so set the environment variable: + +```bash +RCT_SPM_AUTOLINKING_CONFIG_COMMAND='["npx","expo-modules-autolinking","react-native-config","--json","--platform","ios"]' \ + npx react-native spm add +``` + +or run the script directly with `--config-command`: ```bash -npx react-native spm add --configCommand '["npx","expo-modules-autolinking","react-native-config","--json","--platform","ios"]' +node node_modules/react-native/scripts/setup-apple-spm.js add \ + --config-command '["npx","expo-modules-autolinking","react-native-config","--json","--platform","ios"]' ``` Every action that needs `autolinking.json` — `add`, `update`, `scaffold`, and the build-time `sync` — resolves the command in this order: -1. `--configCommand` +1. `--config-command` (raw script only) 2. `RCT_SPM_AUTOLINKING_CONFIG_COMMAND` 3. the `configCommand` pinned in `MyApp.xcodeproj/.spm-injected.json` by an - earlier `add`/`update` + earlier `add`/`update`/`scaffold` 4. the default `@react-native-community/cli config` -`add`/`update` pin whichever of the first two routes supplied the command, -validated as an argv array; a later run that passes neither keeps the existing -pin, and passing `--configCommand` again replaces it. The pin exists because the -**Sync SPM Autolinking** build phase inherits neither your flag nor the shell -that exported the env var — without it, a successful `add` is followed by -failing builds, because the phase re-derives `autolinking.json` with the default -command. A pin never shadows the env var, so an override in your shell still -takes effect, and a pin that no longer parses is ignored in favor of the +`add`/`update`/`scaffold` pin the command from whichever of the first two routes +supplied it, validated as an argv array. A later run that passes neither keeps +the existing pin. A new command passed by either route replaces it. + +The pin exists because the **Sync SPM Autolinking** build phase inherits neither +your flag nor the shell that exported the env var. Without the pin, the phase +re-derives `autolinking.json` with the default command, and builds fail after a +successful `add`. A pin never shadows the env var, so an override in your shell +still takes effect. A pin that no longer parses is ignored in favor of the default. -`deinit` deletes `.spm-injected.json`, and the pin with it. A later `add` -therefore falls back to the default command unless you pass `--configCommand` -(or export the env var) again. +`deinit` deletes `.spm-injected.json`, and the pin with it. A later `add` then +falls back to the default command unless you export the env var (or pass +`--config-command` to the raw script) again. ### Pinning the React Native version The resolved version selects **which artifact slots the project is wired to**, -so it has to stay the same from one run to the next. `--version` is therefore -recorded in the `.spm-injected.json` marker (as `artifactsVersionOverride`) and -read back by later runs, which resolve the version in this order: +so it must stay the same from one run to the next. So `--version` is recorded in +the `.spm-injected.json` marker (as `artifactsVersionOverride`) and read back by +later runs, which resolve the version in this order: 1. an explicit `--version `, 2. the version a previous `--version` pinned into the marker, 3. `node_modules/react-native/package.json`. -So you pass the flag once, and a later flagless `add`/`update` stays on the -slots it selected. Without the pin, that flagless run falls back to -`package.json` and re-points the project at different artifact slots while the -marker still advertises the pinned version. +You pass the flag once, and a later flagless `add`/`update` stays on the slots +it selected. Without the pin, that flagless run would fall back to +`package.json`. It would re-point the project at different artifact slots while +the marker still advertises the pinned version. -`deinit` deletes the marker, and with it the pin — a later `add` resolves +`deinit` deletes the marker, and with it the pin. A later `add` resolves `node_modules/react-native/package.json` again unless you pass `--version`. ### Debug/Release flavor is automatic -React Native ships **flavored** prebuilt binaries: the _debug_ `React.framework` -(and `hermes-engine` / `ReactNativeDependencies`) carry the dev experience — dev -menu, assertions, `RN_DEBUG_STRING_CONVERTIBLE` — while _release_ strips them -for production. A Debug build must embed the debug binaries and a -Release/archive the release ones. +React Native ships **flavored** prebuilt binaries. The _debug_ `React.framework` +(and `hermes-engine` / `ReactNativeDependencies`) carry the dev experience: dev +menu, assertions, `RN_DEBUG_STRING_CONVERTIBLE`. The _release_ binaries strip +them for production. A Debug build must embed the debug binaries, and a +Release/archive build the release ones. SwiftPM `binaryTarget`s can't branch on the build configuration, so runtime -frameworks are deliberately kept out of the package graph. `spm add` downloads -and validates **both** flavors into immutable app-local slots. It injects -SDK/architecture-qualified Xcode settings that link the exact selected binaries, -plus one phase that copies and signs the selected frameworks into the app. +frameworks are deliberately kept out of the package graph. Instead: + +- `spm add` downloads and validates **both** flavors into immutable app-local + slots. +- It injects SDK/architecture-qualified Xcode settings that link the exact + selected binaries. +- It adds one phase that copies and signs the selected frameworks into the app. + Configurations containing `debug` or `development` select Debug; every other configuration selects Release. Selection uses only generated build settings and -standard macOS tools: builds do not run Node, mutate symlinks, regenerate the +standard macOS tools. Builds do not run Node, mutate symlinks, regenerate the package graph, or require a second build. -Those same debug-flavored configurations also get -`SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited) DEBUG"` — the only thing -that makes Swift's `#if DEBUG` true (`GCC_PREPROCESSOR_DEFINITIONS` reaches -C/ObjC/C++ only), and what `AppDelegate.swift`'s `bundleURL()` branches on to -load from Metro instead of a bundled `main.jsbundle`. CocoaPods injects it at -`pod install` time, so this keeps SwiftPM apps at parity. An existing value is -left alone. +Those debug-flavored configurations also get `DEBUG` in +`SWIFT_ACTIVE_COMPILATION_CONDITIONS`, injected as `("$(inherited)", DEBUG)` +when the setting is absent. Only this makes Swift's `#if DEBUG` true +(`GCC_PREPROCESSOR_DEFINITIONS` reaches C/ObjC/C++ only). `AppDelegate.swift`'s +`bundleURL()` branches on it to load from Metro instead of a bundled +`main.jsbundle`. CocoaPods injects it at `pod install` time, so this keeps +SwiftPM apps at parity. An existing value is left alone only if it already +contains `DEBUG`. Otherwise `DEBUG` is appended, and a scalar value is promoted +to an array (see [Files the tool touches](#files-the-tool-touches)). ### iOS deployment target -Every manifest React Native generates — the `Autolinked` aggregate, the synth -package per dependency, and each scaffolded community package — declares the -same platform floor: your app's `IPHONEOS_DEPLOYMENT_TARGET`, never below React -Native's own minimum (15.1). SwiftPM refuses to link a product whose minimum is -higher than the target depending on it, so a dependency that needs more (Expo's -packages need iOS 16.4) only resolves once the app asks for at least as much: -raise the deployment target in Xcode and re-run `react-native spm update`. - -A floor set in an `.xcconfig` your configuration is based on is honored, -`#include` chains included; one set through a build-setting variable -(`$(MY_FLOOR)`) is not, and falls back to React Native's minimum. `spm add` and -`spm update` also refresh the platform-floor line of existing scaffolded -manifests (they never create new ones) — if you persisted a scaffold with -`patch-package`, re-run `npx patch-package ` afterwards. +The `Autolinked` aggregate, the synth package per local module, and each +scaffolded community package declare the same platform floor: your app's +`IPHONEOS_DEPLOYMENT_TARGET`, never below React Native's own minimum (15.1). The +codegen package (`build/generated/ios/Package.swift`) always declares iOS 15. +`build/xcframeworks/Package.swift` declares no platforms. + +SwiftPM refuses to link a product whose minimum is higher than that of the +target depending on it. So a dependency that needs more (Expo's packages need +iOS 16.4) resolves only once the app asks for at least as much. Raise the +deployment target in Xcode and re-run `react-native spm update`. + +- A floor set in an `.xcconfig` your configuration is based on is honored, + `#include` chains included. +- A floor set through a build-setting variable (`$(MY_FLOOR)`) is not honored; + it falls back to React Native's minimum. +- `spm add` and `spm update` also refresh the platform-floor line of existing + scaffolded manifests. They never create new ones. If you persisted a scaffold + with `patch-package`, re-run `npx patch-package ` afterwards. ## Files the tool touches @@ -239,93 +260,110 @@ Paths are relative to the Xcode project directory (`ios/`) unless noted. ### In your repo — committed -| Path | Written by | What happens | Undone by `deinit`? | -| --------------------------------------------------- | ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------ | -| `MyApp.xcodeproj/project.pbxproj` | `add`, `update` | SwiftPM package refs, the React build settings, the Sync build phase, and the flavored-framework embed phase are added. Purely additive; a re-run is a no-op. | Yes — exactly what was injected, per the marker (one exception below) | -| `MyApp.xcodeproj/.spm-injected.json` | `add`, `update` | Created. Three roles: it records every edit made — including the pre-injection value of any build setting rewritten — so removal is surgical and re-runs stay idempotent; it records the frameworks the project links (`linkedFrameworks`), which the build-time sync compares with the frameworks plugins provide; and it **pins configuration** later runs and Xcode builds must reuse (see the two pins below). | Yes — deleted, and the pins go with it | -| `MyApp.xcodeproj/xcshareddata/xcschemes/*.xcscheme` | `add`, `update` | The sync pre-action is added to the scheme that builds your target; a shared scheme is created if there is none. Commit this or teammates lose the pre-action. | Yes — the scheme is deleted if `add` created it, otherwise only the pre-action is stripped | -| `.gitignore` | `add` only | Created if absent, else appended: a `# SPM – auto-generated at build time` block adding `Package.resolved`, `build/generated/`, `build/xcframeworks/`, `.build/`. | **No** — the block is left behind | -| `Podfile` | `add --deintegrate` | Only the React Native directives (`use_react_native!`, `use_native_modules!`, `prepare_react_native_project!`) are stripped. Your own `pod '…'` lines are preserved. | **No** — re-add the directives yourself to go back to CocoaPods | -| `Pods/`, `Pods-*.xcconfig`, `[CP]` phases | `add --deintegrate` | Removed by `pod deintegrate`. The `.xcworkspace` referencing them is left on disk. | **No** — run `pod install` to restore | +| Path | Written by | What happens | Undone by `deinit`? | +| --------------------------------------------------- | --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `MyApp.xcodeproj/project.pbxproj` | `add`, `update`, `scaffold` | SwiftPM package refs, the React build settings, the Sync build phase, and the flavored-framework embed phase are added; a re-run is a no-op. A `${PODS_ROOT}`-anchored `REACT_NATIVE_PATH` is replaced with the SwiftPM path. `add` also removes the template's unused `JavaScriptCore.framework` reference, and `add --deintegrate` removes an empty `Pods` group. | Yes — exactly what was injected, per the marker, including the original `REACT_NATIVE_PATH` (one exception below). The removed `JavaScriptCore.framework` reference and `Pods` group are **not** restored. | +| `MyApp.xcodeproj/.spm-injected.json` | `add`, `update`, `scaffold` | Created. Three roles: it records every edit made — including the pre-injection value of any build setting rewritten — so removal is surgical and re-runs stay idempotent; it records the frameworks the project links (`linkedFrameworks`), which the build-time sync compares with the frameworks plugins provide; and it **pins configuration** later runs and Xcode builds must reuse (see the two pins below). | Yes — deleted, and the pins go with it | +| `MyApp.xcodeproj/xcshareddata/xcschemes/*.xcscheme` | `add`, `update`, `scaffold` | The sync pre-action is added to the scheme that builds your target; a shared scheme is created if there is none. Commit this or teammates lose the pre-action. | Yes — a scheme `add` created is deleted only if it is still byte-identical to the generated one; otherwise only the pre-action is stripped | +| `.gitignore` | `add` only | Created if absent, else appended: a `# SPM – auto-generated at build time` block adding `Package.resolved`, `build/generated/`, `build/xcframeworks/`, `.build/`. | **No** — the block is left behind | +| `Podfile` | `add --deintegrate` | Only the lines containing the React Native directives (`use_react_native!`, `use_native_modules!`, `prepare_react_native_project!`) are stripped. Your own `pod '…'` lines are preserved. The argument lines of a multi-line `use_react_native!(` call and `react_native_post_install(...)` remain — remove them by hand before `pod install`. | **No** — re-add the directives yourself to go back to CocoaPods | +| `Pods/`, `Pods-*.xcconfig`, `[CP]` phases | `add --deintegrate` | Removed by `pod deintegrate`. The `.xcworkspace` referencing them is left on disk. | **No** — run `pod install` to restore | -The two pinned settings are the `--version` pin (`artifactsVersionOverride`, see +The two pins are the `--version` pin (`artifactsVersionOverride`, see [Pinning the React Native version](#pinning-the-react-native-version)) and the [autolinking config command](#the-autolinking-config-command-is-remembered). -Because `deinit` drops the marker, it drops both. ### In your repo — generated, gitignored -| Path | Written by | Contents | -| ------------------------------ | ----------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `build/generated/ios/` | `add`, `update`, `sync`, `codegen` | Codegen output plus the SwiftPM codegen manifest (the `React-GeneratedCode` package). | -| `build/generated/autolinking/` | `add`, `update`, `sync` | `Package.swift`, `autolinking.json`, `packages/`, `libs/`, `headers/`, the `.spm-sync-stamp`, `.spm-sync-watch-paths`, `.spm-sync-watch-absent`, `.spm-plugin-mismatch` (the errors of a failed plugin framework check), and any `.spm-plugin-*.json` plugin manifests. | -| `build/xcframeworks/` | `add`, `update`, `sync`, `download` | The `debug/` and `release/` flavor slots (symlinks into the cache), `ReactHeadersTarget/`, the headers-only xcframeworks, `Package.swift`, `flavored-frameworks.json`, `.artifact-stamp`. | -| `.build/`, `Package.resolved` | Xcode / SwiftPM | SwiftPM's own build directory and resolution file. Machine-specific. | +| Path | Written by | Contents | +| ------------------------------ | ---------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `build/generated/ios/` | `add`, `update`, `scaffold`, `sync`, `codegen` | Codegen output plus the SwiftPM codegen manifest (the `React-GeneratedCode` package). | +| `build/generated/autolinking/` | `add`, `update`, `scaffold`, `sync` | `Package.swift`, `autolinking.json`, `packages/`, `libs/`, `headers/`, the `.spm-sync-stamp`, `.spm-sync-watch-paths`, `.spm-sync-watch-absent`, `.spm-plugin-mismatch` (the errors of a failed plugin framework check), and any `.spm-plugin-*.json` plugin manifests. | +| `build/xcframeworks/` | `add`, `update`, `scaffold` | The `debug/` and `release/` flavor slots (symlinks into the cache), `ReactHeadersTarget/`, the headers-only xcframeworks, `Package.swift`, `flavored-frameworks.json`, `.artifact-stamp`. | +| `.build/`, `Package.resolved` | Xcode / SwiftPM | SwiftPM's own build directory and resolution file. Machine-specific. | -`deinit` leaves all of the above in place — it is regenerable, and removing it -is `rm -rf build/ .build/` (see [Removing / resetting](#removing--resetting)). +`deinit` leaves all of the above in place. It is regenerable, and removing it is +`rm -rf build/ .build/` (see [Removing / resetting](#removing--resetting)). ### Outside your repo -| Path | Written by | Notes | -| ---------------------------------------------------------------- | ----------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `node_modules//Package.swift` | `scaffold` | A generated manifest for a dep that ships none. Not committed — persist with `patch-package` (see [Community packages without a Package.swift](#community-packages-without-a-packageswift)). | -| `~/Library/Caches/ReactNative/spm-artifacts///` | `add`, `update`, `sync`, `download` | The immutable artifact slots the `build/xcframeworks/` symlinks point at. Shared across apps on the machine. | -| `~/Library/Caches/ReactNative/` | `download` | Downloaded tarballs, shared with CocoaPods. `RCT_SKIP_CACHES=1` bypasses the cache. | - -Injection is **purely additive** and **idempotent**: every other byte of your -project — signing, capabilities, your own Build Phases — stays untouched, and a -re-run is a no-op. The injected refs point at three stable sub-package paths -under `build/`, so adding or removing community deps changes the sub-package -contents (gitignored) and never re-injects. `deinit` removes exactly what was -injected, leaving the project byte-identical to its pre-`add` state — with the -exceptions called out above, and one more described next. - -**Build settings that already exist** are edited in place. The four array -settings `add` merges into — `HEADER_SEARCH_PATHS`, `OTHER_LDFLAGS`, -`FRAMEWORK_SEARCH_PATHS`, `LD_RUNPATH_SEARCH_PATHS` — keep the shape they were -written in: Xcode's multi-line form as well as the compact one-line form hand -edits and other generators (XcodeGen, Tuist) emit. One that exists as a plain -_scalar_ is promoted to a `( … )` array — the shape an Xcode-authored target can -carry, e.g. a +| Path | Written by | Notes | +| ---------------------------------------------------------------- | --------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `node_modules//Package.swift` | `scaffold` | A generated manifest for a dep that ships none. Not committed — persist with `patch-package` (see [Community packages without a Package.swift](#community-packages-without-a-packageswift)). | +| `~/Library/Caches/ReactNative/spm-artifacts///` | `add`, `update`, `scaffold`, `download` | The immutable artifact slots the `build/xcframeworks/` symlinks point at. Shared across apps on the machine. | +| `~/Library/Caches/ReactNative/` | `download`, `add`, `update`, `scaffold` | Downloaded tarballs, shared with CocoaPods. `RCT_SKIP_CACHES=1` bypasses the cache. | + +Apart from the `project.pbxproj` edits listed above, injection is **additive**: +every other byte of your project — signing, capabilities, your own Build Phases +— stays untouched. The injected refs point at three stable sub-package paths +under `build/`. So adding or removing community deps changes only the +sub-package contents (gitignored) and never re-injects. `deinit` leaves the +project byte-identical to its pre-`add` state, with the exceptions above and one +more, described next. + +**Build settings that already exist** are edited in place. `add` merges into +five array settings: `HEADER_SEARCH_PATHS`, `OTHER_LDFLAGS`, +`FRAMEWORK_SEARCH_PATHS`, `LD_RUNPATH_SEARCH_PATHS`, and (on debug +configurations) `SWIFT_ACTIVE_COMPILATION_CONDITIONS`. Each keeps the shape it +was written in: Xcode's multi-line form, or the compact one-line form that hand +edits and other generators (XcodeGen, Tuist) emit. + +A setting that exists as a plain _scalar_ is promoted to a `( … )` array. An +Xcode-authored target can carry that shape, e.g. `LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";` written as a scalar rather than a list. `add` records the pre-injection value in the -marker and `deinit` restores it by rewriting the whole field — once folded -together, the injected members and your own are indistinguishable — so **members +marker. `deinit` restores it by rewriting the whole field, because once folded +together, the injected members and your own are indistinguishable. So **members you add to a promoted array by hand afterwards are lost**. That applies to `update` too, which reverts to the recorded baseline before re-injecting. +## Environment variables + +| Variable | Effect | +| -------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `RCT_SPM_AUTOLINKING_CONFIG_COMMAND` | JSON argv array that replaces the default `@react-native-community/cli config` command. `add`/`update`/`scaffold` pin it — see [The autolinking config command is remembered](#the-autolinking-config-command-is-remembered). | +| `RCT_SKIP_CACHES` | `1` bypasses the shared tarball cache in `~/Library/Caches/ReactNative/`. | +| `RN_CORE_TARBALL_PATH` | Local React core tarball, used instead of a download. Skips the shared cache. | +| `RN_HEADERS_TARBALL_PATH` | Local `ReactNativeHeaders` tarball, used instead of the copy inside the React core tarball. | +| `RN_DEPS_TARBALL_PATH` | Local `ReactNativeDependencies` tarball, used instead of a download. Skips the shared cache. | +| `RN_DEPS_HEADERS_TARBALL_PATH` | Local `ReactNativeDependenciesHeaders` tarball, used instead of the copy inside the dependencies tarball. | +| `RCT_REACT_NATIVE_MAVEN_MIRROR_ENABLED` | `false` or `0` turns off the React Native Maven mirror. The mirror is on by default and is tried before Maven Central. | +| `ENTERPRISE_REPOSITORY` | Maven repository URL. When set, it is the only repository consulted for releases; the snapshot fallback still uses Maven Central snapshots. | +| `RN_SPM_REMOTE_URL`, `RN_SPM_REMOTE_VERSION` | Turn on remote-package mode — see [Remote-package mode](./spm-header-paths-contract.md#remote-package-mode). | +| `RN_DEP_VERSION` | Version of the `ReactNativeDependencies` artifact, or `nightly`. Defaults to the React Native version. | +| `HERMES_VERSION` | Version of the Hermes artifact, `nightly`, or `latest-v1`. Defaults to the locally pinned `hermes-compiler` version, else `latest-v1`. | + +A tarball path that does not exist fails the download. Each tarball override +applies to both flavors, so the Debug and Release slots get the same file. + ## Fresh clones & CI -Everything under `build/` is gitignored, so a clean checkout has no resolvable +Everything under `build/` is gitignored. So a clean checkout has no resolvable Swift packages until they are regenerated. Xcode resolves the package graph before build phases **and** before scheme pre-actions, so neither -[auto-sync hook](#auto-sync) can rescue this: with `build/generated/autolinking` -missing, the build stops at _"Resolve Package Graph … doesn't exist"_ having run +[auto-sync hook](#auto-sync) can fix this. With `build/generated/autolinking` +missing, the build stops at _"Resolve Package Graph … doesn't exist"_ and runs neither hook. -Verified on Xcode 26.6 against a freshly-injected app with `build/` deleted: +Verified on Xcode 26.6, against a freshly-injected app with `build/` deleted: `xcodebuild -scheme … build` fails in nine lines of log, with -`Resolve Package Graph` as the first step and no trace of the pre-action; -`xcodebuild -resolvePackageDependencies` fails identically. Opening the project +`Resolve Package Graph` as the first step and no trace of the pre-action. +`xcodebuild -resolvePackageDependencies` fails the same way. Opening the project in Xcode also resolves the graph on load, before you press Build. -So run the setup command once after cloning, before building — the SwiftPM +So run the setup command once after cloning, before building. It is the SwiftPM analog of `pod install`: ```bash npx react-native spm # downloads artifacts (if missing) + regenerates build/ ``` -On an already-injected project this routes to `update`: it fetches the -xcframework artifacts into the shared cache if they aren't present and -regenerates `build/xcframeworks` + `build/generated`. After this first run, -incremental dependency changes are picked up automatically by the auto-sync -hooks. +On an already-injected project this routes to `update`. It fetches the +xcframework artifacts into the shared cache if they aren't present, and +regenerates `build/xcframeworks` + `build/generated`. -**Automate it** so nobody has to remember — add a `postinstall` hook, which runs -as part of the `npm install` / `yarn install` your CI already does before -`xcodebuild`: +**Automate it** with a `postinstall` hook. It runs as part of the `npm install` +/ `yarn install` your CI already does before `xcodebuild`: ```json { @@ -335,22 +373,22 @@ as part of the `npm install` / `yarn install` your CI already does before } ``` -`npx react-native spm` auto-redirects from the JS root into `ios/`, so the hook -works from the app root; in CI (non-interactive) it proceeds without prompting. -It re-runs the full pipeline (codegen + an idempotent re-inject that is a no-op -when nothing changed), so it is slightly heavier than the internal `sync` the -build phase calls — a fine trade for not having to remember a command. +The hook works from the app root, because `npx react-native spm` redirects from +the JS root into `ios/`. In CI (non-interactive) it proceeds without prompting. +It re-runs the full pipeline: codegen, plus an idempotent re-inject that is a +no-op when nothing changed. So it is slightly heavier than the internal `sync` +the build phase calls. > A future remote-package distribution (a tagged `Package.swift` repo + -> `binaryTarget(url:checksum:)`) removes this step entirely: SwiftPM resolves -> and fetches the artifacts itself during normal package resolution. Until then, -> the one-time setup run is required on clean machines. +> `binaryTarget(url:checksum:)`) removes this step: SwiftPM will resolve and +> fetch the artifacts itself during normal package resolution. Until then, clean +> machines need the one-time setup run. ## Local Native Modules -Modules not discovered via autolinking are declared in the app's package.json. -Each `path` is relative to the file that declares it — the project root for a -package.json there, the Xcode project directory for a config kept there: +Declare modules that autolinking does not discover in the app's package.json. +Each `path` is relative to the file that declares it: the project root for a +package.json there, or the Xcode project directory for a config kept there: ```json { @@ -359,16 +397,42 @@ package.json there, the Xcode project directory for a config kept there: { "name": "MyNativeModule", "path": "ios/MyNativeModule", - "exclude": ["*.podspec"] + "exclude": ["*.podspec"], + "sources": ["**/*.{h,m,mm}"] } ] } } ``` -Each entry becomes a target in `build/generated/autolinking/Package.swift`. -Sources outside `build/generated/autolinking/` are automatically mirrored with -file-level symlinks. +Each entry becomes its own package at +`build/generated/autolinking/packages//Package.swift`, with a `root` +directory symlink to the module directory. Its headers are mirrored with +file-level symlinks into `build/generated/autolinking/headers//`. The +aggregator `build/generated/autolinking/Package.swift` references each one as +`.package(path: "packages/")`. A module directory that ships its own +`Package.swift` is used as is. + +A module that mixes Swift and C-family (`.m`/`.mm`/`.c`/`.cpp`) sources is +rejected, because SwiftPM cannot compile both in one target. Split it into +single-language modules, or ship a hand-written `Package.swift`. Module names +get the same checks as [library names](#library-names): a name React Native +reserves, or one that collides with another module or an autolinked library, is +a hard error. + +`sources` is optional. It is a glob allowlist relative to `path`, like a +podspec's `source_files`. Without it, or when it matches no file, the target +gets every `.h`, `.hpp`, `.m`, `.mm`, `.c`, `.cpp` and `.swift` file under +`path`, minus `exclude`. Directories named `android`, `test`, `tests`, +`__tests__`, `__mocks__`, `jest` or `node_modules` are skipped at any depth, +also when `sources` is set. + +Modules reach one app target only: the one `spm add` links the `Autolinked` +aggregate to. Other targets in the project do not see them. Choose the target +with `--productName`. + +Add third-party Swift packages to the app target in Xcode (File > Add Package +Dependencies). Local modules cannot import them. ## Where SwiftPM settings live @@ -393,22 +457,22 @@ alongside `codegenConfig`. } ``` -A library's settings come from its own package.json. An **app's** are resolved -field by field, each from the directory holding its package.json — the JS root, -where codegen reads `codegenConfig` — falling back to the Xcode project -directory. An unrecognised field is ignored with a warning naming it, so a typo -does not pass silently. +A library's settings come from its own package.json. An **app's** settings are +resolved field by field. Each field comes from the directory that holds its +package.json (the JS root, where codegen reads `codegenConfig`), falling back to +the Xcode project directory. An unrecognised field is ignored with a warning +naming it, so a typo does not pass silently. -These settings used to live in an `spm` block in `react-native.config.js`. That -block is **deprecated** but still read, with the same field names, so nothing -breaks: move the keys across as they are, and package.json wins field by field. -`npx react-native spm scaffold` writes the `name` for you — see +The `spm` block in `react-native.config.js` is **deprecated** but still read, +with the same field names, so nothing breaks. Move the keys across as they are; +package.json wins field by field. `npx react-native spm scaffold` writes the +`name` for you — see [Community packages without a Package.swift](#community-packages-without-a-packageswift). ## Library names An autolinked library's SwiftPM target name is also the prefix its headers are -imported under (`#import `), so it is not cosmetic: it has to be the +imported under (`#import `). So it is not cosmetic: it must be the prefix the library's own sources and its dependents already use. It is resolved in this order: @@ -421,42 +485,51 @@ It is resolved in this order: 5. podspec name — `react-native-svg` → `RNSVG` 6. npm package name — `react-native-svg` → `ReactNativeSvg` -Steps 3–5 are a **migration path, not the destination**. They exist so that -libraries work unchanged today, a podspec being what they already ship, and -`npx react-native spm scaffold` closes them out: it records the name it derived -as `swiftpmConfig.name` in the library's package.json, after which the podspec -is never consulted for naming again. A library that declares its own name needs -no podspec for SwiftPM at all. - -Within those three steps, `header_dir` comes first because that is what a -library sets when its import prefix differs from its pod name, then -`module_name`, what CocoaPods compiles the module as and so what Swift and -`@import` consumers write. But a `header_dir` only a **subspec** declares names -that subspec's headers, not the library, so the pod name stands -(react-native-svg is `RNSVG`, not `rnsvg`; its subspec prefix resolves through -the header search paths instead) — unless the subspec reuses the parent's block -variable (`do |s|`), which hides which scope declared what, so the podspec is -read by CocoaPods or not at all. An unreadable podspec falls through rather than -failing the build — with a warning, since a machine that can read it (one with -CocoaPods installed) may resolve a different name. A prefix Swift cannot spell -is normalized — `Some.Pod` becomes `Some_Pod`, what SwiftPM would compile it as -anyway — with a warning naming `swiftpmConfig.name`. - -Two names are refused outright: one React Native reserves (`ReactNative`, -`ReactHeaders`, `ReactNativeHeaders`, `ReactNativeDependenciesHeaders`, -`ReactAppHeaders`, `React-GeneratedCode`, `ReactCodegen`, -`ReactAppDependencyProvider`, `Autolinked`), and one another library already -took. Both are **hard errors** naming `swiftpmConfig.name` — nothing is renamed -automatically, because a name the build invented is one no `#import` in your -sources can predict. Two names must differ by more than case or punctuation to -be two targets: `worklets` and `Worklets` share a headers directory, and -`foo-bar` and `foo_bar` are one module, since SwiftPM replaces every character -C99 rejects with `_`. +Steps 3–5 are a **migration path, not the destination**. They let libraries work +unchanged today, because a podspec is what they already ship. +`npx react-native spm scaffold` closes them out: it records the derived name as +`swiftpmConfig.name` in the library's package.json, and the podspec is then +never consulted for naming again. A library that declares its own name needs no +podspec for SwiftPM at all. + +Within those three steps: + +- `header_dir` comes first, because a library sets it when its import prefix + differs from its pod name. +- `module_name` comes next. It is what CocoaPods compiles the module as, and so + what Swift and `@import` consumers write. +- A `header_dir` that only a **subspec** declares names that subspec's headers, + not the library, so the pod name stands. react-native-svg is `RNSVG`, not + `rnsvg`; its subspec prefix resolves through the header search paths instead. + The exception is a subspec that reuses the parent's block variable (`do |s|`). + That hides which scope declared what, so the podspec is read by CocoaPods or + not at all. +- An unreadable podspec falls through rather than failing the build. It logs a + warning, because a machine that can read it (one with CocoaPods installed) may + resolve a different name. +- A prefix Swift cannot spell is normalized, e.g. `Some.Pod` becomes `Some_Pod`, + which is what SwiftPM would compile it as anyway. A warning names + `swiftpmConfig.name`. + +Two names are refused outright: + +- a name React Native reserves (`ReactNative`, `ReactHeaders`, + `ReactNativeHeaders`, `ReactNativeDependenciesHeaders`, `ReactAppHeaders`, + `React-GeneratedCode`, `ReactCodegen`, `ReactAppDependencyProvider`, + `Autolinked`), and +- a name another library already took. + +Both are **hard errors** that name `swiftpmConfig.name`. Nothing is renamed +automatically, because no `#import` in your sources can predict a name the build +invented. Two names must differ by more than case or punctuation to be two +targets. `worklets` and `Worklets` share a headers directory, and `foo-bar` and +`foo_bar` are one module, since SwiftPM replaces every character C99 rejects +with `_`. ## Dependencies between libraries -SwiftPM has no equivalent of a podspec's `s.dependency`, so a library that needs -another native library declares it explicitly in its **own** package.json — a +SwiftPM has no equivalent of a podspec's `s.dependency`. So a library that needs +another native library declares it explicitly in its **own** package.json, as a list of npm names: ```json @@ -467,15 +540,15 @@ list of npm names: ``` The autolinker follows these **recursively** from the directly-autolinked deps -and dedupes, so a transitive dependency joins the package graph even when the +and dedupes. So a transitive dependency joins the package graph even when the app never depends on it directly. ### Config module format `react-native.config.js` may be CommonJS or ESM, and both named and default -exports are read. A key defined twice — as a named export and on the default -export — resolves to the named one. Avoid that shape anyway: the Community CLI -has two loaders that disagree about it, a sync one that sees named exports and +exports are read. A key defined twice, as a named export and on the default +export, resolves to the named one. Avoid that shape anyway. The Community CLI +has two loaders that disagree about it: a sync one that sees named exports, and an async one that takes only the default export. For maximum compatibility, prefer the one-line CommonJS form: @@ -483,33 +556,32 @@ prefer the one-line CommonJS form: module.exports = {dependency: {platforms: {ios: {}}}}; ``` -If the config fails to load, a warning names the file and the reason — any -deprecated `spm` settings in it are ignored rather than silently applied. +If the config fails to load, a warning names the file and the reason. Any +deprecated `spm` settings in it are then ignored rather than silently applied. ## Self-managed community packages -A community library that ships its own `Package.swift` is referenced directly by -the autolinker instead of being wrapped. To keep SwiftPM's package identity -(which it derives from the path basename) unique across deps — even when several -libs put their manifest inside an `ios/` subdir — each self-managed dep is -exposed through a uniquely-named symlink at -`build/generated/autolinking/libs//`. The aggregator `Package.swift` -references that path, so two libs both shipping `/ios/Package.swift` never -collide on identity `"ios"`. +The autolinker references a community library that ships its own `Package.swift` +directly, instead of wrapping it. SwiftPM derives package identity from the path +basename, and several libs may put their manifest inside an `ios/` subdir. To +keep identity unique, each self-managed dep is exposed through a uniquely-named +symlink at `build/generated/autolinking/libs//`. The aggregator +`Package.swift` references that path, so two libs that both ship +`/ios/Package.swift` never collide on identity `"ios"`. -The `libs/` directory is wiped and recreated on every autolinker run, so -deleting a dep via `npm uninstall` cleans up the alias automatically on the next -build. +The `libs/` directory is not recreated on each autolinker run. An alias that +does not change keeps its inode, because Xcode holds each one as a loaded +package root. Aliases for deps that are no longer self-managed are pruned. So +after `npm uninstall` removes a dep, the next build cleans up its alias. ## Community packages without a Package.swift If an autolinked library ships **no `Package.swift`**, `spm add`/`update` stops with a per-dep error (`Package.swift is missing for library ""`) and exits -**2** — a distinct code from a generic failure, so CI and the Xcode sync hooks -can treat it as a hard error while staying lenient about transient sync -failures. +with code **2**. This code is distinct from a generic failure, so CI and the +Xcode sync hooks can treat it as a hard error (see [Auto-Sync](#auto-sync)). -`add` and `update` deliberately **never** scaffold on your behalf: +`add` and `update` deliberately **never** scaffold for you, because auto-scaffolding would hide a real gap in the dependency's SPM support. Generate the manifest from the library's podspec explicitly, then re-run setup: @@ -518,19 +590,21 @@ npx react-native spm scaffold # writes Package.swift into node_modules/ # then commit the generated patch ``` **Better: contribute the manifest upstream.** The generated `Package.swift` is a -normal, committable manifest — the ideal fix is for the library to ship it -itself, so every consumer gets SwiftPM support without a local patch. Please -**file an issue or open a PR on the library** with the scaffolded -`Package.swift` (mention it was generated by `react-native spm scaffold` for -React Native SwiftPM support). Until it lands upstream, the `patch-package` -workaround keeps your app building. - -> A library whose sources mix Swift **and** Objective-C/C++ in one target, or -> that ships neither a `Package.swift` nor a podspec, can't be scaffolded -> automatically — the error says so. Opt it out via `react-native.config.js` -> (`platforms.ios = null`) or ask the maintainer for a prebuilt xcframework. A +normal, committable manifest. The ideal fix is for the library to ship it, so +every consumer gets SwiftPM support without a local patch. Please **file an +issue or open a PR on the library** with the scaffolded `Package.swift`, and +mention that `react-native spm scaffold` generated it for React Native SwiftPM +support. Until it lands upstream, the `patch-package` workaround keeps your app +building. + +> A library can't be scaffolded automatically if its sources mix Swift **and** +> Objective-C/C++ in one target, or if it ships neither a `Package.swift` nor a +> podspec. The error says so. Opt it out via `react-native.config.js` +> (`platforms.ios = null`), or ask the maintainer for a prebuilt xcframework. A > library can also opt out of scaffolding alone with > `"swiftpmConfig": {"scaffold": false}`. ## Framework plugins (Preview) Frameworks with their own module system (e.g. Expo) contribute to the -autolinking graph through a **plugin** — a function invoked on every -regeneration (including the build-time sync) that adds SwiftPM package refs, -product dependencies, and generated sources. Discovery is transitive (installing -the framework is enough), and the plugin returns data that RN merges -idempotently. - -See **[spm-autolinking-plugins.md](./spm-autolinking-plugins.md)** for the -discovery mechanism, the full context/return contract, lifecycle, and failure -behavior. +autolinking graph through a **plugin**. See +**[spm-autolinking-plugins.md](./spm-autolinking-plugins.md)** for discovery, +the context/return contract, lifecycle, and failure behavior. ## Removing / resetting @@ -574,38 +642,38 @@ react-native spm deinit # surgically removes everything `add` injected pod install # then, to restore CocoaPods ``` -To reset the regenerable build state (without un-injecting), just delete the -gitignored dirs and re-run: +To reset the regenerable build state without un-injecting, delete the gitignored +dirs and re-run: ```bash rm -rf build/xcframeworks build/generated .build react-native spm update ``` -Xcode's "Clean Build Folder" (Cmd+Shift+K) only removes DerivedData — it does -not touch SwiftPM-generated directories. The cached xcframework slot is shared +Xcode's "Clean Build Folder" (Cmd+Shift+K) only removes DerivedData. It does not +touch SwiftPM-generated directories. The cached xcframework slot is shared across apps; refresh it with `react-native spm update --download force`. ## Troubleshooting -| Problem | Fix | -| ---------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `xcodebuild` fails: "Could not resolve package dependencies … `build/generated/autolinking` doesn't exist" | Fresh clone — run `npx react-native spm` once before building (see [Fresh clones & CI](#fresh-clones--ci)) | -| `spm add` fails: "CocoaPods-integrated project" | Re-run `spm add --deintegrate` (runs `pod deintegrate` + strips RN from the Podfile), or `pod deintegrate` yourself first. | -| `spm add` fails: "no .xcodeproj found" | Create an app first (`npx @react-native-community/cli init`) or make a project in Xcode, then `spm add`. | -| `spm add` fails: "multiple .xcodeproj found" | Pass `--xcodeproj ` (and `--product-name ` if multiple app targets). | -| `Package.swift is missing for library ""` (exit 2) | The dep ships no SwiftPM support. `npx react-native spm scaffold`, then re-run setup; persist with `patch-package`. See [Community packages without a Package.swift](#community-packages-without-a-packageswift) | -| ` () is a precompiled framework from an autolinking plugin, but the Xcode project does not link it` (exit 2) | A plugin provides a framework on this machine that the committed project does not link. Run `npx react-native spm update` and commit the project: every machine on the team then has to precompile the framework. | -| `The Xcode project links .framework, but no autolinking plugin provides it on this machine` (exit 2) | The committed project links a framework that this machine has not precompiled. Running `npx react-native spm update` removes the framework from the committed project for the whole team. Instead, precompile it, then run `npx react-native spm sync`. | -| `SPM Swift name collision` | Two libraries resolved to one Swift name, or one took a name React Native reserves. Set `swiftpmConfig.name` in the library's package.json — see [Library names](#library-names) | -| Missing headers | Re-run `react-native spm` | -| "not contained in target" | Re-run setup (regenerates file-level symlinks) | -| Codegen fails | Use `--skipCodegen` to iterate on other parts | -| "SPM sync failed" warning | Check Xcode build log for details; node may not be in PATH — ensure `with-environment.sh` is present | -| "Sync SPM Autolinking" build phase fails: `'npx --no-install @react-native-community/cli config' exited with status 1` | This app replaces `@react-native-community/cli` autolinking (e.g. an Expo app). Re-run `spm add`/`update` with `--configCommand` (or with `RCT_SPM_AUTOLINKING_CONFIG_COMMAND` exported) so the working command is pinned for the build phase to reuse — see [The autolinking config command is remembered](#the-autolinking-config-command-is-remembered). | -| Autolinking not updating on build | Touch `package.json` to force a sync, or delete `build/generated/autolinking/.spm-sync-stamp` | -| Stale SwiftPM state or corrupted build | `rm -rf build/ .build/`, then `react-native spm update`, then reopen Xcode | -| Want to revert to CocoaPods | `react-native spm deinit`, then `pod install` | +| Problem | Fix | +| ---------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `xcodebuild` fails: "Could not resolve package dependencies … `build/generated/autolinking` doesn't exist" | Fresh clone — run `npx react-native spm` once before building (see [Fresh clones & CI](#fresh-clones--ci)) | +| `spm add` fails: "CocoaPods-integrated project" | Re-run `spm add --deintegrate` (runs `pod deintegrate` + strips RN from the Podfile), or `pod deintegrate` yourself first. | +| `spm add` fails: "no .xcodeproj found" | Create an app first (`npx @react-native-community/cli init`) or make a project in Xcode, then `spm add`. | +| `spm add` fails: "multiple .xcodeproj found" | Pass `--xcodeproj ` (and `--productName ` if multiple app targets). | +| `Package.swift is missing for library ""` (exit 2) | The dep ships no SwiftPM support. `npx react-native spm scaffold`, then re-run setup; persist with `patch-package`. See [Community packages without a Package.swift](#community-packages-without-a-packageswift) | +| ` () is a precompiled framework from an autolinking plugin, but the Xcode project does not link it` (exit 2) | A plugin provides a framework on this machine that the committed project does not link. Run `npx react-native spm update` and commit the project: every machine on the team then has to precompile the framework. | +| `The Xcode project links .framework, but no autolinking plugin provides it on this machine` (exit 2) | The committed project links a framework that this machine has not precompiled. Running `npx react-native spm update` removes the framework from the committed project for the whole team. Instead, precompile it, then run `npx react-native spm sync`. | +| `SPM Swift name collision` | Two libraries resolved to one Swift name, or one took a name React Native reserves. Set `swiftpmConfig.name` in the library's package.json — see [Library names](#library-names) | +| Missing headers | Re-run `react-native spm` | +| "not contained in target" | Re-run setup (regenerates file-level symlinks) | +| Codegen fails | Use `--skipCodegen` to iterate on other parts | +| "SPM sync failed" warning | Check Xcode build log for details; node may not be in PATH — ensure `with-environment.sh` is present | +| "Sync SPM Autolinking" build phase fails: `'npx --no-install @react-native-community/cli config' exited with status 1` | This app replaces `@react-native-community/cli` autolinking (e.g. an Expo app). Re-run `spm add`/`update` with `RCT_SPM_AUTOLINKING_CONFIG_COMMAND` exported (or the raw script with `--config-command`) so the working command is pinned for the build phase to reuse — see [The autolinking config command is remembered](#the-autolinking-config-command-is-remembered). | +| Autolinking not updating on build | Touch `package.json` to force a sync, or delete `build/generated/autolinking/.spm-sync-stamp` | +| Stale SwiftPM state or corrupted build | `rm -rf build/ .build/`, then `react-native spm update`, then reopen Xcode | +| Want to revert to CocoaPods | `react-native spm deinit`, then `pod install` | --- @@ -630,13 +698,13 @@ across apps; refresh it with `react-native spm update --download force`. ```text my-app/ios/ MyApp.xcodeproj/ <-- committed (your project; SwiftPM injected in place, carries .spm-injected.json) - Podfile <-- present until `pod deintegrate` (CocoaPods coexistence is best-effort) + Podfile <-- kept; `--deintegrate` only strips the React Native lines (CocoaPods coexistence is best-effort) build/ generated/ autolinking/ <-- gitignored (regenerated at build time) Package.swift autolinking.json - packages/ <-- synth wrappers for autolinker-managed deps + packages/ <-- synth wrappers for swiftpmConfig.modules (local modules) libs/ <-- symlinks to self-managed deps' Package.swift dirs, named by Swift module so SwiftPM package identity stays unique @@ -660,44 +728,23 @@ my-app/ios/ ### Header Resolution -React Native uses CocoaPods-style imports (`#import `) that -SwiftPM doesn't natively support. The prebuilt artifacts serve them through -SwiftPM package products — no `-I` search-path flags, and no clang VFS overlay: - -1. **`` and `import React`** resolve through the invariant - **`ReactHeaders` Clang target**. It stages one canonical header copy after - proving Debug and Release expose identical public headers, and uses a plain - `module React` module map with `React/`-prefixed paths. -2. **Lowercase C++ `react/` and every other RN namespace** (`yoga/`, `jsi/`, - `jsinspector-modern`, …) comes from **`ReactNativeHeaders.xcframework`**, a - headers-only (LIBRARY-type) binaryTarget whose per-slice `Headers/` SwiftPM - auto-serves to dependents. -3. **Third-party dependency namespaces** (`folly/`, `glog/`, `boost/`, `fmt/`, - `double-conversion/`, `fast_float/`, `SocketRocket/`) come from - **`ReactNativeDependenciesHeaders.xcframework`**, the deps headers-only - sidecar (same mechanism — the binary `ReactNativeDependencies.xcframework` is - framework-type and can't expose those headers to SwiftPM). - -Targets that compile against React take these as product dependencies -(`ReactHeaders`, `ReactNativeHeaders`, `ReactNativeDependenciesHeaders`, plus -the app's `ReactAppHeaders`), so all of the above resolve with zero search-path -flags. +React Native's headers resolve through SwiftPM package products, with no `-I` +search-path flags and no clang VFS overlay. See +[How headers resolve](./spm-header-paths-contract.md#how-headers-resolve-no-search-paths) +for the products, the namespaces each one serves, and why. ### Auto-Sync -Autolinking is kept up to date without manual re-runs of `react-native spm` by -**two hooks running the same sync script**, injected by `add`/`update`: +`add`/`update` inject **two hooks that run the same sync script**. They keep +autolinking up to date without manual re-runs of `react-native spm`: | Hook | Where | Role | | ---------------------------------- | ------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- | | Scheme pre-action | The app's **shared** scheme (`xcshareddata/xcschemes/`), under `BuildAction` → `PreActions` | Fires earlier in the build than a build phase can, so it is the one that normally does the re-sync. | | `Sync SPM Autolinking` build phase | `.xcodeproj`, prepended before `Sources` | **Safety net** for builds that bypass the scheme (and for a scheme whose pre-action was stripped). | -Neither hook can bootstrap a clean checkout. Xcode resolves the Swift package -graph before build phases **and** before scheme pre-actions, so if the generated -packages are missing entirely, resolution fails and the build stops before -either hook runs — see [Fresh clones & CI](#fresh-clones--ci). The hooks keep an -_existing_ set of generated packages current; they do not create the first one. +The hooks keep an _existing_ set of generated packages current. They do not +create the first one: see [Fresh clones & CI](#fresh-clones--ci). **How the sync script works:** @@ -707,14 +754,18 @@ _existing_ set of generated packages current; they do not create the first one. - `react-native.config.js` — autolinking config - `MyApp.xcodeproj/.spm-injected.json` — the frameworks the project links, so a pull that changes them re-checks them + - lockfiles found walking up from the project root — `package-lock.json`, + `npm-shrinkwrap.json`, `yarn.lock`, `pnpm-lock.yaml`, `bun.lock`, + `bun.lockb`, `.pnp.cjs`, `.pnp.loader.mjs` - `node_modules/` directory mtime — updated by any package manager (npm, yarn, pnpm, bun); also checks parent `node_modules` for monorepo setups - - a missing `build/xcframeworks/` (e.g. after a manual clean) also marks - stale - every path in `.spm-sync-watch-paths` — RN's own inputs plus any [plugin](./spm-autolinking-plugins.md#watchpaths--plugin-staleness-inputs) `watchPaths`; a watched file that is newer, a watched dir with a newer - child, or a watched path that has **vanished** all mark stale + descendant (the scan skips `.swiftpm`), or a watched path that has + **vanished** all mark stale + - the commit time of the latest `git log` entry touching `*.js` / `*.ts`, + compared with the stamp's mtime - every path in `.spm-sync-watch-absent` — watched paths that did not exist at the last sync; one that **appears** marks stale. This includes each dependency's root `Package.swift` and `.react-native/`: one that appears @@ -725,9 +776,12 @@ _existing_ set of generated packages current; they do not create the first one. (the marker and the plugin manifest included): prints the recorded `error:` lines and fails the build without re-running sync. 3. If any input is newer (or the stamp is missing): runs - `npx react-native spm sync`, which re-executes autolinking + package - generation (downloading artifacts if the cache slot is incomplete) and writes - the stamp file. + `"$NODE_BINARY" "$RN_DIR/scripts/setup-apple-spm.js" sync`. It falls back to + `npx react-native spm sync` only when that script or `NODE_BINARY` is + missing. `sync` regenerates `autolinking.json` (CLI config), runs codegen, + installs the codegen template, re-runs autolinking, rebuilds the header farm, + and writes the stamp file. It does not download artifacts or regenerate + `build/xcframeworks/`. 4. If all inputs are fresh: exits immediately (~1ms). **Ordering.** As observed in an `xcodebuild -scheme … build` log on Xcode 26.6: @@ -745,31 +799,33 @@ _existing_ set of generated packages current; they do not create the first one. | Resources (copy) | build phase 5 | | Build JS Bundle | build phase 6 | -Resolution coming first is what makes the one-time setup run necessary on a -clean checkout; it is not something either hook can work around. - -A sync failure is lenient by default but **not unconditionally**. The generated +A sync failure is lenient by default, but **not unconditionally**. The generated script branches on the exit code: -- **Exit 2** — a problem that only a terminal command fixes. This **fails the - build** (`exit 1`), deliberately: sync has already printed `error:` lines that - name the command. Two cases use it: - - An autolinked dependency ships no `Package.swift` (see - [Community packages without a Package.swift](#community-packages-without-a-packageswift)). +- **Exit 2** — **fails the build** (`exit 1`), whatever the cause. Four errors + exit with 2: + - an autolinked dependency ships no `Package.swift` — the autolinker has + already printed an `error:` line per dep, and the fix needs a terminal (see + [Community packages without a Package.swift](#community-packages-without-a-packageswift)); + - the autolinking config command fails (see + [The autolinking config command is remembered](#the-autolinking-config-command-is-remembered)); + - remote mode is on but no usable React Native version resolves (see + [Remote-package mode](./spm-header-paths-contract.md#remote-package-mode)); - The precompiled frameworks that autolinking plugins provide differ from the frameworks that the Xcode project links (see [Troubleshooting](#troubleshooting)). Sync writes the stamp and records the `error:` lines in `.spm-plugin-mismatch`. When the scheme pre-action ran that sync, the build phase prints the recorded lines instead of running sync again. Later builds also print them without a sync, until an input changes. - `spm update` changes inputs, so the next build syncs. Precompiling a - framework changes none, so run `npx react-native spm sync` after it. A sync - that passes the check deletes the file. + `spm update` changes inputs, so the next build syncs. If precompiling a + framework changes no watched input, run `npx react-native spm sync` after + it. A sync that passes the check deletes the file. - **Any other non-zero exit** — emits `warning: SPM sync failed — build may use stale codegen/autolinking` and lets the build continue, so an already-generated package graph can still produce a successful build. -That split is the whole reason these cases have their own exit code: a transient -sync hiccup should not break a build that could still succeed, while a missing -manifest or an out-of-date project should not pass silently. +These errors have their own exit code because a transient sync hiccup should not +break a build that could still succeed, while a missing manifest, an out-of-date +project, a broken config command, or an unresolvable remote version should not +pass silently. From 27150439fbd675012d27e7ba2e14f150c72edee3 Mon Sep 17 00:00:00 2001 From: Christian Falch Date: Wed, 21 Oct 2026 17:24:15 +0200 Subject: [PATCH 5/5] SwiftPM: move injected build settings and build scripts out of project.pbxproj (#58834) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Port of 6a2c7c27342 (#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)/` through `node_modules/react-native`. Before, with pnpm, it could be the resolved `.pnpm/react-native@_` 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=]` 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=]` 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=]` 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: https://github.com/react/react-native/pull/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 6a2c7c27342d16cab93e398f950ad5b5565d2942, without the #59043 changes. --- .../scripts/spm/__docs__/spm-scripts.md | 134 +-- .../__tests__/generate-spm-xcodeproj-test.js | 94 ++ .../__tests__/inject-spm-xcodeproj-test.js | 576 ++++++++++--- .../__tests__/remove-spm-injection-test.js | 527 ++++++++++-- .../scripts/spm/__tests__/spm-pbxproj-test.js | 37 + .../scripts/spm/generate-spm-xcodeproj.js | 800 ++++++++++++------ .../scripts/spm/ios-deployment-target.js | 79 +- .../react-native/scripts/spm/spm-pbxproj.js | 11 +- .../scripts/spm/xcconfig-files.js | 149 ++++ 9 files changed, 1840 insertions(+), 567 deletions(-) create mode 100644 packages/react-native/scripts/spm/xcconfig-files.js diff --git a/packages/react-native/scripts/spm/__docs__/spm-scripts.md b/packages/react-native/scripts/spm/__docs__/spm-scripts.md index e2a2e1ef858c..f3c1fa885c42 100644 --- a/packages/react-native/scripts/spm/__docs__/spm-scripts.md +++ b/packages/react-native/scripts/spm/__docs__/spm-scripts.md @@ -113,15 +113,15 @@ zero-arg path also implies `--deintegrate` (the safe-gate). So From the JS root of a standard RN app (sibling `ios/` subdir), the command redirects into `ios/` and prints a banner. -| Action | Description | -| --------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `add` | Inject SwiftPM packages (package refs, build settings, the Sync build phase) into the existing `.xcodeproj`, in place. Idempotent. Default on first run. `--deintegrate` first runs `pod deintegrate` + strips React Native from the Podfile. | -| `update` | Re-run the pipeline and refresh the existing injection. Default once a project is injected. | -| `deinit` | The inverse of `add`: surgically remove only what `add` injected (recorded in `.spm-injected.json`) and drop the marker. Git-recoverable; no prompt. Some edits it does not undo — see [Files the tool touches](#files-the-tool-touches). | -| `scaffold` | Generate `Package.swift` into `node_modules//` for community RN libraries that ship only a podspec. | -| `sync` (advanced) | Lightweight resync invoked by the Xcode auto-sync hooks. Regenerates invariant codegen and autolinking output only. Not for humans. | -| `codegen` (advanced) | Run codegen and install the SwiftPM codegen template only. | -| `download` (advanced) | Download/check xcframework artifacts only. | +| Action | Description | +| --------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `add` | Inject SwiftPM packages (package refs, the base reference to `ReactNativeSPM/ReactNativeSPM.xcconfig`, the Sync build phase) into the existing `.xcodeproj`, in place. Idempotent. Default on first run. `--deintegrate` first runs `pod deintegrate` + strips React Native from the Podfile. | +| `update` | Re-run the pipeline and refresh the existing injection. Default once a project is injected. | +| `deinit` | The inverse of `add`: surgically remove only what `add` injected (recorded in `.spm-injected.json`) and drop the marker. Git-recoverable; no prompt. Some edits it does not undo — see [Files the tool touches](#files-the-tool-touches). | +| `scaffold` | Generate `Package.swift` into `node_modules//` for community RN libraries that ship only a podspec. | +| `sync` (advanced) | Lightweight resync invoked by the Xcode auto-sync hooks. Regenerates invariant codegen and autolinking output only. Not for humans. | +| `codegen` (advanced) | Run codegen and install the SwiftPM codegen template only. | +| `download` (advanced) | Download/check xcframework artifacts only. | ## CLI Options @@ -214,8 +214,9 @@ frameworks are deliberately kept out of the package graph. Instead: - `spm add` downloads and validates **both** flavors into immutable app-local slots. -- It injects SDK/architecture-qualified Xcode settings that link the exact - selected binaries. +- It writes SDK/architecture-qualified Xcode settings to + `ReactNativeSPM/ReactNativeSPM.xcconfig`. They link the exact selected + binaries. - It adds one phase that copies and signs the selected frameworks into the app. Configurations containing `debug` or `development` select Debug; every other @@ -223,15 +224,19 @@ configuration selects Release. Selection uses only generated build settings and standard macOS tools. Builds do not run Node, mutate symlinks, regenerate the package graph, or require a second build. +The xcconfig sets `RN_SPM_FLAVOR` for each configuration by name. A +configuration added later, e.g. duplicated in Xcode, falls back to release until +you run `npx react-native spm update`. + Those debug-flavored configurations also get `DEBUG` in -`SWIFT_ACTIVE_COMPILATION_CONDITIONS`, injected as `("$(inherited)", DEBUG)` -when the setting is absent. Only this makes Swift's `#if DEBUG` true +`SWIFT_ACTIVE_COMPILATION_CONDITIONS`, set in the xcconfig as +`$(inherited) DEBUG`. Only this makes Swift's `#if DEBUG` true (`GCC_PREPROCESSOR_DEFINITIONS` reaches C/ObjC/C++ only). `AppDelegate.swift`'s `bundleURL()` branches on it to load from Metro instead of a bundled `main.jsbundle`. CocoaPods injects it at `pod install` time, so this keeps -SwiftPM apps at parity. An existing value is left alone only if it already -contains `DEBUG`. Otherwise `DEBUG` is appended, and a scalar value is promoted -to an array (see [Files the tool touches](#files-the-tool-touches)). +SwiftPM apps at parity. `add` and `update` refuse a target value without +`$(inherited)`, because it would hide this one (see +[Build settings](#build-settings)). ### iOS deployment target @@ -260,14 +265,16 @@ Paths are relative to the Xcode project directory (`ios/`) unless noted. ### In your repo — committed -| Path | Written by | What happens | Undone by `deinit`? | -| --------------------------------------------------- | --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `MyApp.xcodeproj/project.pbxproj` | `add`, `update`, `scaffold` | SwiftPM package refs, the React build settings, the Sync build phase, and the flavored-framework embed phase are added; a re-run is a no-op. A `${PODS_ROOT}`-anchored `REACT_NATIVE_PATH` is replaced with the SwiftPM path. `add` also removes the template's unused `JavaScriptCore.framework` reference, and `add --deintegrate` removes an empty `Pods` group. | Yes — exactly what was injected, per the marker, including the original `REACT_NATIVE_PATH` (one exception below). The removed `JavaScriptCore.framework` reference and `Pods` group are **not** restored. | -| `MyApp.xcodeproj/.spm-injected.json` | `add`, `update`, `scaffold` | Created. Three roles: it records every edit made — including the pre-injection value of any build setting rewritten — so removal is surgical and re-runs stay idempotent; it records the frameworks the project links (`linkedFrameworks`), which the build-time sync compares with the frameworks plugins provide; and it **pins configuration** later runs and Xcode builds must reuse (see the two pins below). | Yes — deleted, and the pins go with it | -| `MyApp.xcodeproj/xcshareddata/xcschemes/*.xcscheme` | `add`, `update`, `scaffold` | The sync pre-action is added to the scheme that builds your target; a shared scheme is created if there is none. Commit this or teammates lose the pre-action. | Yes — a scheme `add` created is deleted only if it is still byte-identical to the generated one; otherwise only the pre-action is stripped | -| `.gitignore` | `add` only | Created if absent, else appended: a `# SPM – auto-generated at build time` block adding `Package.resolved`, `build/generated/`, `build/xcframeworks/`, `.build/`. | **No** — the block is left behind | -| `Podfile` | `add --deintegrate` | Only the lines containing the React Native directives (`use_react_native!`, `use_native_modules!`, `prepare_react_native_project!`) are stripped. Your own `pod '…'` lines are preserved. The argument lines of a multi-line `use_react_native!(` call and `react_native_post_install(...)` remain — remove them by hand before `pod install`. | **No** — re-add the directives yourself to go back to CocoaPods | -| `Pods/`, `Pods-*.xcconfig`, `[CP]` phases | `add --deintegrate` | Removed by `pod deintegrate`. The `.xcworkspace` referencing them is left on disk. | **No** — run `pod install` to restore | +| Path | Written by | What happens | Undone by `deinit`? | +| --------------------------------------------------- | --------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `MyApp.xcodeproj/project.pbxproj` | `add`, `update`, `scaffold` | SwiftPM package refs, a reference to `ReactNativeSPM/ReactNativeSPM.xcconfig` set as the base configuration of each app configuration, the Sync build phase, and the flavored-framework embed phase are added. The two phases run their scripts from `ReactNativeSPM/Scripts/`. A re-run is a no-op. A `${PODS_ROOT}`-anchored `REACT_NATIVE_PATH` is removed, so the xcconfig's SwiftPM path applies. `add` also removes the template's unused `JavaScriptCore.framework` reference, and `add --deintegrate` removes an empty `Pods` group. | Yes — exactly what was injected, per the marker. The removed `REACT_NATIVE_PATH`, `JavaScriptCore.framework` reference, and `Pods` group are **not** restored. | +| `MyApp.xcodeproj/.spm-injected.json` | `add`, `update`, `scaffold` | Created. Three roles: it records every edit made — including which configurations it based on the generated xcconfig — so removal is surgical and re-runs stay idempotent; it records the frameworks the project links (`linkedFrameworks`), which the build-time sync compares with the frameworks plugins provide; and it **pins configuration** later runs and Xcode builds must reuse (see the two pins below). | Yes — deleted, and the pins go with it | +| `ReactNativeSPM/ReactNativeSPM.xcconfig` | `add`, `update`, `scaffold` | Regenerated on each run and written when its content changes. Holds the React build settings: search paths, linker flags, `REACT_NATIVE_PATH`, the C++ standard, each configuration's flavor (`RN_SPM_FLAVOR`), and `DEBUG` for Swift on debug configurations (see [Build settings](#build-settings)). **Commit it.** Xcode reads it before any build phase runs, so a build cannot regenerate it. If it is missing, run `npx react-native spm update` and commit it. | Yes — deleted, unless a configuration is based on an xcconfig of your own, which may `#include` it. Then the file is kept, and `deinit` names those configurations. | +| `ReactNativeSPM/Scripts/` | `add`, `update`, `scaffold` | Regenerated on each run and written when their content changes: `sync-autolinking.sh` and `embed-flavored-frameworks.sh`, the scripts the Sync and embed phases run. Plugin script phases keep their scripts in `project.pbxproj`. **Commit it.** When a script is missing, its phase fails the build with `error: is missing. Run 'npx react-native spm update' to generate it.` | Yes — both scripts are deleted | +| `MyApp.xcodeproj/xcshareddata/xcschemes/*.xcscheme` | `add`, `update`, `scaffold` | The sync pre-action is added to the scheme that builds your target; a shared scheme is created if there is none. Commit this or teammates lose the pre-action. | Yes — a scheme `add` created is deleted only if it is still byte-identical to the generated one; otherwise only the pre-action is stripped | +| `.gitignore` | `add` only | Created if absent, else appended: a `# SPM – auto-generated at build time` block adding `Package.resolved`, `build/generated/`, `build/xcframeworks/`, `.build/`. | **No** — the block is left behind | +| `Podfile` | `add --deintegrate` | Only the lines containing the React Native directives (`use_react_native!`, `use_native_modules!`, `prepare_react_native_project!`) are stripped. Your own `pod '…'` lines are preserved. The argument lines of a multi-line `use_react_native!(` call and `react_native_post_install(...)` remain — remove them by hand before `pod install`. | **No** — re-add the directives yourself to go back to CocoaPods | +| `Pods/`, `Pods-*.xcconfig`, `[CP]` phases | `add --deintegrate` | Removed by `pod deintegrate`. The `.xcworkspace` referencing them is left on disk. | **No** — run `pod install` to restore | The two pins are the `--version` pin (`artifactsVersionOverride`, see [Pinning the React Native version](#pinning-the-react-native-version)) and the @@ -298,24 +305,32 @@ every other byte of your project — signing, capabilities, your own Build Phase — stays untouched. The injected refs point at three stable sub-package paths under `build/`. So adding or removing community deps changes only the sub-package contents (gitignored) and never re-injects. `deinit` leaves the -project byte-identical to its pre-`add` state, with the exceptions above and one -more, described next. - -**Build settings that already exist** are edited in place. `add` merges into -five array settings: `HEADER_SEARCH_PATHS`, `OTHER_LDFLAGS`, -`FRAMEWORK_SEARCH_PATHS`, `LD_RUNPATH_SEARCH_PATHS`, and (on debug -configurations) `SWIFT_ACTIVE_COMPILATION_CONDITIONS`. Each keeps the shape it -was written in: Xcode's multi-line form, or the compact one-line form that hand -edits and other generators (XcodeGen, Tuist) emit. - -A setting that exists as a plain _scalar_ is promoted to a `( … )` array. An -Xcode-authored target can carry that shape, e.g. -`LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";` written -as a scalar rather than a list. `add` records the pre-injection value in the -marker. `deinit` restores it by rewriting the whole field, because once folded -together, the injected members and your own are indistinguishable. So **members -you add to a promoted array by hand afterwards are lost**. That applies to -`update` too, which reverts to the recorded baseline before re-injecting. +project byte-identical to its pre-`add` state, with the exceptions above. + +### Build settings + +The React build settings live in `ReactNativeSPM/ReactNativeSPM.xcconfig`, not +in `project.pbxproj`. Each app configuration is based on that file. Its list +settings start with `$(inherited)`. `add` and `update` refuse two setups, and +the error names the fix: + +- **A configuration already based on an xcconfig of your own.** React Native + does not edit xcconfig files it does not own. Add + `#include "/ReactNativeSPM.xcconfig"` to your file instead, with the + path relative to the folder that holds your file, and re-run. `#include` + chains are followed. The configuration then keeps your file as its base. +- **A list setting the target sets without `$(inherited)`.** These are + `HEADER_SEARCH_PATHS`, `OTHER_LDFLAGS`, `FRAMEWORK_SEARCH_PATHS`, + `LD_RUNPATH_SEARCH_PATHS`, and on debug configurations + `SWIFT_ACTIVE_COMPILATION_CONDITIONS`. A target's own value outranks the + xcconfig, so it would hide React Native's values. Add `$(inherited)` and + re-run. + +A project set up by an earlier version carries these settings in +`project.pbxproj`. The next `update` or `deinit` reverses the edits the marker +recorded. It removes the settings that version added and puts back any value it +replaced. An array that version promoted from a scalar goes back to its original +value, so **members you added to it by hand are lost**. ## Environment variables @@ -362,6 +377,11 @@ On an already-injected project this routes to `update`. It fetches the xcframework artifacts into the shared cache if they aren't present, and regenerates `build/xcframeworks` + `build/generated`. +`ReactNativeSPM/` is not under `build/`. It is committed with the project, so a +fresh clone already has it (see +[In your repo — committed](#in-your-repo--committed)). `update` rewrites it when +its content changes. + **Automate it** with a `postinstall` hook. It runs as part of the `npm install` / `yarn install` your CI already does before `xcodebuild`: @@ -642,6 +662,12 @@ react-native spm deinit # surgically removes everything `add` injected pod install # then, to restore CocoaPods ``` +`deinit` removes each configuration's base reference to +`ReactNativeSPM/ReactNativeSPM.xcconfig` and deletes the generated scripts. It +deletes the xcconfig too, unless a configuration is based on an xcconfig of your +own. That file may `#include` it, so `deinit` keeps it and names those +configurations. Remove the `#include` and the file yourself. + To reset the regenerable build state without un-injecting, delete the gitignored dirs and re-run: @@ -659,6 +685,7 @@ across apps; refresh it with `react-native spm update --download force`. | Problem | Fix | | ---------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `xcodebuild` fails: "Could not resolve package dependencies … `build/generated/autolinking` doesn't exist" | Fresh clone — run `npx react-native spm` once before building (see [Fresh clones & CI](#fresh-clones--ci)) | +| Build fails: `error: …/ReactNativeSPM/Scripts/.sh is missing` | `ReactNativeSPM/` was not committed. Run `npx react-native spm update`, then commit `ReactNativeSPM/` | | `spm add` fails: "CocoaPods-integrated project" | Re-run `spm add --deintegrate` (runs `pod deintegrate` + strips RN from the Podfile), or `pod deintegrate` yourself first. | | `spm add` fails: "no .xcodeproj found" | Create an app first (`npx @react-native-community/cli init`) or make a project in Xcode, then `spm add`. | | `spm add` fails: "multiple .xcodeproj found" | Pass `--xcodeproj ` (and `--productName ` if multiple app targets). | @@ -683,21 +710,24 @@ across apps; refresh it with `react-native spm update --download force`. `react-native spm add` and `react-native spm update` orchestrate these steps: -| Step | Script | Output | -| -------------- | ---------------------------------------- | ------------------------------------------------------------------------------------------------- | -| 1. CLI config | `spm/generate-spm-autolinking-config.js` | `build/generated/autolinking/autolinking.json` | -| 2. Codegen | `generate-codegen-artifacts.js` | `build/generated/ios/` | -| 3. Autolinking | `spm/generate-spm-autolinking.js` | `build/generated/autolinking/Package.swift` | -| 4. Download | `spm/download-spm-artifacts.js` | Complete Debug and Release cache slots | -| 5. Package | `spm/generate-spm-package.js` | Immutable flavor slots, central manifest, canonical `ReactHeaders`, and invariant `Package.swift` | -| 6. Inject | `spm/generate-spm-xcodeproj.js` | Invariant SwiftPM products plus configuration-qualified linker settings and the embed/sign phase | -| Auto-sync | `spm/sync-spm-autolinking.js` | Re-runs invariant codegen/autolinking output only at Xcode build time | +| Step | Script | Output | +| -------------- | ---------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 1. CLI config | `spm/generate-spm-autolinking-config.js` | `build/generated/autolinking/autolinking.json` | +| 2. Codegen | `generate-codegen-artifacts.js` | `build/generated/ios/` | +| 3. Autolinking | `spm/generate-spm-autolinking.js` | `build/generated/autolinking/Package.swift` | +| 4. Download | `spm/download-spm-artifacts.js` | Complete Debug and Release cache slots | +| 5. Package | `spm/generate-spm-package.js` | Immutable flavor slots, central manifest, canonical `ReactHeaders`, and invariant `Package.swift` | +| 6. Inject | `spm/generate-spm-xcodeproj.js` | Invariant SwiftPM products and the embed/sign phase in the project, plus `ReactNativeSPM/` with the configuration-qualified linker settings and the phase scripts | +| Auto-sync | `spm/sync-spm-autolinking.js` | Re-runs invariant codegen/autolinking output only at Xcode build time | ### Directory Layout ```text my-app/ios/ MyApp.xcodeproj/ <-- committed (your project; SwiftPM injected in place, carries .spm-injected.json) + ReactNativeSPM/ <-- committed, rewritten by add/update + ReactNativeSPM.xcconfig <-- React build settings; base configuration of the app configurations + Scripts/ <-- scripts the Sync and embed phases run Podfile <-- kept; `--deintegrate` only strips the React Native lines (CocoaPods coexistence is best-effort) build/ generated/ @@ -741,7 +771,7 @@ autolinking up to date without manual re-runs of `react-native spm`: | Hook | Where | Role | | ---------------------------------- | ------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- | | Scheme pre-action | The app's **shared** scheme (`xcshareddata/xcschemes/`), under `BuildAction` → `PreActions` | Fires earlier in the build than a build phase can, so it is the one that normally does the re-sync. | -| `Sync SPM Autolinking` build phase | `.xcodeproj`, prepended before `Sources` | **Safety net** for builds that bypass the scheme (and for a scheme whose pre-action was stripped). | +| `Sync SPM Autolinking` build phase | `.xcodeproj`, prepended before `Sources`; runs `ReactNativeSPM/Scripts/sync-autolinking.sh` | **Safety net** for builds that bypass the scheme (and for a scheme whose pre-action was stripped). | The hooks keep an _existing_ set of generated packages current. They do not create the first one: see [Fresh clones & CI](#fresh-clones--ci). diff --git a/packages/react-native/scripts/spm/__tests__/generate-spm-xcodeproj-test.js b/packages/react-native/scripts/spm/__tests__/generate-spm-xcodeproj-test.js index 9a7dda111218..1aa0e6d55d5d 100644 --- a/packages/react-native/scripts/spm/__tests__/generate-spm-xcodeproj-test.js +++ b/packages/react-native/scripts/spm/__tests__/generate-spm-xcodeproj-test.js @@ -17,6 +17,7 @@ const { buildSyncAutolinkingScript, flavorForBuildConfiguration, frameworkConditionalSettings, + generateReactNativeXcconfig, generateXcscheme, readScriptPhasesManifest, } = require('../generate-spm-xcodeproj'); @@ -604,6 +605,99 @@ describe('configuration-driven framework settings', () => { }); }); +describe('generateReactNativeXcconfig', () => { + const DEVICE_ONLY = {...FRAMEWORK, slices: [FRAMEWORK.slices[0]]}; + + it('writes every setting the app target needs, extending inherited lists', () => { + expect( + generateReactNativeXcconfig( + ['Debug', 'Release'], + '../node_modules/react-native', + [DEVICE_ONLY], + ), + ).toBe( + `// Generated by React Native on every \`spm add\` and \`spm update\`. +// Do not edit: changes are overwritten. + +CLANG_CXX_LANGUAGE_STANDARD = c++20 +REACT_NATIVE_PATH = $(SRCROOT)/../node_modules/react-native + +HEADER_SEARCH_PATHS = $(inherited) "$(SRCROOT)/build/generated/autolinking/headers" +OTHER_LDFLAGS = $(inherited) -ObjC "$(RN_SPM_REACT_BINARY)" +FRAMEWORK_SEARCH_PATHS = $(inherited) "$(RN_SPM_REACT_SEARCH_PATH)" +LD_RUNPATH_SEARCH_PATHS = $(inherited) @executable_path/Frameworks + +RN_SPM_FLAVOR = release +RN_SPM_FLAVOR[config=Debug] = debug +SWIFT_ACTIVE_COMPILATION_CONDITIONS[config=Debug] = $(inherited) DEBUG +RN_SPM_FLAVOR[config=Release] = release + +RN_SPM_REACT_FRAMEWORK[sdk=iphoneos*] = $(SRCROOT)/build/xcframeworks/$(RN_SPM_FLAVOR)/React.xcframework/ios-arm64/React.framework +RN_SPM_REACT_BINARY[sdk=iphoneos*] = $(SRCROOT)/build/xcframeworks/$(RN_SPM_FLAVOR)/React.xcframework/ios-arm64/React.framework/React +RN_SPM_REACT_SEARCH_PATH[sdk=iphoneos*] = $(SRCROOT)/build/xcframeworks/$(RN_SPM_FLAVOR)/React.xcframework/ios-arm64 +`, + ); + }); + + it('anchors REACT_NATIVE_PATH on SRCROOT itself when react-native sits there', () => { + expect(generateReactNativeXcconfig(['Debug'], '', [FRAMEWORK])).toContain( + '\nREACT_NATIVE_PATH = $(SRCROOT)\n', + ); + }); + + it("conditions the flavor on the project's own configuration names", () => { + const xcconfig = generateReactNativeXcconfig( + ['Development', 'Staging'], + '../node_modules/react-native', + [FRAMEWORK], + ); + expect(xcconfig).toContain('RN_SPM_FLAVOR[config=Development] = debug\n'); + expect(xcconfig).toContain( + 'SWIFT_ACTIVE_COMPILATION_CONDITIONS[config=Development] = $(inherited) DEBUG\n', + ); + expect(xcconfig).toContain('RN_SPM_FLAVOR[config=Staging] = release\n'); + expect(xcconfig).not.toContain( + 'SWIFT_ACTIVE_COMPILATION_CONDITIONS[config=Staging]', + ); + expect(xcconfig).not.toMatch(/\[config=(Debug|Release)\]/); + }); + + // A configuration duplicated in Xcode keeps the base reference but has no + // line of its own until the next sync, and an unset flavor would resolve + // every framework path to nothing. + it('falls back to the release flavor for a configuration it does not list', () => { + const lines = generateReactNativeXcconfig( + ['Debug', 'Release'], + '../node_modules/react-native', + [FRAMEWORK], + ).split('\n'); + const fallback = lines.indexOf('RN_SPM_FLAVOR = release'); + expect(fallback).toBeGreaterThan(-1); + expect(fallback).toBeLessThan( + lines.findIndex(line => line.startsWith('RN_SPM_FLAVOR[config=')), + ); + expect( + lines.some(line => + line.startsWith('SWIFT_ACTIVE_COMPILATION_CONDITIONS ='), + ), + ).toBe(false); + }); + + it('writes no plist quoting around conditional keys or path values', () => { + const xcconfig = generateReactNativeXcconfig( + ['Debug'], + '../node_modules/react-native', + [FRAMEWORK], + ); + expect(xcconfig).toContain( + '\nRN_SPM_REACT_BINARY[sdk=iphonesimulator*] = $(SRCROOT)/', + ); + expect(xcconfig).toContain('\nRN_SPM_REACT_SEARCH_PATH[sdk=macosx*] = '); + expect(xcconfig).not.toMatch(/^"/m); + expect(xcconfig).not.toContain('= "$(SRCROOT)/build/xcframeworks'); + }); +}); + describe('embed framework phase script', () => { const script = buildEmbedFrameworksScript([FRAMEWORK]); 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 3d4093c9334c..85840bd0a247 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 @@ -12,11 +12,15 @@ const { buildPhaseOrder, + buildSyncAutolinkingScript, + generateReactNativeXcconfig, injectSpmIntoPbxproj, planInjection, } = require('../generate-spm-xcodeproj'); +const {findField, findObjectByUuid, quoteIfNeeded} = require('../spm-pbxproj'); const {isBalanced} = require('./pbxproj-oracles'); const fs = require('node:fs'); +const os = require('node:os'); const path = require('node:path'); const PLAIN = fs.readFileSync( @@ -56,17 +60,41 @@ function buildSettingsOf(text, configUuid) { ); return text.slice(open, text.indexOf('};', open)); } -// Derive a variant whose app-target configs already carry HEADER_SEARCH_PATHS, -// set to any valid pbxproj value: a plain scalar (which injection promotes to an -// array) or an array injection appends to. -function withHeaderSearchPaths(value) { +// Seed a whole `KEY = value;` field into both app-target configs. +function withAppSetting(field) { return PLAIN.replaceAll( 'PRODUCT_BUNDLE_IDENTIFIER = com.example.MyApp;', - `HEADER_SEARCH_PATHS = ${value};\n\t\t\t\tPRODUCT_BUNDLE_IDENTIFIER = com.example.MyApp;`, + `${field}\n\t\t\t\tPRODUCT_BUNDLE_IDENTIFIER = com.example.MyApp;`, ); } +function withHeaderSearchPaths(value) { + return withAppSetting(`HEADER_SEARCH_PATHS = ${value};`); +} + +// The app target's Debug config already based on an xcconfig of the user's own. +const FOREIGN_XCCONFIG = PLAIN.replace( + DEBUG_CONFIG_HEAD, + 'AA0000000000000000000901 /* Debug */ = {\n\t\t\tisa = XCBuildConfiguration;\n\t\t\tbaseConfigurationReference = CC0000000000000000000001 /* App.xcconfig */;\n\t\t\tbuildSettings = {', +).replace( + '/* End PBXFileReference section */', + '\t\tCC0000000000000000000001 /* App.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Config/App.xcconfig; sourceTree = ""; };\n/* End PBXFileReference section */', +); + +const XCCONFIG_PATH = 'ReactNativeSPM/ReactNativeSPM.xcconfig'; +const SYNC_SCRIPT_PATH = 'ReactNativeSPM/Scripts/sync-autolinking.sh'; +const EMBED_SCRIPT_PATH = 'ReactNativeSPM/Scripts/embed-flavored-frameworks.sh'; + +// The raw (still plist-quoted) shellScript value of the phase labelled `label`. +function shellScriptOf(text, label) { + const uuid = new RegExp(`([0-9A-F]{24}) /\\* ${label} \\*/ = \\{`).exec( + text, + )[1]; + return findField(text, findObjectByUuid(text, uuid), 'shellScript').value; +} + const RN_PATH = '../node_modules/react-native'; +const RN_PATHS = {fromAppRoot: RN_PATH, fromSrcRoot: RN_PATH}; const TEST_FRAMEWORKS = [ { id: 'react', @@ -117,7 +145,7 @@ function inject( frameworksPhaseUuid: plan.frameworksPhaseUuid, sourcesPhaseUuid: plan.sourcesPhaseUuid, }, - RN_PATH, + RN_PATHS, remote, generatedSources, TEST_FRAMEWORKS, @@ -179,6 +207,233 @@ describe('planInjection', () => { expect(plan.ok).toBe(false); expect(plan.reason).toMatch(/no application target/); }); + + // The generated xcconfig sits below the target's own settings, so a list + // setting the target sets without $(inherited) hides React Native's. + it.each([ + ['HEADER_SEARCH_PATHS', '"$(SRCROOT)/Vendor"'], + ['OTHER_LDFLAGS', '(\n\t\t\t\t\t"-lc++",\n\t\t\t\t)'], + ['FRAMEWORK_SEARCH_PATHS', '""'], + ['LD_RUNPATH_SEARCH_PATHS', '"@executable_path/Frameworks"'], + ])('refuses %s set without $(inherited), naming it', (key, value) => { + const plan = planInjection(withAppSetting(`${key} = ${value};`), {}); + expect(plan.ok).toBe(false); + expect(plan.reason).toContain(`${key} (Debug, Release)`); + expect(plan.reason).toContain('$(inherited)'); + }); + + it('refuses SWIFT_ACTIVE_COMPILATION_CONDITIONS without $(inherited) on a debug configuration', () => { + const plan = planInjection(withDebugCondition(PLAIN, 'MY_FLAG'), {}); + expect(plan.ok).toBe(false); + expect(plan.reason).toContain( + 'SWIFT_ACTIVE_COMPILATION_CONDITIONS (Debug)', + ); + }); + + it('accepts SWIFT_ACTIVE_COMPILATION_CONDITIONS without $(inherited) on a release configuration, which gets none', () => { + const releaseHead = DEBUG_CONFIG_HEAD.replace( + 'AA0000000000000000000901 /* Debug */', + 'AA00000000000000000000A2 /* Release */', + ); + const text = PLAIN.replace( + releaseHead, + `${releaseHead}\n\t\t\t\tSWIFT_ACTIVE_COMPILATION_CONDITIONS = MY_FLAG;`, + ); + expect(text).not.toBe(PLAIN); + expect(planInjection(text, {}).ok).toBe(true); + }); + + it.each([ + ['a scalar', '"$(inherited) $(SRCROOT)/Vendor"'], + [ + 'an array', + '(\n\t\t\t\t\t"$(inherited)",\n\t\t\t\t\t"$(SRCROOT)/Vendor",\n\t\t\t\t)', + ], + ['the brace form', '"${inherited} $(SRCROOT)/Vendor"'], + ])( + 'accepts a list setting that keeps $(inherited) in %s', + (_label, value) => { + expect(planInjection(withHeaderSearchPaths(value), {}).ok).toBe(true); + }, + ); + + it("refuses a configuration already based on the user's own xcconfig", () => { + const plan = planInjection(FOREIGN_XCCONFIG, {}); + expect(plan.ok).toBe(false); + expect(plan.reason).toContain('Debug'); + expect(plan.reason).toContain('Config/App.xcconfig'); + expect(plan.reason).toContain('#include'); + expect(plan.reason).toContain(XCCONFIG_PATH); + }); + + it('accepts a project it already based on the generated xcconfig', () => { + expect(planInjection(inject(PLAIN).text, {}).ok).toBe(true); + }); +}); + +// FOREIGN_XCCONFIG with the reference inside a `Config` group, the way Xcode +// files an xcconfig dragged into a folder of the navigator. +const FOREIGN_XCCONFIG_IN_GROUP = FOREIGN_XCCONFIG.replace( + 'path = Config/App.xcconfig;', + 'path = App.xcconfig;', +) + .replace( + '\t\t\t\tAA00000000000000000000F1 /* Products */,\n', + '\t\t\t\tAA00000000000000000000F1 /* Products */,\n\t\t\t\tCC0000000000000000000002 /* Config */,\n', + ) + .replace( + '/* End PBXGroup section */', + '\t\tCC0000000000000000000002 /* Config */ = {\n\t\t\tisa = PBXGroup;\n\t\t\tchildren = (\n\t\t\t\tCC0000000000000000000001 /* App.xcconfig */,\n\t\t\t);\n\t\t\tpath = Config;\n\t\t\tsourceTree = "";\n\t\t};\n/* End PBXGroup section */', + ); + +describe("planInjection — a configuration based on the user's own xcconfig", () => { + let roots = []; + afterEach(() => { + for (const root of roots) { + fs.rmSync(root, {recursive: true, force: true}); + } + roots = []; + }); + + // An app root (== SRCROOT) holding `files`, app-root-relative path → content. + function appRootWith(files) { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'spm-include-')); + roots.push(root); + for (const [relPath, content] of Object.entries(files)) { + fs.mkdirSync(path.dirname(path.join(root, relPath)), {recursive: true}); + fs.writeFileSync(path.join(root, relPath), content, 'utf8'); + } + return root; + } + + function plan(files, text = FOREIGN_XCCONFIG) { + const root = appRootWith(files); + return planInjection(text, {appRoot: root, srcRoot: root}); + } + + const INCLUDES_GENERATED = `#include "../${XCCONFIG_PATH}"\n`; + + it('accepts it when the xcconfig #includes the generated one', () => { + const result = plan({'Config/App.xcconfig': INCLUDES_GENERATED}); + expect(result.ok).toBe(true); + expect(result.includedByConfigUuids).toEqual([APP_DEBUG_CONFIG]); + }); + + it('includes no configuration it bases on the generated xcconfig itself', () => { + expect(planInjection(PLAIN, {}).includedByConfigUuids).toEqual([]); + }); + + it('finds an xcconfig filed under a navigator group', () => { + expect( + plan( + {'Config/App.xcconfig': INCLUDES_GENERATED}, + FOREIGN_XCCONFIG_IN_GROUP, + ).ok, + ).toBe(true); + }); + + it('follows #include? chains, each path relative to the file that holds it', () => { + expect( + plan({ + 'Config/App.xcconfig': + '// Shared settings\n#include? "Shared/Base.xcconfig"\n', + 'Config/Shared/Base.xcconfig': `#include "../../${XCCONFIG_PATH}"\n`, + }).ok, + ).toBe(true); + }); + + it.each([ + [ + 'a path relative to the project directory', + `#include "${XCCONFIG_PATH}"\n`, + ], + ['a commented-out include', `// ${INCLUDES_GENERATED}`], + ['an include of a file that is missing', '#include? "Missing.xcconfig"\n'], + ])('refuses %s', (_label, content) => { + expect(plan({'Config/App.xcconfig': content}).ok).toBe(false); + }); + + it('refuses an include cycle that never reaches the generated xcconfig', () => { + const result = plan({ + 'Config/App.xcconfig': '#include "Base.xcconfig"\n', + 'Config/Base.xcconfig': '#include "App.xcconfig"\n', + }); + expect(result.ok).toBe(false); + }); + + it('still refuses a list setting without $(inherited) on it', () => { + const result = plan( + {'Config/App.xcconfig': INCLUDES_GENERATED}, + FOREIGN_XCCONFIG.replaceAll( + 'PRODUCT_BUNDLE_IDENTIFIER = com.example.MyApp;', + 'HEADER_SEARCH_PATHS = /vendor;\n\t\t\t\tPRODUCT_BUNDLE_IDENTIFIER = com.example.MyApp;', + ), + ); + expect(result.ok).toBe(false); + expect(result.reason).toContain('HEADER_SEARCH_PATHS (Debug, Release)'); + }); + + it('names the exact line to add, relative to the xcconfig that needs it', () => { + const result = plan({'Config/App.xcconfig': 'MY_SETTING = 1\n'}); + expect(result.ok).toBe(false); + expect(result.reason).toContain( + `#include "../${XCCONFIG_PATH}" to Config/App.xcconfig`, + ); + expect(result.reason).not.toContain('project directory'); + }); + + it('says the path is relative to the xcconfig when it cannot find the file', () => { + const result = plan({}); + expect(result.ok).toBe(false); + expect(result.reason).toContain( + `#include ${XCCONFIG_PATH} from Config/App.xcconfig`, + ); + expect(result.reason).toContain( + 'relative to the folder that holds Config/App.xcconfig', + ); + expect(result.reason).not.toContain('project directory'); + }); + + it("keeps the user's base and records the configuration as including the generated xcconfig", () => { + const root = appRootWith({'Config/App.xcconfig': INCLUDES_GENERATED}); + const accepted = planInjection(FOREIGN_XCCONFIG, { + appRoot: root, + srcRoot: root, + }); + const {text, xcconfig, generatedFiles} = injectSpmIntoPbxproj( + FOREIGN_XCCONFIG, + { + rootUuid: accepted.rootUuid, + targetUuid: accepted.target.uuid, + configUuids: accepted.configUuids, + includedByConfigUuids: accepted.includedByConfigUuids, + frameworksPhaseUuid: accepted.frameworksPhaseUuid, + sourcesPhaseUuid: accepted.sourcesPhaseUuid, + }, + RN_PATHS, + null, + [], + TEST_FRAMEWORKS, + ); + const baseOf = configUuid => + findField( + text, + findObjectByUuid(text, configUuid), + 'baseConfigurationReference', + )?.value; + expect(baseOf(APP_DEBUG_CONFIG)).toBe( + 'CC0000000000000000000001 /* App.xcconfig */', + ); + expect(baseOf(APP_RELEASE_CONFIG)).toBe( + `${xcconfig.fileRefUuid} /* ReactNativeSPM.xcconfig */`, + ); + expect(xcconfig.configUuids).toEqual([APP_RELEASE_CONFIG]); + expect(xcconfig.includedByConfigUuids).toEqual([APP_DEBUG_CONFIG]); + expect(text).toContain(`path = ${XCCONFIG_PATH};`); + expect(generatedFiles[XCCONFIG_PATH]).toContain( + 'RN_SPM_FLAVOR[config=Debug] = debug\n', + ); + }); }); describe('injectSpmIntoPbxproj — Tier 1 (SPM graph)', () => { @@ -225,71 +480,153 @@ describe('injectSpmIntoPbxproj — Tier 1 (SPM graph)', () => { }); describe('injectSpmIntoPbxproj — Tier 2 (build settings + phase)', () => { - it('merges React build settings into BOTH build configurations', () => { + const PROJECT_DEBUG_CONFIG = 'AA0000000000000000000701'; + const PROJECT_RELEASE_CONFIG = 'AA0000000000000000000801'; + const ALL_CONFIGS = [ + PROJECT_DEBUG_CONFIG, + PROJECT_RELEASE_CONFIG, + APP_DEBUG_CONFIG, + APP_RELEASE_CONFIG, + ]; + const appSettingsOf = text => + [APP_DEBUG_CONFIG, APP_RELEASE_CONFIG].map(config => + buildSettingsOf(text, config), + ); + // React Native installed elsewhere, with `frameworks` in place of the + // default ones — what a later `spm update` sees. + const injectRelocated = frameworks => { + const plan = planInjection(PLAIN, {}); + const relocated = '../../elsewhere/react-native'; + return injectSpmIntoPbxproj( + PLAIN, + { + rootUuid: plan.rootUuid, + targetUuid: plan.target.uuid, + configUuids: plan.configUuids, + frameworksPhaseUuid: plan.frameworksPhaseUuid, + sourcesPhaseUuid: plan.sourcesPhaseUuid, + }, + {fromAppRoot: relocated, fromSrcRoot: relocated}, + null, + [], + frameworks, + [], + ); + }; + + // A routine `spm update` must not touch project.pbxproj, so none of these may + // live in it — they are the ones whose values follow the installed frameworks. + it('writes no React build setting into any configuration', () => { const {text} = inject(PLAIN); - expect(text.match(/-ObjC/g)).toHaveLength(2); - expect(text.match(/REACT_NATIVE_PATH = /g)).toHaveLength(2); - expect(text).not.toContain('fmodule-map-file='); - expect(text).toContain('build/generated/autolinking/headers'); - expect(text.match(/CLANG_CXX_LANGUAGE_STANDARD = "c\+\+20"/g)).toHaveLength( - 2, + for (const config of ALL_CONFIGS) { + const settings = buildSettingsOf(text, config); + expect(settings).toBe(buildSettingsOf(PLAIN, config)); + expect(settings).not.toMatch( + /RN_SPM_|OTHER_LDFLAGS|FRAMEWORK_SEARCH_PATHS|HEADER_SEARCH_PATHS|LD_RUNPATH_SEARCH_PATHS|SWIFT_ACTIVE_COMPILATION_CONDITIONS|CLANG_CXX_LANGUAGE_STANDARD|REACT_NATIVE_PATH/, + ); + } + }); + + it('bases every app configuration on the generated xcconfig, next to isa', () => { + const {text, injectedUuids, xcconfig} = inject(PLAIN); + const ref = + /baseConfigurationReference = ([0-9A-F]{24}) \/\* ReactNativeSPM\.xcconfig \*\/;/.exec( + text, + )[1]; + for (const [config, name] of [ + [APP_DEBUG_CONFIG, 'Debug'], + [APP_RELEASE_CONFIG, 'Release'], + ]) { + expect(text).toContain( + `${config} /* ${name} */ = {\n\t\t\tisa = XCBuildConfiguration;\n` + + `\t\t\tbaseConfigurationReference = ${ref} /* ReactNativeSPM.xcconfig */;\n` + + '\t\t\tbuildSettings = {', + ); + } + expect(text.match(/baseConfigurationReference = /g)).toHaveLength(2); + expect(xcconfig.configUuids).toEqual([ + APP_DEBUG_CONFIG, + APP_RELEASE_CONFIG, + ]); + + const fileRef = findObjectByUuid(text, ref); + const field = key => findField(text, fileRef, key)?.value; + expect(field('isa')).toBe('PBXFileReference'); + expect(field('lastKnownFileType')).toBe('text.xcconfig'); + expect(field('name')).toBe('ReactNativeSPM.xcconfig'); + expect(field('path')).toBe(XCCONFIG_PATH); + expect(field('sourceTree')).toBe('SOURCE_ROOT'); + expect(injectedUuids).toContain(ref); + // Visible in the navigator, at the top of the project. + const mainGroup = findObjectByUuid(text, 'AA00000000000000000000E1'); + expect(findField(text, mainGroup, 'children').value).toContain( + `${ref} /* ReactNativeSPM.xcconfig */,`, + ); + }); + + it('returns the xcconfig generated for the configurations based on it', () => { + const {generatedFiles} = inject(PLAIN); + expect(generatedFiles[XCCONFIG_PATH]).toBe( + generateReactNativeXcconfig( + ['Debug', 'Release'], + RN_PATH, + TEST_FRAMEWORKS, + ), ); - expect(text).toContain('RN_SPM_FLAVOR = debug'); - expect(text).toContain('RN_SPM_FLAVOR = release'); - expect(text).toContain('RN_SPM_REACT_BINARY[sdk=iphoneos*]'); - expect(text).toContain('RN_SPM_REACT_BINARY[sdk=iphonesimulator*]'); - expect(text).toContain('$(RN_SPM_REACT_BINARY)'); }); // 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); + it('never writes HERMES_CLI_PATH, into the project or a generated file', () => { + const {text, generatedFiles} = inject(PLAIN); expect(text).not.toContain('HERMES_CLI_PATH'); - expect( - buildSettingChanges.flatMap(change => change.createdScalars ?? []), - ).not.toContain('HERMES_CLI_PATH'); + for (const content of Object.values(generatedFiles)) { + expect(content).not.toContain('HERMES_CLI_PATH'); + } }); - // Swift's `#if DEBUG` — which AppDelegate.swift's bundleURL() uses to pick the - // Metro URL — is gated by this setting alone. CocoaPods injects it at `pod - // install`; an SPM app has to get it here or a Debug build looks for a - // main.jsbundle it never built. - it('sets SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG on the debug config only', () => { - const {text} = inject(PLAIN); - const debugSettings = buildSettingsOf(text, APP_DEBUG_CONFIG); - expect(debugSettings).toMatch( - /SWIFT_ACTIVE_COMPILATION_CONDITIONS = \(\s*"\$\(inherited\)",\s*DEBUG,\s*\)/, - ); - expect(buildSettingsOf(text, APP_RELEASE_CONFIG)).not.toContain( + it.each([ + ['HEADER_SEARCH_PATHS', withHeaderSearchPaths('"$(inherited)"')], + [ + 'HEADER_SEARCH_PATHS', + withHeaderSearchPaths('(\n\t\t\t\t\t"$(inherited)",\n\t\t\t\t)'), + ], + [ 'SWIFT_ACTIVE_COMPILATION_CONDITIONS', - ); + withDebugCondition(PLAIN, '"$(inherited) MY_DEBUG_UI"'), + ], + ])("leaves the configuration's own %s untouched", (_key, before) => { + expect(appSettingsOf(inject(before).text)).toEqual(appSettingsOf(before)); }); - it('leaves a config that already sets DEBUG (scalar form) untouched', () => { - const {text} = inject(withDebugCondition(PLAIN, '"$(inherited) DEBUG"')); - // Not promoted to an array, not re-appended — DEBUG is already there. - expect(buildSettingsOf(text, APP_DEBUG_CONFIG)).toContain( - 'SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited) DEBUG";', + // The CocoaPods template anchors REACT_NATIVE_PATH on ${PODS_ROOT}, which + // resolves empty once CocoaPods is gone, and a target's own value outranks + // any xcconfig — so it has to leave the project for the xcconfig's to apply. + it('removes a ${PODS_ROOT}-anchored REACT_NATIVE_PATH so the xcconfig value applies', () => { + const before = withAppSetting( + 'REACT_NATIVE_PATH = "${PODS_ROOT}/../../node_modules/react-native";', + ); + const {text, xcconfig, generatedFiles} = inject(before); + expect(appSettingsOf(text)).toEqual(appSettingsOf(PLAIN)); + expect(xcconfig.removedPodsRootReactNativePath).toBe(true); + expect(generatedFiles[XCCONFIG_PATH]).toContain( + `\nREACT_NATIVE_PATH = $(SRCROOT)/${RN_PATH}\n`, ); - expect(text.match(/\bDEBUG\b/g)).toHaveLength(1); }); - it("adds DEBUG alongside the user's own compilation conditions", () => { - const {text} = inject( - withDebugCondition(PLAIN, '"$(inherited) MY_DEBUG_UI"'), + it("keeps a REACT_NATIVE_PATH of the project's own", () => { + const before = withAppSetting( + 'REACT_NATIVE_PATH = ../../node_modules/react-native;', ); - // MY_DEBUG_UI must not be mistaken for DEBUG by a substring check. - const debugSettings = buildSettingsOf(text, APP_DEBUG_CONFIG); - expect(debugSettings).toContain('"$(inherited) MY_DEBUG_UI"'); - expect(debugSettings).toMatch(/^\s*DEBUG,$/m); + const {text, xcconfig} = inject(before); + expect(appSettingsOf(text)).toEqual(appSettingsOf(before)); + expect(xcconfig.removedPodsRootReactNativePath).toBe(false); }); it('prepends the Sync SPM Autolinking build phase', () => { const {text} = inject(PLAIN); expect(text).toContain('Sync SPM Autolinking'); - expect(text).toContain('npx react-native spm sync'); // It runs before Sources. const syncIdx = text.indexOf('Sync SPM Autolinking */,'); const sourcesIdx = text.indexOf('Sources */,'); @@ -297,6 +634,57 @@ describe('injectSpmIntoPbxproj — Tier 2 (build settings + phase)', () => { expect(syncIdx).toBeLessThan(sourcesIdx); }); + // The script bodies change whenever React Native's do, so they live in + // generated files; the phase only carries a wrapper that never changes. + it.each([ + ['Sync SPM Autolinking', SYNC_SCRIPT_PATH, 'npx react-native spm sync'], + [ + 'Embed React Native Flavored Frameworks', + EMBED_SCRIPT_PATH, + 'copy_and_sign "${RN_SPM_REACT_FRAMEWORK:-}" "React.framework"', + ], + ])('runs the %s script from %s', (label, scriptPath, bodyLine) => { + const {text, generatedFiles} = inject(PLAIN); + const wrapper = shellScriptOf(text, label); + expect(wrapper).toContain(`$SRCROOT/${scriptPath}`); + expect(wrapper).toContain('spm update'); + expect(text).not.toContain(quoteIfNeeded(bodyLine).slice(1, -1)); + expect(text).not.toContain('set -euo pipefail'); + expect(generatedFiles[scriptPath]).toContain(bodyLine); + }); + + it('keeps the script wrappers identical across React Native versions and frameworks', () => { + const {text} = inject(PLAIN); + const other = injectRelocated([]).text; + for (const label of [ + 'Sync SPM Autolinking', + 'Embed React Native Flavored Frameworks', + ]) { + expect(shellScriptOf(other, label)).toBe(shellScriptOf(text, label)); + } + }); + + // A routine `spm update` must leave project.pbxproj alone while the + // frameworks keep their ids and names: slice paths and architectures land + // only in the generated xcconfig. + it('produces a byte-identical project.pbxproj across a version bump (path + slice content change)', () => { + const bumped = TEST_FRAMEWORKS.map(framework => ({ + ...framework, + slices: framework.slices.map(slice => ({ + ...slice, + architectures: [...slice.architectures, 'arm64e'], + libraryIdentifier: `${slice.libraryIdentifier}_arm64e`, + binaryPath: 'React.framework/Versions/Current/React', + })), + })); + const before = inject(PLAIN); + const after = injectRelocated(bumped); + expect(after.generatedFiles[XCCONFIG_PATH]).not.toBe( + before.generatedFiles[XCCONFIG_PATH], + ); + expect(after.text).toBe(before.text); + }); + it('runs every injected shell-script build phase under bash, not /bin/sh', () => { // Their bodies start with `set -euo pipefail`, which a non-bash /bin/sh // (e.g. dash) rejects at runtime. @@ -326,50 +714,6 @@ describe('injectSpmIntoPbxproj — Tier 2 (build settings + phase)', () => { ); }); - it.each([ - [ - '"$(inherited)"', - ['"$(inherited)"', '"$(SRCROOT)/build/generated/autolinking/headers"'], - ], - [ - '"$(inherited) $(SRCROOT)/vendor/include"', - [ - '"$(inherited)"', - '"$(inherited) $(SRCROOT)/vendor/include"', - '"$(SRCROOT)/build/generated/autolinking/headers"', - ], - ], - ])( - 'promotes a pre-existing HEADER_SEARCH_PATHS scalar (%s) to an array, keeping its value and one $(inherited)', - (scalar, expectedMembers) => { - const {text} = inject(withHeaderSearchPaths(scalar)); - const arrays = [ - ...text.matchAll(/HEADER_SEARCH_PATHS = \(\n([\s\S]*?)\t+\);/g), - ].map(m => - m[1] - .split('\n') - .map(line => line.trim().replace(/,$/, '')) - .filter(member => member.length > 0), - ); - // Both app-target configs (Debug + Release). - expect(arrays).toEqual([expectedMembers, expectedMembers]); - }, - ); - - it('appends to a pre-existing ONE-LINE HEADER_SEARCH_PATHS array in place', () => { - const {text} = inject(withHeaderSearchPaths('("$(inherited)", )')); - expect(isBalanced(text)).toBe(true); - const arrays = [ - ...text.matchAll(/HEADER_SEARCH_PATHS = \(([^\n]*)\);/g), - ].map(m => m[1]); - // Both app-target configs, each keeping the one-line shape it was written in. - expect(arrays).toEqual( - Array(2).fill( - '"$(inherited)", "$(SRCROOT)/build/generated/autolinking/headers", ', - ), - ); - }); - it('adds one generated embed phase immediately after Frameworks', () => { const {text} = inject(PLAIN); expect(text).not.toContain('Fix SPM Embedded Flavor'); @@ -386,6 +730,19 @@ describe('injectSpmIntoPbxproj — Tier 2 (build settings + phase)', () => { '$(TARGET_BUILD_DIR)/$(FRAMEWORKS_FOLDER_PATH)/React.framework', ); }); + + // The phase only runs the script file, so the file has to be an input for + // Xcode to re-run the phase when the file alone changes. + it('lists its script file among the embed phase inputs', () => { + const {text} = inject(PLAIN); + const uuid = + /([0-9A-F]{24}) \/\* Embed React Native Flavored Frameworks \*\/ = \{/.exec( + text, + )[1]; + expect( + findField(text, findObjectByUuid(text, uuid), 'inputPaths').value, + ).toContain(`${quoteIfNeeded(`$(SRCROOT)/${EMBED_SCRIPT_PATH}`)},`); + }); }); describe('injectSpmIntoPbxproj — Tier 3 (plugin generated sources)', () => { @@ -445,7 +802,7 @@ describe('injectSpmIntoPbxproj — Tier 3 (plugin generated sources)', () => { frameworksPhaseUuid: plan.frameworksPhaseUuid, sourcesPhaseUuid: plan.sourcesPhaseUuid, }, - RN_PATH, + RN_PATHS, null, [PROVIDER_SOURCE], TEST_FRAMEWORKS, @@ -484,7 +841,7 @@ describe('injectSpmIntoPbxproj — Tier 3 (plugin generated sources)', () => { frameworksPhaseUuid: plan.frameworksPhaseUuid, sourcesPhaseUuid: plan.sourcesPhaseUuid, }, - RN_PATH, + RN_PATHS, null, [PROVIDER_SOURCE], TEST_FRAMEWORKS, @@ -1118,22 +1475,24 @@ describe('injectSpmIntoPbxproj — invariants', () => { }); it('is idempotent — a second injection is a byte-for-byte no-op', () => { - const first = inject(PLAIN).text; - const plan = planInjection(first, {}); + const first = inject(PLAIN); + const plan = planInjection(first.text, {}); const second = injectSpmIntoPbxproj( - first, + first.text, { rootUuid: plan.rootUuid, targetUuid: plan.target.uuid, configUuids: plan.configUuids, frameworksPhaseUuid: plan.frameworksPhaseUuid, }, - RN_PATH, + RN_PATHS, null, [], TEST_FRAMEWORKS, - ).text; - expect(second).toBe(first); + ); + expect(second.text).toBe(first.text); + expect(second.generatedFiles).toEqual(first.generatedFiles); + expect(second.xcconfig).toEqual(first.xcconfig); }); it('keeps the diff small — only adds lines, never removes original ones', () => { @@ -1150,15 +1509,13 @@ describe('injectSpmIntoPbxproj — invariants', () => { expect(added).toBeLessThan(220); }); - it('refreshes a stale shellScript on re-injection', () => { + it('replaces an inline script body from an earlier version with the wrapper', () => { const first = inject(PLAIN).text; - // Simulate an earlier run whose generated script has since changed (e.g. - // fixed dispatch logic) by corrupting a substring of the baked-in script. const stale = first.replace( - 'npx react-native spm sync', - 'STALE_OLD_SYNC_COMMAND', + shellScriptOf(first, 'Sync SPM Autolinking'), + quoteIfNeeded(buildSyncAutolinkingScript(RN_PATH)), ); - expect(stale).not.toBe(first); + expect(stale).toContain('npx react-native spm sync'); const plan = planInjection(stale, {}); const second = injectSpmIntoPbxproj( stale, @@ -1168,14 +1525,11 @@ describe('injectSpmIntoPbxproj — invariants', () => { configUuids: plan.configUuids, frameworksPhaseUuid: plan.frameworksPhaseUuid, }, - RN_PATH, + RN_PATHS, null, [], TEST_FRAMEWORKS, ).text; - // The stale marker is gone and the current script is restored. - expect(second).not.toContain('STALE_OLD_SYNC_COMMAND'); - expect(second).toContain('npx react-native spm sync'); expect(second).toBe(first); }); 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 d039a55eb3fd..628b5219c6c6 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 @@ -76,6 +76,11 @@ function scaffoldApp(pbxproj /*: string */ = PLAIN) { ); const rnRoot = path.join(appRoot, 'node_modules', 'react-native'); fs.mkdirSync(rnRoot, {recursive: true}); + seedArtifacts(appRoot); + return {appRoot, xcodeprojPath, rnRoot}; +} + +function seedArtifacts(appRoot /*: string */) { const artifactRoot = path.join(appRoot, 'build', 'xcframeworks'); fs.mkdirSync(artifactRoot, {recursive: true}); fs.writeFileSync( @@ -83,7 +88,6 @@ function scaffoldApp(pbxproj /*: string */ = PLAIN) { JSON.stringify({version: 1, frameworks: []}), ); fs.writeFileSync(path.join(artifactRoot, '.artifact-stamp'), 'test\n'); - return {appRoot, xcodeprojPath, rnRoot}; } // The hoisted `hermes-compiler` layout a real installed app has: the package @@ -167,6 +171,57 @@ function readMarker(xcodeprojPath) { return JSON.parse(markerTextOf(xcodeprojPath)); } +const XCCONFIG_REL = 'ReactNativeSPM/ReactNativeSPM.xcconfig'; +const GENERATED_FILES = [ + XCCONFIG_REL, + 'ReactNativeSPM/Scripts/sync-autolinking.sh', + 'ReactNativeSPM/Scripts/embed-flavored-frameworks.sh', +]; + +function generatedFileContents(appRoot) { + return GENERATED_FILES.map(file => + fs.existsSync(path.join(appRoot, file)) + ? fs.readFileSync(path.join(appRoot, file), 'utf8') + : null, + ); +} + +const APP_DEBUG_CONFIG = 'AA0000000000000000000901'; + +// The app target's Debug configuration based on Config/App.xcconfig, an +// xcconfig of the user's own. +const FOREIGN_XCCONFIG = PLAIN.replace( + `${APP_DEBUG_CONFIG} /* Debug */ = {\n\t\t\tisa = XCBuildConfiguration;\n`, + `${APP_DEBUG_CONFIG} /* Debug */ = {\n\t\t\tisa = XCBuildConfiguration;\n\t\t\tbaseConfigurationReference = CC0000000000000000000001 /* App.xcconfig */;\n`, +).replace( + '/* End PBXFileReference section */', + '\t\tCC0000000000000000000001 /* App.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Config/App.xcconfig; sourceTree = ""; };\n/* End PBXFileReference section */', +); + +function writeUserXcconfig(appRoot, content) { + fs.mkdirSync(path.join(appRoot, 'Config'), {recursive: true}); + fs.writeFileSync(path.join(appRoot, 'Config', 'App.xcconfig'), content); +} + +// What re-basing Debug on the user's own xcconfig in Xcode does after `add`. +function rebaseDebugOnUserXcconfig(xcodeprojPath) { + const text = pbxprojOf(xcodeprojPath); + const start = text.indexOf(`${APP_DEBUG_CONFIG} /* Debug */ = {`); + const end = text.indexOf('buildSettings', start); + fs.writeFileSync( + path.join(xcodeprojPath, 'project.pbxproj'), + text.slice(0, start) + + text + .slice(start, end) + .replace( + /baseConfigurationReference = [0-9A-F]{24} \/\* ReactNativeSPM\.xcconfig \*\//, + 'baseConfigurationReference = CC0000000000000000000001 /* App.xcconfig */', + ) + + text.slice(end), + 'utf8', + ); +} + function schemePathOf(xcodeprojPath) { return path.join( xcodeprojPath, @@ -204,11 +259,120 @@ describe('injectSpmIntoExistingXcodeproj — HERMES_CLI_PATH', () => { expect(pbxprojOf(xcodeprojPath)).not.toContain('HERMES_CLI_PATH'); expect(pbxprojOf(xcodeprojPath)).not.toContain(hermesc); + for (const file of GENERATED_FILES) { + expect(fs.readFileSync(path.join(appRoot, file), 'utf8')).not.toContain( + 'HERMES_CLI_PATH', + ); + } + expect(markerTextOf(xcodeprojPath)).not.toContain('HERMES_CLI_PATH'); + }); +}); + +// pnpm's layout: the project's node_modules/react-native links into a store +// directory named after the version and dependency hash, and the Xcode project +// sits one level down, in ios/. Config commands report the store path. +function scaffoldPnpmApp() { + const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'spm-pnpm-')); + scaffoldedAppRoots.push(projectRoot); + const nodeModules = path.join(projectRoot, 'node_modules'); + const store = path.join( + nodeModules, + '.pnpm', + 'react-native@0.87.0_abc123', + 'node_modules', + 'react-native', + ); + fs.mkdirSync(store, {recursive: true}); + fs.symlinkSync( + path.relative(nodeModules, store), + path.join(nodeModules, 'react-native'), + ); + const appRoot = path.join(projectRoot, 'ios'); + const xcodeprojPath = path.join(appRoot, 'MyApp.xcodeproj'); + fs.mkdirSync(xcodeprojPath, {recursive: true}); + fs.writeFileSync(path.join(xcodeprojPath, 'project.pbxproj'), PLAIN, 'utf8'); + seedArtifacts(appRoot); + return {appRoot, xcodeprojPath, rnRoot: fs.realpathSync(store)}; +} + +// The xcconfig, scripts and scheme are committed, so they must name +// react-native the way the app resolves it, not by its store location. +describe('injectSpmIntoExistingXcodeproj — REACT_NATIVE_PATH', () => { + it('writes the node_modules link, not the pnpm store path', () => { + const {appRoot, xcodeprojPath, rnRoot} = scaffoldPnpmApp(); + expect(rnRoot).toContain('.pnpm'); + expect( - readMarker(xcodeprojPath).buildSettingChanges.flatMap( - change => change.createdScalars ?? [], - ), - ).not.toContain('HERMES_CLI_PATH'); + injectSpmIntoExistingXcodeproj({ + appRoot, + reactNativeRoot: rnRoot, + xcodeprojPath, + }).status, + ).toBe('injected'); + + const generated = generatedFileContents(appRoot); + const [xcconfig, syncScript] = generated; + expect(xcconfig).toContain( + '\nREACT_NATIVE_PATH = $(SRCROOT)/../node_modules/react-native\n', + ); + expect(syncScript).toContain('RN_DIR="../node_modules/react-native"'); + for (const content of [ + ...generated, + fs.readFileSync(schemePathOf(xcodeprojPath), 'utf8'), + ]) { + expect(content).not.toContain('.pnpm'); + } + }); + + it('prefers the nearest node_modules link to react-native', () => { + const {appRoot, xcodeprojPath, rnRoot} = scaffoldPnpmApp(); + fs.mkdirSync(path.join(appRoot, 'node_modules')); + fs.symlinkSync(rnRoot, path.join(appRoot, 'node_modules', 'react-native')); + + injectSpmIntoExistingXcodeproj({ + appRoot, + reactNativeRoot: rnRoot, + xcodeprojPath, + }); + + expect(fs.readFileSync(path.join(appRoot, XCCONFIG_REL), 'utf8')).toContain( + '\nREACT_NATIVE_PATH = $(SRCROOT)/node_modules/react-native\n', + ); + }); + + it('skips a nearer node_modules/react-native that is another install', () => { + const {appRoot, xcodeprojPath, rnRoot} = scaffoldPnpmApp(); + fs.mkdirSync(path.join(appRoot, 'node_modules', 'react-native'), { + recursive: true, + }); + + injectSpmIntoExistingXcodeproj({ + appRoot, + reactNativeRoot: rnRoot, + xcodeprojPath, + }); + + expect(fs.readFileSync(path.join(appRoot, XCCONFIG_REL), 'utf8')).toContain( + '\nREACT_NATIVE_PATH = $(SRCROOT)/../node_modules/react-native\n', + ); + }); + + it('falls back to the given path when no node_modules link resolves to it', () => { + const {appRoot, xcodeprojPath} = scaffoldApp(); + const vendored = path.join(appRoot, 'vendor', 'react-native'); + fs.mkdirSync(vendored, {recursive: true}); + + injectSpmIntoExistingXcodeproj({ + appRoot, + reactNativeRoot: vendored, + xcodeprojPath, + }); + + const [xcconfig, syncScript] = generatedFileContents(appRoot); + expect(xcconfig).toContain( + '\nREACT_NATIVE_PATH = $(SRCROOT)/vendor/react-native\n', + ); + expect(syncScript).toContain('RN_DIR="vendor/react-native"'); }); }); @@ -228,11 +392,15 @@ describe('removeSpmInjection — the surgical inverse of add', () => { expect(fs.existsSync(path.join(xcodeprojPath, SPM_INJECTED_MARKER))).toBe( true, ); + expect(generatedFileContents(appRoot)).not.toContain(null); const removed = removeSpmInjection({appRoot, xcodeprojPath}); expect(removed.status).toBe('removed'); // Byte-identical to the pre-add pbxproj. expect(pbxprojOf(xcodeprojPath)).toBe(before); + expect(generatedFileContents(appRoot)).toEqual( + GENERATED_FILES.map(() => null), + ); // Marker is gone. expect(fs.existsSync(path.join(xcodeprojPath, SPM_INJECTED_MARKER))).toBe( false, @@ -258,12 +426,14 @@ describe('removeSpmInjection — the surgical inverse of add', () => { sync(); const injected = pbxprojOf(xcodeprojPath); const marker = markerTextOf(xcodeprojPath); + const files = generatedFileContents(appRoot); const schemePath = schemePathOf(xcodeprojPath); expect(fs.existsSync(schemePath)).toBe(true); sync(); - // The re-sync changed neither the project… + // The re-sync changed neither the project, nor the files it generates… expect(pbxprojOf(xcodeprojPath)).toBe(injected); + expect(generatedFileContents(appRoot)).toEqual(files); // …nor the record of what has to be undone. expect(markerTextOf(xcodeprojPath)).toBe(marker); @@ -374,6 +544,267 @@ describe('removeSpmInjection — the surgical inverse of add', () => { expect(removed.status).toBe('removed'); expect(pbxprojOf(xcodeprojPath)).toBe(before); }); + + it('keeps a base configuration the user re-pointed after add, and the xcconfig it may include', () => { + const {appRoot, xcodeprojPath, rnRoot} = scaffoldApp(); + injectSpmIntoExistingXcodeproj({ + appRoot, + reactNativeRoot: rnRoot, + xcodeprojPath, + }); + const repointed = pbxprojOf(xcodeprojPath).replace( + /baseConfigurationReference = [0-9A-F]{24} \/\* ReactNativeSPM\.xcconfig \*\//g, + 'baseConfigurationReference = CC0000000000000000000001 /* App.xcconfig */', + ); + fs.writeFileSync( + path.join(xcodeprojPath, 'project.pbxproj'), + repointed, + 'utf8', + ); + + expect(removeSpmInjection({appRoot, xcodeprojPath}).status).toBe('removed'); + expect( + pbxprojOf(xcodeprojPath).match( + /baseConfigurationReference = CC0000000000000000000001 \/\* App\.xcconfig \*\/;/g, + ), + ).toHaveLength(2); + expect(fs.existsSync(path.join(appRoot, XCCONFIG_REL))).toBe(true); + }); + + it('keeps the xcconfig while one configuration is re-based on a user xcconfig, removing the scripts', () => { + const {appRoot, xcodeprojPath, rnRoot} = scaffoldApp(); + injectSpmIntoExistingXcodeproj({ + appRoot, + reactNativeRoot: rnRoot, + xcodeprojPath, + }); + rebaseDebugOnUserXcconfig(xcodeprojPath); + const logSpy = jest.spyOn(console, 'log').mockImplementation(() => {}); + + removeSpmInjection({appRoot, xcodeprojPath}); + + const logged = logSpy.mock.calls.map(([line]) => line).join('\n'); + logSpy.mockRestore(); + expect(pbxprojOf(xcodeprojPath)).toContain( + 'baseConfigurationReference = CC0000000000000000000001 /* App.xcconfig */;', + ); + expect(pbxprojOf(xcodeprojPath)).not.toContain('ReactNativeSPM.xcconfig'); + expect( + generatedFileContents(appRoot).map(content => content != null), + ).toEqual([true, false, false]); + expect(logged).toContain(`Kept ${XCCONFIG_REL}`); + }); + + it('accepts a user xcconfig that #includes the generated one, across syncs and deinit', () => { + const {appRoot, xcodeprojPath, rnRoot} = scaffoldApp(FOREIGN_XCCONFIG); + writeUserXcconfig(appRoot, `#include "../${XCCONFIG_REL}"\n`); + const sync = () => + injectSpmIntoExistingXcodeproj({ + appRoot, + reactNativeRoot: rnRoot, + xcodeprojPath, + }); + + expect(sync().status).toBe('injected'); + const injected = pbxprojOf(xcodeprojPath); + expect(readMarker(xcodeprojPath).xcconfig.includedByConfigUuids).toEqual([ + APP_DEBUG_CONFIG, + ]); + expect(sync().status).toBe('injected'); + expect(pbxprojOf(xcodeprojPath)).toBe(injected); + + removeSpmInjection({appRoot, xcodeprojPath}); + expect(pbxprojOf(xcodeprojPath)).toBe(FOREIGN_XCCONFIG); + expect(fs.existsSync(path.join(appRoot, XCCONFIG_REL))).toBe(true); + }); + + it('keeps the xcconfig a recorded configuration included, even once its base is gone', () => { + const {appRoot, xcodeprojPath, rnRoot} = scaffoldApp(FOREIGN_XCCONFIG); + writeUserXcconfig(appRoot, `#include "../${XCCONFIG_REL}"\n`); + injectSpmIntoExistingXcodeproj({ + appRoot, + reactNativeRoot: rnRoot, + xcodeprojPath, + }); + fs.writeFileSync( + path.join(xcodeprojPath, 'project.pbxproj'), + pbxprojOf(xcodeprojPath).replace( + '\t\t\tbaseConfigurationReference = CC0000000000000000000001 /* App.xcconfig */;\n', + '', + ), + 'utf8', + ); + + removeSpmInjection({appRoot, xcodeprojPath}); + expect(fs.existsSync(path.join(appRoot, XCCONFIG_REL))).toBe(true); + }); + + it('deletes the xcconfig for a marker written before includes were recorded', () => { + const {appRoot, xcodeprojPath, rnRoot} = scaffoldApp(); + injectSpmIntoExistingXcodeproj({ + appRoot, + reactNativeRoot: rnRoot, + xcodeprojPath, + }); + const marker = readMarker(xcodeprojPath); + delete marker.xcconfig.includedByConfigUuids; + fs.writeFileSync( + path.join(xcodeprojPath, SPM_INJECTED_MARKER), + JSON.stringify(marker, null, 2) + '\n', + ); + + removeSpmInjection({appRoot, xcodeprojPath}); + expect(pbxprojOf(xcodeprojPath)).toBe(PLAIN); + expect(generatedFileContents(appRoot)).toEqual( + GENERATED_FILES.map(() => null), + ); + }); + + // A ${PODS_ROOT}-anchored value resolves empty without CocoaPods, so putting + // it back would only restore a broken setting. + it('does not restore the ${PODS_ROOT}-anchored REACT_NATIVE_PATH it removed', () => { + const {appRoot, xcodeprojPath, rnRoot} = scaffoldApp( + withSetting( + 'REACT_NATIVE_PATH = "${PODS_ROOT}/../../node_modules/react-native";', + ), + ); + injectSpmIntoExistingXcodeproj({ + appRoot, + reactNativeRoot: rnRoot, + xcodeprojPath, + }); + expect(pbxprojOf(xcodeprojPath)).not.toContain('PODS_ROOT'); + expect( + readMarker(xcodeprojPath).xcconfig.removedPodsRootReactNativePath, + ).toBe(true); + + // Sticky: the update that follows no longer sees the value it removed. + injectSpmIntoExistingXcodeproj({ + appRoot, + reactNativeRoot: rnRoot, + xcodeprojPath, + }); + expect( + readMarker(xcodeprojPath).xcconfig.removedPodsRootReactNativePath, + ).toBe(true); + + removeSpmInjection({appRoot, xcodeprojPath}); + expect(pbxprojOf(xcodeprojPath)).toBe(PLAIN); + }); + + it('refuses a setting without $(inherited) and leaves the project untouched', () => { + const {appRoot, xcodeprojPath, rnRoot} = scaffoldApp( + withSetting('HEADER_SEARCH_PATHS = ;'), + ); + const before = pbxprojOf(xcodeprojPath); + const result = injectSpmIntoExistingXcodeproj({ + appRoot, + reactNativeRoot: rnRoot, + xcodeprojPath, + }); + expect(result.status).toBe('refused'); + expect(result.reason).toContain('HEADER_SEARCH_PATHS (Debug, Release)'); + expect(pbxprojOf(xcodeprojPath)).toBe(before); + expect(fs.existsSync(path.join(xcodeprojPath, SPM_INJECTED_MARKER))).toBe( + false, + ); + expect(generatedFileContents(appRoot)).toEqual( + GENERATED_FILES.map(() => null), + ); + }); +}); + +// --------------------------------------------------------------------------- +// A project injected before the settings moved to the generated xcconfig +// carries them in project.pbxproj, recorded per configuration in the marker's +// `buildSettingChanges`. The next sync reverses that record first, so the +// project moves over on its own. +// --------------------------------------------------------------------------- +describe('a project injected with settings in project.pbxproj', () => { + const DEBUG = 'AA0000000000000000000901'; + const RELEASE = 'AA00000000000000000000A2'; + + function scaffoldLegacyApp(fields, change) { + const app = scaffoldApp( + fields.length > 0 ? withSetting(fields.join('\n\t\t\t\t')) : PLAIN, + ); + fs.writeFileSync( + path.join(app.xcodeprojPath, SPM_INJECTED_MARKER), + JSON.stringify({ + target: 'MyApp', + targetUuid: 'AA0000000000000000000301', + injectedUuids: [], + createdArrayFields: [], + buildSettingChanges: [DEBUG, RELEASE].map(configUuid => ({ + configUuid, + createdArrayKeys: [], + appendedArrayValues: {}, + createdScalars: [], + ...change, + })), + }), + ); + return app; + } + + it('moves them into the generated xcconfig on the next sync', () => { + const {appRoot, xcodeprojPath, rnRoot} = scaffoldLegacyApp( + [ + 'OTHER_LDFLAGS = (\n\t\t\t\t\t"$(inherited)",\n\t\t\t\t\t"-ObjC",\n\t\t\t\t);', + 'REACT_NATIVE_PATH = ../node_modules/react-native;', + 'RN_SPM_FLAVOR = debug;', + ], + { + createdArrayKeys: ['OTHER_LDFLAGS'], + createdScalars: ['REACT_NATIVE_PATH', 'RN_SPM_FLAVOR'], + }, + ); + expect( + injectSpmIntoExistingXcodeproj({ + appRoot, + reactNativeRoot: rnRoot, + xcodeprojPath, + }).status, + ).toBe('injected'); + + const migrated = pbxprojOf(xcodeprojPath); + expect(migrated).not.toMatch( + /OTHER_LDFLAGS|REACT_NATIVE_PATH|RN_SPM_FLAVOR/, + ); + expect(migrated.match(/baseConfigurationReference = /g)).toHaveLength(2); + expect(readMarker(xcodeprojPath)).not.toHaveProperty('buildSettingChanges'); + + removeSpmInjection({appRoot, xcodeprojPath}); + expect(pbxprojOf(xcodeprojPath)).toBe(PLAIN); + }); + + // The setting the user had before the promotion lacks $(inherited), which + // only shows once the promotion is reversed. + it('refuses when reversing a promotion leaves a setting without $(inherited)', () => { + const {appRoot, xcodeprojPath, rnRoot} = scaffoldLegacyApp( + [ + 'OTHER_LDFLAGS = (\n\t\t\t\t\t"$(inherited)",\n\t\t\t\t\t"-lc++",\n\t\t\t\t\t"-ObjC",\n\t\t\t\t);', + ], + {promotedArrayScalars: {OTHER_LDFLAGS: '"-lc++"'}}, + ); + const result = injectSpmIntoExistingXcodeproj({ + appRoot, + reactNativeRoot: rnRoot, + xcodeprojPath, + }); + expect(result.status).toBe('refused'); + expect(result.reason).toContain('OTHER_LDFLAGS (Debug, Release)'); + }); + + it('does not resurrect a promoted setting the user has since deleted', () => { + const {appRoot, xcodeprojPath} = scaffoldLegacyApp([], { + promotedArrayScalars: { + HEADER_SEARCH_PATHS: '"$(inherited) $(SRCROOT)/vendor/include"', + }, + }); + removeSpmInjection({appRoot, xcodeprojPath}); + expect(pbxprojOf(xcodeprojPath)).toBe(PLAIN); + }); }); // --------------------------------------------------------------------------- @@ -1325,10 +1756,9 @@ describe.each(Object.entries(PRE_EXISTING_HEADER_SEARCH_PATHS))( // findField's token for a BARE scalar ends AT the `;`, so it includes any // whitespace before it. Deinit must put those bytes back exactly, not a // tidied-up version of them. -describe.each([ - 'HEADER_SEARCH_PATHS = $(inherited) ; /* note */', - 'HEADER_SEARCH_PATHS = ;', -])('removeSpmInjection with the untrimmed scalar `%s`', field => { +describe('removeSpmInjection with an untrimmed scalar', () => { + const field = 'HEADER_SEARCH_PATHS = $(inherited) ; /* note */'; + it('restores it byte-for-byte', () => { const {appRoot, xcodeprojPath, rnRoot} = scaffoldApp(withSetting(field)); const before = pbxprojOf(xcodeprojPath); @@ -1345,12 +1775,10 @@ describe.each([ }); }); -describe('a scalar array setting injection has nothing to add to', () => { +describe("a list setting of the project's own that keeps $(inherited)", () => { const SCALAR = 'FRAMEWORK_SEARCH_PATHS = "$(inherited)";'; const EDITED = 'FRAMEWORK_SEARCH_PATHS = "$(inherited) $(SRCROOT)/Vendor";'; - // The fixture's flavored-frameworks manifest is empty, so - // FRAMEWORK_SEARCH_PATHS is injected with no values at all. it('is left untouched, unrecorded, and survives a later user edit', () => { const {appRoot, xcodeprojPath, rnRoot} = scaffoldApp(withSetting(SCALAR)); @@ -1363,11 +1791,7 @@ describe('a scalar array setting injection has nothing to add to', () => { const injected = pbxprojOf(xcodeprojPath); expect(injected).toContain(SCALAR); expect(injected).not.toMatch(/FRAMEWORK_SEARCH_PATHS = \(/); - for (const change of readMarker(xcodeprojPath).buildSettingChanges) { - expect(change.promotedArrayScalars ?? {}).not.toHaveProperty( - 'FRAMEWORK_SEARCH_PATHS', - ); - } + expect(markerTextOf(xcodeprojPath)).not.toContain('FRAMEWORK_SEARCH_PATHS'); fs.writeFileSync( path.join(xcodeprojPath, 'project.pbxproj'), @@ -1381,70 +1805,3 @@ describe('a scalar array setting injection has nothing to add to', () => { expect(after).not.toContain(SCALAR); }); }); - -describe('a promoted array setting the user deleted after add', () => { - const SCALAR = '"$(inherited) $(SRCROOT)/vendor/include"'; - - it('is not resurrected by deinit', () => { - const {appRoot, xcodeprojPath, rnRoot} = scaffoldApp( - withHeaderSearchPaths(SCALAR), - ); - const before = pbxprojOf(xcodeprojPath); - - injectSpmIntoExistingXcodeproj({ - appRoot, - reactNativeRoot: rnRoot, - xcodeprojPath, - }); - - const deleted = pbxprojOf(xcodeprojPath).replace( - /\n\t+HEADER_SEARCH_PATHS = \(\n[\s\S]*?\n\t+\);/g, - '', - ); - expect(deleted).not.toContain('HEADER_SEARCH_PATHS'); - fs.writeFileSync( - path.join(xcodeprojPath, 'project.pbxproj'), - deleted, - 'utf8', - ); - - removeSpmInjection({appRoot, xcodeprojPath}); - - // Everything else is back to its pre-injection bytes; only the setting the - // user deleted stays gone. - expect(pbxprojOf(xcodeprojPath)).toBe( - before.replaceAll(`\n\t\t\t\tHEADER_SEARCH_PATHS = ${SCALAR};`, ''), - ); - }); -}); - -// `deinit` removes appendedArrayValues before it restores promotedArrayScalars, -// so recording a key under both happens to come out right today: the scalar -// restore rewrites the whole value last. That makes the exclusivity below -// invisible to a round-trip test, which is why it is asserted on the marker -// directly — reversing those two loops would otherwise silently start removing -// array members from an already-restored scalar. -describe('a promoted scalar is recorded once, not twice', () => { - it('records promotedArrayScalars and not appendedArrayValues for the key', () => { - const {appRoot, xcodeprojPath, rnRoot} = scaffoldApp( - withHeaderSearchPaths('"$(inherited) $(SRCROOT)/vendor/include"'), - ); - - injectSpmIntoExistingXcodeproj({ - appRoot, - reactNativeRoot: rnRoot, - xcodeprojPath, - }); - - const changes = readMarker(xcodeprojPath).buildSettingChanges; - expect(changes.length).toBeGreaterThan(0); - for (const change of changes) { - expect(Object.keys(change.promotedArrayScalars ?? {})).toContain( - 'HEADER_SEARCH_PATHS', - ); - expect(Object.keys(change.appendedArrayValues ?? {})).not.toContain( - 'HEADER_SEARCH_PATHS', - ); - } - }); -}); diff --git a/packages/react-native/scripts/spm/__tests__/spm-pbxproj-test.js b/packages/react-native/scripts/spm/__tests__/spm-pbxproj-test.js index 755d808f7949..16db46f653ab 100644 --- a/packages/react-native/scripts/spm/__tests__/spm-pbxproj-test.js +++ b/packages/react-native/scripts/spm/__tests__/spm-pbxproj-test.js @@ -424,6 +424,43 @@ describe('ensureScalarField', () => { out, ); }); + + // Xcode writes an object's fields `isa` first, then alphabetically — landing + // anywhere else plants a diff the next time Xcode saves the project. + it('adds the field directly after `after` when that field exists', () => { + const config = findObjectByUuid(PLAIN_PBXPROJ, 'AA0000000000000000000901'); + const out = ensureScalarField( + PLAIN_PBXPROJ, + config, + 'baseConfigurationReference', + 'DD0000000000000000000101 /* App.xcconfig */', + {after: 'isa'}, + ); + expect(out).toContain( + 'AA0000000000000000000901 /* Debug */ = {\n' + + '\t\t\tisa = XCBuildConfiguration;\n' + + '\t\t\tbaseConfigurationReference = DD0000000000000000000101 /* App.xcconfig */;\n' + + '\t\t\tbuildSettings = {', + ); + expect( + removeField( + out, + findObjectByUuid(out, 'AA0000000000000000000901'), + 'baseConfigurationReference', + ), + ).toBe(PLAIN_PBXPROJ); + }); + + it('falls back to the top of the body when `after` is absent', () => { + const project = findProjectObject(PLAIN_PBXPROJ); + expect( + ensureScalarField(PLAIN_PBXPROJ, project, 'ORGANIZATIONNAME', 'Acme', { + after: 'NO_SUCH_FIELD', + }), + ).toBe( + ensureScalarField(PLAIN_PBXPROJ, project, 'ORGANIZATIONNAME', 'Acme'), + ); + }); }); describe('setScalarField', () => { diff --git a/packages/react-native/scripts/spm/generate-spm-xcodeproj.js b/packages/react-native/scripts/spm/generate-spm-xcodeproj.js index 3be29aa47f4a..fe1d021d663b 100644 --- a/packages/react-native/scripts/spm/generate-spm-xcodeproj.js +++ b/packages/react-native/scripts/spm/generate-spm-xcodeproj.js @@ -14,11 +14,12 @@ * generate-spm-xcodeproj.js – Surgical, in-place Swift Package Manager * integration toolkit for an existing `.xcodeproj`. * - * `injectSpmIntoExistingXcodeproj` adds the SPM package references, React build - * settings, the "Sync SPM Autolinking" build phase, and a scheme pre-action to - * a user's existing project — purely additively, recording every edit in a - * `.spm-injected.json` marker. `removeSpmInjection` is the exact inverse (used - * by `spm deinit`). Consumed as a library by setup-apple-spm.js; not a CLI. + * `injectSpmIntoExistingXcodeproj` adds the SPM package references, a generated + * `.xcconfig` carrying the React build settings, the "Sync SPM Autolinking" build + * phase, and a scheme pre-action to a user's existing project — purely + * additively, recording every edit in a `.spm-injected.json` marker. + * `removeSpmInjection` is the exact inverse (used by `spm deinit`). Consumed as a + * library by setup-apple-spm.js; not a CLI. */ const { @@ -30,7 +31,6 @@ const { const {parseConfigCommandJson} = require('./generate-spm-autolinking-config'); const { addArrayMembers, - addArrayStringValues, commentSafe, ensureScalarField, findApplicationTargets, @@ -61,6 +61,11 @@ const { makeLogger, remotePackageConfig, } = require('./spm-utils'); +const { + readXcconfig, + xcconfigIncludes, + xcconfigReferencePaths, +} = require('./xcconfig-files'); const fs = require('node:fs'); const path = require('node:path'); @@ -102,6 +107,23 @@ const SPM_SCRIPT_PHASES_MANIFEST = path.join( '.spm-plugin-script-phases.json', ); +// Everything that follows the installed React Native version and frameworks is +// written to these files on every `spm add`/`update`, so that a routine update +// leaves project.pbxproj alone — it only references them. Relative to the app +// root (== SRCROOT). Deliberately NOT under `build/`: that tree holds the SPM +// package roots (build/xcframeworks, build/generated/autolinking, +// build/generated/ios) and is gitignored + regenerated by the in-build "Sync +// SPM Autolinking" phase. These three files are read while Xcode resolves +// build settings and build-phase scripts — before any build phase has run — +// so unlike the package roots they cannot be lazily regenerated at build +// time. They must exist on a fresh clone, so they are committed like +// project.pbxproj itself. +const GENERATED_XCCONFIG_PATH = 'ReactNativeSPM/ReactNativeSPM.xcconfig'; +const SYNC_AUTOLINKING_SCRIPT_PATH = + 'ReactNativeSPM/Scripts/sync-autolinking.sh'; +const EMBED_FRAMEWORKS_SCRIPT_PATH = + 'ReactNativeSPM/Scripts/embed-flavored-frameworks.sh'; + // The single navigator group all injected generated sources are parented under // (created on first use). Its namespacedUUID id + display name. const SPM_GENERATED_SOURCES_GROUP_ID = 'SPMGeneratedSources'; @@ -141,7 +163,9 @@ const SPM_PRODUCT_PACKAGES /*: Array<{product: string, packagePath: string, pack /*:: type RemoteCfg = {url: string, version: string, identity: string}; // Precise record of the build-setting edits injection made to ONE build config, -// so deinit can reverse exactly those (and nothing the user already had). +// written by versions that put the React build settings into project.pbxproj +// directly. Now only read back, so the next sync or deinit can reverse exactly +// those edits (and nothing the user already had). type BuildSettingChange = { configUuid: string, createdArrayKeys: Array, @@ -160,6 +184,19 @@ type BuildSettingChange = { // An array field injection CREATED (rather than appended to a pre-existing // one), so deinit removes the whole field and lands byte-identical. type CreatedArrayField = {container: 'project' | 'target', key: string}; +// How the app configurations were based on the generated xcconfig. Deinit +// clears `baseConfigurationReference` only on these, and only while it still +// points at `fileRefUuid`. `includedByConfigUuids` keep a base of the user's +// own whose xcconfig #includes the generated one, so deinit keeps that file; +// markers written before it existed lack it. `removedPodsRootReactNativePath` +// is sticky and informational: deinit does not put that value back, because it +// resolves empty without CocoaPods. +type XcconfigRecord = { + fileRefUuid: string, + configUuids: Array, + includedByConfigUuids?: Array, + removedPodsRootReactNativePath: boolean, +}; // A plugin-contributed source, normalized for pbxproj emission. `path` is // SRCROOT-relative when under the app root, else absolute; `sourceTree` is the // matching pbxproj token ('SOURCE_ROOT' or '""'). @@ -359,6 +396,7 @@ function buildConfigurationName( return name.value.replace(/^"|"$/g, ''); } +/** The per-slice framework locations, as xcconfig keys and values. */ function frameworkConditionalSettings( frameworks /*: ReadonlyArray */, ) /*: Array<{key: string, value: string}> */ { @@ -384,17 +422,14 @@ function frameworkConditionalSettings( `${framework.artifactRelativePath}/${slice.libraryIdentifier}`; settings.push( { - key: quoteIfNeeded(`${prefix}_FRAMEWORK${condition}`), - value: quoteIfNeeded(`${root}/${slice.libraryPath}`), + key: `${prefix}_FRAMEWORK${condition}`, + value: `${root}/${slice.libraryPath}`, }, { - key: quoteIfNeeded(`${prefix}_BINARY${condition}`), - value: quoteIfNeeded(`${root}/${slice.binaryPath}`), - }, - { - key: quoteIfNeeded(`${prefix}_SEARCH_PATH${condition}`), - value: quoteIfNeeded(root), + key: `${prefix}_BINARY${condition}`, + value: `${root}/${slice.binaryPath}`, }, + {key: `${prefix}_SEARCH_PATH${condition}`, value: root}, ); }; if (slices.length === 1) { @@ -418,14 +453,16 @@ function frameworkConditionalSettings( return settings; } -function frameworkArrayBuildSettings( +// List-valued settings, in xcconfig syntax: quotes group a value whose expansion +// may contain spaces into one list element. +function frameworkListSettings( frameworks /*: ReadonlyArray */, ) /*: Array<{key: string, values: Array}> */ { return [ { key: 'OTHER_LDFLAGS', values: [ - '"-ObjC"', + '-ObjC', ...frameworks.map( framework => `"$(${frameworkSettingPrefix(framework.id)}_BINARY)"`, ), @@ -439,11 +476,33 @@ function frameworkArrayBuildSettings( }, { key: 'LD_RUNPATH_SEARCH_PATHS', - values: ['"@executable_path/Frameworks"'], + values: ['@executable_path/Frameworks'], }, ]; } +function generatedScript(body /*: string */) /*: string */ { + return `#!/bin/bash +# Generated by React Native on every \`spm add\` and \`spm update\`. +# Do not edit: changes are overwritten. + +${body}`; +} + +// What a build phase runs in place of its generated script, which lives in a +// file so that the phase itself never changes. The file is committed (see +// GENERATED_XCCONFIG_PATH's comment) so a fresh clone always has it; the +// guard below is for the rarer case of a checkout that predates this +// mechanism, or the file having been deleted by hand. +function generatedScriptWrapper(scriptPath /*: string */) /*: string */ { + return `SCRIPT="$SRCROOT/${scriptPath}" +if [ ! -f "$SCRIPT" ]; then + echo "error: $SCRIPT is missing. Run 'npx react-native spm update' to generate it." + exit 1 +fi +exec /bin/bash "$SCRIPT"`; +} + function pbxPathList(paths /*: ReadonlyArray */) /*: string */ { if (paths.length === 0) { return '(\n\t\t\t)'; @@ -1108,24 +1167,25 @@ function ensureStubPackages(appRoot /*: string */) /*: void */ { // In-place injection: add SPM packages to a user's EXISTING xcodeproj. // // This never creates a target or scans sources — it splices the SPM dependency -// graph, the React build settings, and the sync build phase / scheme pre-action -// into the project the user already owns, leaving everything else +// graph, a reference to the generated xcconfig carrying the React build +// settings, and the sync build phase / scheme pre-action into the project the +// user already owns, leaving everything else // byte-identical. The whole `spm add` / `spm update` xcodeproj strategy, so // hand-tuned signing / capabilities / extra targets survive. Fails loud (the // caller surfaces the error) when the project is CocoaPods-integrated or its // shape can't be safely anchored. // --------------------------------------------------------------------------- -// The React build settings the app target needs to compile against the SPM -// products. -const INJECTED_ARRAY_SETTINGS = [ +// The React list settings the app target needs to compile against the SPM +// products, in xcconfig syntax. +const REACT_LIST_SETTINGS = [ { key: 'HEADER_SEARCH_PATHS', values: ['"$(SRCROOT)/build/generated/autolinking/headers"'], }, ]; -// Array build settings injected only into debug-flavored configurations. +// List settings given only to debug-flavored configurations. // // Swift's `#if DEBUG` is gated by SWIFT_ACTIVE_COMPILATION_CONDITIONS, NOT by // GCC_PREPROCESSOR_DEFINITIONS (which only reaches C/ObjC/C++). The app @@ -1140,10 +1200,181 @@ const INJECTED_ARRAY_SETTINGS = [ // // Paired with RN_SPM_FLAVOR via flavorForBuildConfiguration, so a config that // links the debug xcframeworks also compiles its Swift with DEBUG. -const DEBUG_ARRAY_SETTINGS = [ +const DEBUG_LIST_SETTINGS = [ {key: 'SWIFT_ACTIVE_COMPILATION_CONDITIONS', values: ['DEBUG']}, ]; +/** + * The generated xcconfig every app configuration in `configNames` is based on: + * all of the React build settings, shared by every configuration, with the + * flavor-specific ones conditioned on each configuration's own name. List + * settings extend `$(inherited)`, the project-level value; a target's own + * value still outranks all of them (see listSettingsHidingXcconfig). + */ +function generateReactNativeXcconfig( + configNames /*: ReadonlyArray */, + reactNativePathFromSrcRoot /*: string */, + flavoredFrameworks /*: ReadonlyArray */, +) /*: string */ { + const listLine = (key /*: string */, values /*: ReadonlyArray */) => + `${key} = ${['$(inherited)', ...values].join(' ')}`; + const sections /*: Array> */ = [ + [ + '// Generated by React Native on every `spm add` and `spm update`.', + '// Do not edit: changes are overwritten.', + ], + [ + 'CLANG_CXX_LANGUAGE_STANDARD = c++20', + `REACT_NATIVE_PATH = ${['$(SRCROOT)', reactNativePathFromSrcRoot].filter(Boolean).join('/')}`, + ], + [...REACT_LIST_SETTINGS, ...frameworkListSettings(flavoredFrameworks)] + .filter(({values}) => values.length > 0) + .map(({key, values}) => listLine(key, values)), + [ + // For a configuration added since the last sync (duplicated in Xcode, + // which copies the base reference): flavorForBuildConfiguration's own + // fallback, rather than an unset flavor that empties every framework + // path. + 'RN_SPM_FLAVOR = release', + ...configNames.flatMap(name => { + const flavor = flavorForBuildConfiguration(name); + return [ + `RN_SPM_FLAVOR[config=${name}] = ${flavor}`, + ...(flavor === 'debug' ? DEBUG_LIST_SETTINGS : []).map( + ({key, values}) => listLine(`${key}[config=${name}]`, values), + ), + ]; + }), + ], + frameworkConditionalSettings(flavoredFrameworks).map( + ({key, value}) => `${key} = ${value}`, + ), + ]; + return ( + sections + .filter(lines => lines.length > 0) + .map(lines => lines.join('\n')) + .join('\n\n') + '\n' + ); +} + +function xcconfigFileRefUuid(rootUuid /*: string */) /*: string */ { + return namespacedUUID(rootUuid, 'PBXFileReference', GENERATED_XCCONFIG_PATH); +} + +/** A build configuration's `buildSettings` dict, as a body range. */ +function buildSettingsRange( + text /*: string */, + configUuid /*: string */, +) /*: ?{bodyOpen: number, bodyClose: number} */ { + const config = findObjectByUuid(text, configUuid); + const settings = + config != null ? findField(text, config, 'buildSettings') : null; + return settings != null + ? {bodyOpen: settings.valueStart, bodyClose: settings.tokenEnd - 1} + : null; +} + +/** + * The list settings the generated xcconfig sets that a configuration of the + * target sets itself without `$(inherited)` — setting → configuration names. A + * target's own value outranks the xcconfig it is based on, so each of these + * would silently drop the value React Native provides. + */ +function listSettingsHidingXcconfig( + text /*: string */, + configUuids /*: ReadonlyArray */, +) /*: Map> */ { + const hiding /*: Map> */ = new Map(); + for (const configUuid of configUuids) { + const name = buildConfigurationName(text, configUuid); + const settings = buildSettingsRange(text, configUuid); + if (settings == null) { + continue; + } + const keys = [ + ...REACT_LIST_SETTINGS, + ...frameworkListSettings([]), + ...(flavorForBuildConfiguration(name) === 'debug' + ? DEBUG_LIST_SETTINGS + : []), + ].map(({key}) => key); + for (const key of keys) { + const field = findField(text, settings, key); + if (field != null && !/\$[({]inherited[)}]/.test(field.value)) { + hiding.set(key, [...(hiding.get(key) ?? []), name]); + } + } + } + return hiding; +} + +// Xcode writes a PBXFileReference on one line, which findField can't read. +function fileReferenceField( + text /*: string */, + ref /*: {bodyOpen: number, bodyClose: number, ...} */, + key /*: string */, +) /*: ?string */ { + const value = new RegExp( + `[\\s{;]${key} = ("(?:[^"\\\\]|\\\\.)*"|[^;]*);`, + ).exec(text.slice(ref.bodyOpen, ref.bodyClose))?.[1]; + return value != null ? unquotePlist(value) : null; +} + +/** + * The app configurations based on an xcconfig other than the generated one — + * React Native never edits an xcconfig it does not own, so it cannot base + * those configurations on its own. `file` is the reference's path, and + * `absPath`/`content` the file on disk when it can be read. + */ +function foreignBaseConfigurations( + text /*: string */, + configUuids /*: ReadonlyArray */, + ownFileRefUuid /*: string */, + srcRoot /*: ?string */, +) /*: Array<{configUuid: string, config: string, file: string, onDisk: ?{absPath: string, content: string}}> */ { + const foreign = []; + for (const configUuid of configUuids) { + const config = findObjectByUuid(text, configUuid); + const base = + config != null + ? findField(text, config, 'baseConfigurationReference') + : null; + const refUuid = base?.value.match(/[0-9A-Fa-f]{24}/)?.[0]; + if (refUuid == null || refUuid === ownFileRefUuid) { + continue; + } + const ref = findObjectByUuid(text, refUuid); + const refPath = ref != null ? fileReferenceField(text, ref, 'path') : null; + let onDisk /*: ?{absPath: string, content: string} */ = null; + if (ref != null && refPath != null && srcRoot != null) { + const candidates = xcconfigReferencePaths( + text, + { + uuid: refUuid, + path: refPath, + sourceTree: fileReferenceField(text, ref, 'sourceTree') ?? '', + }, + srcRoot, + ); + for (const absPath of candidates) { + const content = readXcconfig(absPath); + if (content != null) { + onDisk = {absPath, content}; + break; + } + } + } + foreign.push({ + configUuid, + config: buildConfigurationName(text, configUuid), + file: refPath ?? refUuid, + onDisk, + }); + } + return foreign; +} + /** The XCBuildConfiguration UUIDs of a target (via its buildConfigurationList). */ function targetBuildConfigUuids( text /*: string */, @@ -1187,8 +1418,11 @@ function configUsesPods( * the chosen app target + its config/frameworks anchors, or a refusal reason * the caller surfaces (fail-loud). */ -function planInjection(text /*: string */, opts /*: {appName?: ?string} */) /*: - | {ok: true, rootUuid: string, target: {uuid: string, name: string, bodyOpen: number, bodyClose: number}, configUuids: Array, frameworksPhaseUuid: string, sourcesPhaseUuid: ?string} +function planInjection( + text /*: string */, + opts /*: {appName?: ?string, appRoot?: ?string, srcRoot?: ?string} */, +) /*: + | {ok: true, rootUuid: string, target: {uuid: string, name: string, bodyOpen: number, bodyClose: number}, configUuids: Array, includedByConfigUuids: Array, frameworksPhaseUuid: string, sourcesPhaseUuid: ?string} | {ok: false, reason: string} */ { const project = findProjectObject(text); if (project == null) { @@ -1231,6 +1465,55 @@ function planInjection(text /*: string */, opts /*: {appName?: ?string} */) /*: 'supports SPM-only targets', }; } + const generatedXcconfig = + opts.appRoot != null + ? path.resolve(opts.appRoot, GENERATED_XCCONFIG_PATH) + : null; + const includedByConfigUuids /*: Array */ = []; + for (const foreign of foreignBaseConfigurations( + text, + configUuids, + xcconfigFileRefUuid(project.uuid), + opts.srcRoot, + )) { + const {onDisk} = foreign; + if ( + onDisk != null && + generatedXcconfig != null && + xcconfigIncludes(onDisk.absPath, onDisk.content, generatedXcconfig) + ) { + includedByConfigUuids.push(foreign.configUuid); + continue; + } + const fix = + onDisk != null && generatedXcconfig != null + ? `add #include "${path + .relative(path.dirname(onDisk.absPath), generatedXcconfig) + .split(path.sep) + .join('/')}" to ${foreign.file} instead` + : `#include ${GENERATED_XCCONFIG_PATH} from ${foreign.file} instead, ` + + `with the path relative to the folder that holds ${foreign.file}`; + return { + ok: false, + reason: + `the ${foreign.config} configuration is already based on ` + + `${foreign.file}, and React Native does not edit xcconfig files it ` + + `does not own — ${fix}`, + }; + } + const hiding = listSettingsHidingXcconfig(text, configUuids); + if (hiding.size > 0) { + const listed = [...hiding] + .map(([key, configs]) => `${key} (${configs.join(', ')})`) + .join(', '); + return { + ok: false, + reason: + `the target sets ${listed} without $(inherited), which would hide ` + + `the values React Native provides in ${GENERATED_XCCONFIG_PATH} — ` + + 'add $(inherited) to each and re-run', + }; + } // The target's own Frameworks build phase (where product build files link). const buildPhases = findField(text, target, 'buildPhases'); const phaseUuids = @@ -1271,26 +1554,29 @@ function planInjection(text /*: string */, opts /*: {appName?: ?string} */) /*: rootUuid: project.uuid, target, configUuids, + includedByConfigUuids, frameworksPhaseUuid, sourcesPhaseUuid, }; } /** - * Splice the SPM dependency graph + React build settings + sync build phase - * into `text` and return the modified pbxproj. Pure string transform (no I/O), - * idempotent: objects already present (by UUID) and array members / settings - * already applied are skipped, so a second run is a no-op. + * Splice the SPM dependency graph, the generated xcconfig reference and the + * build phases into `text`, returning the modified pbxproj together with the + * files it references (`generatedFiles`, app-root-relative path → content) for + * the caller to write. Pure string transform (no I/O), idempotent: objects + * already present (by UUID) and array members / fields already applied are + * skipped, so a second run is a no-op. */ function injectSpmIntoPbxproj( input /*: string */, - plan /*: {rootUuid: string, targetUuid: string, configUuids: Array, frameworksPhaseUuid: string, sourcesPhaseUuid?: ?string} */, - reactNativePath /*: string */, + plan /*: {rootUuid: string, targetUuid: string, configUuids: Array, includedByConfigUuids?: ?ReadonlyArray, frameworksPhaseUuid: string, sourcesPhaseUuid?: ?string} */, + reactNativePaths /*: {fromAppRoot: string, fromSrcRoot: string} */, remote /*: ?RemoteCfg */, generatedSources /*: ReadonlyArray */ = [], flavoredFrameworks /*: ReadonlyArray */ = [], scriptPhases /*: ReadonlyArray */ = [], -) /*: {text: string, injectedUuids: Array, createdArrayFields: Array, buildSettingChanges: Array, generatedSourceUuids: {[string]: Array}, scriptPhaseUuids: {[string]: string}} */ { +) /*: {text: string, injectedUuids: Array, createdArrayFields: Array, xcconfig: XcconfigRecord, generatedFiles: {[string]: string}, generatedSourceUuids: {[string]: Array}, scriptPhaseUuids: {[string]: string}} */ { let text = input; const mkUuid = (section /*: string */, id /*: string */) => namespacedUUID(plan.rootUuid, section, id); @@ -1378,30 +1664,81 @@ function injectSpmIntoPbxproj( ); } - // 5. React build settings into every build config (Debug + Release). - const buildSettingChanges /*: Array */ = []; + // 5. Every app configuration is based on the generated xcconfig, which + // carries all React build settings — except one whose own xcconfig + // already #includes it. planInjection refused any other base + // configuration, so an existing one here is already ours. + const xcconfigName = path.basename(GENERATED_XCCONFIG_PATH); + const xcconfigRefUuid = xcconfigFileRefUuid(plan.rootUuid); + insertObjects('PBXFileReference', [ + { + uuid: xcconfigRefUuid, + comment: xcconfigName, + fields: { + isa: 'PBXFileReference', + lastKnownFileType: 'text.xcconfig', + name: xcconfigName, + path: GENERATED_XCCONFIG_PATH, + sourceTree: 'SOURCE_ROOT', + }, + }, + ]); + const xcconfigGroup = mainGroupObject(text); + if (xcconfigGroup != null) { + text = addArrayMembers(text, xcconfigGroup, 'children', [ + {uuid: xcconfigRefUuid, comment: xcconfigName}, + ]); + } + const includedBy = new Set(plan.includedByConfigUuids ?? []); + let removedPodsRootReactNativePath = false; for (const configUuid of plan.configUuids) { - const merged = mergeReactBuildSettings( - text, - configUuid, - buildConfigurationName(text, configUuid), - reactNativePath, - flavoredFrameworks, - ); - text = merged.text; - buildSettingChanges.push(merged.change); + const config = findObjectByUuid(text, configUuid); + if (config != null && !includedBy.has(configUuid)) { + text = ensureScalarField( + text, + config, + 'baseConfigurationReference', + `${xcconfigRefUuid}${uuidComment(xcconfigName)}`, + {after: 'isa'}, + ); + } + // The CocoaPods template's REACT_NATIVE_PATH is ${PODS_ROOT}-anchored, which + // resolves empty once CocoaPods is gone — and a target's own value outranks + // the xcconfig's, so it has to go for the working one to apply. + const settings = buildSettingsRange(text, configUuid); + if ( + settings != null && + findField(text, settings, 'REACT_NATIVE_PATH')?.value.includes( + 'PODS_ROOT', + ) === true + ) { + text = removeField(text, settings, 'REACT_NATIVE_PATH'); + removedPodsRootReactNativePath = true; + } } + const generatedFiles /*: {[string]: string} */ = { + [GENERATED_XCCONFIG_PATH]: generateReactNativeXcconfig( + plan.configUuids.map(uuid => buildConfigurationName(text, uuid)), + reactNativePaths.fromSrcRoot, + flavoredFrameworks, + ), + [SYNC_AUTOLINKING_SCRIPT_PATH]: generatedScript( + buildSyncAutolinkingScript(reactNativePaths.fromAppRoot), + ), + [EMBED_FRAMEWORKS_SCRIPT_PATH]: generatedScript( + buildEmbedFrameworksScript(flavoredFrameworks), + ), + }; // 6. The Sync SPM Autolinking build phase (safety net; the scheme pre-action // is what fires before SPM resolution). Prepended so it runs before // Sources. We do NOT add a JS-bundle phase — an existing app already // bundles JS via its own phase. - const syncScript = buildSyncAutolinkingScript(reactNativePath); const syncPhaseUuid = mkUuid('PBXShellScriptBuildPhase', 'SyncAutolinking'); const syncEntry = shellScriptPhase( syncPhaseUuid, 'Sync SPM Autolinking', - syncScript, + generatedScriptWrapper(SYNC_AUTOLINKING_SCRIPT_PATH), ); if (!text.includes(syncPhaseUuid)) { text = insertObjectsIntoSection( @@ -1411,11 +1748,11 @@ function injectSpmIntoPbxproj( ); } else { // Already injected on a prior run — the phase object owns these fields, - // so refresh them in place (same quoting used at creation) in case the - // generated script changed, or a project injected before shellPath moved - // to bash (see shellScriptPhase) still has the stale value. Byte-identical - // when neither did; field order and every other byte of the phase are - // untouched. + // so refresh them in place (same quoting used at creation) in case a + // project injected before the script moved to its own file still carries + // it inline, or one injected before shellPath moved to bash (see + // shellScriptPhase) still has the stale value. Byte-identical when neither + // did; field order and every other byte of the phase are untouched. for (const key of ['shellScript', 'shellPath']) { const current = findObjectByUuid(text, syncPhaseUuid); if (current != null) { @@ -1439,9 +1776,9 @@ function injectSpmIntoPbxproj( 'PBXShellScriptBuildPhase', 'EmbedFlavoredFrameworks', ); - const embedScript = buildEmbedFrameworksScript(flavoredFrameworks); const embedInputs = [ '$(SRCROOT)/build/xcframeworks/.artifact-stamp', + `$(SRCROOT)/${EMBED_FRAMEWORKS_SCRIPT_PATH}`, ...flavoredFrameworks.map( framework => `$(${frameworkSettingPrefix(framework.id)}_FRAMEWORK)`, ), @@ -1453,7 +1790,7 @@ function injectSpmIntoPbxproj( const embedEntry = shellScriptPhase( embedPhaseUuid, 'Embed React Native Flavored Frameworks', - embedScript, + generatedScriptWrapper(EMBED_FRAMEWORKS_SCRIPT_PATH), { inputPaths: pbxPathList(embedInputs), outputPaths: pbxPathList(embedOutputs), @@ -1586,17 +1923,7 @@ function injectSpmIntoPbxproj( // to a pre-existing children array, so no createdArrayField is recorded — // deinit removes the group's membership via removeArrayMembersByUuid and // the group object itself via removeObjectByUuid (groupUuid is injected). - const proj = findProjectObject(text); - const mainGroupField = - proj != null ? findField(text, proj, 'mainGroup') : null; - const mainGroupMatch = - mainGroupField != null - ? mainGroupField.value.match(/[0-9A-Fa-f]{24}/) - : null; - const mainGroupObj = - mainGroupMatch != null - ? findObjectByUuid(text, mainGroupMatch[0]) - : null; + const mainGroupObj = mainGroupObject(text); if (mainGroupObj != null) { text = addArrayMembers(text, mainGroupObj, 'children', [ {uuid: groupUuid, comment: SPM_GENERATED_SOURCES_GROUP_NAME}, @@ -1684,12 +2011,28 @@ function injectSpmIntoPbxproj( text, injectedUuids, createdArrayFields, - buildSettingChanges, + xcconfig: { + fileRefUuid: xcconfigRefUuid, + configUuids: plan.configUuids.filter(uuid => !includedBy.has(uuid)), + includedByConfigUuids: [...includedBy], + removedPodsRootReactNativePath, + }, + generatedFiles, generatedSourceUuids, scriptPhaseUuids, }; } +function mainGroupObject( + text /*: string */, +) /*: ?{uuid: string, bodyOpen: number, bodyClose: number} */ { + const project = findProjectObject(text); + const mainGroup = + project != null ? findField(text, project, 'mainGroup') : null; + const uuid = mainGroup?.value.match(/[0-9A-Fa-f]{24}/)?.[0]; + return uuid != null ? findObjectByUuid(text, uuid) : null; +} + /** Re-locate an application target by UUID against the current text. */ function findApplicationTargetByUuid( text /*: string */, @@ -1702,180 +2045,11 @@ function findApplicationTargetByUuid( return obj; } -/** Strip the surrounding plist quotes from a build-setting token, if any. */ +/** Strip the surrounding plist quotes from a token, if any. */ function unquotePlist(s /*: string */) /*: string */ { return s.replace(/^"/, '').replace(/"$/, ''); } -/** - * The individual values a build setting already carries, unquoted — for both - * shapes a pbxproj uses: the array form Xcode writes for a multi-value setting - * (`("$(inherited)", DEBUG)`) and the scalar form the app template and - * hand-edits use (`"$(inherited) DEBUG"`). Membership, not substring: the - * latter would read `MY_DEBUG_FLAG` as `DEBUG` already being set and silently - * skip the injection. - */ -function buildSettingValueTokens(value /*: string */) /*: Set */ { - return new Set( - value - .split(/[\s,()]+/) - .filter(Boolean) - .map(unquotePlist), - ); -} - -/** - * 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 */, - 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)}, - ]; - // Re-locate the buildSettings dict before each edit (offsets shift). - const dict = () => { - const cfg = findObjectByUuid(text, configUuid); - if (cfg == null) { - return null; - } - const bs = findField(text, cfg, 'buildSettings'); - if (bs == null) { - return null; - } - return { - uuid: configUuid, - bodyOpen: bs.valueStart, - bodyClose: bs.tokenEnd - 1, - }; - }; - const createdArrayKeys /*: Array */ = []; - const appendedArrayValues /*: {[string]: Array} */ = {}; - const promotedArrayScalars /*: {[string]: string} */ = {}; - const createdScalars /*: Array */ = []; - const arraySettings = [ - ...INJECTED_ARRAY_SETTINGS, - ...(flavorForBuildConfiguration(configurationName) === 'debug' - ? DEBUG_ARRAY_SETTINGS - : []), - ...frameworkArrayBuildSettings(flavoredFrameworks), - ]; - for (const {key, values} of arraySettings) { - const d = dict(); - if (d == null) { - continue; - } - const existing = findField(text, d, key); - // Non-null only for a scalar addArrayStringValues would promote to an array - // (same array-vs-scalar test it uses). Kept RAW: findField's token for a - // bare scalar ends at the `;`, so it carries any whitespace before it, and - // deinit has to write those bytes back verbatim. - const priorScalar = - existing != null && !existing.value.trimStart().startsWith('(') - ? existing.value - : null; - if (existing == null) { - createdArrayKeys.push(key); - } else { - const present = buildSettingValueTokens(existing.value); - const fresh = values.filter(v => !present.has(unquotePlist(v))); - if (fresh.length === 0) { - // Nothing to add. Skip addArrayStringValues entirely: its dedupe is by - // EXACT array member, so a value the user carries in the scalar form - // (`SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited) DEBUG"`) would - // otherwise be promoted to an array and re-appended — an edit `deinit` - // has no record of and so could never reverse. - continue; - } - if (priorScalar == null) { - appendedArrayValues[key] = fresh; - } - } - const beforeAdd = text; - text = addArrayStringValues(text, d, key, values); - // Record only a promotion that actually happened: addArrayStringValues - // no-ops when `values` is empty or every value is already a member, and a - // recorded-but-untouched field would have deinit clobber whatever the user - // has there by then. Restoring the scalar subsumes removing the injected - // members, so the two records stay mutually exclusive per key. - if (priorScalar != null && text !== beforeAdd) { - promotedArrayScalars[key] = priorScalar; - } - } - const replacedScalars /*: {[string]: string} */ = {}; - for (const {key, value} of scalars) { - const d = dict(); - if (d == null) { - continue; - } - const existing = findField(text, d, key); - if (existing == null) { - createdScalars.push(key); - } else if ( - key === 'REACT_NATIVE_PATH' && - existing.value.includes('PODS_ROOT') - ) { - // A ${PODS_ROOT}-anchored REACT_NATIVE_PATH (the CocoaPods template - // default) dangles once CocoaPods is deintegrated: PODS_ROOT resolves - // empty at build time, so the Bundle React Native code and images - // phase looks for "/../…/scripts/xcode/with-environment.sh". Replace - // it with the SPM-computed path, recording the original for deinit. - replacedScalars[key] = existing.value; - text = removeField(text, d, key); - const d2 = dict(); - if (d2 == null) { - continue; - } - text = ensureScalarField(text, d2, key, value); - continue; - } - text = ensureScalarField(text, d, key, value); - } - const ownedScalars = [ - { - key: 'RN_SPM_FLAVOR', - value: flavorForBuildConfiguration(configurationName), - }, - ...frameworkConditionalSettings(flavoredFrameworks), - ]; - for (const {key, value} of ownedScalars) { - const d = dict(); - if (d == null) { - continue; - } - const existing = findField(text, d, key); - if (existing == null) { - createdScalars.push(key); - } else if (existing.value !== value) { - replacedScalars[key] = existing.value; - } - text = setScalarField(text, d, key, value); - } - return { - text, - change: { - configUuid, - createdArrayKeys, - appendedArrayValues, - createdScalars, - replacedScalars, - ...(Object.keys(promotedArrayScalars).length > 0 - ? {promotedArrayScalars} - : {}), - }, - }; -} - // Write only when content changed (avoids spurious Xcode reloads / git churn). function writeIfChanged( filePath /*: string */, @@ -2256,7 +2430,7 @@ function readScriptPhasesManifest( */ function readMarker( xcodeprojPath /*: string */, -) /*: ?{targetUuid?: ?string, generatedSources?: {[string]: Array}, scriptPhases?: {[string]: string}, linkedFrameworks?: Array<{id: string, frameworkName: string}>, artifactsVersionOverride?: ?string, configCommand?: ?Array, buildSettingChanges?: Array, createdArrayFields?: Array, scheme?: {file?: ?string, created?: ?boolean}, ...} */ { +) /*: ?{targetUuid?: ?string, generatedSources?: {[string]: Array}, scriptPhases?: {[string]: string}, linkedFrameworks?: Array<{id: string, frameworkName: string}>, artifactsVersionOverride?: ?string, configCommand?: ?Array, buildSettingChanges?: Array, xcconfig?: XcconfigRecord, createdArrayFields?: Array, scheme?: {file?: ?string, created?: ?boolean}, ...} */ { const markerPath = path.join(xcodeprojPath, SPM_INJECTED_MARKER); try { // $FlowFixMe[incompatible-return] JSON.parse returns any @@ -2541,6 +2715,32 @@ function mergeCreatedArrayFields( return merged; } +// The react-native dir the app reaches through `node_modules/react-native`, in +// the nearest ancestor of `appRoot` where that link resolves to +// `reactNativeRoot`. Config commands report the realpath, which under pnpm is a +// version- and hash-named store dir that must not end up in committed files. +function linkedReactNativeRoot( + appRoot /*: string */, + reactNativeRoot /*: string */, +) /*: string */ { + try { + const target = fs.realpathSync(reactNativeRoot); + let dir = path.resolve(appRoot); + while (true) { + const candidate = path.join(dir, 'node_modules', 'react-native'); + if (fs.existsSync(candidate) && fs.realpathSync(candidate) === target) { + return candidate; + } + const parent = path.dirname(dir); + if (parent === dir) { + break; + } + dir = parent; + } + } catch {} + return reactNativeRoot; +} + /** * Add SPM packages to a user's EXISTING xcodeproj in place. Returns * {status: 'injected', target} on success, or {status: 'refused', reason} @@ -2558,18 +2758,33 @@ function injectSpmIntoExistingXcodeproj( }; } const original = fs.readFileSync(pbxprojPath, 'utf8'); - const plan = planInjection(original, {appName: opts.appName}); + const prevMarker = readMarker(xcodeprojPath); + // A project injected before the React build settings moved to the generated + // xcconfig carries them in project.pbxproj. Reversing that record first moves + // it over, and has to precede planning: a setting the user had without + // $(inherited) only shows again once the promotion that added it is undone. + const withoutRecordedSettings = removeRecordedBuildSettings( + original, + prevMarker?.buildSettingChanges ?? [], + ); + const plan = planInjection(withoutRecordedSettings, { + appName: opts.appName, + appRoot, + srcRoot: path.dirname(xcodeprojPath), + }); if (!plan.ok) { return {status: 'refused', reason: plan.reason}; } - const reactNativePath = path.relative(appRoot, reactNativeRoot); + const reactNativeDir = linkedReactNativeRoot(appRoot, reactNativeRoot); + const reactNativePaths = { + fromAppRoot: path.relative(appRoot, reactNativeDir), + fromSrcRoot: path.relative(path.dirname(xcodeprojPath), reactNativeDir), + }; const remote = remotePackageConfig(appRoot); const generatedSources = readGeneratedSourcesManifest(appRoot); const scriptPhases = readScriptPhasesManifest(appRoot); const flavoredFrameworks = readFlavoredFrameworksManifest(appRoot).frameworks; - const prevMarker = readMarker(xcodeprojPath); - // Reconcile generated sources injected on a PRIOR run that are no longer in // the manifest (a plugin's entry was dropped, or the plugin was removed). // Diff the marker's `generatedSources` map against the current manifest and @@ -2604,13 +2819,7 @@ function injectSpmIntoExistingXcodeproj( staleUuids.push(prevScriptPhases[id]); } } - // Re-apply generated settings from a clean recorded baseline. This removes - // linker entries for plugin frameworks that disappeared and keeps the new - // marker a complete inverse after an idempotent update. - let base = removeRecordedBuildSettings( - original, - prevMarker?.buildSettingChanges ?? [], - ); + let base = withoutRecordedSettings; if (staleUuids.length > 0) { base = removeArrayMembersByUuid(base, staleUuids); for (const u of staleUuids) { @@ -2622,7 +2831,8 @@ function injectSpmIntoExistingXcodeproj( text, injectedUuids, createdArrayFields, - buildSettingChanges, + xcconfig, + generatedFiles, generatedSourceUuids, scriptPhaseUuids, } = injectSpmIntoPbxproj( @@ -2631,16 +2841,21 @@ function injectSpmIntoExistingXcodeproj( rootUuid: plan.rootUuid, targetUuid: plan.target.uuid, configUuids: plan.configUuids, + includedByConfigUuids: plan.includedByConfigUuids, frameworksPhaseUuid: plan.frameworksPhaseUuid, sourcesPhaseUuid: plan.sourcesPhaseUuid, }, - reactNativePath, + reactNativePaths, remote, generatedSources, flavoredFrameworks, scriptPhases, ); + // Before the project, so an open Xcode that reloads it finds them in place. + for (const [relPath, content] of Object.entries(generatedFiles)) { + writeIfChanged(path.join(appRoot, relPath), content); + } const changed = writeIfChanged(pbxprojPath, text); log( changed @@ -2655,7 +2870,7 @@ function injectSpmIntoExistingXcodeproj( projName, // The scheme pre-action is SYNC-ONLY (no flavor swap). A pre-action swap // could win its race and mask a mismatch from the in-target detector. - syncScript: buildSchemePreActionScript(reactNativePath), + syncScript: buildSchemePreActionScript(reactNativePaths.fromAppRoot), }); log(`Scheme sync pre-action: ${schemeResult.status}`); @@ -2698,7 +2913,13 @@ function injectSpmIntoExistingXcodeproj( prevMarker?.createdArrayFields ?? [], createdArrayFields, ), - buildSettingChanges, + xcconfig: { + ...xcconfig, + // Sticky: the next sync no longer sees the value this one removed. + removedPodsRootReactNativePath: + xcconfig.removedPodsRootReactNativePath || + prevMarker?.xcconfig?.removedPodsRootReactNativePath === true, + }, // Normalized path → [fileRefUuid, buildFileUuid]. Read back on the next // `update` to reconcile away entries that left the manifest. generatedSources: generatedSourceUuids, @@ -2870,6 +3091,36 @@ function removeRecordedBuildSettings( return text; } +/** + * The configurations, by name, that may #include the generated xcconfig once + * deinit has un-based its own: those based on any other xcconfig, and those + * recorded as including it. + */ +function configsThatMayInclude( + text /*: string */, + targetUuid /*: string */, + xcconfig /*: XcconfigRecord */, +) /*: Array */ { + const target = findObjectByUuid(text, targetUuid); + const rebased = ( + target != null ? targetBuildConfigUuids(text, target) : [] + ).filter(uuid => { + const config = findObjectByUuid(text, uuid); + const base = + config != null + ? findField(text, config, 'baseConfigurationReference') + : null; + return base != null && !base.value.includes(xcconfig.fileRefUuid); + }); + return [ + ...new Set([...rebased, ...(xcconfig.includedByConfigUuids ?? [])]), + ].map(uuid => + findObjectByUuid(text, uuid) != null + ? buildConfigurationName(text, uuid) + : uuid, + ); +} + /** * The exact inverse of `add` (injectSpmIntoExistingXcodeproj): using the * `.spm-injected.json` marker's precise record of every edit, remove only what @@ -2923,10 +3174,52 @@ function removeSpmInjection( '', ); - // 2. Reverse the per-config build-setting edits (only what we added). + // 2. Un-base the configurations still based on the generated xcconfig, and + // delete it only then, and only while no configuration is based on an + // xcconfig of the user's own — that file may #include it, as the ones + // recorded in `includedByConfigUuids` do. A ${PODS_ROOT}-anchored + // REACT_NATIVE_PATH injection removed is deliberately not put back: it + // resolves empty without CocoaPods. + const xcconfig /*: ?XcconfigRecord */ = marker.xcconfig; + let ownsXcconfig = false; + if (xcconfig != null) { + for (const configUuid of xcconfig.configUuids) { + const config = findObjectByUuid(text, configUuid); + if ( + config != null && + findField(text, config, 'baseConfigurationReference')?.value.includes( + xcconfig.fileRefUuid, + ) === true + ) { + text = removeField(text, config, 'baseConfigurationReference'); + ownsXcconfig = true; + } + } + } + const includingConfigs = + xcconfig != null + ? configsThatMayInclude(text, marker.targetUuid, xcconfig) + : []; + if (includingConfigs.length > 0) { + log( + `Kept ${GENERATED_XCCONFIG_PATH}: an xcconfig of your own may still ` + + `#include it (configurations: ${includingConfigs.join(', ')})`, + ); + } + // Settings a project injected before the generated xcconfig still carries + // in project.pbxproj (only what we added). text = removeRecordedBuildSettings(text, marker.buildSettingChanges ?? []); writeIfChanged(pbxprojPath, text); log(`Removed SPM injection from ${path.relative(appRoot, pbxprojPath)}`); + for (const relPath of [ + ...(ownsXcconfig && includingConfigs.length === 0 + ? [GENERATED_XCCONFIG_PATH] + : []), + SYNC_AUTOLINKING_SCRIPT_PATH, + EMBED_FRAMEWORKS_SCRIPT_PATH, + ]) { + fs.rmSync(path.join(appRoot, relPath), {force: true}); + } // 3. Scheme: delete it if injection created it AND still owns its contents, // else strip the pre-action and leave the file (see isGeneratedScheme). @@ -2969,6 +3262,7 @@ module.exports = { flavorForBuildConfiguration, targetBuildConfigUuids, frameworkConditionalSettings, + generateReactNativeXcconfig, ensureStubPackages, buildSpmDependencyGraph, spmGraphToEntries, diff --git a/packages/react-native/scripts/spm/ios-deployment-target.js b/packages/react-native/scripts/spm/ios-deployment-target.js index b6239b01925e..5d76ac522201 100644 --- a/packages/react-native/scripts/spm/ios-deployment-target.js +++ b/packages/react-native/scripts/spm/ios-deployment-target.js @@ -23,9 +23,12 @@ const { findField, findObjectByUuid, findProjectObject, - forEachObjectInSection, - uuidsInArray, } = require('./spm-pbxproj'); +const { + readXcconfig, + xcconfigIncludePath, + xcconfigReferencePaths, +} = require('./xcconfig-files'); const fs = require('node:fs'); const path = require('node:path'); @@ -87,14 +90,6 @@ function sanitizeIosDeploymentTarget(raw /*: ?string */) /*: string */ { : MIN_IOS_VERSION_SUPPORTED; } -function defaultReadFile(absPath /*: string */) /*: ?string */ { - try { - return fs.readFileSync(absPath, 'utf8'); - } catch { - return null; - } -} - /** * `IPHONEOS_DEPLOYMENT_TARGET` as an xcconfig defines it, following `#include` * lines in place so a later assignment in the includer wins. Null when the file @@ -116,10 +111,10 @@ function parseXcconfigSetting( let value /*: ?string */ = null; for (const rawLine of content.split('\n')) { const line = rawLine.replace(/\/\/.*$/, '').trim(); - const include = line.match(/^#include\??\s+"([^"]+)"/); - if (include != null) { + const includePath = xcconfigIncludePath(absPath, line); + if (includePath != null) { const included = readXcconfigSetting( - path.resolve(path.dirname(absPath), include[1]), + includePath, readFile, visited, depth + 1, @@ -154,41 +149,6 @@ function readXcconfigSetting( : null; } -/** - * Directory components of the PBXGroup chain holding `uuid`, outermost first — - * how a `""` file reference's path is anchored to the project dir. - */ -function groupPathPrefix( - text /*: string */, - uuid /*: string */, -) /*: Array */ { - const groups /*: Array<{uuid: string, path: ?string, children: Set}> */ = - []; - forEachObjectInSection(text, 'PBXGroup', ({uuid: groupUuid, ...body}) => { - const children = findField(text, body, 'children'); - const groupPath = findField(text, body, 'path'); - groups.push({ - uuid: groupUuid, - path: groupPath != null ? unquote(groupPath.value) : null, - children: children != null ? uuidsInArray(children.value) : new Set(), - }); - }); - - const parts /*: Array */ = []; - let current = uuid; - for (let i = 0; i < groups.length; i++) { - const parent = groups.find(group => group.children.has(current)); - if (parent == null) { - break; - } - if (parent.path != null) { - parts.unshift(parent.path); - } - current = parent.uuid; - } - return parts; -} - /** * Absolute paths to try for the xcconfig a configuration is based on, in order: * the reference's own anchoring first, then plain `/`. @@ -211,23 +171,16 @@ function xcconfigCandidates( if (pathField == null) { return []; } - const refPath = unquote(pathField.value); - if (path.isAbsolute(refPath)) { - return [refPath]; - } const sourceTreeField = findField(text, ref, 'sourceTree'); - const sourceTree = - sourceTreeField != null ? unquote(sourceTreeField.value) : ''; - const fallback = path.join(srcRoot, refPath); - if (sourceTree !== '') { - return [fallback]; - } - const anchored = path.join( + return xcconfigReferencePaths( + text, + { + uuid: refUuid, + path: unquote(pathField.value), + sourceTree: sourceTreeField != null ? unquote(sourceTreeField.value) : '', + }, srcRoot, - ...groupPathPrefix(text, refUuid), - refPath, ); - return anchored === fallback ? [fallback] : [anchored, fallback]; } function configName( @@ -300,7 +253,7 @@ function readIosDeploymentTargetFromPbxproj( ) /*: ?string */ { const ctx /*: XcconfigContext */ = { srcRoot: opts?.srcRoot, - readFile: opts?.readFile ?? defaultReadFile, + readFile: opts?.readFile ?? readXcconfig, }; const targetUuid = opts?.targetUuid; const marked = targetUuid != null ? findObjectByUuid(text, targetUuid) : null; diff --git a/packages/react-native/scripts/spm/spm-pbxproj.js b/packages/react-native/scripts/spm/spm-pbxproj.js index 9cd40df867a1..066f50b0792a 100644 --- a/packages/react-native/scripts/spm/spm-pbxproj.js +++ b/packages/react-native/scripts/spm/spm-pbxproj.js @@ -530,21 +530,26 @@ function addArrayStringValues( } /** - * Add a scalar field after the object's `{` only when ABSENT (never clobbers a - * value the user already set). Returns text unchanged if the key exists. + * Add a scalar field only when ABSENT (never clobbers a value the user already + * set). Returns text unchanged if the key exists. Lands right after the field + * named `after` when the object has one, else after the object's `{`. */ function ensureScalarField( text /*: string */, obj /*: BodyRange */, key /*: string */, value /*: string */, + options /*: {after?: string} */ = {}, ) /*: string */ { if (findField(text, obj, key) != null) { return text; } const fieldIndent = detectFieldIndent(text, obj); const block = `\n${fieldIndent}${key} = ${value};`; - return text.slice(0, obj.bodyOpen + 1) + block + text.slice(obj.bodyOpen + 1); + const anchor = + options.after != null ? findField(text, obj, options.after) : null; + const at = anchor != null ? anchor.tokenEnd + 1 : obj.bodyOpen + 1; + return text.slice(0, at) + block + text.slice(at); } /** diff --git a/packages/react-native/scripts/spm/xcconfig-files.js b/packages/react-native/scripts/spm/xcconfig-files.js new file mode 100644 index 000000000000..42a6021901a7 --- /dev/null +++ b/packages/react-native/scripts/spm/xcconfig-files.js @@ -0,0 +1,149 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict-local + * @format + */ + +'use strict'; + +/** + * Where the xcconfig file a project references lives on disk, and the files it + * pulls in through `#include` — resolved the way Xcode resolves them. + */ + +const { + findField, + forEachObjectInSection, + uuidsInArray, +} = require('./spm-pbxproj'); +const fs = require('node:fs'); +const path = require('node:path'); + +function unquote(value /*: string */) /*: string */ { + return value.replace(/^"|"$/g, ''); +} + +function readXcconfig(absPath /*: string */) /*: ?string */ { + try { + return fs.readFileSync(absPath, 'utf8'); + } catch { + return null; + } +} + +/** + * Directory components of the PBXGroup chain holding `uuid`, outermost first — + * how a `""` file reference's path is anchored to the project dir. + */ +function groupPathPrefix( + text /*: string */, + uuid /*: string */, +) /*: Array */ { + const groups /*: Array<{uuid: string, path: ?string, children: Set}> */ = + []; + forEachObjectInSection(text, 'PBXGroup', ({uuid: groupUuid, ...body}) => { + const children = findField(text, body, 'children'); + const groupPath = findField(text, body, 'path'); + groups.push({ + uuid: groupUuid, + path: groupPath != null ? unquote(groupPath.value) : null, + children: children != null ? uuidsInArray(children.value) : new Set(), + }); + }); + + const parts /*: Array */ = []; + let current = uuid; + for (let i = 0; i < groups.length; i++) { + const parent = groups.find(group => group.children.has(current)); + if (parent == null) { + break; + } + if (parent.path != null) { + parts.unshift(parent.path); + } + current = parent.uuid; + } + return parts; +} + +/** + * Absolute paths to try for the file a PBXFileReference names, in order: the + * reference's own anchoring first, then plain `/`. `ref.path` + * and `ref.sourceTree` are unquoted. + */ +function xcconfigReferencePaths( + text /*: string */, + ref /*: {uuid: string, path: string, sourceTree: string} */, + srcRoot /*: string */, +) /*: Array */ { + if (path.isAbsolute(ref.path)) { + return [ref.path]; + } + const fallback = path.join(srcRoot, ref.path); + if (ref.sourceTree !== '') { + return [fallback]; + } + const anchored = path.join( + srcRoot, + ...groupPathPrefix(text, ref.uuid), + ref.path, + ); + return anchored === fallback ? [fallback] : [anchored, fallback]; +} + +/** + * The absolute path an `#include "…"` or `#include? "…"` line of the xcconfig + * at `absPath` names, or null for any other line. Xcode resolves it from the + * folder of the file holding the line. + */ +function xcconfigIncludePath( + absPath /*: string */, + line /*: string */, +) /*: ?string */ { + const include = line + .replace(/\/\/.*$/, '') + .trim() + .match(/^#include\??\s+"([^"]+)"/); + return include != null + ? path.resolve(path.dirname(absPath), include[1]) + : null; +} + +/** + * Whether the xcconfig at `absPath` (holding `content`) pulls in `targetPath` + * through its `#include` chain. An included file that cannot be read ends its + * branch of the chain. + */ +function xcconfigIncludes( + absPath /*: string */, + content /*: string */, + targetPath /*: string */, + visited /*: Set */ = new Set(), +) /*: boolean */ { + visited.add(absPath); + for (const line of content.split('\n')) { + const included = xcconfigIncludePath(absPath, line); + if (included == null || visited.has(included)) { + continue; + } + if (included === path.resolve(targetPath)) { + return true; + } + const next = readXcconfig(included); + if (next != null && xcconfigIncludes(included, next, targetPath, visited)) { + return true; + } + } + return false; +} + +module.exports = { + readXcconfig, + xcconfigIncludePath, + xcconfigIncludes, + xcconfigReferencePaths, +};