Skip to content

Commit 2b655d7

Browse files
committed
feat(spm): nightly cache freshness, spm.name override, scanner hardening
* Cache slot is keyed on the resolved nightly hash for 1000.0.0 / nightly versions, so each published nightly is its own slot. Stable versions pass through unchanged. resolveCacheSlotVersion() is shared between setup-apple-spm and sync-spm-autolinking; sync now also re-points the symlinks to the expected slot on every run. * SPM manifests carry the resolved slot path as a string literal in xcfwHeaders / depsHeaders (synth Package.swift + codegen template), so SPM's manifest hash bumps on every slot change — Xcode re-resolves and re-copies React.framework instead of sticking on a stale BUILT_PRODUCTS copy. xcframeworks/Package.swift carries a slot-identifier comment as a belt-and-braces invalidator (binary target paths stay relative per SPM's rules). Codegen template now goes through a render step with __SPM_XCFW_HEADERS_EXPR__ / __SPM_DEPS_HEADERS_EXPR__ placeholders. * Library authors can override the auto-derived Swift target name via `spm.name` in react-native.config.js (e.g. 'worklets' instead of 'ReactNativeWorklets'). expand-spm-dependencies validates the value and throws on collisions across deps. Symmetric with the existing `name` field on spmModules. * scanProjectFiles skips files named Package.swift, plus `build/`, `node_modules/`, `Pods/`, `*.xcodeproj`, `*.xcworkspace`, and Xcode test-target dirs matching /(?:UI)?Tests?$/i — so the generated xcodeproj doesn't pull SPM manifests or XCTest sources into the app target when run from an RN project root.
1 parent d0816d6 commit 2b655d7

13 files changed

Lines changed: 615 additions & 61 deletions

packages/react-native/scripts/codegen/templates/Package.swift.spm-template

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,11 +20,16 @@
2020
import PackageDescription
2121
import Foundation
2222

