Skip to content

Commit 9dda3a4

Browse files
chrfalchclaude
andcommitted
Derive an SPM library's Swift name from its podspec, and give SwiftPM config a home
An autolinked library's SwiftPM target name is also its header import prefix, so deriving it from the npm package name was wrong for most of the ecosystem (react-native-svg publishes RNSVG, not ReactNativeSvg) and wrong silently — no error, just headers nobody can import under the expected name. A package's SwiftPM settings now live in `swiftpmConfig` in its package.json, following codegenConfig's conventions: `name`, `dependencies`, `autolinkingPlugin` and `scaffold` for a library, `modules` and `denyPlugins` for an app. The `spm` block in react-native.config.js is deprecated — still read, so nothing breaks, but it warns once per file and package.json wins field by field. A name resolves from `swiftpmConfig.name`, then the deprecated `spm.name`, then the podspec's `header_dir` or name, then the npm name. The podspec is thereby transitional rather than permanent: `spm scaffold` records the name it derived as `swiftpmConfig.name` in the library's package.json, so the next run needs no podspec to name it. It never overwrites a name the library already declares, never records a name guessed from the npm name, and reports what it did. A prefix Swift cannot spell is normalized to the identifier SwiftPM would compile it as, with a warning. A reserved name or two deps landing on one name is a hard error naming swiftpmConfig.name as the fix; the scope-borrowing that auto-corrected collisions is removed, since a name the build invents is a name no #import can predict. Collision checks key on SwiftPM's c99 name, so react-native-svg and react_native_svg no longer pass and then compile as one module. Name resolution reads the two podspec fields it needs with the regex parser, so it adds no `pod ipc spec` spawn, and the full read is memoized on the resolved path so one run reads a podspec once across name resolution, header search paths and scaffolding. rn-tester and the Apple test library move to the new location. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 92cd588 commit 9dda3a4

22 files changed

Lines changed: 2421 additions & 899 deletions

packages/react-native-test-library/apple/package.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,5 +27,10 @@
2727
"peerDependencies": {
2828
"react": "*",
2929
"react-native": "1000.0.0"
30+
},
31+
"swiftpmConfig": {
32+
"dependencies": [
33+
"react-native-test-library-common"
34+
]
3035
}
3136
}

packages/react-native-test-library/apple/react-native.config.js

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,4 @@ module.exports = {
1616
ios: {},
1717
},
1818
},
19-
spm: {
20-
dependencies: ['react-native-test-library-common'],
21-
},
2219
};

