Skip to content

Commit ef87a2f

Browse files
committed
feat(spm): support self-managed Package.swift under <dep>/ios/
Probe both <dep>/Package.swift and <dep>/ios/Package.swift so community RN libraries can co-locate their hand-authored SPM manifest with their iOS sources, keeping the npm-package root free of SPM artifacts (.build/, .swiftpm/, Package.resolved). - generate-spm-autolinking.js: replace local isSelfManagedPackage with a module-scope findSelfManagedPackageDir that returns the actual manifest directory. The aggregator's .package(path:) now points at the resolved dir, so for the nested layout SPM resolves against <dep>/ios. Headers tree still walks from the dep root so cross-package includes stay complete. AUTOGEN_MARKER lifted to module scope and exported. - scaffold-package-swift.js: skip-rule also probes <dep>/ios/Package.swift so the scaffolder never writes a stray root manifest that would shadow a hand-authored nested one. - Tests: 6 findSelfManagedPackageDir cases (none / root / autogen-marker rejection / nested-only / root-preferred / fallback-when-root-is-autogen) plus 1 scaffolder case for the nested skip-rule. 222 spm tests pass.
1 parent 49b13e8 commit ef87a2f

4 files changed

Lines changed: 175 additions & 22 deletions

File tree

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

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,10 @@
1111
'use strict';
1212

1313
const {
14+
AUTOGEN_MARKER,
1415
collectSpmSources,
1516
expandSpmSourceGlobs,
17+
findSelfManagedPackageDir,
1618
generateAutolinkedPackageSwift,
1719
generateSynthPackageSwift,
1820
linkHeaderTree,
@@ -657,3 +659,79 @@ describe('generateSynthPackageSwift (spm.name override)', () => {
657659
expect(result).not.toContain('ReactNativeWorklets');
658660
});
659661
});
662+
663+
// ---------------------------------------------------------------------------
664+
// findSelfManagedPackageDir — detects hand-authored Package.swift at either
665+
// the dep root or under ios/. The nested layout lets community libraries
666+
// keep their npm-package root free of SPM artifacts (.build/, .swiftpm/).
667+
// ---------------------------------------------------------------------------
668+
669+
describe('findSelfManagedPackageDir', () => {
670+
let depRoot;
671+
672+
beforeEach(() => {
673+
depRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'spm-selfmgd-'));
674+
});
675+
676+
afterEach(() => {
677+
fs.rmSync(depRoot, {recursive: true, force: true});
678+
});
679+
680+
it('returns null when no Package.swift exists at any candidate location', () => {
681+
expect(findSelfManagedPackageDir(depRoot)).toBe(null);
682+
});
683+
684+
it('returns the dep root when <dep>/Package.swift exists without the AUTOGEN marker', () => {
685+
fs.writeFileSync(
686+
path.join(depRoot, 'Package.swift'),
687+
'// swift-tools-version: 6.0\n// Hand-authored.\n',
688+
);
689+
expect(findSelfManagedPackageDir(depRoot)).toBe(depRoot);
690+
});
691+
692+
it('returns null when <dep>/Package.swift carries the AUTOGEN marker', () => {
693+
fs.writeFileSync(
694+
path.join(depRoot, 'Package.swift'),
695+
AUTOGEN_MARKER + '\n// synth wrapper content\n',
696+
);
697+
expect(findSelfManagedPackageDir(depRoot)).toBe(null);
698+
});
699+
700+
it('returns <dep>/ios when only the nested manifest exists and lacks the AUTOGEN marker', () => {
701+
fs.mkdirSync(path.join(depRoot, 'ios'));
702+
fs.writeFileSync(
703+
path.join(depRoot, 'ios', 'Package.swift'),
704+
'// swift-tools-version: 6.0\n// Hand-authored nested manifest.\n',
705+
);
706+
expect(findSelfManagedPackageDir(depRoot)).toBe(path.join(depRoot, 'ios'));
707+
});
708+
709+
it('prefers the root manifest when both root and nested manifests exist', () => {
710+
fs.writeFileSync(
711+
path.join(depRoot, 'Package.swift'),
712+
'// Root manifest.\n',
713+
);
714+
fs.mkdirSync(path.join(depRoot, 'ios'));
715+
fs.writeFileSync(
716+
path.join(depRoot, 'ios', 'Package.swift'),
717+
'// Nested manifest.\n',
718+
);
719+
expect(findSelfManagedPackageDir(depRoot)).toBe(depRoot);
720+
});
721+
722+
it('falls back to the nested manifest when the root one is autolinker-generated', () => {
723+
// Models the transition state: dep was previously autolinker-wrapped and
724+
// recently shipped its own ios/Package.swift. The root file (a leftover
725+
// synth manifest from a prior run) shouldn't shadow the hand-authored one.
726+
fs.writeFileSync(
727+
path.join(depRoot, 'Package.swift'),
728+
AUTOGEN_MARKER + '\n// stale synth\n',
729+
);
730+
fs.mkdirSync(path.join(depRoot, 'ios'));
731+
fs.writeFileSync(
732+
path.join(depRoot, 'ios', 'Package.swift'),
733+
'// Hand-authored nested manifest.\n',
734+
);
735+
expect(findSelfManagedPackageDir(depRoot)).toBe(path.join(depRoot, 'ios'));
736+
});
737+
});

