From 3058c981406cb8c7e2a46a0c356b0e18da566ee9 Mon Sep 17 00:00:00 2001 From: Christian Falch Date: Tue, 22 Sep 2026 06:57:35 -0700 Subject: [PATCH 1/3] Stop re-creating library package roots on every sync (#58597) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Summary: Building an app in Xcode fails when it autolinks a library that ships its own `Package.swift`, with one error per such library: ``` Missing package product 'reactnativeskia_ReactNativeSkia.ReactNativeSkia' Missing package product 'reactnativesafeareacontext_ReactNativeSafeAreaContext.ReactNativeSafeAreaContext' ``` The same project builds fine from the command line. Two problems combine. **The sync destroys package roots Xcode has already loaded.** Autolinking writes one symlink per self-managed library into `build/generated/autolinking/libs/`, and Xcode treats each as a local Swift package root. The generator deleted that whole directory and recreated it on every run, even when the generated output was byte-for-byte identical. Measured on a real app, across one no-op sync: the `libs/` inode changed from 1913513750 to 1913930645, `libs/ReactNativeSkia` from 1913513937 to 1913930649, while the generated `Package.swift` kept the same MD5. Recreating a package root that Xcode has already resolved is what produces the error above. **Xcode's own bookkeeping made the sync run every time.** The "Sync SPM Autolinking" build phase re-syncs when a watched input looks newer than its stamp, and it checked each library's whole directory with `find -newer `. Xcode writes its per-user scheme state inside that directory, at `/.swiftpm/xcode/xcuserdata/.xcuserdatad/xcschemes/xcschememanagement.plist`. So Xcode's own write marked the next build stale, which triggered the destructive re-sync, which broke that build. `xcodebuild` does not write that file, which is why command-line builds were never affected. This change makes the `libs/` tree idempotent — unchanged entries keep their inode, and entries that are no longer autolinked are pruned instead of wiped — and makes the staleness check skip `.swiftpm`. ### How to verify In an app that autolinks a library shipping its own `Package.swift`, build in Xcode twice in a row. Both builds should succeed. Before this change the second build fails with `Missing package product`. ## Changelog: [IOS][FIXED] - Stop SwiftPM autolinking from recreating library package roots on every sync, which broke Xcode builds of apps using libraries that ship their own Package.swift Pull Request resolved: https://github.com/react/react-native/pull/58597 Test Plan: **Unit tests.** `yarn test packages/react-native/scripts/spm` — 21 suites, 1009 tests pass. New tests, written and seen failing before the fix: - Two consecutive generation runs over an unchanged self-managed dependency keep both the `libs/` inode and each entry's inode. Failed before the fix with the same inode churn measured on the real app. - A dependency removed between two runs leaves no symlink under `libs/`. - The emitted staleness snippet is extracted from the generated script and executed under `/bin/bash -c` with `set -euo pipefail` against a temporary tree. A write under `.swiftpm/` is ignored; a real source change is still detected. **Real app.** A React Native 0.87.1 app on Xcode 27 autolinking `shopify/react-native-skia` and `react-native-safe-area-context`, both self-managed. Before: `xcodebuild` succeeded, Xcode failed in about 5 seconds with the two errors above, on nearly every build. **Not run:** the full CI matrix, and no Android-side check — this touches iOS SwiftPM tooling only. ## Scope Deliberately minimal. One case still re-syncs: the first time Xcode creates `.swiftpm` inside a library, that bumps the library directory's own mtime, so the build after a fresh checkout reports stale once per library. That is harmless now that the sync is idempotent. One related item is left alone: the aggregate `Package.swift` is still rewritten on every sync even when its content is unchanged, which bumps its mtime and can make Xcode re-resolve. That is wasteful but not destructive, and it is no longer reached on an ordinary IDE build now that `.swiftpm` writes do not mark the sync stale. Reviewed By: cipolleschi Differential Revision: D120740029 Pulled By: shwanton fbshipit-source-id: 3783d4cee4791ab13517d8f0dcac71f752cb7c1b (cherry picked from commit 97cc934dc75184977363e06b1793277de82fed54) Conflict resolution for 0.87-stable: the new libs/ symlink tests inline their temp-dir and console-spy setup, because the useTempApps() helper from #58290 does not exist on 0.87. --- .../generate-spm-autolinking-test.js | 111 ++++++++++++++++++ .../__tests__/generate-spm-xcodeproj-test.js | 64 ++++++++++ .../scripts/spm/generate-spm-autolinking.js | 29 +++-- .../scripts/spm/generate-spm-xcodeproj.js | 4 +- 4 files changed, 198 insertions(+), 10 deletions(-) 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 a1a92e1fa4d2..64ad7a7478ca 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 @@ -1743,6 +1743,117 @@ describe('main() — .spm-sync-watch-paths emission', () => { }); }); +// --------------------------------------------------------------------------- +// main() — libs/ symlinks for self-managed deps +// +// Xcode loads each libs/ symlink as a local package root. Replacing +// one that did not change invalidates the package graph Xcode already holds, +// and the build then fails with "Missing package product". So a sync that +// changes nothing must leave every inode under libs/ — and libs/ itself — +// untouched, while a dep that is gone must lose its symlink. +// --------------------------------------------------------------------------- + +describe('main() — libs/ symlinks for self-managed deps', () => { + let created = []; + let spies = []; + + beforeEach(() => { + for (const m of ['log', 'warn', 'error']) { + spies.push(jest.spyOn(console, m).mockImplementation(() => {})); + } + }); + + afterEach(() => { + for (const s of spies) s.mockRestore(); + spies = []; + for (const d of created) fs.rmSync(d, {recursive: true, force: true}); + created = []; + }); + + function buildApp(depNames) { + const appRoot = fs.realpathSync( + fs.mkdtempSync(path.join(os.tmpdir(), 'spm-libs-sync-')), + ); + 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'}), + ); + + const dependencies = {}; + for (const npmName of depNames) { + // A hand-authored root Package.swift (no AUTOGEN marker) is what makes a + // dep self-managed. + const depDir = path.join(appRoot, 'node_modules', npmName); + fs.mkdirSync(depDir, {recursive: true}); + fs.writeFileSync( + path.join(depDir, 'Package.swift'), + '// swift-tools-version:5.9\n// hand-authored\n', + ); + fs.writeFileSync(path.join(depDir, 'Source.swift'), '// src\n'); + dependencies[npmName] = {root: depDir, platforms: {ios: {}}}; + } + + const autolinkDir = path.join(appRoot, 'build', 'generated', 'autolinking'); + fs.mkdirSync(autolinkDir, {recursive: true}); + const writeAutolinkingJson = names => + fs.writeFileSync( + path.join(autolinkDir, 'autolinking.json'), + JSON.stringify({ + dependencies: Object.fromEntries( + names.map(n => [n, dependencies[n]]), + ), + }), + ); + writeAutolinkingJson(depNames); + + return { + libsDir: path.join(autolinkDir, 'libs'), + writeAutolinkingJson, + sync: () => main(['--app-root', appRoot, '--react-native-root', rnRoot]), + }; + } + + const inodesOf = libsDir => + Object.fromEntries( + ['.', ...fs.readdirSync(libsDir)].map(entry => [ + entry, + fs.lstatSync(path.join(libsDir, entry)).ino, + ]), + ); + + it('keeps every inode when nothing changed', () => { + const app = buildApp(['react-native-foo', 'react-native-bar']); + + app.sync(); + const before = inodesOf(app.libsDir); + expect(Object.keys(before).sort()).toEqual([ + '.', + 'ReactNativeBar', + 'ReactNativeFoo', + ]); + + app.sync(); + expect(inodesOf(app.libsDir)).toEqual(before); + }); + + it('drops the symlink of a dep that is no longer autolinked', () => { + const app = buildApp(['react-native-foo', 'react-native-bar']); + + app.sync(); + expect(fs.readdirSync(app.libsDir).sort()).toEqual([ + 'ReactNativeBar', + 'ReactNativeFoo', + ]); + + app.writeAutolinkingJson(['react-native-foo']); + app.sync(); + expect(fs.readdirSync(app.libsDir)).toEqual(['ReactNativeFoo']); + }); +}); + // --------------------------------------------------------------------------- // main() — scope disambiguation: the borrowed name reaching a real manifest. // --------------------------------------------------------------------------- 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 93a443a5b00f..2b79e3b39c4e 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 @@ -184,6 +184,70 @@ describe('sync scripts', () => { ); }); + // Xcode writes per-user scheme state into /.swiftpm/ on every + // IDE build. Counting that as a change made every IDE build re-sync. + describe('the watched-directory staleness probe', () => { + // Runs the generated `find` in isolation, with $P/$STAMP bound as the + // build phase binds them. + function probe(watchedDir, stampFile) { + const findCommand = /\$\((find "\$P"[^()]*)\)/.exec(script)?.[1]; + expect(findCommand).toBeDefined(); + return execFileSync( + '/bin/bash', + [ + '-c', + `set -euo pipefail\nP="$1"\nSTAMP="$2"\n${String(findCommand)}\n`, + 'probe', + watchedDir, + stampFile, + ], + {encoding: 'utf8'}, + ); + } + + let root; + let watchedDir; + let stampFile; + let schemeState; + let source; + + beforeEach(() => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'spm-sync-stale-')); + watchedDir = path.join(root, 'node_modules', 'react-native-foo'); + schemeState = path.join( + watchedDir, + '.swiftpm/xcode/xcuserdata/someone.xcuserdatad/xcschemes/xcschememanagement.plist', + ); + source = path.join(watchedDir, 'Foo.swift'); + fs.mkdirSync(path.dirname(schemeState), {recursive: true}); + fs.writeFileSync(schemeState, '\n'); + fs.writeFileSync(source, '// src\n'); + // The stamp is written after the tree, so nothing is newer until a test + // makes it so. + stampFile = path.join(root, '.spm-sync-stamp'); + fs.writeFileSync(stampFile, ''); + }); + + afterEach(() => { + fs.rmSync(root, {recursive: true, force: true}); + }); + + const touch = file => { + const future = new Date(Date.now() + 10_000); + fs.utimesSync(file, future, future); + }; + + it('ignores Xcode-owned state under .swiftpm', () => { + touch(schemeState); + expect(probe(watchedDir, stampFile)).toBe(''); + }); + + it('still reports a changed source file', () => { + touch(source); + expect(probe(watchedDir, stampFile).trim()).toBe(source); + }); + }); + 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/generate-spm-autolinking.js b/packages/react-native/scripts/spm/generate-spm-autolinking.js index b77decb60562..b22785c0c380 100644 --- a/packages/react-native/scripts/spm/generate-spm-autolinking.js +++ b/packages/react-native/scripts/spm/generate-spm-autolinking.js @@ -1492,11 +1492,14 @@ function main(argv /*:: ?: Array */) /*: void */ { // is the Swift module name (guaranteed unique per dep), so SPM's // path-basename-based package identity never collides — even when two // libs ship their own Package.swift inside `ios/` (a common convention). - // Wiped on every run; populated below as self-managed deps are visited. + // Populated below as self-managed deps are visited, then pruned. Entries + // that do not change keep their inode: Xcode holds each one as a loaded + // package root, and recreating one it already resolved fails the build with + // "Missing package product". const libsDir = path.join(outputDir, 'libs'); + const wantedLibAliases /*: Set */ = new Set(); fs.mkdirSync(packagesDir, {recursive: true}); fs.mkdirSync(headersDir, {recursive: true}); - fs.rmSync(libsDir, {recursive: true, force: true}); fs.mkdirSync(libsDir, {recursive: true}); const wrapperDirs /*: Map */ = new Map(); @@ -1629,6 +1632,7 @@ function main(argv /*:: ?: Array */) /*: void */ { const realPackageDir = selfManagedDirs.get(target.name) ?? absSource; const aliasPath = path.join(libsDir, target.name); ensureSymlink(aliasPath, realPackageDir); + wantedLibAliases.add(target.name); aggregatorPackageDeps.push({ swiftName: target.name, packagePath: `libs/${target.name}`, @@ -1755,23 +1759,30 @@ function main(argv /*:: ?: Array */) /*: void */ { }); } - // Prune stale wrappers + header dirs for entries no longer autolinked. - // Preserve both wrapper-managed and self-managed names; only entries that - // are no longer autolinked at all get removed. Note: `packages/` only has - // wrapper-managed names (self-managed deps live in their own source dirs), - // but `headers/` has both since we populate the central tree for everyone. + // Prune stale wrappers, header dirs and lib aliases for entries no longer + // autolinked. Preserve both wrapper-managed and self-managed names; only + // entries that are no longer autolinked at all get removed. Note: + // `packages/` only has wrapper-managed names (self-managed deps live in + // their own source dirs), but `headers/` has both since we populate the + // central tree for everyone. `libs/` keeps only the aliases written above, + // so a dep that stopped being self-managed loses its alias too. const activeNames /*: Set */ = new Set([ ...wrapperDirs.keys(), ...selfManagedDirs.keys(), ]); - for (const subdir of ['packages', 'headers']) { + const pruneTargets /*: Array<[string, Set]> */ = [ + ['packages', activeNames], + ['headers', activeNames], + ['libs', wantedLibAliases], + ]; + for (const [subdir, keptNames] of pruneTargets) { const dir = path.join(outputDir, subdir); try { const existing /*: Array<{name: string, isSymbolicLink(): boolean, isDirectory(): boolean}> */ = // $FlowFixMe[incompatible-type] Dirent typing fs.readdirSync(dir, {withFileTypes: true}); for (const entry of existing) { - if (activeNames.has(entry.name)) continue; + if (keptNames.has(entry.name)) continue; const stale = path.join(dir, entry.name); if (entry.isSymbolicLink() || !entry.isDirectory()) { fs.unlinkSync(stale); diff --git a/packages/react-native/scripts/spm/generate-spm-xcodeproj.js b/packages/react-native/scripts/spm/generate-spm-xcodeproj.js index fe4687a22101..0433927741ed 100644 --- a/packages/react-native/scripts/spm/generate-spm-xcodeproj.js +++ b/packages/react-native/scripts/spm/generate-spm-xcodeproj.js @@ -763,7 +763,9 @@ if [ "$STALE" -eq 0 ] && [ -f "$WATCH_FILE" ]; then while IFS= read -r P; do [ -z "$P" ] && continue if [ -d "$P" ]; then - if [ -n "$(find "$P" -newer "$STAMP" -print -quit 2>/dev/null)" ]; then + # .swiftpm holds Xcode's own per-user scheme state, which it rewrites + # during a build — reading it as a change makes every IDE build re-sync. + if [ -n "$(find "$P" -name .swiftpm -prune -o -newer "$STAMP" -print -quit 2>/dev/null)" ]; then STALE=1 break fi From 13fe467437db3af69a7475e71f62673463b82605 Mon Sep 17 00:00:00 2001 From: Christian Falch Date: Mon, 5 Oct 2026 23:07:26 -0700 Subject: [PATCH 2/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) Conflict resolution for 0.87-stable: the test file keeps 0.87's bare 'fs'/'os'/'path'/'child_process' requires and adds only the new @xmldom/xmldom import, since the node: prefix change on main is from another PR. --- .../__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 2b79e3b39c4e..04bb7290df33 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('child_process'); const fs = require('fs'); const os = require('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 f8c4dbdfe2f3..25643e6af954 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 @@ -443,6 +443,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 0433927741ed..dc787ea7e2e0 100644 --- a/packages/react-native/scripts/spm/generate-spm-xcodeproj.js +++ b/packages/react-native/scripts/spm/generate-spm-xcodeproj.js @@ -858,28 +858,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( @@ -2533,8 +2526,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; @@ -2548,9 +2542,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); } /** @@ -2572,10 +2564,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 cc6668470da05c683206d78631e1b760b90a6557 Mon Sep 17 00:00:00 2001 From: Christian Falch Date: Wed, 14 Oct 2026 10:43:09 -0700 Subject: [PATCH 3/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 3a23c378f91e..c09c909e7f75 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 af1c6e8a463c..bb70910571eb 100644 --- a/packages/react-native/scripts/spm/__docs__/spm-scripts.md +++ b/packages/react-native/scripts/spm/__docs__/spm-scripts.md @@ -241,7 +241,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. | @@ -605,6 +605,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 64ad7a7478ca..ae0369db3d1f 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 @@ -1627,7 +1627,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', () => { @@ -1681,8 +1681,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'); @@ -1701,7 +1701,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 ' ],', ' };', @@ -1709,6 +1709,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( @@ -1733,6 +1741,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); @@ -1740,6 +1751,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 04bb7290df33..680c9b0c01e1 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 @@ -283,6 +283,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 78eab0be9602..97db445c1f51 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 b22785c0c380..a62e228ddd27 100644 --- a/packages/react-native/scripts/spm/generate-spm-autolinking.js +++ b/packages/react-native/scripts/spm/generate-spm-autolinking.js @@ -1895,12 +1895,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; @@ -1911,14 +1914,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 dc787ea7e2e0..3c702e20c5bd 100644 --- a/packages/react-native/scripts/spm/generate-spm-xcodeproj.js +++ b/packages/react-native/scripts/spm/generate-spm-xcodeproj.js @@ -781,6 +781,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 849fecff5637..732309968199 100644 --- a/packages/react-native/scripts/spm/spm-types.js +++ b/packages/react-native/scripts/spm/spm-types.js @@ -314,7 +314,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`/