packages/react-native/scripts/setup-apple-spm.js

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -490,15 +490,27 @@ async function runScaffold(
490490

491491
if (written.length > 0) {
492492
log(`Scaffolded Package.swift for ${written.length} dep(s):`);
493+
// Silent for the outcomes that changed nothing ('already-set', 'skipped').
494+
const nameOutcome = (outcome /*: ?string */) /*: string */ => {
495+
if (outcome === 'created' || outcome === 'inserted') {
496+
return " — also recorded 'swiftpmConfig.name' in its package.json";
497+
}
498+
if (outcome === 'failed') {
499+
return " — could NOT record 'swiftpmConfig.name'; the manifest is written, the name is not";
500+
}
501+
return '';
502+
};
493503
for (const r of written) {
494-
log(` • ${r.depName}`);
504+
log(` • ${r.depName}${nameOutcome(r.swiftpmName)}`);
495505
}
496506
log('');
497507
log(
498508
'node_modules is NOT committed and is wiped by `npm install`. To keep\n' +
499509
'these manifests, create and commit a patch with a tool like patch-package:\n' +
500510
' • `npx patch-package <dep>` for each scaffolded dep, then commit the patch.\n' +
501-
'Also consider asking the maintainer to ship a Package.swift upstream.\n' +
511+
'The patch also captures any recorded `swiftpmConfig.name`, which is what\n' +
512+
'lets the library be named without reading its podspec. Better still, ask the\n' +
513+
'maintainer to ship both upstream.\n' +
502514
'Without a committed patch the build will hard-error again after a fresh install.',
503515
);
504516
log('');

packages/react-native/scripts/spm/__docs__/spm-autolinking-plugins.md

Lines changed: 19 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,8 @@ generates. See [spm-scripts.md](./spm-scripts.md) for the base tool.
1212

1313
The documented extension points don't cover a framework:
1414

15-
- `spm.modules` in `react-native.config.js` is a **static** list of simple
16-
source modules. A framework discovers its modules **dynamically** (scanning
15+
- `swiftpmConfig.modules` in package.json is a **static** list of simple source
16+
modules. A framework discovers its modules **dynamically** (scanning
1717
`node_modules`), generates a **module registry**, and ships mixed
1818
Swift/ObjC/C++ modules (e.g. `ExpoModulesCore`) that `spm scaffold` can't
1919
handle.
@@ -32,28 +32,28 @@ stale.
3232

3333
## Discovery — transitive, zero app config
3434

35-
A dependency opts in from its **own** `react-native.config.js`, so installing
36-
the framework is enough (mirrors how CocoaPods pulls in `use_expo_modules!`
37-
transitively):
35+
A dependency opts in from its **own** package.json, so installing the framework
36+
is enough (mirrors how CocoaPods pulls in `use_expo_modules!` transitively):
3837

39-
```js
40-
// node_modules/expo/react-native.config.js
41-
module.exports = {
42-
spm: {autolinkingPlugin: './spm/autolinking-plugin.js'},
43-
};
38+
```json
39+
// node_modules/expo/package.json
40+
{
41+
"swiftpmConfig": {"autolinkingPlugin": "./spm/autolinking-plugin.js"}
42+
}
4443
```
4544

46-
The autolinker already walks every dependency's `react-native.config.js`; any
47-
that declares `spm.autolinkingPlugin` is `require`d and invoked. No app-level
48-
registration or allowlist is required.
45+
The autolinker reads every dependency's SwiftPM settings; any that declares
46+
`autolinkingPlugin` is `require`d and invoked. No app-level registration or
47+
allowlist is required. The deprecated `spm.autolinkingPlugin` in
48+
`react-native.config.js` is still read — see
49+
[Migrating from react-native.config.js](spm-scripts.md#where-swiftpm-settings-live).
4950

50-
**Opt-out escape hatch.** An app can exclude a plugin from its own
51-
`react-native.config.js`:
51+
**Opt-out escape hatch.** An app can exclude a plugin from its own package.json:
5252

53-
```js
54-
module.exports = {
55-
spm: {denyPlugins: ['some-framework']}, // npm names to skip
56-
};
53+
```json
54+
{
55+
"swiftpmConfig": {"denyPlugins": ["some-framework"]}
56+
}
5757
```
5858

5959
## The contract

packages/react-native/scripts/spm/__docs__/spm-scripts.md

Lines changed: 104 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -331,49 +331,110 @@ build phase calls — a fine trade for not having to remember a command.
331331
332332
## Local Native Modules
333333

334-
Modules not discovered via autolinking can be declared in
335-
`react-native.config.js`:
334+
Modules not discovered via autolinking are declared in the app's package.json.
335+
Each `path` is relative to the file that declares it — the project root for a
336+
package.json there, the Xcode project directory for a config kept there:
336337

337-
```js
338-
module.exports = {
339-
spm: {
340-
modules: [
338+
```json
339+
{
340+
"swiftpmConfig": {
341+
"modules": [
341342
{
342-
name: 'MyNativeModule',
343-
path: 'ios/MyNativeModule', // relative to app root
344-
exclude: ['*.podspec'], // optional
345-
},
346-
],
347-
},
348-
};
343+
"name": "MyNativeModule",
344+
"path": "ios/MyNativeModule",
345+
"exclude": ["*.podspec"]
346+
}
347+
]
348+
}
349+
}
349350
```
350351

351352
Each entry becomes a target in `build/generated/autolinking/Package.swift`.
352353
Sources outside `build/generated/autolinking/` are automatically mirrored with
353354
file-level symlinks.
354355

356+
## Where SwiftPM settings live
357+
358+
A package's SwiftPM settings live under `swiftpmConfig` in its **package.json**,
359+
alongside `codegenConfig`.
360+
361+
| Field | Set by | What it does |
362+
| ------------------- | ------- | ----------------------------------------------------------- |
363+
| `name` | library | Its SwiftPM target name, and so its header import prefix |
364+
| `dependencies` | library | npm names of native libraries it builds against |
365+
| `autolinkingPlugin` | library | Path to an [autolinking plugin](spm-autolinking-plugins.md) |
366+
| `scaffold` | library | `false` opts the library out of `spm scaffold` |
367+
| `modules` | app | [Local native modules](#local-native-modules) to build |
368+
| `denyPlugins` | app | npm names whose autolinking plugin to skip |
369+
370+
```json
371+
{
372+
"swiftpmConfig": {
373+
"name": "RNSVG",
374+
"dependencies": ["react-native-worklets"]
375+
}
376+
}
377+
```
378+
379+
A library's settings come from its own package.json. An **app's** are resolved
380+
field by field, each from the directory holding its package.json — the JS root,
381+
where codegen reads `codegenConfig` — falling back to the Xcode project
382+
directory. An unrecognised field is ignored with a warning naming it, so a typo
383+
does not pass silently.
384+
385+
These settings used to live in an `spm` block in `react-native.config.js`. That
386+
block is **deprecated** but still read, with the same field names, so nothing
387+
breaks: move the keys across as they are, and package.json wins field by field.
388+
`npx react-native spm scaffold` writes the `name` for you — see
389+
[Community packages without a Package.swift](#community-packages-without-a-packageswift).
390+
391+
## Library names
392+
393+
An autolinked library's SwiftPM target name is also the prefix its headers are
394+
imported under (`#import <RNSVG/…>`), so it is not cosmetic: it has to be the
395+
prefix the library's own sources and its dependents already use.
396+
397+
It is resolved in this order:
398+
399+
1. `swiftpmConfig.name` in the library's package.json
400+
2. `spm.name` in `react-native.config.js` (deprecated)
401+
3. podspec `header_dir``React-Core``React`
402+
4. podspec name — `react-native-svg``RNSVG`
403+
5. npm package name — `react-native-svg``ReactNativeSvg`
404+
405+
The podspec is transitional, `header_dir` first because that is what a library
406+
sets when its import prefix differs from its pod name; an unreadable one falls
407+
through rather than failing the build. A prefix Swift cannot spell is normalized
408+
`Some.Pod` becomes `Some_Pod`, what SwiftPM would compile it as anyway — with
409+
a warning naming `swiftpmConfig.name`.
410+
411+
Two names are refused outright: one React Native reserves (`ReactNative`,
412+
`ReactHeaders`, `ReactNativeHeaders`, `ReactNativeDependenciesHeaders`,
413+
`ReactAppHeaders`, `React-GeneratedCode`, `ReactCodegen`,
414+
`ReactAppDependencyProvider`, `Autolinked`), and one another library already
415+
took. Both are **hard errors** naming `swiftpmConfig.name` — nothing is renamed
416+
automatically, because a name the build invented is one no `#import` in your
417+
sources can predict. Two names must differ by more than case or punctuation to
418+
be two targets: `worklets` and `Worklets` share a headers directory, and
419+
`foo-bar` and `foo_bar` are one module, since SwiftPM replaces every character
420+
C99 rejects with `_`.
421+
355422
## Dependencies between libraries
356423

