Hold an app's spm.modules names to the same rules as a library's (#58060)

Summary:
An app declares extra native modules through spm.modules in its react-native.config.js. Those names went into the generated package graph unvalidated, and two of the ways they can go wrong fail silently.

This PR fixes this by using the same Swift name collision detection/resolving as we introduced in https://github.com/react/react-native/issues/58044

> **NOTE:** https://github.com/react/react-native/issues/58044 must be merged before this one so that we can change the base branch for this one to `main`

## Changelog:
[IOS] [FIXED] - Reject colliding or invalid spm.modules names instead of silently dropping a module from the build

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

Test Plan: ✅ Unit tests/CI

Reviewed By: mdvacca

Differential Revision: D117360298

Pulled By: cipolleschi

fbshipit-source-id: d2531d51b6d371cf6bc98dc85c9071ee81fb7a37
(cherry picked from commit 39cd1dfc4c)
This commit is contained in:
Christian Falch
2026-08-26 11:13:08 +01:00
committed by Riccardo Cipolleschi
parent c9511295ad
commit e6e404eb6c
3 changed files with 189 additions and 7 deletions
@@ -1235,6 +1235,137 @@ describe('main() — autolinking plugin host exemption', () => {
);
});
// ---------------------------------------------------------------------------
// main() — spm.modules name validation
//
// App-local module names land in the manifest exactly as written, so they need
// the checks an autolinked dep's Swift name gets: a valid identifier, not one
// of React Native's reserved names, and unique across modules and deps.
// ---------------------------------------------------------------------------
describe('main() — spm.modules names', () => {
let created = [];
let spies = [];
beforeEach(() => {
for (const m of ['log', 'warn', 'error']) {
spies.push(jest.spyOn(console, m).mockImplementation(() => {}));
}
});
afterEach(() => {
for (const s of spies) s.mockRestore();
spies = [];
for (const d of created) fs.rmSync(d, {recursive: true, force: true});
created = [];
});
// App fixture whose react-native.config.js declares `spm.modules`, plus an
// optional autolinked dep (for the module-vs-dep collision case).
function buildApp({modules, dep}) {
const appRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'spm-modules-'));
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'}),
);
for (const mod of modules) {
const modDir = path.join(appRoot, mod.path);
fs.mkdirSync(modDir, {recursive: true});
fs.writeFileSync(path.join(modDir, 'Module.mm'), '// native source\n');
}
fs.writeFileSync(
path.join(appRoot, 'react-native.config.js'),
`module.exports = ${JSON.stringify({spm: {modules}})};\n`,
);
const dependencies = {};
if (dep != null) {
const depDir = path.join(appRoot, 'node_modules', dep.name);
fs.mkdirSync(path.join(depDir, 'ios'), {recursive: true});
fs.writeFileSync(
path.join(depDir, 'ios', 'Dep.mm'),
'// native source\n',
);
fs.writeFileSync(
path.join(depDir, 'Package.swift'),
'// swift-tools-version: 6.0\n',
);
dependencies[dep.name] = {root: depDir, platforms: {ios: {}}};
}
const autolinkDir = path.join(appRoot, 'build', 'generated', 'autolinking');
fs.mkdirSync(autolinkDir, {recursive: true});
fs.writeFileSync(
path.join(autolinkDir, 'autolinking.json'),
JSON.stringify({dependencies}),
);
return {appRoot, rnRoot};
}
const run = ({appRoot, rnRoot}) =>
main(['--app-root', appRoot, '--react-native-root', rnRoot]);
it('accepts a normal module name', () => {
const app = buildApp({
modules: [{name: 'MyNativeModule', path: 'ios/MyNativeModule'}],
});
expect(() => run(app)).not.toThrow();
});
it('rejects a module named after a reserved React Native name', () => {
const app = buildApp({
modules: [{name: 'ReactNative', path: 'ios/MyNativeModule'}],
});
expect(() => run(app)).toThrow(SpmNameCollisionError);
expect(() => run(app)).toThrow(
/the 'spm.modules' entry 'ReactNative' resolves to 'ReactNative', which React Native reserves/,
);
expect(() => run(app)).toThrow(/'spm\.modules'\.$/);
});
it('rejects a reserved product name in any casing', () => {
const app = buildApp({
modules: [{name: 'reactheaders', path: 'ios/MyNativeModule'}],
});
expect(() => run(app)).toThrow(SpmNameCollisionError);
expect(() => run(app)).toThrow(
/the 'spm\.modules' entry 'reactheaders' resolves to 'reactheaders', which differs from React Native's reserved 'ReactHeaders' only in case/,
);
});
it('rejects a module name that is not a valid Swift identifier', () => {
const app = buildApp({
modules: [{name: 'My Module', path: 'ios/MyNativeModule'}],
});
expect(() => run(app)).toThrow(/invalid 'spm.modules' name "My Module"/);
});
it('rejects two modules resolving to the same name', () => {
const app = buildApp({
modules: [
{name: 'Shared', path: 'ios/one'},
{name: 'shared', path: 'ios/two'},
],
});
expect(() => run(app)).toThrow(SpmNameCollisionError);
expect(() => run(app)).toThrow(
/the 'spm.modules' entry 'shared' differs from the existing target 'Shared' only in case/,
);
});
it('rejects a module colliding with an autolinked dep', () => {
const app = buildApp({
modules: [{name: 'ReactNativeFoo', path: 'ios/MyNativeModule'}],
dep: {name: 'react-native-foo'},
});
expect(() => run(app)).toThrow(SpmNameCollisionError);
expect(() => run(app)).toThrow(
/the 'spm.modules' entry 'ReactNativeFoo' is already the name of another autolinked target/,
);
});
});
// ---------------------------------------------------------------------------
// main() — plugin flavoredFrameworks sidecar
//
@@ -73,6 +73,7 @@ class SpmNameCollisionError extends Error {
// The charset `spm.name` must satisfy — permissive on purpose, since it has to
// admit header-dir style (lowercase with hyphens) as well as Swift identifiers.
// Shared with the app's own `spm.modules` names.
function isValidSwiftName(name /*: unknown */) /*: boolean */ {
return typeof name === 'string' && /^[A-Za-z_][A-Za-z0-9_-]*$/.test(name);
}
@@ -59,9 +59,12 @@
const {discoverPlugins, invokePlugins} = require('./autolinking-plugins');
const {
SpmNameCollisionError,
assertSwiftNameNotReserved,
defaultReadConfig,
defaultResolveDep,
expandSpmDependencies,
isValidSwiftName,
} = require('./expand-spm-dependencies');
const {readPodspec} = require('./read-podspec');
const {
@@ -264,6 +267,41 @@ function readSpmModulesFromConfig(
}
}
/**
* Validates one app-local `spm.modules` name against the same rules a library's
* `spm.name` gets: a usable Swift identifier, not a name React Native reserves,
* and not one already taken by another module or an autolinked dep.
* `taken` maps lower-cased name → the name as written.
*/
function assertSpmModuleName(
name /*: unknown */,
taken /*: Map<string, string> */,
) /*: void */ {
const remedy =
"Rename it in this app's react-native.config.js 'spm.modules'.";
if (typeof name !== 'string' || !isValidSwiftName(name)) {
throw new Error(
`react-native autolinking: invalid 'spm.modules' name ${JSON.stringify(name) ?? 'undefined'}: must start with a letter or underscore and contain only letters, digits, underscores, or hyphens.`,
);
}
const moduleName = name;
assertSwiftNameNotReserved(moduleName, {
label: `the 'spm.modules' entry '${moduleName}'`,
remedy,
extraReservedNames: reservedNamesForRun(),
});
const clash = taken.get(moduleName.toLowerCase());
if (clash != null) {
throw new SpmNameCollisionError(
`react-native autolinking: SPM Swift name collision: the 'spm.modules' entry '${moduleName}' ` +
(clash === moduleName
? `is already the name of another autolinked target.`
: `differs from the existing target '${clash}' only in case, which collides on case-insensitive filesystems.`) +
` ${remedy}`,
);
}
}
/**
* Reads the app's `spm.denyPlugins` — npm names of autolinking plugins to
* skip. The escape hatch for the transitive plugin discovery (an app opts a
@@ -1377,7 +1415,15 @@ function main(argv /*:: ?: Array<string> */) /*: void */ {
// the globs now relative to its dir and attach the file list to the target
// so the emission loop below renders `sources: [...]` literally.
const configModules = readSpmModulesFromConfig(appRoot);
// Module names land in the manifest exactly as written, so they get the same
// checks a dep's Swift name gets. Seeded with the dep target names already
// emitted so a module can't shadow an autolinked library either.
const takenSwiftNames /*: Map<string, string> */ = new Map(
entries.map(entry => [entry.target.name.toLowerCase(), entry.target.name]),
);
for (const mod of configModules) {
assertSpmModuleName(mod.name, takenSwiftNames);
takenSwiftNames.set(mod.name.toLowerCase(), mod.name);
const absPath = path.resolve(appRoot, mod.path);
const relPath = path.relative(outputDir, absPath);
const userSources =
@@ -1460,8 +1506,9 @@ function main(argv /*:: ?: Array<string> */) /*: void */ {
// longer silently synthesize one for them (that duplicated the scaffolder and
// hid the gap from the developer and the library author) — collect them and
// fail with an actionable message after the classification pass. spmModules
// (app-local, podspec-less, explicitly declared in react-native.config.js)
// keep their synth wrappers: there is nothing to scaffold for them.
// (app-local, explicitly declared in react-native.config.js) keep their synth
// wrappers: an app-local dir has no npm identity, so there is no package for
// the aggregator to reference until one is written for it.
const missingManifests /*: Array<{name: string, npmName: string, hasPodspec: boolean, mixed?: boolean}> */ =
[];
@@ -1513,11 +1560,14 @@ function main(argv /*:: ?: Array<string> */) /*: void */ {
}
continue;
}
// spmModule: synth wrapper is the legitimate mechanism (no podspec exists
// to scaffold from, and the app developer declared it explicitly). But a
// mixed-language module can't be wrapped either — SPM can't compile Swift +
// C-family sources in one target, and a synth wrapper would fail with a
// cryptic SPM resolve error. Surface the same friendly diagnostic the
// spmModule: the synth wrapper is the mechanism, not a fallback — an
// app-local dir has no npm identity, so the wrapper is the only package the
// aggregator can reference. No podspec is read on this route by design:
// app-local native code isn't required to carry one. (A hand-written
// Package.swift still wins — the self-managed check above claims it first.)
// But a mixed-language module can't be wrapped either — SPM can't compile
// Swift + C-family sources in one target, and a synth wrapper would fail
// with a cryptic SPM resolve error. Surface the same friendly diagnostic the
// community-dep path uses instead of letting SPM emit the cryptic one.
if (hasMixedLanguageSources(absSource)) {
throw new Error(