Skip to content

Commit df8edb7

Browse files
chrfalchclaude
andcommitted
feat(spm): manifest+plugin watch paths, artifact version pinning, template warning
Three staleness/robustness hardening items: 1. Watch-path staleness (closes three gaps): the sync phase's .spm-sync-watch-paths file now carries mixed dirs AND files. Each autolinked npm dep's checked-in root Package.swift and .react-native/ dir are watched (dep root threaded from the autolinking model), so editing a library manifest finally trips the in-build re-sync (file edits never bump parent-dir mtimes, so the old dirs-only watch missed them). Plugins gain a watchPaths contract (absolute dirs/files, validated warn-and-drop like flavoredArtifacts) so plugin packages contribute staleness inputs. A watched path that VANISHES now forces a re-sync so the autolinker surfaces the real config error instead of dangling-symlink noise. The generated phase loop distinguishes -d (find -newer probe) / -f ([ -nt ]) / vanished (stale). Behavioral sh tests drive the real generated loop under /bin/sh. 2. Artifact version pinning: an explicit `spm add/update --version <v>` is recorded as artifactsVersionOverride in .spm-injected.json (preserved on later runs without --version; removed by deinit), and the in-build sync prefers it over the package.json-derived version — previously a version-mismatched setup had every in-build sync heal against the wrong artifact slot. findInjectedXcodeproj moved to generate-spm-xcodeproj.js as the single marker-lookup authority. 3. installSpmCodegenTemplate warns loudly when the SPM codegen template file is missing (previously a silent no-op that left codegen's mis-rooted default Package.swift breaking every subsequent Resolve Package Graph). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent c4cb70d commit df8edb7

13 files changed

Lines changed: 695 additions & 45 deletions

packages/react-native/scripts/setup-apple-spm.js

Lines changed: 6 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -88,9 +88,9 @@ const {
8888
const {main: generatePackage} = require('./spm/generate-spm-package');
8989
const {findSourcePath} = require('./spm/generate-spm-package');
9090
const {
91-
SPM_INJECTED_MARKER,
9291
cleanupDanglingJavaScriptCoreRef,
9392
cleanupLeftoverPodsGroup,
93+
findInjectedXcodeproj,
9494
injectSpmIntoExistingXcodeproj,
9595
removeSpmInjection,
9696
} = require('./spm/generate-spm-xcodeproj');
@@ -884,6 +884,11 @@ async function setupXcodeproj(
884884
reactNativeRoot,
885885
xcodeprojPath,
886886
appName: args.productName,
887+
// Only an EXPLICIT `--version` pins the artifacts-cache slot in the
888+
// marker; omitting it (null) leaves any previously-recorded pin alone
889+
// (injectSpmIntoExistingXcodeproj preserves it — see
890+
// generate-spm-xcodeproj.js).
891+
artifactsVersionOverride: args.version ?? null,
887892
});
888893
if (result.status !== 'injected') {
889894
logError(`SPM injection failed: ${result.reason}`);
@@ -903,28 +908,6 @@ async function setupXcodeproj(
903908
}
904909
}
905910

906-
// Returns the `*.xcodeproj` carrying a `.spm-injected.json` marker (the
907-
// user-owned project SPM packages were injected into in place), else null.
908-
function findInjectedXcodeproj(appRoot /*: string */) /*: string | null */ {
909-
let entries /*: Array<{name: string, isDirectory(): boolean}> */;
910-
try {
911-
// $FlowFixMe[incompatible-type] Dirent typing
912-
entries = fs.readdirSync(appRoot, {withFileTypes: true});
913-
} catch {
914-
return null;
915-
}
916-
for (const entry of entries) {
917-
if (!entry.isDirectory()) continue;
918-
// $FlowFixMe[incompatible-type] Dirent.name is string|Buffer in Flow stubs
919-
const name /*: string */ = entry.name;
920-
if (!name.endsWith('.xcodeproj')) continue;
921-
if (fs.existsSync(path.join(appRoot, name, SPM_INJECTED_MARKER))) {
922-
return path.join(appRoot, name);
923-
}
924-
}
925-
return null;
926-
}
927-
928911
// True when `git status --porcelain` reports the path dirty/untracked. Returns
929912
// null when git is unavailable or the path is outside a repo (no safety net).
930913
function gitTrackedAndClean(

packages/react-native/scripts/spm/__doc__/spm-autolinking-plugins.md

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,12 @@ module.exports = function plugin(context) {
9292
},
9393
},
9494
],
95+
watchPaths: [
96+
// Inputs whose edits must re-trigger the auto-sync — the plugin's own
97+
// manifest and per-module config (absolute paths, dirs or files):
98+
'/…/node_modules/expo/Package.swift',
99+
'/…/node_modules/expo/expo-module.config.json',
100+
],
95101
};
96102
};
97103
```
@@ -119,6 +125,23 @@ plugin links in the same pass as RN's own frameworks. Because that repoint runs
119125
in the scheme pre-action, plugin artifacts inherit graph-time embed correctness
120126
for free — no plugin-side swap script needed.
121127

128+
#### `watchPaths` — plugin staleness inputs
129+
130+
`watchPaths` is an array of **absolute** paths (dirs **or** files) the Xcode
131+
auto-sync build phase watches to decide whether it must re-sync. RN already
132+
watches each module's source dir plus every npm dep's checked-in `Package.swift`
133+
and `.react-native/` dir; a plugin adds the inputs only it knows about — e.g.
134+
`packages/expo/Package.swift`, `expo-module.config.json`, and per-module
135+
manifests. On the next build the phase re-syncs when a watched **file** is newer
136+
than the last sync, a watched **dir** has a newer child, or a watched path has
137+
**vanished** (a rename forces a re-sync so the config error surfaces).
138+
139+
Validation mirrors `flavoredArtifacts`: a non-array is ignored with a warning
140+
(never fatal), and each non-string / empty / **relative** entry is dropped with a
141+
warning. Absolute-only, because the generated phase tests these paths with no cwd
142+
context. The kept paths are folded into `<outputDir>/.spm-sync-watch-paths`
143+
alongside RN's own, then deduped and sorted.
144+
122145
### Context (input)
123146

124147
| Field | Meaning |

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

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -307,4 +307,64 @@ describe('invokePlugins', () => {
307307
true,
308308
);
309309
});
310+
311+
it('keeps valid absolute watchPaths (dirs or files) across plugins', () => {
312+
const res = invokePlugins(
313+
[
314+
mk('expo', () => ({
315+
watchPaths: [
316+
'/app/node_modules/expo/Package.swift',
317+
'/app/node_modules/expo/expo-module.config.json',
318+
],
319+
})),
320+
mk('b', () => ({watchPaths: ['/app/node_modules/b']})),
321+
],
322+
ctx,
323+
);
324+
expect(res.watchPaths).toEqual([
325+
'/app/node_modules/expo/Package.swift',
326+
'/app/node_modules/expo/expo-module.config.json',
327+
'/app/node_modules/b',
328+
]);
329+
});
330+
331+
it('defaults watchPaths to [] when no plugin declares any', () => {
332+
const res = invokePlugins([mk('a', () => ({}))], ctx);
333+
expect(res.watchPaths).toEqual([]);
334+
});
335+
336+
it('drops relative / empty / non-string watchPaths with a per-entry warning', () => {
337+
const warnings = [];
338+
const res = invokePlugins(
339+
[
340+
mk('expo', () => ({
341+
watchPaths: [
342+
'/app/node_modules/expo/Package.swift', // kept
343+
'relative/Package.swift', // relative → dropped
344+
'', // empty → dropped
345+
42, // non-string → dropped
346+
],
347+
})),
348+
],
349+
ctx,
350+
{warn: m => warnings.push(m)},
351+
);
352+
expect(res.watchPaths).toEqual(['/app/node_modules/expo/Package.swift']);
353+
expect(warnings).toHaveLength(3);
354+
expect(warnings.every(w => /invalid watchPath/.test(w))).toBe(true);
355+
});
356+
357+
it('ignores a non-array watchPaths with a warning (never throws)', () => {
358+
const warnings = [];
359+
let res;
360+
expect(() => {
361+
res = invokePlugins(
362+
[mk('expo', () => ({watchPaths: '/app/x'}))], // string, not array
363+
ctx,
364+
{warn: m => warnings.push(m)},
365+
);
366+
}).not.toThrow();
367+
expect(res.watchPaths).toEqual([]);
368+
expect(warnings.some(w => /non-array watchPaths/.test(w))).toBe(true);
369+
});
310370
});

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

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1250,3 +1250,126 @@ describe('main() — flavoredArtifacts sidecar', () => {
12501250
]);
12511251
});
12521252
});
1253+
1254+
// ---------------------------------------------------------------------------
1255+
// main() — .spm-sync-watch-paths emission (mixed dirs + files)
1256+
//
1257+
// The watch file drives the Xcode auto-sync stale check. It must carry, mixed
1258+
// and deduped/sorted: (1) each module's source DIR; (2) each npm dep's
1259+
// checked-in root Package.swift (FILE) and .react-native/ (DIR) — threaded from
1260+
// the autolinking model's dep root, not derived by walking up; (3) plugin
1261+
// watchPaths. Nonexistent paths are filtered at emission time.
1262+
// ---------------------------------------------------------------------------
1263+
1264+
describe('main() — .spm-sync-watch-paths emission', () => {
1265+
let created = [];
1266+
let spies = [];
1267+
1268+
beforeEach(() => {
1269+
for (const m of ['log', 'warn', 'error']) {
1270+
spies.push(jest.spyOn(console, m).mockImplementation(() => {}));
1271+
}
1272+
});
1273+
afterEach(() => {
1274+
for (const s of spies) s.mockRestore();
1275+
spies = [];
1276+
for (const d of created) fs.rmSync(d, {recursive: true, force: true});
1277+
created = [];
1278+
});
1279+
1280+
function readWatchLines(appRoot) {
1281+
const contents = fs.readFileSync(
1282+
path.join(appRoot, 'build/generated/autolinking/.spm-sync-watch-paths'),
1283+
'utf8',
1284+
);
1285+
return contents.split('\n').filter(l => l.length > 0);
1286+
}
1287+
1288+
it('emits source dirs + dep manifests + .react-native dirs + plugin paths, deduped and sorted', () => {
1289+
// realpath so paths derived here match a plugin's realpath'd __dirname
1290+
// (macOS /var → /private/var symlink).
1291+
const appRoot = fs.realpathSync(
1292+
fs.mkdtempSync(path.join(os.tmpdir(), 'spm-watch-emit-')),
1293+
);
1294+
created.push(appRoot);
1295+
const rnRoot = path.join(appRoot, 'rn');
1296+
fs.mkdirSync(rnRoot, {recursive: true});
1297+
fs.writeFileSync(
1298+
path.join(appRoot, 'package.json'),
1299+
JSON.stringify({name: 'app'}),
1300+
);
1301+
1302+
// (B) A self-managed community dep: root Package.swift (no AUTOGEN marker)
1303+
// makes it self-managed; it also ships a .react-native/ metadata dir.
1304+
const fooDir = path.join(appRoot, 'node_modules', 'react-native-foo');
1305+
fs.mkdirSync(fooDir, {recursive: true});
1306+
fs.writeFileSync(
1307+
path.join(fooDir, 'Package.swift'),
1308+
'// swift-tools-version:5.9\n// hand-authored\n',
1309+
);
1310+
fs.mkdirSync(path.join(fooDir, '.react-native'));
1311+
fs.writeFileSync(path.join(fooDir, '.react-native', 'meta.json'), '{}\n');
1312+
fs.writeFileSync(path.join(fooDir, 'Foo.swift'), '// src\n');
1313+
1314+
// (C) A plugin-host dep contributing watchPaths: one existing absolute path
1315+
// (kept), one absent absolute path (filtered at emission), one relative
1316+
// path (dropped by invokePlugins).
1317+
const expoDir = path.join(appRoot, 'node_modules', 'expo');
1318+
fs.mkdirSync(path.join(expoDir, 'ios'), {recursive: true});
1319+
fs.writeFileSync(path.join(expoDir, 'ios', 'Expo.mm'), '// native\n');
1320+
fs.writeFileSync(path.join(expoDir, 'Package.swift'), '// expo manifest\n');
1321+
fs.writeFileSync(
1322+
path.join(expoDir, 'react-native.config.js'),
1323+
"module.exports = { spm: { autolinkingPlugin: './spm-plugin.js' } };\n",
1324+
);
1325+
fs.writeFileSync(
1326+
path.join(expoDir, 'spm-plugin.js'),
1327+
[
1328+
"const path = require('path');",
1329+
'module.exports = function () {',
1330+
' return {',
1331+
" packageDependencies: [{name: 'ExpoModulesCore', path: '../../../../node_modules/expo/ios'}],",
1332+
" productDependencies: [{name: 'ExpoModulesCore', package: 'ExpoModulesCore'}],",
1333+
' watchPaths: [',
1334+
" path.join(__dirname, 'Package.swift'),", // exists → kept
1335+
" path.join(__dirname, 'MISSING.swift'),", // absent → filtered at emission
1336+
" 'rel/manifest',", // relative → dropped by invokePlugins
1337+
' ],',
1338+
' };',
1339+
'};',
1340+
].join('\n') + '\n',
1341+
);
1342+
1343+
const autolinkDir = path.join(appRoot, 'build', 'generated', 'autolinking');
1344+
fs.mkdirSync(autolinkDir, {recursive: true});
1345+
fs.writeFileSync(
1346+
path.join(autolinkDir, 'autolinking.json'),
1347+
JSON.stringify({
1348+
dependencies: {
1349+
'react-native-foo': {root: fooDir, platforms: {ios: {}}},
1350+
expo: {root: expoDir, platforms: {ios: {}}},
1351+
},
1352+
}),
1353+
);
1354+
1355+
main(['--app-root', appRoot, '--react-native-root', rnRoot]);
1356+
1357+
const lines = readWatchLines(appRoot);
1358+
1359+
// (A/B) foo's source dir, root manifest FILE, and .react-native DIR.
1360+
expect(lines).toContain(fooDir);
1361+
expect(lines).toContain(path.join(fooDir, 'Package.swift'));
1362+
expect(lines).toContain(path.join(fooDir, '.react-native'));
1363+
1364+
// (C) plugin's existing absolute watchPath is kept.
1365+
expect(lines).toContain(path.join(expoDir, 'Package.swift'));
1366+
1367+
// Filtered / dropped entries never reach the file.
1368+
expect(lines.some(l => l.includes('MISSING.swift'))).toBe(false);
1369+
expect(lines.some(l => l.includes('rel/manifest'))).toBe(false);
1370+
1371+
// Deduped and sorted (stable, deterministic output).
1372+
expect(new Set(lines).size).toBe(lines.length);
1373+
expect([...lines].sort()).toEqual(lines);
1374+
});
1375+
});

0 commit comments

Comments
 (0)