357424
SwiftPM has no equivalent of a podspec's `s.dependency`, so a library that needs
358-
another native library declares it explicitly with `spm.dependencies` in its
359-
**own** `react-native.config.js` — a list of npm names:
425+
another native library declares it explicitly in its **own** package.json — a
426+
list of npm names:
360427

361-
```js
362-
// react-native-reanimated/react-native.config.js
363-
module.exports = {
364-
dependency: {platforms: {ios: {}}},
365-
spm: {dependencies: ['react-native-worklets']},
366-
};
428+
```json
429+
// react-native-reanimated/package.json
430+
{
431+
"swiftpmConfig": {"dependencies": ["react-native-worklets"]}
432+
}
367433
```
368434

369-
The autolinker starts from the directly-autolinked deps, follows each one's
370-
`spm.dependencies` **recursively**, and dedupes the result, so a transitive
371-
dependency is pulled into the package graph even when the app never depends on
372-
it directly. Declared names are mapped to Swift target names, so the dependent
373-
library's target can import it.
374-
375-
This is a **library-author** surface, like the podspec dependency it replaces —
376-
apps don't normally set it.
435+
The autolinker follows these **recursively** from the directly-autolinked deps
436+
and dedupes, so a transitive dependency joins the package graph even when the
437+
app never depends on it directly.
377438

378439
### Config module format
379440

@@ -385,11 +446,11 @@ an async one that takes only the default export. For maximum compatibility,
385446
prefer the one-line CommonJS form:
386447

387448
```js
388-
module.exports = {dependency: {platforms: {ios: {}}}, spm: {name: 'worklets'}};
449+
module.exports = {dependency: {platforms: {ios: {}}}};
389450
```
390451

391-
If the config fails to load, a warning names the file and the reason — the `spm`
392-
settings in it are ignored rather than silently applied.
452+
If the config fails to load, a warning names the file and the reason — any
453+
deprecated `spm` settings in it are ignored rather than silently applied.
393454

394455
## Self-managed community packages
395456

@@ -427,7 +488,12 @@ npx react-native spm # then inject/update as usual
427488
does not inject into the `.xcodeproj` — so on a first-time setup you still
428489
follow it with `npx react-native spm`.)
429490

430-
Because `node_modules/` isn't committed, persist it so it survives the next
491+
`scaffold` also records the name it derived from the podspec as
492+
`swiftpmConfig.name` in the library's package.json — the one step that lets the
493+
library be named without reading a podspec at all. It never overwrites a name
494+
the library already declares, and it says which packages it edited.
495+
496+
Because `node_modules/` isn't committed, persist both so they survive the next
431497
install:
432498

433499
```bash
@@ -445,7 +511,9 @@ workaround keeps your app building.
445511
> A library whose sources mix Swift **and** Objective-C/C++ in one target, or
446512
> that ships neither a `Package.swift` nor a podspec, can't be scaffolded
447513
> automatically — the error says so. Opt it out via `react-native.config.js`
448-
> (`platforms.ios = null`) or ask the maintainer for a prebuilt xcframework.
514+
> (`platforms.ios = null`) or ask the maintainer for a prebuilt xcframework. A
515+
> library can also opt out of scaffolding alone with
516+
> `"swiftpmConfig": {"scaffold": false}`.
449517
450518
## Framework plugins (Preview)
451519

