Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -1982,6 +1982,103 @@ describe('main() — .spm-sync-watch-paths emission', () => {
});
});

// ---------------------------------------------------------------------------
// main() — libs/ symlinks for self-managed deps
//
// Xcode loads each libs/<SwiftName> 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.
// ---------------------------------------------------------------------------
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,70 @@ describe('sync scripts', () => {
);
});

// Xcode writes per-user scheme state into <watched dir>/.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, '<plist/>\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);
Expand Down
29 changes: 20 additions & 9 deletions packages/react-native/scripts/spm/generate-spm-autolinking.js
Original file line number Diff line number Diff line change
Expand Up @@ -1515,11 +1515,14 @@ function main(argv /*:: ?: Array<string> */) /*: 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<string> */ = 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<string, string> */ = new Map();
Expand Down Expand Up @@ -1652,6 +1655,7 @@ function main(argv /*:: ?: Array<string> */) /*: 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}`,
Expand Down Expand Up @@ -1779,23 +1783,30 @@ function main(argv /*:: ?: Array<string> */) /*: 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<string> */ = new Set([
...wrapperDirs.keys(),
...selfManagedDirs.keys(),
]);
for (const subdir of ['packages', 'headers']) {
const pruneTargets /*: Array<[string, Set<string>]> */ = [
['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);
Expand Down
4 changes: 3 additions & 1 deletion packages/react-native/scripts/spm/generate-spm-xcodeproj.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading