Fail with clear error message when a dep depends on an autolinking plugin host (#58087)

Summary:
A library can ship with its own SwiftPM autolinking plugin, making the RN autolinker skip generating a target for it since the plugin now owns its native contribution.

If another library depends on a library that contains such a plugin, we currently just fail without any explanation to why and how we can fix this.

This PR diagnoses this at the declaration level, checking if a dependency is an existing target or not - emitting a clear error about what happens and why it happens.

## Changelog:

[IOS] [FIXED] - Added hard fail and clear error message when an autolinking plugin host is referenced as a dependency

Pull Request resolved: https://github.com/react/react-native/pull/58087

Test Plan: ✅ Unit tests passes

Reviewed By: Abbondanzo

Differential Revision: D117196705

Pulled By: cipolleschi

fbshipit-source-id: fa7865ec6a5c07232e67240d322c315dad570d17
This commit is contained in:
Christian Falch
2026-08-24 07:48:16 -07:00
committed by meta-codesync[bot]
parent 25ebffd1f7
commit 90a539c92d
2 changed files with 88 additions and 0 deletions
@@ -1154,6 +1154,63 @@ describe('main() — autolinking plugin host exemption', () => {
main(['--app-root', appRoot, '--react-native-root', rnRoot]),
).toThrow(MissingManifestError);
});
// Adds a dep that declares the plugin host in its own `spm.dependencies`.
// `selfManaged` gives it a Package.swift of its own — which is what decides
// whether React Native emits its package references or the dep does.
function addDependentOfExpo(appRoot, name, {selfManaged = false} = {}) {
const depDir = path.join(appRoot, 'node_modules', name);
fs.mkdirSync(path.join(depDir, 'ios'), {recursive: true});
fs.writeFileSync(path.join(depDir, 'ios', 'Dep.mm'), '// native source\n');
if (selfManaged) {
fs.writeFileSync(
path.join(depDir, 'Package.swift'),
'// swift-tools-version: 6.0\n',
);
}
fs.writeFileSync(
path.join(depDir, 'react-native.config.js'),
'module.exports = {dependency: {platforms: {ios: {}}}, ' +
"spm: {dependencies: ['expo']}};\n",
);
const jsonPath = path.join(
appRoot,
'build/generated/autolinking/autolinking.json',
);
const json = JSON.parse(fs.readFileSync(jsonPath, 'utf8'));
json.dependencies[name] = {root: depDir, platforms: {ios: {}}};
fs.writeFileSync(jsonPath, JSON.stringify(json));
}
it('fails when a dep whose manifest RN generates declares the plugin host in its spm.dependencies', () => {
const {appRoot, rnRoot} = buildFixture({withPlugin: true});
addDependentOfExpo(appRoot, 'react-native-y');
const run = () =>
main(['--app-root', appRoot, '--react-native-root', rnRoot]);
// Both packages, so the reader knows which config to edit and why.
expect(run).toThrow(/'react-native-y'/);
expect(run).toThrow(/'expo'/);
expect(run).toThrow(/autolinking plugin/);
expect(run).toThrow(/spm\.dependencies/);
});
it('leaves a self-managed dependent alone — its own Package.swift declares its package references, so RN emits none', () => {
const {appRoot, rnRoot} = buildFixture({withPlugin: true});
addDependentOfExpo(appRoot, 'react-native-y', {selfManaged: true});
expect(() =>
main(['--app-root', appRoot, '--react-native-root', rnRoot]),
).not.toThrow();
});
it('leaves the same pair alone when the host ships no plugin — a plain spm.dependency is not a plugin host', () => {
const {appRoot, rnRoot} = buildFixture({withPlugin: false});
addDependentOfExpo(appRoot, 'react-native-y');
// Both deps ship no manifest, so the missing-manifest error is the expected
// one — the plugin-host diagnosis must not fire for a plain dependency.
expect(() =>
main(['--app-root', appRoot, '--react-native-root', rnRoot]),
).toThrow(MissingManifestError);
});
});
// ---------------------------------------------------------------------------
@@ -1295,8 +1295,39 @@ function main(argv /*:: ?: Array<string> */) /*: void */ {
discoveredPlugins.map(p => p.depName),
);
// Skipped means no sibling package is created for the host either, so a
// dep declaring it in `spm.dependencies` gets a package reference to a
// path this run never writes — SPM then reports only the missing path.
// Only manifests React Native emits can carry that reference: a dep
// shipping its own Package.swift declares its package references itself,
// and the classification loop below would treat it as self-managed.
const pluginHostDependents /*: Map<string, Array<string>> */ = new Map();
for (const dep of allDeps) {
const declaredHosts = (dep.spmDependencies ?? []).filter(name =>
pluginHostDeps.has(name),
);
if (declaredHosts.length === 0) {
continue;
}
const sourceDir = dep.platforms.ios.sourceDir ?? dep.root;
if (sourceDir == null || findSelfManagedPackageDir(sourceDir) != null) {
continue;
}
for (const host of declaredHosts) {
const dependents = pluginHostDependents.get(host) ?? [];
dependents.push(dep.name);
pluginHostDependents.set(host, dependents);
}
}
for (const dep of allDeps) {
if (pluginHostDeps.has(dep.name)) {
const dependents = pluginHostDependents.get(dep.name);
if (dependents != null) {
throw new Error(
`react-native autolinking: '${dep.name}' ships an SPM autolinking plugin, which owns its native contribution — so React Native does not build it as a sibling target for anything to depend on. It is declared in 'spm.dependencies' by ${dependents.map(name => `'${name}'`).join(', ')}. Remove it there; nothing is lost. Its plugin links its products into the app and resolves its own ecosystem's dependencies, so a library that builds against it does not declare it here.`,
);
}
log(
`Skipping ${dep.name} target generation — provided by its SPM autolinking plugin`,
);