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
23 changes: 17 additions & 6 deletions packages/react-native/scripts/spm/__docs__/spm-scripts.md
Original file line number Diff line number Diff line change
Expand Up @@ -608,12 +608,23 @@ deprecated `spm` settings in it are then ignored rather than silently applied.
## Self-managed community packages

The autolinker references a community library that ships its own `Package.swift`
directly, instead of wrapping it. SwiftPM derives package identity from the path
basename, and several libs may put their manifest inside an `ios/` subdir. To
keep identity unique, each self-managed dep is exposed through a uniquely-named
symlink at `build/generated/autolinking/libs/<SwiftName>/`. The aggregator
`Package.swift` references that path, so two libs that both ship
`<dep>/ios/Package.swift` never collide on identity `"ios"`.
directly, instead of wrapping it. It looks for the manifest at the library root,
then in `ios/`, then in `apple/`, and uses the first one it did not generate
itself. A manifest in `ios/` or `apple/` keeps SwiftPM artifacts (`.build/`,
`.swiftpm/`, `Package.resolved`) out of the npm package root.

For a self-managed library, `swiftpmConfig.name` must equal the name of a
product its `Package.swift` declares. The aggregator depends on the library as
`.product(name: "<SwiftName>", package: "<SwiftName>")`, and SwiftPM fails to
resolve the package graph if no product has that name. A manifest in `ios/` or
`apple/` follows the same rule as one at the root.

SwiftPM derives package identity from the path basename, and several libs may
put their manifest inside an `ios/` or `apple/` subdir. To keep identity unique,
each self-managed dep is exposed through a uniquely-named symlink at
`build/generated/autolinking/libs/<SwiftName>/`. The aggregator `Package.swift`
references that path, so two libs that both ship `<dep>/ios/Package.swift` never
collide on identity `"ios"`.

The `libs/` directory is not recreated on each autolinker run. An alias that
does not change keeps its inode, because Xcode holds each one as a loaded
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -973,9 +973,10 @@ describe('generateSynthPackageSwift (spm.name override)', () => {
});

// ---------------------------------------------------------------------------
// findSelfManagedPackageDir — detects hand-authored Package.swift at either
// the dep root or under ios/. The nested layout lets community libraries
// keep their npm-package root free of SPM artifacts (.build/, .swiftpm/).
// findSelfManagedPackageDir — detects hand-authored Package.swift at the dep
// root, under ios/, or under apple/. The nested layouts let community
// libraries keep their npm-package root free of SPM artifacts (.build/,
// .swiftpm/).
// ---------------------------------------------------------------------------