23-
// Derive all paths from this file's location – no machine-specific absolute paths.
23+
// Derive all paths from this file's location.
2424
let packageDir = URL(fileURLWithPath: #filePath).deletingLastPathComponent().path
2525
let appRoot = URL(fileURLWithPath: packageDir + "/../../..").standardized.path
26-
let xcfwHeaders = URL(fileURLWithPath: appRoot + "/build/xcframeworks/React.xcframework").resolvingSymlinksInPath().path + "/Headers"
27-
let depsHeaders = URL(fileURLWithPath: appRoot + "/build/xcframeworks/ReactNativeDependencies.xcframework").resolvingSymlinksInPath().path + "/Headers"
26+
// xcfwHeaders / depsHeaders: when the install step resolves the current
27+
// cache-slot symlinks, the placeholders below are replaced with absolute
28+
// string literals so SPM's manifest hash bumps on every slot change
29+
// (otherwise the cached `.resolvingSymlinksInPath()` value would stick on
30+
// the prior slot). Unsubstituted, falls back to the runtime expression.
31+
let xcfwHeaders = __SPM_XCFW_HEADERS_EXPR__
32+
let depsHeaders = __SPM_DEPS_HEADERS_EXPR__
2833
let vfsOverlay = appRoot + "/build/xcframeworks/React-VFS.yaml"
2934

3035
let package = Package(

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

Lines changed: 23 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,10 @@
6060
* After running: open <AppName>-SPM.xcodeproj in Xcode.
6161
*/
6262

63-
const {main: downloadArtifacts} = require('./spm/download-spm-artifacts');
63+
const {
64+
main: downloadArtifacts,
65+
resolveCacheSlotVersion,
66+
} = require('./spm/download-spm-artifacts');
6467
const {main: generateAutolinking} = require('./spm/generate-spm-autolinking');
6568
const {
6669
generateAutolinkingConfig,
@@ -75,6 +78,7 @@ const {
7578
findProjectRoot,
7679
makeLogger,
7780
readPackageJson,
81+
renderCodegenTemplate,
7882
resolveAndWriteVFSOverlay,
7983
} = require('./spm/spm-utils');
8084
const {execSync} = require('child_process');
@@ -373,7 +377,11 @@ function runCodegenStep(
373377
'Package.swift.spm-template',
374378
);
375379
if (codegenSucceeded && fs.existsSync(path.dirname(codegenPkgSwift))) {
376-
fs.copyFileSync(spmTemplate, codegenPkgSwift);
380+
const rendered = renderCodegenTemplate(
381+
fs.readFileSync(spmTemplate, 'utf8'),
382+
appRoot,
383+
);
384+
fs.writeFileSync(codegenPkgSwift, rendered, 'utf8');
377385
log('Installed SPM codegen template → build/generated/ios/Package.swift');
378386
}
379387
}
@@ -446,10 +454,16 @@ async function ensureArtifacts(
446454
version /*: string */,
447455
artifactsDir /*: string | null */,
448456
) /*: Promise<string | null> */ {
457+
// Resolve the cache-slot version before computing the cache dir. For dev /
458+
// nightly labels this is the actual nightly hash, so each nightly gets its
459+
// own slot and a new nightly invalidates the old slot automatically. Stable
460+
// versions pass through unchanged.
461+
const rawVersion = args.version ?? version;
462+
const slotVersion = await resolveCacheSlotVersion(rawVersion);
449463
const resolvedArtifactsDir =
450464
artifactsDir != null
451465
? path.resolve(artifactsDir)
452-
: defaultCacheDir(args.version ?? version, args.flavor);
466+
: defaultCacheDir(slotVersion, args.flavor);
453467

454468
if (args.forceDownload && resolvedArtifactsDir != null) {
455469
log('Clearing cached artifacts (--force-download)...');
@@ -467,10 +481,10 @@ async function ensureArtifacts(
467481
!fs.existsSync(artifactsJsonPath);
468482

469483
if (needsDownload === true && resolvedArtifactsDir != null) {
470-
log('Downloading xcframework artifacts...');
484+
log(`Downloading xcframework artifacts (slot: ${slotVersion})...`);
471485
await downloadArtifacts([
472486
'--version',
473-
args.version ?? version,
487+
rawVersion,
474488
'--flavor',
475489
args.flavor,
476490
'--output',
@@ -634,11 +648,10 @@ async function main(argv /*:: ?: Array<string> */) /*: Promise<void> */ {
634648
const scriptsDir = path.join(reactNativeRoot, 'scripts');
635649
const version = determineVersion(args, reactNativeRoot);
636650
log(`React Native version: ${version}`);
637-
if (args.localXcframework == null) {
638-
log(
639-
`Artifact cache: ${displayPath(defaultCacheDir(args.version ?? version, args.flavor))}`,
640-
);
641-
}
651+
// The artifact cache directory is resolved later in ensureArtifacts so the
652+
// nightly hash can be folded in for dev / nightly labels. That branch logs
653+
// either "Downloading xcframework artifacts (slot: ...)" or
654+
// "Artifacts already present in ...".
642655

643656
if (action === 'codegen') {
644657
runCodegenStep(projectRoot, appRoot, scriptsDir, false);

packages/react-native/scripts/spm/__tests__/expand-spm-dependencies-test.js

Lines changed: 128 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,11 @@
3333
* I/O is injected (readConfig, resolveDep) so the tests stay pure.
3434
*/
3535

36-
const {expandSpmDependencies} = require('../expand-spm-dependencies');
36+
const {
37+
expandSpmDependencies,
38+
resolveSwiftName,
39+
} = require('../expand-spm-dependencies');
40+
const {toSwiftName} = require('../spm-utils');
3741

3842
function makeReadConfig(configs /*: {[string]: ?Object} */) {
3943
return (root /*: string */) =>
@@ -52,13 +56,15 @@ function makeResolveDep(resolutions /*: {[string]: ?string} */) {
5256
// ---------------------------------------------------------------------------
5357

5458
describe('expandSpmDependencies', () => {
55-
it('returns direct deps unchanged when none declare spm.dependencies', () => {
59+
it('returns direct deps with auto-derived swiftName when none declare spm.dependencies', () => {
5660
const direct = [{name: 'a', root: '/a', platforms: {ios: {}}}];
5761
const result = expandSpmDependencies(direct, {
5862
readConfig: makeReadConfig({'/a': {}}),
5963
resolveDep: makeResolveDep({}),
6064
});
61-
expect(result).toEqual([{...direct[0], spmDependencies: []}]);
65+
expect(result).toEqual([
66+
{...direct[0], swiftName: toSwiftName('a'), spmDependencies: []},
67+
]);
6268
});
6369

6470
it('pulls in one transitive dep declared by a direct dep', () => {
@@ -247,4 +253,123 @@ describe('expandSpmDependencies', () => {
247253
});
248254
expect(receivedFromRoot).toBe('/apple');
249255
});
256+
257+
// -------------------------------------------------------------------------
258+
// swiftName resolution: each dep gets a Swift target name on the way out.
259+
// Default is toSwiftName(npmName); the dep's react-native.config.js
260+
// `spm.name` overrides it. Required for libraries whose import prefix
261+
// differs from the auto-derived name (e.g. `react-native-worklets`
262+
// publishes headers under `<worklets/...>`).
263+
// -------------------------------------------------------------------------
264+
265+
it('populates swiftName via toSwiftName when no spm.name override is set', () => {
266+
const direct = [
267+
{name: 'react-native-foo', root: '/foo', platforms: {ios: {}}},
268+
];
269+
const [foo] = expandSpmDependencies(direct, {
270+
readConfig: makeReadConfig({'/foo': {}}),
271+
resolveDep: makeResolveDep({}),
272+
});
273+
expect(foo.swiftName).toBe(toSwiftName('react-native-foo'));
274+
expect(foo.swiftName).toBe('ReactNativeFoo');
275+
});
276+
277+
it('uses spm.name as swiftName when the direct dep declares one', () => {
278+
const direct = [
279+
{name: 'react-native-worklets', root: '/w', platforms: {ios: {}}},
280+
];
281+
const [w] = expandSpmDependencies(direct, {
282+
readConfig: makeReadConfig({'/w': {spm: {name: 'worklets'}}}),
283+
resolveDep: makeResolveDep({}),
284+
});
285+
expect(w.swiftName).toBe('worklets');
286+
});
287+
288+
it('applies spm.name override to transitive deps too', () => {
289+
const direct = [
290+
{name: 'react-native-reanimated', root: '/r', platforms: {ios: {}}},
291+
];
292+
const result = expandSpmDependencies(direct, {
293+
readConfig: makeReadConfig({
294+
'/r': {
295+
dependency: {platforms: {ios: {}}},
296+
spm: {name: 'reanimated', dependencies: ['react-native-worklets']},
297+
},
298+
'/w': {
299+
dependency: {platforms: {ios: {}}},
300+
spm: {name: 'worklets'},
301+
},
302+
}),
303+
resolveDep: makeResolveDep({'react-native-worklets': '/w'}),
304+
});
305+
const reanimated = result.find(d => d.name === 'react-native-reanimated');
306+
const worklets = result.find(d => d.name === 'react-native-worklets');
307+
expect(reanimated.swiftName).toBe('reanimated');
308+
expect(worklets.swiftName).toBe('worklets');
309+
});
310+
311+
it('throws on swiftName collision between two deps (override vs auto-derived)', () => {
312+
// 'react-native-worklets' would auto-derive to 'ReactNativeWorklets', but
313+
// here a second dep overrides its spm.name to that same value.
314+
const direct = [
315+
{name: 'react-native-worklets', root: '/w', platforms: {ios: {}}},
316+
{name: 'other-package', root: '/o', platforms: {ios: {}}},
317+
];
318+
expect(() =>
319+
expandSpmDependencies(direct, {
320+
readConfig: makeReadConfig({
321+
'/w': {},
322+
'/o': {spm: {name: 'ReactNativeWorklets'}},
323+
}),
324+
resolveDep: makeResolveDep({}),
325+
}),
326+
).toThrow(/ReactNativeWorklets/);
327+
});
328+
329+
it('rejects empty-string spm.name with a clear error citing the npm name', () => {
330+
const direct = [{name: 'a', root: '/a', platforms: {ios: {}}}];
331+
expect(() =>
332+
expandSpmDependencies(direct, {
333+
readConfig: makeReadConfig({'/a': {spm: {name: ''}}}),
334+
resolveDep: makeResolveDep({}),
335+
}),
336+
).toThrow(/'a' has an invalid 'spm.name'/);
337+
});
338+
339+
it('rejects non-string spm.name (e.g. number, object) with a clear error', () => {
340+
const direct = [{name: 'a', root: '/a', platforms: {ios: {}}}];
341+
expect(() =>
342+
expandSpmDependencies(direct, {
343+
readConfig: makeReadConfig({'/a': {spm: {name: 42}}}),
344+
resolveDep: makeResolveDep({}),
345+
}),
346+
).toThrow(/invalid 'spm.name'/);
347+
});
348+
349+
it('rejects spm.name with disallowed characters (spaces, slashes, dots)', () => {
350+
expect(() =>
351+
resolveSwiftName('a', {spm: {name: 'foo bar'}}),
352+
).toThrow(/invalid 'spm.name'/);
353+
expect(() => resolveSwiftName('a', {spm: {name: 'foo/bar'}})).toThrow(
354+
/invalid 'spm.name'/,
355+
);
356+
expect(() => resolveSwiftName('a', {spm: {name: 'foo.bar'}})).toThrow(
357+
/invalid 'spm.name'/,
358+
);
359+
});
360+
361+
it('accepts lowercase-with-hyphen and CamelCase spm.name values', () => {
362+
expect(resolveSwiftName('a', {spm: {name: 'reanimated'}})).toBe(
363+
'reanimated',
364+
);
365+
expect(resolveSwiftName('a', {spm: {name: 'hermes-engine'}})).toBe(
366+
'hermes-engine',
367+
);
368+
expect(resolveSwiftName('a', {spm: {name: 'RNWorklets'}})).toBe(
369+
'RNWorklets',
370+
);
371+
expect(resolveSwiftName('a', {spm: {name: 'react_native_foo'}})).toBe(
372+
'react_native_foo',
373+
);
374+
});
250375
});

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

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -610,3 +610,52 @@ describe('generateSynthPackageSwift (sources: allowlist)', () => {
610610
expect(b).not.toContain('sources: [');
611611
});
612612
});
613+
614+
// ---------------------------------------------------------------------------
615+
// spm.name override — verifies that a non-default Swift name (set by a
616+
// library author via react-native.config.js `spm.name`) flows verbatim into
617+
// the synth Package.swift: target name, library name, product references,
618+
// and sibling package paths all use the override.
619+
// ---------------------------------------------------------------------------
620+
621+
describe('generateSynthPackageSwift (spm.name override)', () => {
622+
it('uses the override Swift name for the target, library, and product', () => {
623+
const result = generateSynthPackageSwift({
624+
swiftName: 'worklets', // override from spm.name (default would be "ReactNativeWorklets")
625+
hasReactDep: true,
626+
hasXcfwHeaders: true,
627+
targetPath: 'root',
628+
appRootAbsolute: '/abs/app',
629+
autogenHeadersAbsolute: '/abs/app/headers',
630+
});
631+
expect(result).toContain('name: "worklets"');
632+
expect(result).toContain('.library(name: "worklets"');
633+
expect(result).toContain('targets: ["worklets"]');
634+
// The auto-derived name must not appear anywhere.
635+
expect(result).not.toContain('ReactNativeWorklets');
636+
});
637+
638+
it('emits the override name in sibling .package(...) and .product(...) refs when a transitive dep was overridden', () => {
639+
// Simulates the case where reanimated declares spm.dependencies on
640+
// react-native-worklets, and worklets has set spm.name: "worklets".
641+
// The autolinker's swiftNameByNpm map resolves the transitive to
642+
// "worklets" before passing it to generateSynthPackageSwift.
643+
const result = generateSynthPackageSwift({
644+
swiftName: 'reanimated',
645+
hasReactDep: true,
646+
hasXcfwHeaders: true,
647+
targetPath: 'root',
648+
appRootAbsolute: '/abs/app',
649+
autogenHeadersAbsolute: '/abs/app/headers',
650+
spmDependencies: [{swiftName: 'worklets'}],
651+
siblingSynthAbsolutePaths: {worklets: '/abs/app/packages/worklets'},
652+
});
653+
expect(result).toContain(
654+
'.package(name: "worklets", path: "/abs/app/packages/worklets")',
655+
);
656+
expect(result).toContain(
657+
'.product(name: "worklets", package: "worklets")',
658+
);
659+
expect(result).not.toContain('ReactNativeWorklets');
660+
});
661+
});

packages/react-native/scripts/spm/__tests__/spm-pbxproj-test.js

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,69 @@ describe('scanProjectFiles', () => {
126126
plists: [],
127127
});
128128
});
129+
130+
// -------------------------------------------------------------------------
131+
// SPM-collision guards — files named Package.swift are SPM manifests, never
132+
// app target sources. swiftc refuses to compile two files with the same
133+
// basename in one target, so the legacy bug here was: scanning an appRoot
134+
// like ios/ recursively pulled in Package.swift from build/xcframeworks/,
135+
// build/generated/ios/, and the app's own Package.swift sibling — breaking
136+
// every build with "Filename Package.swift used twice".
137+
// -------------------------------------------------------------------------
138+
139+
it('excludes files named Package.swift even at the source root', () => {
140+
fs.writeFileSync(path.join(tempDir, 'AppDelegate.swift'), '');
141+
fs.writeFileSync(path.join(tempDir, 'Package.swift'), '');
142+
const result = scanProjectFiles(tempDir);
143+
expect(result.sources).toEqual(['AppDelegate.swift']);
144+
});
145+
146+
it('skips the build/ directory so nested Package.swift / generated artifacts never enter the source list', () => {
147+
fs.writeFileSync(path.join(tempDir, 'AppDelegate.swift'), '');
148+
fs.mkdirSync(path.join(tempDir, 'build', 'xcframeworks'), {recursive: true});
149+
fs.writeFileSync(
150+
path.join(tempDir, 'build', 'xcframeworks', 'Package.swift'),
151+
'',
152+
);
153+
fs.writeFileSync(
154+
path.join(tempDir, 'build', 'xcframeworks', 'Generated.swift'),
155+
'',
156+
);
157+
const result = scanProjectFiles(tempDir);
158+
expect(result.sources).toEqual(['AppDelegate.swift']);
159+
});
160+
161+
it('skips node_modules/, Pods/, and *.xcodeproj subdirectories', () => {
162+
fs.writeFileSync(path.join(tempDir, 'Real.m'), '');
163+
for (const skip of ['node_modules', 'Pods']) {
164+
fs.mkdirSync(path.join(tempDir, skip));
165+
fs.writeFileSync(path.join(tempDir, skip, 'Hidden.m'), '');
166+
}
167+
fs.mkdirSync(path.join(tempDir, 'Helloworld.xcodeproj'));
168+
fs.writeFileSync(
169+
path.join(tempDir, 'Helloworld.xcodeproj', 'embedded.swift'),
170+
'',
171+
);
172+
const result = scanProjectFiles(tempDir);
173+
expect(result.sources).toEqual(['Real.m']);
174+
});
175+
176+
it('skips Xcode test target dirs (*Tests, *UITests) so XCTest-only files do not enter the app target', () => {
177+
fs.writeFileSync(path.join(tempDir, 'AppDelegate.swift'), '');
178+
for (const dir of ['HelloWorldTests', 'HelloWorldUITests', 'AppTest']) {
179+
fs.mkdirSync(path.join(tempDir, dir));
180+
fs.writeFileSync(path.join(tempDir, dir, 'Suite.m'), '');
181+
}
182+
// Directories whose names CONTAIN "test" but don't end with Test(s)/UITests
183+
// are legitimate sources — don't over-exclude.
184+
fs.mkdirSync(path.join(tempDir, 'TestUtils'));
185+
fs.writeFileSync(path.join(tempDir, 'TestUtils', 'Helper.m'), '');
186+
const result = scanProjectFiles(tempDir);
187+
expect(result.sources.sort()).toEqual([
188+
'AppDelegate.swift',
189+
'TestUtils/Helper.m',
190+
]);
191+
});
129192
});
130193

131194
// ---------------------------------------------------------------------------

0 commit comments

Comments
 (0)