diff --git a/packages/react-native/scripts/spm/__tests__/generate-spm-autolinking-test.js b/packages/react-native/scripts/spm/__tests__/generate-spm-autolinking-test.js index 39939562e15..1a756095efb 100644 --- a/packages/react-native/scripts/spm/__tests__/generate-spm-autolinking-test.js +++ b/packages/react-native/scripts/spm/__tests__/generate-spm-autolinking-test.js @@ -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); + }); }); // --------------------------------------------------------------------------- diff --git a/packages/react-native/scripts/spm/generate-spm-autolinking.js b/packages/react-native/scripts/spm/generate-spm-autolinking.js index cf2932731a4..759d738936b 100644 --- a/packages/react-native/scripts/spm/generate-spm-autolinking.js +++ b/packages/react-native/scripts/spm/generate-spm-autolinking.js @@ -1296,8 +1296,39 @@ function main(argv /*:: ?: Array */) /*: 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> */ = 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`, );