describe('findSelfManagedPackageDir', () => {
Expand Down Expand Up @@ -1046,6 +1047,75 @@ describe('findSelfManagedPackageDir', () => {
);
expect(findSelfManagedPackageDir(depRoot)).toBe(path.join(depRoot, 'ios'));
});

it('returns <dep>/apple when only an apple/ manifest exists and lacks the AUTOGEN marker', () => {
// Libraries that share sources across iOS, macOS, tvOS and visionOS often
// keep them (and their manifest) in apple/ instead of ios/.
fs.mkdirSync(path.join(depRoot, 'apple'));
fs.writeFileSync(
path.join(depRoot, 'apple', 'Package.swift'),
'// swift-tools-version: 6.0\n// Hand-authored apple/ manifest.\n',
);
expect(findSelfManagedPackageDir(depRoot)).toBe(
path.join(depRoot, 'apple'),
);
});

it('returns null when the apple/ manifest carries the AUTOGEN marker', () => {
fs.mkdirSync(path.join(depRoot, 'apple'));
fs.writeFileSync(
path.join(depRoot, 'apple', 'Package.swift'),
AUTOGEN_MARKER + '\n// synth wrapper content\n',
);
expect(findSelfManagedPackageDir(depRoot)).toBe(null);
});

it('prefers the root manifest over an apple/ manifest', () => {
fs.writeFileSync(
path.join(depRoot, 'Package.swift'),
'// Root manifest.\n',
);
fs.mkdirSync(path.join(depRoot, 'apple'));
fs.writeFileSync(
path.join(depRoot, 'apple', 'Package.swift'),
'// apple/ manifest.\n',
);
expect(findSelfManagedPackageDir(depRoot)).toBe(depRoot);
});

it('prefers ios/ over apple/ when both ship a hand-authored manifest', () => {
fs.mkdirSync(path.join(depRoot, 'ios'));
fs.writeFileSync(
path.join(depRoot, 'ios', 'Package.swift'),
'// ios/ manifest.\n',
);
fs.mkdirSync(path.join(depRoot, 'apple'));
fs.writeFileSync(
path.join(depRoot, 'apple', 'Package.swift'),
'// apple/ manifest.\n',
);
expect(findSelfManagedPackageDir(depRoot)).toBe(path.join(depRoot, 'ios'));
});

it('falls back to apple/ when the root and ios/ manifests are autolinker-generated', () => {
fs.writeFileSync(
path.join(depRoot, 'Package.swift'),
AUTOGEN_MARKER + '\n// stale synth\n',
);
fs.mkdirSync(path.join(depRoot, 'ios'));
fs.writeFileSync(
path.join(depRoot, 'ios', 'Package.swift'),
AUTOGEN_MARKER + '\n// stale synth\n',
);
fs.mkdirSync(path.join(depRoot, 'apple'));
fs.writeFileSync(
path.join(depRoot, 'apple', 'Package.swift'),
'// Hand-authored apple/ manifest.\n',
);
expect(findSelfManagedPackageDir(depRoot)).toBe(
path.join(depRoot, 'apple'),
);
});
});

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -2176,6 +2246,53 @@ describe('main() — libs/ symlinks for self-managed deps', () => {
app.sync();
expect(fs.readdirSync(app.libsDir)).toEqual(['ReactNativeFoo']);
});

it('links a dep whose hand-authored manifest lives in apple/', () => {
const appRoot = fs.realpathSync(
fs.mkdtempSync(path.join(os.tmpdir(), 'spm-libs-apple-')),
);
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'}),
);

// Manifest and sources live in apple/, nothing at the dep root.
const depDir = path.join(appRoot, 'node_modules', 'react-native-baz');
fs.mkdirSync(path.join(depDir, 'apple'), {recursive: true});
fs.writeFileSync(
path.join(depDir, 'apple', 'Package.swift'),
'// swift-tools-version:5.9\n// hand-authored\n',
);
fs.writeFileSync(path.join(depDir, 'apple', 'Baz.swift'), '// src\n');

const autolinkDir = path.join(appRoot, 'build', 'generated', 'autolinking');
fs.mkdirSync(autolinkDir, {recursive: true});
fs.writeFileSync(
path.join(autolinkDir, 'autolinking.json'),
JSON.stringify({
dependencies: {
'react-native-baz': {root: depDir, platforms: {ios: {}}},
},
}),
);

main(['--app-root', appRoot, '--react-native-root', rnRoot]);

// Referenced as self-managed: the alias points at apple/, and no synth
// wrapper is written for it.
expect(
fs.realpathSync(path.join(autolinkDir, 'libs', 'ReactNativeBaz')),
).toBe(path.join(depDir, 'apple'));
expect(
fs.existsSync(path.join(autolinkDir, 'packages', 'ReactNativeBaz')),
).toBe(false);
expect(
fs.readFileSync(path.join(autolinkDir, 'Package.swift'), 'utf8'),
).toContain('path: "libs/ReactNativeBaz"');
});
});

// ---------------------------------------------------------------------------
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -968,6 +968,28 @@ end
).toBe(nestedContent);
});

