Skip to content

Commit e19cd06

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 ce621bb commit e19cd06

25 files changed

Lines changed: 2759 additions & 911 deletions

packages/react-native-test-library/apple/TestLibraryApple.mm

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
// wraps each autolinked dep as a Foo.framework under PackageFrameworks/. That
1212
// gives angle-bracket imports the standard <Module/Header.h> resolution path,
1313
// matching how most React Native libraries already organize their headers.
14-
#import <ReactNativeTestLibraryCommon/TestLibraryCommon.h>
14+
#import <TestLibraryCommon/TestLibraryCommon.h>
1515

1616
@implementation TestLibraryApple
1717

packages/react-native-test-library/apple/TestLibraryApple.podspec

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,10 +19,10 @@ Pod::Spec.new do |s|
1919
s.source_files = "*.{h,m,mm,swift}"
2020
s.requires_arc = true
2121

22-
# TestLibraryApple.mm imports <ReactNativeTestLibraryCommon/TestLibraryCommon.h>.
23-
# CocoaPods resolves it leniently through the shared Public headers dir, but the
24-
# dependency edge must be declared for SwiftPM (the scaffolder wires sibling
25-
# packages from podspec dependencies).
22+
# TestLibraryApple.mm imports <TestLibraryCommon/TestLibraryCommon.h> — the
23+
# prefix the pod name gives it under CocoaPods and SwiftPM alike. CocoaPods
24+
# resolves it leniently through the shared Public headers dir; SwiftPM needs
25+
# the dependency edge declared (the scaffolder wires siblings from it).
2626
s.dependency "TestLibraryCommon"
2727

2828
install_modules_dependencies(s)

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: 114 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -331,49 +331,120 @@ 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+
Steps 3–4 are a **migration path, not the destination**. They exist so that
406+
libraries work unchanged today, a podspec being what they already ship, and
407+
`npx react-native spm scaffold` closes them out: it records the name it derived
408+
as `swiftpmConfig.name` in the library's package.json, after which the podspec
409+
is never consulted for naming again. A library that declares its own name needs
410+
no podspec for SwiftPM at all.
411+
412+
Within those two steps, `header_dir` comes first because that is what a library
413+
sets when its import prefix differs from its pod name — but a `header_dir` only
414+
a **subspec** declares names that subspec's headers, not the library, so the pod
415+
name stands (react-native-svg is `RNSVG`, not `rnsvg`; its subspec prefix
416+
resolves through the header search paths instead). An unreadable podspec falls
417+
through rather than failing the build. A prefix Swift cannot spell is normalized
418+
`Some.Pod` becomes `Some_Pod`, what SwiftPM would compile it as anyway — with
419+
a warning naming `swiftpmConfig.name`.
420+
421+
Two names are refused outright: one React Native reserves (`ReactNative`,
422+
`ReactHeaders`, `ReactNativeHeaders`, `ReactNativeDependenciesHeaders`,
423+
`ReactAppHeaders`, `React-GeneratedCode`, `ReactCodegen`,
424+
`ReactAppDependencyProvider`, `Autolinked`), and one another library already
425+
took. Both are **hard errors** naming `swiftpmConfig.name` — nothing is renamed
426+
automatically, because a name the build invented is one no `#import` in your
427+
sources can predict. Two names must differ by more than case or punctuation to
428+
be two targets: `worklets` and `Worklets` share a headers directory, and
429+
`foo-bar` and `foo_bar` are one module, since SwiftPM replaces every character
430+
C99 rejects with `_`.
431+
355432
## Dependencies between libraries
356433

357434
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:
435+
another native library declares it explicitly in its **own** package.json — a
436+
list of npm names:
360437

361-
```js
362-
// react-native-reanimated/react-native.config.js
363-
module.exports = {
364-
dependency: {platforms: {ios: {}}},
365-
spm: {dependencies: ['react-native-worklets']},
366-
};
438+
```json
439+
// react-native-reanimated/package.json
440+
{
441+
"swiftpmConfig": {"dependencies": ["react-native-worklets"]}
442+
}
367443
```
368444

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.
445+
The autolinker follows these **recursively** from the directly-autolinked deps
446+
and dedupes, so a transitive dependency joins the package graph even when the
447+
app never depends on it directly.
377448

378449
### Config module format
379450

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

387458
```js
388-
module.exports = {dependency: {platforms: {ios: {}}}, spm: {name: 'worklets'}};
459+
module.exports = {dependency: {platforms: {ios: {}}}};
389460
```
390461

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.
462+
If the config fails to load, a warning names the file and the reason — any
463+
deprecated `spm` settings in it are ignored rather than silently applied.
393464

394465
## Self-managed community packages
395466

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

430-
Because `node_modules/` isn't committed, persist it so it survives the next
501+
`scaffold` also records the name it derived from the podspec as
502+
`swiftpmConfig.name` in the library's package.json — the one step that lets the
503+
library be named without reading a podspec at all. It never overwrites a name
504+
the library already declares, and it says which packages it edited.
505+
506+
Because `node_modules/` isn't committed, persist both so they survive the next
431507
install:
432508

433509
```bash
@@ -445,7 +521,9 @@ workaround keeps your app building.
445521
> A library whose sources mix Swift **and** Objective-C/C++ in one target, or
446522
> that ships neither a `Package.swift` nor a podspec, can't be scaffolded
447523
> 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.
524+
> (`platforms.ios = null`) or ask the maintainer for a prebuilt xcframework. A
525+
> library can also opt out of scaffolding alone with
526+
> `"swiftpmConfig": {"scaffold": false}`.
449527
450528
## Framework plugins (Preview)
451529

@@ -490,6 +568,7 @@ across apps; refresh it with `react-native spm update --download force`.
490568
| `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`. |
491569
| `spm add` fails: "multiple .xcodeproj found" | Pass `--xcodeproj <path>` (and `--product-name <target>` if multiple app targets). |
492570
| `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) |
571+
| `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) |
493572
| Missing headers | Re-run `react-native spm` |
494573
| "not contained in target" | Re-run setup (regenerates file-level symlinks) |
495574
| Codegen fails | Use `--skipCodegen` to iterate on other parts |
@@ -596,7 +675,7 @@ _existing_ set of generated packages current; they do not create the first one.
596675
1. Compares timestamps of staleness inputs against
597676
`build/generated/autolinking/.spm-sync-stamp`:
598677
- `package.json` — dependency declarations
599-
- `react-native.config.js``spm.modules` config
678+
- `react-native.config.js`autolinking config
600679
- `node_modules/` directory mtime — updated by any package manager (npm,
601680
yarn, pnpm, bun); also checks parent `node_modules` for monorepo setups
602681
- a missing `build/xcframeworks/` (e.g. after a manual clean) also marks

0 commit comments

Comments
 (0)