@@ -490,6 +558,7 @@ across apps; refresh it with `react-native spm update --download force`.
490558
| `spm add` fails: "no .xcodeproj found" | Create an app first (`npx @react-native-community/cli init`) or make a project in Xcode, then `spm add`. |
491559
| `spm add` fails: "multiple .xcodeproj found" | Pass `--xcodeproj <path>` (and `--product-name <target>` if multiple app targets). |
492560
| `Package.swift is missing for library "<name>"` (exit 2) | The dep ships no SwiftPM support. `npx react-native spm scaffold`, then re-run setup; persist with `patch-package`. See [Community packages without a Package.swift](#community-packages-without-a-packageswift) |
561+
| `SPM Swift name collision` | Two libraries resolved to one Swift name, or one took a name React Native reserves. Set `swiftpmConfig.name` in the library's package.json — see [Library names](#library-names) |
493562
| Missing headers | Re-run `react-native spm` |
494563
| "not contained in target" | Re-run setup (regenerates file-level symlinks) |
495564
| Codegen fails | Use `--skipCodegen` to iterate on other parts |
@@ -596,7 +665,7 @@ _existing_ set of generated packages current; they do not create the first one.
596665
1. Compares timestamps of staleness inputs against
597666
`build/generated/autolinking/.spm-sync-stamp`:
598667
- `package.json` — dependency declarations
599-
- `react-native.config.js``spm.modules` config
668+
- `react-native.config.js`autolinking config
600669
- `node_modules/` directory mtime — updated by any package manager (npm,
601670
yarn, pnpm, bun); also checks parent `node_modules` for monorepo setups
602671
- a missing `build/xcframeworks/` (e.g. after a manual clean) also marks

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

Lines changed: 37 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -34,48 +34,77 @@ describe('discoverPlugins', () => {
3434
return {name, root};
3535
}
3636

37-
// readConfig fake: a dep opts in when opted[name] is truthy.
37+
// A dep opts in through its package.json; `noConfig` stands in for the
38+
// react-native.config.js the caller loads (absent for these cases).
39+
const declarePlugin = dep =>
40+
fs.writeFileSync(
41+
path.join(dep.root, 'package.json'),
42+
JSON.stringify({
43+
name: dep.name,
44+
swiftpmConfig: {autolinkingPlugin: './plugin.js'},
45+
}),
46+
);
47+
const noConfig = () => null;
48+
49+
// The deprecated home, which Expo still ships.
3850
const readConfigFor = opted => root => {
3951
const name = path.basename(root);
4052
return opted[name] ? {spm: {autolinkingPlugin: './plugin.js'}} : null;
4153
};
4254

43-
it('discovers a plugin declared via react-native.config.js', () => {
55+
it('discovers a plugin declared in package.json', () => {
4456
const dep = makeDep('expo', 'module.exports = () => ({});');
45-
const found = discoverPlugins([dep], readConfigFor({expo: true}));
57+
declarePlugin(dep);
58+
const found = discoverPlugins([dep], noConfig);
4659
expect(found).toHaveLength(1);
4760
expect(found[0].depName).toBe('expo');
4861
expect(typeof found[0].plugin).toBe('function');
4962
});
5063

64+
it('still discovers a plugin declared the deprecated way', () => {
65+
const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
66+
try {
67+
const dep = makeDep('expo', 'module.exports = () => ({});');
68+
const found = discoverPlugins([dep], readConfigFor({expo: true}));
69+
expect(found).toHaveLength(1);
70+
expect(warnSpy).toHaveBeenCalled();
71+
} finally {
72+
warnSpy.mockRestore();
73+
}
74+
});
75+
5176
it('skips deps that do not declare a plugin', () => {
5277
const dep = makeDep('react-native-svg', null);
5378
expect(discoverPlugins([dep], readConfigFor({}))).toHaveLength(0);
5479
});
5580

5681
it('honors the app deny-list (opt-out, no allowlist needed)', () => {
5782
const dep = makeDep('expo', 'module.exports = () => ({});');
58-
const found = discoverPlugins([dep], readConfigFor({expo: true}), ['expo']);
59-
expect(found).toHaveLength(0);
83+
declarePlugin(dep);
84+
expect(discoverPlugins([dep], noConfig, ['expo'])).toHaveLength(0);
6085
});
6186

6287
it('accepts default/plugin export interop', () => {
6388
const a = makeDep('a', 'module.exports.default = () => ({});');
6489
const b = makeDep('b', 'module.exports.plugin = () => ({});');
65-
const found = discoverPlugins([a, b], readConfigFor({a: true, b: true}));
90+
declarePlugin(a);
91+
declarePlugin(b);
92+
const found = discoverPlugins([a, b], noConfig);
6693
expect(found.map(f => f.depName).sort()).toEqual(['a', 'b']);
6794
});
6895

6996
it('fails closed when the plugin module is missing', () => {
7097
const dep = makeDep('expo', null); // opted in below but no plugin.js
71-
expect(() => discoverPlugins([dep], readConfigFor({expo: true}))).toThrow(
98+
declarePlugin(dep);
99+
expect(() => discoverPlugins([dep], noConfig)).toThrow(
72100
/failed to load the autolinking plugin for 'expo'/,
73101
);
74102
});
75103

76104
it('fails closed when the module does not export a function', () => {
77105
const dep = makeDep('expo', 'module.exports = {nope: 1};');
78-
expect(() => discoverPlugins([dep], readConfigFor({expo: true}))).toThrow(
106+
declarePlugin(dep);
107+
expect(() => discoverPlugins([dep], noConfig)).toThrow(
79108
/does not export a function/,
80109
);
81110
});

0 commit comments

Comments
 (0)