it('refuses to scaffold when a nested apple/Package.swift exists without markers', () => {
makePodspec();
// Same as the ios/ case, for libraries that keep sources shared across
// Apple platforms (and their manifest) in apple/.
fs.mkdirSync(path.join(depRoot, 'apple'), {recursive: true});
const nestedContent =
'// swift-tools-version: 6.0\n// Hand-written apple/ manifest.\n';
fs.writeFileSync(
path.join(depRoot, 'apple', 'Package.swift'),
nestedContent,
);
const result = scaffoldPackageSwiftForDep(makeDep(), makeCtx());
expect(result.status).toBe('skipped-self-managed');
expect(result.reason).toContain('apple/Package.swift');
// Root stayed clean
expect(fs.existsSync(path.join(depRoot, 'Package.swift'))).toBe(false);
// Nested file untouched
expect(
fs.readFileSync(path.join(depRoot, 'apple', 'Package.swift'), 'utf8'),
).toBe(nestedContent);
});

it('refuses to overwrite a Package.swift carrying the autolinker AUTOGEN_MARKER', () => {
makePodspec();
fs.writeFileSync(
Expand Down
21 changes: 15 additions & 6 deletions packages/react-native/scripts/spm/generate-spm-autolinking.js
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ const {findPodspecs, readPodspecCached} = require('./read-podspec');
const {
AUTOLINKED_PACKAGE_NAME,
CXX_API_DEFINES,
NESTED_PACKAGE_SWIFT_DIRS,
REACT_CODEGEN_PACKAGE_NAME,
REACT_CODEGEN_PRODUCTS,
REACT_NATIVE_PACKAGE_NAME,
Expand Down Expand Up @@ -462,19 +463,26 @@ const AUTOGEN_MARKER =
* libraries that want to ship a real SPM manifest and have full control
* over their target settings.
*
* Two layouts are recognized:
* Three layouts are recognized, checked in this order:
* 1. <dep>/Package.swift — manifest at the npm-package root
* 2. <dep>/ios/Package.swift — manifest co-located with ObjC
* sources, keeping the npm-package
* root free of SPM artifacts
* (.build/, .swiftpm/, Package.resolved)
* 3. <dep>/apple/Package.swift — same as 2, for libraries that keep
* sources shared across Apple
* platforms in apple/
*
* The first hand-authored manifest wins, so a root manifest shadows a nested
* one, and ios/ shadows apple/.
*
* Returns the directory that contains the hand-authored manifest, or null
* when no candidate exists. That directory is what the aggregator hands to
* SPM as `.package(path:)` — for layout 2 that means `<dep>/ios`.
* SPM as `.package(path:)` — for layouts 2 and 3 that means `<dep>/ios` or
* `<dep>/apple`.
*/
function findSelfManagedPackageDir(absSource /*: string */) /*: ?string */ {
for (const sub of ['', 'ios']) {
for (const sub of ['', ...NESTED_PACKAGE_SWIFT_DIRS]) {
const dir = sub === '' ? absSource : path.join(absSource, sub);
try {
const content = fs.readFileSync(path.join(dir, 'Package.swift'), 'utf8');
Expand Down Expand Up @@ -1571,9 +1579,10 @@ function main(argv /*:: ?: Array<string> */) /*: void */ {
}
const selfManagedDir = findSelfManagedPackageDir(absSource);
if (selfManagedDir != null) {
// Record the manifest's actual directory — for the nested layout this
// is <dep>/ios, not <dep>. SPM resolves `.package(path:)` against that
// directory expecting Package.swift to live alongside.
// Record the manifest's actual directory — for the nested layouts this
// is <dep>/ios or <dep>/apple, not <dep>. SPM resolves
// `.package(path:)` against that directory expecting Package.swift to
// live alongside.
selfManagedDirs.set(target.name, selfManagedDir);
// If a wrapper exists from a prior synth-mode run (i.e. the dep WAS
// autolinker-wrapped, then later transitioned to self-managed via
Expand Down
38 changes: 22 additions & 16 deletions packages/react-native/scripts/spm/scaffold-package-swift.js
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ const {expandSpmSourceGlobs} = require('./generate-spm-autolinking');
const {MIN_IOS_VERSION_SUPPORTED} = require('./ios-deployment-target');
const {findPodspecs, readPodspecCached} = require('./read-podspec');
const {
NESTED_PACKAGE_SWIFT_DIRS,
REACT_CODEGEN_PACKAGE_NAME,
REACT_CODEGEN_PRODUCTS,
REACT_NATIVE_PACKAGE_NAME,
Expand Down Expand Up @@ -914,24 +915,29 @@ function scaffoldPackageSwiftForDep(
const pkgSwiftPath = path.join(dep.root, 'Package.swift');

// A self-managed Package.swift may live either at the dep root (legacy
// convention) or inside ios/ (preferred for community RN libs that want
// their npm-package root free of SPM artifacts). If a nested manifest
// convention) or inside ios/ or apple/ (preferred for community RN libs that
// want their npm-package root free of SPM artifacts). If a nested manifest
// exists and is user-authored, skip — writing a stray root manifest would
// cause the autolinker to prefer the wrong file.
const nestedPkgSwiftPath = path.join(dep.root, 'ios', 'Package.swift');
if (!fs.existsSync(pkgSwiftPath) && fs.existsSync(nestedPkgSwiftPath)) {
const nested = fs.readFileSync(nestedPkgSwiftPath, 'utf8');
if (
!nested.includes(AUTOGEN_MARKER) &&
!nested.includes(SCAFFOLDER_MARKER)
) {
return {
depName,
status: 'skipped-self-managed',
reason:
'Existing ios/Package.swift was not produced by this scaffolder. ' +
'Leaving it alone.',
};
if (!fs.existsSync(pkgSwiftPath)) {
for (const sub of NESTED_PACKAGE_SWIFT_DIRS) {
const nestedPkgSwiftPath = path.join(dep.root, sub, 'Package.swift');
if (!fs.existsSync(nestedPkgSwiftPath)) {
continue;
}
const nested = fs.readFileSync(nestedPkgSwiftPath, 'utf8');
if (
!nested.includes(AUTOGEN_MARKER) &&
!nested.includes(SCAFFOLDER_MARKER)
) {
return {
depName,
status: 'skipped-self-managed',
reason:
`Existing ${sub}/Package.swift was not produced by this scaffolder. ` +
'Leaving it alone.',
};
}
}
}

Expand Down
11 changes: 11 additions & 0 deletions packages/react-native/scripts/spm/spm-utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -299,6 +299,16 @@ const PER_APP_HEADERS_REL = 'build/generated/ios/ReactAppHeaders';
const SCAFFOLDER_MARKER =
'// AUTO-SCAFFOLDED by react-native spm scaffold — safe to edit & commit via patch-package.';

// Subdirectories of a library where a hand-written Package.swift may live
// instead of the package root, in lookup order (the root is always checked
// first). Both keep SwiftPM artifacts (.build/, .swiftpm/, Package.resolved)
// out of the npm package root: `ios/` sits next to the iOS sources, and
// `apple/` next to sources shared across Apple platforms.
const NESTED_PACKAGE_SWIFT_DIRS /*: ReadonlyArray<string> */ = Object.freeze([
'ios',
'apple',
]);

// Remote SPM package mode (prototype of the GitHub-distribution endgame):
// when active, app + libraries all depend on ONE remote package identity
// (`.package(url:exact:)`) instead of the local path-based artifacts package
Expand Down Expand Up @@ -828,4 +838,5 @@ module.exports = {
isValidScriptPhaseId,
isValidScriptPhaseName,
SCAFFOLDER_MARKER,
NESTED_PACKAGE_SWIFT_DIRS,
};
Loading