Skip to content

Commit 9465ef7

Browse files
chrfalchfabriziocucci
authored andcommitted
Stop re-creating library package roots on every sync (#58597)
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/<Name>`, 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 <dir> -newer <stamp>`. Xcode writes its per-user scheme state inside that directory, at `<lib>/.swiftpm/xcode/xcuserdata/<user>.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: #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 97cc934)
1 parent 6532c25 commit 9465ef7

4 files changed

Lines changed: 184 additions & 10 deletions

File tree

‎packages/react-native/scripts/spm/__tests__/generate-spm-autolinking-test.js‎

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1982,6 +1982,103 @@ describe('main() — .spm-sync-watch-paths emission', () => {
19821982
});
19831983
});
19841984

1985+
// ---------------------------------------------------------------------------
1986+
// main() — libs/ symlinks for self-managed deps
1987+
//
1988+
// Xcode loads each libs/<SwiftName> symlink as a local package root. Replacing
1989+
// one that did not change invalidates the package graph Xcode already holds,
1990+
// and the build then fails with "Missing package product". So a sync that
1991+
// changes nothing must leave every inode under libs/ — and libs/ itself —
1992+
// untouched, while a dep that is gone must lose its symlink.
1993+
// ---------------------------------------------------------------------------
1994+
1995+
describe('main() — libs/ symlinks for self-managed deps', () => {
1996+
const {created} = useTempApps();
1997+
1998+
function buildApp(depNames) {
1999+
const appRoot = fs.realpathSync(
2000+
fs.mkdtempSync(path.join(os.tmpdir(), 'spm-libs-sync-')),
2001+
);
2002+
created.push(appRoot);
2003+
const rnRoot = path.join(appRoot, 'rn');
2004+
fs.mkdirSync(rnRoot, {recursive: true});
2005+
fs.writeFileSync(
2006+
path.join(appRoot, 'package.json'),
2007+
JSON.stringify({name: 'app'}),
2008+
);
2009+
2010+
const dependencies = {};
2011+
for (const npmName of depNames) {
2012+
// A hand-authored root Package.swift (no AUTOGEN marker) is what makes a
2013+
// dep self-managed.
2014+
const depDir = path.join(appRoot, 'node_modules', npmName);
2015+
fs.mkdirSync(depDir, {recursive: true});
2016+
fs.writeFileSync(
2017+
path.join(depDir, 'Package.swift'),
2018+
'// swift-tools-version:5.9\n// hand-authored\n',
2019+
);
2020+
fs.writeFileSync(path.join(depDir, 'Source.swift'), '// src\n');
2021+
dependencies[npmName] = {root: depDir, platforms: {ios: {}}};
2022+
}
2023+
2024+
const autolinkDir = path.join(appRoot, 'build', 'generated', 'autolinking');
2025+
fs.mkdirSync(autolinkDir, {recursive: true});
2026+
const writeAutolinkingJson = names =>
2027+
fs.writeFileSync(
2028+
path.join(autolinkDir, 'autolinking.json'),
2029+
JSON.stringify({
2030+
dependencies: Object.fromEntries(
2031+
names.map(n => [n, dependencies[n]]),
2032+
),
2033+
}),
2034+
);
2035+
writeAutolinkingJson(depNames);
2036+
2037+
return {
2038+
libsDir: path.join(autolinkDir, 'libs'),
2039+
writeAutolinkingJson,
2040+
sync: () => main(['--app-root', appRoot, '--react-native-root', rnRoot]),
2041+
};
2042+
}
2043+
2044+
const inodesOf = libsDir =>
2045+
Object.fromEntries(
2046+
['.', ...fs.readdirSync(libsDir)].map(entry => [
2047+
entry,
2048+
fs.lstatSync(path.join(libsDir, entry)).ino,
2049+
]),
2050+
);
2051+
2052+
it('keeps every inode when nothing changed', () => {
2053+
const app = buildApp(['react-native-foo', 'react-native-bar']);
2054+
2055+
app.sync();
2056+
const before = inodesOf(app.libsDir);
2057+
expect(Object.keys(before).sort()).toEqual([
2058+
'.',
2059+
'ReactNativeBar',
2060+
'ReactNativeFoo',
2061+
]);
2062+
2063+
app.sync();
2064+
expect(inodesOf(app.libsDir)).toEqual(before);
2065+
});
2066+
2067+
it('drops the symlink of a dep that is no longer autolinked', () => {
2068+
const app = buildApp(['react-native-foo', 'react-native-bar']);
2069+
2070+
app.sync();
2071+
expect(fs.readdirSync(app.libsDir).sort()).toEqual([
2072+
'ReactNativeBar',
2073+
'ReactNativeFoo',
2074+
]);
2075+
2076+
app.writeAutolinkingJson(['react-native-foo']);
2077+
app.sync();
2078+
expect(fs.readdirSync(app.libsDir)).toEqual(['ReactNativeFoo']);
2079+
});
2080+
});
2081+
19852082
// ---------------------------------------------------------------------------
19862083
// main() — the name a dep's podspec declares reaching a real manifest.
19872084
// ---------------------------------------------------------------------------

‎packages/react-native/scripts/spm/__tests__/generate-spm-xcodeproj-test.js‎

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -234,6 +234,70 @@ describe('sync scripts', () => {
234234
);
235235
});
236236

237+
// Xcode writes per-user scheme state into <watched dir>/.swiftpm/ on every
238+
// IDE build. Counting that as a change made every IDE build re-sync.
239+
describe('the watched-directory staleness probe', () => {
240+
// Runs the generated `find` in isolation, with $P/$STAMP bound as the
241+
// build phase binds them.
242+
function probe(watchedDir, stampFile) {
243+
const findCommand = /\$\((find "\$P"[^()]*)\)/.exec(script)?.[1];
244+
expect(findCommand).toBeDefined();
245+
return execFileSync(
246+
'/bin/bash',
247+
[
248+
'-c',
249+
`set -euo pipefail\nP="$1"\nSTAMP="$2"\n${String(findCommand)}\n`,
250+
'probe',
251+
watchedDir,
252+
stampFile,
253+
],
254+
{encoding: 'utf8'},
255+
);
256+
}
257+
258+
let root;
259+
let watchedDir;
260+
let stampFile;
261+
let schemeState;
262+
let source;
263+
264+
beforeEach(() => {
265+
root = fs.mkdtempSync(path.join(os.tmpdir(), 'spm-sync-stale-'));
266+
watchedDir = path.join(root, 'node_modules', 'react-native-foo');
267+
schemeState = path.join(
268+
watchedDir,
269+
'.swiftpm/xcode/xcuserdata/someone.xcuserdatad/xcschemes/xcschememanagement.plist',
270+
);
271+
source = path.join(watchedDir, 'Foo.swift');
272+
fs.mkdirSync(path.dirname(schemeState), {recursive: true});
273+
fs.writeFileSync(schemeState, '<plist/>\n');
274+
fs.writeFileSync(source, '// src\n');
275+
// The stamp is written after the tree, so nothing is newer until a test
276+
// makes it so.
277+
stampFile = path.join(root, '.spm-sync-stamp');
278+
fs.writeFileSync(stampFile, '');
279+
});
280+
281+
afterEach(() => {
282+
fs.rmSync(root, {recursive: true, force: true});
283+
});
284+
285+
const touch = file => {
286+
const future = new Date(Date.now() + 10_000);
287+
fs.utimesSync(file, future, future);
288+
};
289+
290+
it('ignores Xcode-owned state under .swiftpm', () => {
291+
touch(schemeState);
292+
expect(probe(watchedDir, stampFile)).toBe('');
293+
});
294+
295+
it('still reports a changed source file', () => {
296+
touch(source);
297+
expect(probe(watchedDir, stampFile).trim()).toBe(source);
298+
});
299+
});
300+
237301
it('is deterministic, shared with the pre-action, and valid POSIX shell', () => {
238302
expect(buildSyncAutolinkingScript(baked)).toBe(script);
239303
expect(buildSchemePreActionScript(baked)).toBe(script);

‎packages/react-native/scripts/spm/generate-spm-autolinking.js‎

Lines changed: 20 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1515,11 +1515,14 @@ function main(argv /*:: ?: Array<string> */) /*: void */ {
15151515
// is the Swift module name (guaranteed unique per dep), so SPM's
15161516
// path-basename-based package identity never collides — even when two
15171517
// libs ship their own Package.swift inside `ios/` (a common convention).
1518-
// Wiped on every run; populated below as self-managed deps are visited.
1518+
// Populated below as self-managed deps are visited, then pruned. Entries
1519+
// that do not change keep their inode: Xcode holds each one as a loaded
1520+
// package root, and recreating one it already resolved fails the build with
1521+
// "Missing package product".
15191522
const libsDir = path.join(outputDir, 'libs');
1523+
const wantedLibAliases /*: Set<string> */ = new Set();
15201524
fs.mkdirSync(packagesDir, {recursive: true});
15211525
fs.mkdirSync(headersDir, {recursive: true});
1522-
fs.rmSync(libsDir, {recursive: true, force: true});
15231526
fs.mkdirSync(libsDir, {recursive: true});
15241527

15251528
const wrapperDirs /*: Map<string, string> */ = new Map();
@@ -1652,6 +1655,7 @@ function main(argv /*:: ?: Array<string> */) /*: void */ {
16521655
const realPackageDir = selfManagedDirs.get(target.name) ?? absSource;
16531656
const aliasPath = path.join(libsDir, target.name);
16541657
ensureSymlink(aliasPath, realPackageDir);
1658+
wantedLibAliases.add(target.name);
16551659
aggregatorPackageDeps.push({
16561660
swiftName: target.name,
16571661
packagePath: `libs/${target.name}`,
@@ -1779,23 +1783,30 @@ function main(argv /*:: ?: Array<string> */) /*: void */ {
17791783
});
17801784
}
17811785

1782-
// Prune stale wrappers + header dirs for entries no longer autolinked.
1783-
// Preserve both wrapper-managed and self-managed names; only entries that
1784-
// are no longer autolinked at all get removed. Note: `packages/` only has
1785-
// wrapper-managed names (self-managed deps live in their own source dirs),
1786-
// but `headers/` has both since we populate the central tree for everyone.
1786+
// Prune stale wrappers, header dirs and lib aliases for entries no longer
1787+
// autolinked. Preserve both wrapper-managed and self-managed names; only
1788+
// entries that are no longer autolinked at all get removed. Note:
1789+
// `packages/` only has wrapper-managed names (self-managed deps live in
1790+
// their own source dirs), but `headers/` has both since we populate the
1791+
// central tree for everyone. `libs/` keeps only the aliases written above,
1792+
// so a dep that stopped being self-managed loses its alias too.
17871793
const activeNames /*: Set<string> */ = new Set([
17881794
...wrapperDirs.keys(),
17891795
...selfManagedDirs.keys(),
17901796
]);
1791-
for (const subdir of ['packages', 'headers']) {
1797+
const pruneTargets /*: Array<[string, Set<string>]> */ = [
1798+
['packages', activeNames],
1799+
['headers', activeNames],
1800+
['libs', wantedLibAliases],
1801+
];
1802+
for (const [subdir, keptNames] of pruneTargets) {
17921803
const dir = path.join(outputDir, subdir);
17931804
try {
17941805
const existing /*: Array<{name: string, isSymbolicLink(): boolean, isDirectory(): boolean}> */ =
17951806
// $FlowFixMe[incompatible-type] Dirent typing
17961807
fs.readdirSync(dir, {withFileTypes: true});
17971808
for (const entry of existing) {
1798-
if (activeNames.has(entry.name)) continue;
1809+
if (keptNames.has(entry.name)) continue;
17991810
const stale = path.join(dir, entry.name);
18001811
if (entry.isSymbolicLink() || !entry.isDirectory()) {
18011812
fs.unlinkSync(stale);

‎packages/react-native/scripts/spm/generate-spm-xcodeproj.js‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -767,7 +767,9 @@ if [ "$STALE" -eq 0 ] && [ -f "$WATCH_FILE" ]; then
767767
while IFS= read -r P; do
768768
[ -z "$P" ] && continue
769769
if [ -d "$P" ]; then
770-
if [ -n "$(find "$P" -newer "$STAMP" -print -quit 2>/dev/null)" ]; then
770+
# .swiftpm holds Xcode's own per-user scheme state, which it rewrites
771+
# during a build — reading it as a change makes every IDE build re-sync.
772+
if [ -n "$(find "$P" -name .swiftpm -prune -o -newer "$STAMP" -print -quit 2>/dev/null)" ]; then
771773
STALE=1
772774
break
773775
fi

0 commit comments

Comments
 (0)