From 0d7aeb7b92c3f0cd6be677d7d4e71e2483e3b944 Mon Sep 17 00:00:00 2001 From: Christian Falch Date: Thu, 20 Aug 2026 16:10:01 +0200 Subject: [PATCH 1/6] Document how a library's SPM Swift name is derived and overridden MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The derivation rule, the `spm.name` override and the names React Native reserves were only discoverable from the autolinker's source or from the error a collision produces. Write them down where a library author looks: the scoped package case that surprises people (`@scope/react-native` derives `ReactNative`, and gets its scope back as `ScopeReactNative`), the two reasons to set `spm.name` and its charset, the reserved names in one table, what happens when two libraries derive the same name — who moves, who stays, and when the build fails anyway — and the npm `files` requirement that makes the override reach consumers at all. Co-Authored-By: Claude Opus 5 (1M context) --- .../scripts/spm/__docs__/spm-scripts.md | 155 +++++++++++++++++- 1 file changed, 148 insertions(+), 7 deletions(-) diff --git a/packages/react-native/scripts/spm/__docs__/spm-scripts.md b/packages/react-native/scripts/spm/__docs__/spm-scripts.md index af1c6e8a463c..4d953f277adf 100644 --- a/packages/react-native/scripts/spm/__docs__/spm-scripts.md +++ b/packages/react-native/scripts/spm/__docs__/spm-scripts.md @@ -329,10 +329,10 @@ build phase calls — a fine trade for not having to remember a command. > and fetches the artifacts itself during normal package resolution. Until then, > the one-time setup run is required on clean machines. -## Local Native Modules +## App-local native modules -Modules not discovered via autolinking can be declared in -`react-native.config.js`: +Native code that lives in the app itself, rather than in an npm package, is +declared in `react-native.config.js`: ```js module.exports = { @@ -340,17 +340,61 @@ module.exports = { modules: [ { name: 'MyNativeModule', - path: 'ios/MyNativeModule', // relative to app root + path: 'ios/MyNativeModule', // relative to the app root exclude: ['*.podspec'], // optional + sources: ['**/*.{h,m,mm}'], // optional }, ], }, }; ``` -Each entry becomes a target in `build/generated/autolinking/Package.swift`. -Sources outside `build/generated/autolinking/` are automatically mirrored with -file-level symlinks. +### Why it is a separate mechanism + +Autolinking only visits installed npm packages, so a directory with no +`package.json` is never discovered — it has to be named somewhere, and this is +where. Each entry becomes its own SwiftPM target with React Native's headers +already wired up: `` resolves inside the module, and the module's own +headers resolve as `#import ` from the app and from +other modules. + +By design, the entry needs no podspec and no `Package.swift` — app-local native +code is not required to carry either. (A hand-written `Package.swift` in the +directory is used as-is; React Native generates nothing.) + +| Field | Meaning | +| --------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `name` | The target's name and its header prefix. A valid Swift identifier, not [reserved](#names-react-native-reserves), and not already taken by another module or an autolinked library. | +| `path` | The module's directory, relative to the app root. | +| `exclude` | Paths inside `path` to keep out of the target, like a podspec's `exclude_files`. Name a directory with a trailing `/`. | +| `sources` | Glob allowlist replacing automatic discovery, like a podspec's `source_files`. Without it, every `.h/.m/.mm/.c/.cpp/.swift` file under `path` is compiled, minus `exclude` and the directories the autolinker always skips (`android/`, `test/`, `tests/`, `__tests__/`, `__mocks__/`, `jest/`, `node_modules/`). | + +Names must be unique because wrapper directories are keyed by name: two entries +resolving to one name — or an entry matching an autolinked library — would share +a directory, and the losing module would silently not be built. So a clash fails +the build with a rename instruction instead. + +### This is an app surface + +`spm.modules` is read from the app's own `react-native.config.js` only — entries +in a dependency's config are ignored, so a library cannot declare its native +code this way. A library ships its own `Package.swift`, or has one scaffolded +from its podspec — see +[community packages without a `Package.swift`](#community-packages-without-a-packageswift). + +### Limits + +- **One target.** `spm add` attaches the `Autolinked` aggregate to a single app + target (`--productName` picks it when the project has several), and modules + reach only that target. Known gap: rn-tester's test targets + (`RNTesterUnitTests`, `RNTesterIntegrationTests`) get their own pods under + CocoaPods, and `spm.modules` has no equivalent for them yet. +- **One language per module.** SwiftPM cannot mix Swift and Objective-C/C++ in + one target; a module that does fails the build. Split it per language, or ship + a hand-written `Package.swift` with a target for each. +- **No third-party Swift packages.** Add those in Xcode (File → Add Package + Dependencies…); React Native does not need to know. Pods you keep are still + [installed by CocoaPods](#keeping-non-rn-pods). ## Dependencies between libraries @@ -391,6 +435,103 @@ module.exports = {dependency: {platforms: {ios: {}}}, spm: {name: 'worklets'}}; If the config fails to load, a warning names the file and the reason — the `spm` settings in it are ignored rather than silently applied. +## Library authors: your library's Swift name + +Every autolinked library becomes one SwiftPM target, whose name is also its +header prefix (`#import `). The name is derived from the +npm package name unless the library overrides it. + +### How the name is derived + +The scope is dropped, the rest is split on runs of non-alphanumeric characters, +each part gets its first character upper-cased, and the parts are joined. +Existing casing is kept (`RNWorklets` stays `RNWorklets`). + +| npm package | Swift name | +| ----------------------- | --------------------- | +| `react-native-worklets` | `ReactNativeWorklets` | +| `@react-native/foo` | `Foo` | +| `@scope/common` | `Common` | +| `@scope/react-native` | `ReactNative` | + +The last row is the surprise: dropping the scope makes `@scope/react-native` +derive `ReactNative`, a [reserved name](#names-react-native-reserves). A scoped +package then gets its scope back — the target becomes `ScopeReactNative`, and +the build logs the rewrite. The same borrow settles a +[collision between two libraries](#when-two-libraries-derive-the-same-name), so +neither case needs an override. + +### Overriding it with `spm.name` + +Set `spm.name` in the library's **own** `react-native.config.js`: + +```js +// react-native-worklets/react-native.config.js +module.exports = { + dependency: {platforms: {ios: {}}}, + spm: {name: 'worklets'}, +}; +``` + +The value must start with a letter or underscore and may contain only letters, +digits, underscores and hyphens. Set it when: + +- **The derived name is reserved** and your package is unscoped — see below. +- **Your headers use a different prefix** (a podspec `s.header_dir` unlike the + derived name): `react-native-worklets` derives `ReactNativeWorklets` but ships + headers as ``, so it sets `spm.name: 'worklets'` to keep + consumers' `#import ` lines resolving. + +### Names React Native reserves + +React Native registers these names for its own package and products, so no +library's target may end up with one of them (matched case-insensitively). They +are also refused to an app's own `spm.modules` entries. + +| Name | What it is | +| -------------------------------- | --------------------------- | +| `ReactNative` | The React Native package | +| `React-GeneratedCode` | The per-app codegen package | +| `Autolinked` | The autolinking aggregator | +| `ReactHeaders` | Product | +| `ReactNativeHeaders` | Product | +| `ReactNativeDependenciesHeaders` | Product | +| `ReactAppHeaders` | Product | +| `ReactCodegen` | Product | +| `ReactAppDependencyProvider` | Product | + +A scoped package that derives a reserved name is auto-corrected by +[borrowing its scope](#how-the-name-is-derived). When the scope cannot help, the +build fails, naming the package and `spm.name` as the fix: + +- **An unscoped package** — no scope to borrow. +- **An `spm.name` you set yourself** — your explicit choice is never rewritten. +- **A scope-prefixed name that is itself reserved.** + +### When two libraries derive the same name + +Dropping the scope also makes `@a/foo` and `@b/foo` both derive `Foo`. Every +scoped library in the group gets its scope prepended, and each rewrite is logged +— neither author has to do anything: + +| npm packages | Swift names | +| -------------------------------------------- | --------------- | +| `@a/foo` + `@b/foo` | `AFoo` + `BFoo` | +| `@a/foo` + `foo` | `AFoo` + `Foo` | +| `@a/foo` (with `spm.name: 'Foo'`) + `@b/foo` | `Foo` + `BFoo` | + +Two kinds of member never move: an unscoped package (no scope to borrow) and an +explicit `spm.name` (your choice wins). If a borrowed name is itself taken +(`AFoo` while a package `a-foo` is installed too) or +[reserved](#names-react-native-reserves), the build fails and one library must +set `spm.name`. + +### Ship the config file + +`react-native.config.js` must be in your package's npm `files` allowlist. If it +isn't published, consumers get the derived name instead of your override, and +the mismatch only shows up as a build error in their app. + ## Self-managed community packages A community library that ships its own `Package.swift` is referenced directly by From befcee7b2ec367813ee2c24e80d209cbc11535a2 Mon Sep 17 00:00:00 2001 From: Christian Falch <875252+chrfalch@users.noreply.github.com> Date: Tue, 25 Aug 2026 15:59:29 +0200 Subject: [PATCH 2/6] Update packages/react-native/scripts/spm/__docs__/spm-scripts.md Co-authored-by: Riccardo Cipolleschi --- packages/react-native/scripts/spm/__docs__/spm-scripts.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/react-native/scripts/spm/__docs__/spm-scripts.md b/packages/react-native/scripts/spm/__docs__/spm-scripts.md index 4d953f277adf..eab44d7dd33f 100644 --- a/packages/react-native/scripts/spm/__docs__/spm-scripts.md +++ b/packages/react-native/scripts/spm/__docs__/spm-scripts.md @@ -359,8 +359,8 @@ headers resolve as `#import ` from the app and from other modules. By design, the entry needs no podspec and no `Package.swift` — app-local native -code is not required to carry either. (A hand-written `Package.swift` in the -directory is used as-is; React Native generates nothing.) +code is not required to carry either. +If the module ships a hand-written `Package.swift`, then React Native generates nothing and uses the Package.swift as-is. | Field | Meaning | | --------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | From 23eae078c7ca29cbeb778e6e30c3a5984b1b7c23 Mon Sep 17 00:00:00 2001 From: Christian Falch <875252+chrfalch@users.noreply.github.com> Date: Tue, 25 Aug 2026 15:59:40 +0200 Subject: [PATCH 3/6] Update packages/react-native/scripts/spm/__docs__/spm-scripts.md Co-authored-by: Riccardo Cipolleschi --- packages/react-native/scripts/spm/__docs__/spm-scripts.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/react-native/scripts/spm/__docs__/spm-scripts.md b/packages/react-native/scripts/spm/__docs__/spm-scripts.md index eab44d7dd33f..30a0766585b9 100644 --- a/packages/react-native/scripts/spm/__docs__/spm-scripts.md +++ b/packages/react-native/scripts/spm/__docs__/spm-scripts.md @@ -366,7 +366,7 @@ If the module ships a hand-written `Package.swift`, then React Native generates | --------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `name` | The target's name and its header prefix. A valid Swift identifier, not [reserved](#names-react-native-reserves), and not already taken by another module or an autolinked library. | | `path` | The module's directory, relative to the app root. | -| `exclude` | Paths inside `path` to keep out of the target, like a podspec's `exclude_files`. Name a directory with a trailing `/`. | +| `exclude` | Paths inside `path` to keep out of the target, like a podspec's `exclude_files`. It also supports directories with a trailing `/`. | | `sources` | Glob allowlist replacing automatic discovery, like a podspec's `source_files`. Without it, every `.h/.m/.mm/.c/.cpp/.swift` file under `path` is compiled, minus `exclude` and the directories the autolinker always skips (`android/`, `test/`, `tests/`, `__tests__/`, `__mocks__/`, `jest/`, `node_modules/`). | Names must be unique because wrapper directories are keyed by name: two entries From 864878193afc874a8137dab91ecc38725efe8619 Mon Sep 17 00:00:00 2001 From: Christian Falch <875252+chrfalch@users.noreply.github.com> Date: Tue, 25 Aug 2026 15:59:49 +0200 Subject: [PATCH 4/6] Update packages/react-native/scripts/spm/__docs__/spm-scripts.md Co-authored-by: Riccardo Cipolleschi --- packages/react-native/scripts/spm/__docs__/spm-scripts.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/react-native/scripts/spm/__docs__/spm-scripts.md b/packages/react-native/scripts/spm/__docs__/spm-scripts.md index 30a0766585b9..440827bfee38 100644 --- a/packages/react-native/scripts/spm/__docs__/spm-scripts.md +++ b/packages/react-native/scripts/spm/__docs__/spm-scripts.md @@ -374,7 +374,7 @@ resolving to one name — or an entry matching an autolinked library — would s a directory, and the losing module would silently not be built. So a clash fails the build with a rename instruction instead. -### This is an app surface +### App Modules vs Libraries `spm.modules` is read from the app's own `react-native.config.js` only — entries in a dependency's config are ignored, so a library cannot declare its native From 20b3ecf7a3b4225f408f0d6315e92a2b11acae24 Mon Sep 17 00:00:00 2001 From: Christian Falch Date: Tue, 25 Aug 2026 16:07:48 +0200 Subject: [PATCH 5/6] Clarify the spm.modules one-app-target limit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The limit read as a cap on how many modules an app may declare, or as all of them being merged into one target. It is neither: each entry is its own SwiftPM target, and the constraint is which targets can use them — only the app target `spm add` attached the aggregate to. Co-Authored-By: Claude Opus 5 (1M context) --- .../scripts/spm/__docs__/spm-scripts.md | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/packages/react-native/scripts/spm/__docs__/spm-scripts.md b/packages/react-native/scripts/spm/__docs__/spm-scripts.md index 440827bfee38..db7616965c6f 100644 --- a/packages/react-native/scripts/spm/__docs__/spm-scripts.md +++ b/packages/react-native/scripts/spm/__docs__/spm-scripts.md @@ -384,11 +384,14 @@ from its podspec — see ### Limits -- **One target.** `spm add` attaches the `Autolinked` aggregate to a single app - target (`--productName` picks it when the project has several), and modules - reach only that target. Known gap: rn-tester's test targets - (`RNTesterUnitTests`, `RNTesterIntegrationTests`) get their own pods under - CocoaPods, and `spm.modules` has no equivalent for them yet. +- **Modules reach one app target.** Each entry is still its own SwiftPM target — + the limit is which targets can use them. `spm add` attaches the `Autolinked` + aggregate to a single app target (`--productName` picks it when the project + has several), and only that target sees the modules. Declare as many entries + as you like; they all arrive through that one aggregate. Known gap: + rn-tester's test targets (`RNTesterUnitTests`, `RNTesterIntegrationTests`) get + their own pods under CocoaPods, and `spm.modules` has no equivalent for them + yet. - **One language per module.** SwiftPM cannot mix Swift and Objective-C/C++ in one target; a module that does fails the build. Split it per language, or ship a hand-written `Package.swift` with a target for each. From 5184c04b0d7204e3dcfebed43aa4aa40fc07ae13 Mon Sep 17 00:00:00 2001 From: Christian Falch Date: Tue, 25 Aug 2026 16:09:07 +0200 Subject: [PATCH 6/6] Format spm-scripts.md after applying review suggestions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The GitHub suggestion commits reflowed prose past the print width, padded a table cell, and left trailing whitespace on a heading — prettier --list-different flags the file, which is what the lint job runs. Co-Authored-By: Claude Opus 5 (1M context) --- .../react-native/scripts/spm/__docs__/spm-scripts.md | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/packages/react-native/scripts/spm/__docs__/spm-scripts.md b/packages/react-native/scripts/spm/__docs__/spm-scripts.md index db7616965c6f..0dfbdc3e475e 100644 --- a/packages/react-native/scripts/spm/__docs__/spm-scripts.md +++ b/packages/react-native/scripts/spm/__docs__/spm-scripts.md @@ -359,14 +359,15 @@ headers resolve as `#import ` from the app and from other modules. By design, the entry needs no podspec and no `Package.swift` — app-local native -code is not required to carry either. -If the module ships a hand-written `Package.swift`, then React Native generates nothing and uses the Package.swift as-is. +code is not required to carry either. If the module ships a hand-written +`Package.swift`, then React Native generates nothing and uses the Package.swift +as-is. | Field | Meaning | | --------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `name` | The target's name and its header prefix. A valid Swift identifier, not [reserved](#names-react-native-reserves), and not already taken by another module or an autolinked library. | | `path` | The module's directory, relative to the app root. | -| `exclude` | Paths inside `path` to keep out of the target, like a podspec's `exclude_files`. It also supports directories with a trailing `/`. | +| `exclude` | Paths inside `path` to keep out of the target, like a podspec's `exclude_files`. It also supports directories with a trailing `/`. | | `sources` | Glob allowlist replacing automatic discovery, like a podspec's `source_files`. Without it, every `.h/.m/.mm/.c/.cpp/.swift` file under `path` is compiled, minus `exclude` and the directories the autolinker always skips (`android/`, `test/`, `tests/`, `__tests__/`, `__mocks__/`, `jest/`, `node_modules/`). | Names must be unique because wrapper directories are keyed by name: two entries @@ -374,7 +375,7 @@ resolving to one name — or an entry matching an autolinked library — would s a directory, and the losing module would silently not be built. So a clash fails the build with a rename instruction instead. -### App Modules vs Libraries +### App Modules vs Libraries `spm.modules` is read from the app's own `react-native.config.js` only — entries in a dependency's config are ignored, so a library cannot declare its native