From 40c43b90c4746e7c55ba020f01ea882fd26b58d4 Mon Sep 17 00:00:00 2001 From: Christian Falch Date: Fri, 18 Sep 2026 19:10:40 +0200 Subject: [PATCH] [iOS][SPM] Stop re-creating library package roots on every sync Autolinking writes one symlink per self-managed library into build/generated/autolinking/libs/, and Xcode holds each as a loaded local package root. The generator deleted and re-created that whole tree on every run, even when the generated output was byte-identical, so an Xcode build failed with "Missing package product" for every such library. The sync ran on every IDE build because its staleness check walked each library's directory with `find -newer`, and Xcode writes its own per-user scheme state under /.swiftpm. That write marked the next build stale, which triggered the destructive re-sync, which broke that build. Keep unchanged entries in place and prune only the ones that are no longer autolinked, and skip .swiftpm when probing for changes. Co-Authored-By: Claude Opus 5 (1M context) --- .../generate-spm-autolinking-test.js | 97 +++++++++++++++++++ .../__tests__/generate-spm-xcodeproj-test.js | 64 ++++++++++++ .../scripts/spm/generate-spm-autolinking.js | 29 ++++-- .../scripts/spm/generate-spm-xcodeproj.js | 4 +- 4 files changed, 184 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 27fb060fee06..aa2d2170ec73 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 @@ -1982,6 +1982,103 @@ 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', () => { + const {created} = useTempApps(); + + 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() — the name a dep's podspec declares 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 ec6a44d4f8a2..f5b1145edf3a 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 @@ -234,6 +234,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 3b41a6b56ad9..17ecbbe7bef9 100644 --- a/packages/react-native/scripts/spm/generate-spm-autolinking.js +++ b/packages/react-native/scripts/spm/generate-spm-autolinking.js @@ -1515,11 +1515,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(); @@ -1652,6 +1655,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}`, @@ -1779,23 +1783,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 acb3c5192b04..349f366b2fa1 100644 --- a/packages/react-native/scripts/spm/generate-spm-xcodeproj.js +++ b/packages/react-native/scripts/spm/generate-spm-xcodeproj.js @@ -767,7 +767,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