packages/react-native/scripts/spm/__tests__/scaffold-package-swift-test.js

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -470,6 +470,26 @@ end
470470
);
471471
});
472472

473+
it('refuses to scaffold when a nested ios/Package.swift exists without markers', () => {
474+
makePodspec();
475+
// Library ships its manifest under ios/ to keep the npm-package root
476+
// free of SPM artifacts. The scaffolder should NOT write a stray root
477+
// Package.swift — that would shadow the nested one (the autolinker
478+
// checks the root first).
479+
fs.mkdirSync(path.join(depRoot, 'ios'), {recursive: true});
480+
const nestedContent =
481+
'// swift-tools-version: 6.0\n// Hand-written nested manifest.\n';
482+
fs.writeFileSync(path.join(depRoot, 'ios', 'Package.swift'), nestedContent);
483+
const result = scaffoldPackageSwiftForDep(makeDep(), makeCtx());
484+
expect(result.status).toBe('skipped-self-managed');
485+
// Root stayed clean
486+
expect(fs.existsSync(path.join(depRoot, 'Package.swift'))).toBe(false);
487+
// Nested file untouched
488+
expect(
489+
fs.readFileSync(path.join(depRoot, 'ios', 'Package.swift'), 'utf8'),
490+
).toBe(nestedContent);
491+
});
492+
473493
it('refuses to overwrite a Package.swift carrying the autolinker AUTOGEN_MARKER', () => {
474494
makePodspec();
475495
fs.writeFileSync(

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

Lines changed: 55 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -210,6 +210,45 @@ const SKIP_DIRS_DEFAULT /*: $ReadOnlySet<string> */ = new Set([
210210
// like "<WRAPPER_ROOT_NAME>/Foo.mm" by following this link.
211211
const WRAPPER_ROOT_NAME = 'root';
212212

213+
// Marker the autolinker stamps onto every synth Package.swift it writes.
214+
// Files lacking this marker are treated as user-managed (self-managed) and
215+
// are referenced directly rather than wrapped — see findSelfManagedPackageDir.
216+
const AUTOGEN_MARKER =
217+
'// AUTO-GENERATED by scripts/generate-spm-autolinking.js';
218+
219+
/**
220+
* A dep is "self-managed" when it ships a hand-written Package.swift
221+
* (i.e. one that lacks our AUTOGEN_MARKER). The autolinker skips wrapping
222+
* it and references the manifest's directory directly — useful for
223+
* libraries that want to ship a real SPM manifest and have full control
224+
* over their target settings.
225+
*
226+
* Two layouts are recognized:
227+
* 1. <dep>/Package.swift — manifest at the npm-package root
228+
* 2. <dep>/ios/Package.swift — manifest co-located with ObjC
229+
* sources, keeping the npm-package
230+
* root free of SPM artifacts
231+
* (.build/, .swiftpm/, Package.resolved)
232+
*
233+
* Returns the directory that contains the hand-authored manifest, or null
234+
* when no candidate exists. That directory is what the aggregator hands to
235+
* SPM as `.package(path:)` — for layout 2 that means `<dep>/ios`.
236+
*/
237+
function findSelfManagedPackageDir(absSource /*: string */) /*: ?string */ {
238+
for (const sub of ['', 'ios']) {
239+
const dir = sub === '' ? absSource : path.join(absSource, sub);
240+
try {
241+
const content = fs.readFileSync(path.join(dir, 'Package.swift'), 'utf8');
242+
if (!content.includes(AUTOGEN_MARKER)) {
243+
return dir;
244+
}
245+
} catch {
246+
// candidate does not exist; try the next one
247+
}
248+
}
249+
return null;
250+
}
251+
213252
/**
214253
* Mirrors every header file under `srcDir` as a relative symlink at the same
215254
* relative location under `destDir`. Used for the centralized cross-package
@@ -1123,24 +1162,6 @@ function main(argv /*:: ?: Array<string> */) /*: void */ {
11231162
const hasXcfwHeaders = xcframeworksRelPath != null;
11241163
const hasDepsHeaders = xcframeworksRelPath != null;
11251164

1126-
const AUTOGEN_MARKER =
1127-
'// AUTO-GENERATED by scripts/generate-spm-autolinking.js';
1128-
1129-
// A dep is "self-managed" when its source dir ships a hand-written
1130-
// Package.swift (i.e. one that lacks our AUTOGEN_MARKER). The autolinker
1131-
// skips wrapping it and references the dep's source dir directly — useful
1132-
// for libraries that want to ship a real SPM manifest and have full
1133-
// control over their target settings.
1134-
const isSelfManagedPackage = (absSource /*: string */) /*: boolean */ => {
1135-
const pkgSwift = path.join(absSource, 'Package.swift');
1136-
try {
1137-
const content = fs.readFileSync(pkgSwift, 'utf8');
1138-
return !content.includes(AUTOGEN_MARKER);
1139-
} catch {
1140-
return false;
1141-
}
1142-
};
1143-
11441165
// Each entry gets a wrapper dir at <outputDir>/packages/<SwiftName>/ that
11451166
// contains the synth Package.swift and a `root` directory symlink pointing
11461167
// at the dep's real source dir. SPM derives package identity from the path
@@ -1173,8 +1194,12 @@ function main(argv /*:: ?: Array<string> */) /*: void */ {
11731194
log(`Skipping ${target.name}: source dir missing (${absSource})`);
11741195
continue;
11751196
}
1176-
if (isSelfManagedPackage(absSource)) {
1177-
selfManagedDirs.set(target.name, absSource);
1197+
const selfManagedDir = findSelfManagedPackageDir(absSource);
1198+
if (selfManagedDir != null) {
1199+
// Record the manifest's actual directory — for the nested layout this
1200+
// is <dep>/ios, not <dep>. SPM resolves `.package(path:)` against that
1201+
// directory expecting Package.swift to live alongside.
1202+
selfManagedDirs.set(target.name, selfManagedDir);
11781203
// If a wrapper exists from a prior synth-mode run (i.e. the dep WAS
11791204
// autolinker-wrapped, then later transitioned to self-managed via
11801205
// `spm scaffold` or shipping its own Package.swift), remove the
@@ -1188,7 +1213,7 @@ function main(argv /*:: ?: Array<string> */) /*: void */ {
11881213
log(`Removed stale wrapper: packages/${target.name}/`);
11891214
}
11901215
log(
1191-
`Self-managed: ${target.name}${path.relative(appRoot, absSource)} (using its own Package.swift)`,
1216+
`Self-managed: ${target.name}${path.relative(appRoot, selfManagedDir)} (using its own Package.swift)`,
11921217
);
11931218
continue;
11941219
}
@@ -1224,10 +1249,16 @@ function main(argv /*:: ?: Array<string> */) /*: void */ {
12241249
// by file path — synth packages use `-fno-implicit-module-maps`, so
12251250
// we can't rely on SPM's auto-generated module map alone.
12261251
if (selfManagedDirs.has(target.name)) {
1252+
// Centralized headers tree walks the WHOLE dep root, not just the
1253+
// manifest's directory — headers may live anywhere (e.g. common/cpp/
1254+
// outside of ios/), and cross-package consumers should still resolve
1255+
// them via the centralized -I path.
12271256
linkHeaderTree(absSource, path.join(headersDir, target.name));
1257+
// packagePath is the directory containing Package.swift — for the
1258+
// nested layout this is <dep>/ios, not the dep root.
12281259
aggregatorPackageDeps.push({
12291260
swiftName: target.name,
1230-
packagePath: absSource,
1261+
packagePath: selfManagedDirs.get(target.name) ?? absSource,
12311262
});
12321263
continue;
12331264
}
@@ -1473,4 +1504,6 @@ module.exports = {
14731504
linkHeaderTree,
14741505
collectSpmSources,
14751506
expandSpmSourceGlobs,
1507+
findSelfManagedPackageDir,
1508+
AUTOGEN_MARKER,
14761509
};

packages/react-native/scripts/spm/scaffold-package-swift.js

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -493,6 +493,28 @@ function scaffoldPackageSwiftForDep(
493493

494494
const pkgSwiftPath = path.join(dep.root, 'Package.swift');
495495

496+
// A self-managed Package.swift may live either at the dep root (legacy
497+
// convention) or inside ios/ (preferred for community RN libs that want
498+
// their npm-package root free of SPM artifacts). If a nested manifest
499+
// exists and is user-authored, skip — writing a stray root manifest would
500+
// cause the autolinker to prefer the wrong file.
501+
const nestedPkgSwiftPath = path.join(dep.root, 'ios', 'Package.swift');
502+
if (!fs.existsSync(pkgSwiftPath) && fs.existsSync(nestedPkgSwiftPath)) {
503+
const nested = fs.readFileSync(nestedPkgSwiftPath, 'utf8');
504+
if (
505+
!nested.includes(AUTOGEN_MARKER) &&
506+
!nested.includes(SCAFFOLDER_MARKER)
507+
) {
508+
return {
509+
depName,
510+
status: 'skipped-self-managed',
511+
reason:
512+
'Existing ios/Package.swift was not produced by this scaffolder. ' +
513+
'Leaving it alone.',
514+
};
515+
}
516+
}
517+
496518
// Skip rules around existing files.
497519
if (fs.existsSync(pkgSwiftPath)) {
498520
const existing = fs.readFileSync(pkgSwiftPath, 'utf8');

0 commit comments

Comments
 (0)