From 1d69b9084d8a46ba35006f37159ac4c0f9dafc16 Mon Sep 17 00:00:00 2001 From: Christian Falch Date: Mon, 5 Oct 2026 23:07:26 -0700 Subject: [PATCH 1/3] 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/3] 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/3] 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};