diff --git a/packages/react-native/scripts/ios-prebuild/__docs__/README.md b/packages/react-native/scripts/ios-prebuild/__docs__/README.md index 70411582212b..0e1431f876ba 100644 --- a/packages/react-native/scripts/ios-prebuild/__docs__/README.md +++ b/packages/react-native/scripts/ios-prebuild/__docs__/README.md @@ -126,11 +126,14 @@ overlay**. The layout contract is defined and validated in code: the `React/` and bare-aliased headers into every slice's `React.framework/Headers`, and `buildReactNativeHeadersXcframework()` assembles the headers-only `ReactNativeHeaders.xcframework` carrying every - other namespace (incl. `react/`) plus the third-party dependency namespaces - (`folly`, `glog`, `boost`, `fmt`, `double-conversion`, `fast_float`). The - Hermes public headers (``) are folded in only on the SwiftPM - consumer side (`ensureHeadersLayout`); the published prebuild artifact does - not yet carry them (TODO in `xcframework.js`). + other React Native namespace (incl. `react/`). The third-party dependency + namespaces (`folly`, `glog`, `boost`, `fmt`, `double-conversion`, + `fast_float`, `SocketRocket`) ship in the separate headers-only + `ReactNativeDependenciesHeaders.xcframework` sidecar, built by the + dependencies prebuild. The Hermes public headers (``) are folded + into the published `ReactNativeHeaders` when the hermes-ios headers are + staged; without them the compose ships no `hermes/`, unless `--require-hermes` + is set, which makes it fail closed. ### Artifacts @@ -139,15 +142,15 @@ The prebuild (`xcframework.js`) always produces: - `React.xcframework` — the compiled React core. Each slice's `React.framework` carries the headers-spec layout (every `` header + the framework module map). CocoaPods consumes that layout directly, through - `FRAMEWORK_SEARCH_PATHS`. SwiftPM consumes the same headers indirectly: the - XCFramework is not a member of the Swift package graph, so the consumer side - stages a copy of `React.framework/Headers` into - `ReactHeadersTarget/include/React` and rewrites `framework module React` to a - plain `module React`, vended as the `ReactHeaders` target (see - `spm-header-paths-contract.md` in the SwiftPM docs). -- `ReactNativeHeaders.xcframework` — headers-only; carries every other - namespace. Consumed by SwiftPM as a `binaryTarget` and by CocoaPods via the - `React-Core-prebuilt` pod (headers flattened onto the header search path). + `FRAMEWORK_SEARCH_PATHS`. SwiftPM consumes the same headers indirectly, + because the XCFramework is not a member of the Swift package graph. The + consumer side stages a copy of `React.framework/Headers` into + `ReactHeadersTarget/include/React`, rewrites `framework module React` to a + plain `module React`, and vends it as the `ReactHeaders` target (see + [spm-header-paths-contract.md](../../spm/__docs__/spm-header-paths-contract.md)). +- `ReactNativeHeaders.xcframework` — headers-only; carries every other React + Native namespace. Consumed by SwiftPM as a `binaryTarget` and by CocoaPods via + the `React-Core-prebuilt` pod (headers flattened onto the header search path). ### CocoaPods consumption diff --git a/packages/react-native/scripts/ios-prebuild/__docs__/headers-rules.md b/packages/react-native/scripts/ios-prebuild/__docs__/headers-rules.md index 7c371601bdd4..ed27ae500e25 100644 --- a/packages/react-native/scripts/ios-prebuild/__docs__/headers-rules.md +++ b/packages/react-native/scripts/ios-prebuild/__docs__/headers-rules.md @@ -120,13 +120,14 @@ resolving `` through `React.framework` requires case-folding `react.framework` → `React.framework`, which only works on case-insensitive filesystems. The header-search-path route (R2) is exact everywhere. -**R2 — every other namespace ships in ONE headers-only xcframework, +**R2 — every other React Native namespace ships in ONE headers-only xcframework, `ReactNativeHeaders`.** Namespace dirs at its `Headers/` root: `react/`, -`yoga/`, `jsi/`, `cxxreact/`, `React_RCTAppDelegate/`, … **including the -third-party deps namespaces** (folly/glog/boost/fmt/double-conversion/ -fast_float, copied out of the ReactNativeDependencies artifact — which thereby -becomes binary-only). SPM/Xcode auto-serve a binaryTarget's `Headers/` on the -consumer's search path, so everything resolves with zero flags. +`yoga/`, `jsi/`, `cxxreact/`, `React_RCTAppDelegate/`, … The third-party deps +namespaces (folly, glog, boost, fmt, double-conversion, fast_float, +SocketRocket) are **not** here: they ship in the headers-only +`ReactNativeDependenciesHeaders` sidecar built by the deps prebuild. SPM/Xcode +auto-serve a binaryTarget's `Headers/` on the consumer's search path, so +everything resolves with zero flags. **R3 — NO include rewriting, anywhere.** Shipped headers are byte-identical to the repo. If a header's includes don't work in the packaged layout, the fix is @@ -271,11 +272,11 @@ module. ### `buildReactNativeHeadersXcframework` -1. Stage all R2 entries, then copy the six deps namespaces from - `third-party/ReactNativeDependencies.xcframework/Headers`. A declared deps - namespace that is missing is a **hard error** — previously a warn-and-ship, - which once produced a silently deps-less artifact (1.6 MB instead of 11 MB). -2. Optionally fold in the `hermes/` public headers (consumer-side compose path). +1. Stage all R2 entries. The deps namespaces are not copied here; they ship in + the `ReactNativeDependenciesHeaders` sidecar, whose namespace guards live in + `buildDepsHeadersXcframework` (`headers-xcframework.js`). +2. Fold in the `hermes/` public headers when they are staged (both the prebuild + compose and the consumer-side compose path). 3. Write the R10 umbrella files, then the R5 module map. 4. Compile a stub static archive per slice (headers-only artifacts still need a library for `xcodebuild -create-xcframework`) and compose the xcframework. @@ -296,13 +297,17 @@ mtime + hermes presence). | `#import ` | same, **textual** via the R9 entry | R9 | | `#import ` (C++) | `ReactNativeHeaders/Headers` search path, textual | R2 | | `#import ` | same, **modular** via the yoga R5 module | R2, R5 | -| `#import ` | same (deps namespaces relocated here) | R2 | +| `#import ` | SwiftPM: the `ReactNativeDependenciesHeaders` sidecar's `Headers/` (deps namespaces are not in `ReactNativeHeaders`) | R2 | | `` | same, modular | R10 | | `#import "RCTFabricComponentsPlugins.h"` (quoted, community Fabric pods) | CocoaPods only: re-vended by the `React-RCTFabric` facade into the pod header map (`rncore_facades.rb`, `FACADE_REEXPOSED_HEADERS`) | — | -- **SwiftPM**: both xcframeworks are plain `.binaryTarget`s; Xcode auto-serves - `React.framework`'s `Headers/`+`Modules/` and `ReactNativeHeaders`' `Headers/` - (incl. its `module.modulemap`) to dependents. Zero flags. +- **SwiftPM**: `React.xcframework` is not in the package graph. Its headers are + copied into `ReactHeadersTarget/include/React` with the module map rewritten + to a plain `module React`, vended as the `ReactHeaders` target. The only + `.binaryTarget`s are `ReactNativeHeaders` and + `ReactNativeDependenciesHeaders`; Xcode auto-serves their `Headers/` (incl. + `module.modulemap`) to dependents. Zero flags. See + [spm-header-paths-contract.md](../../spm/__docs__/spm-header-paths-contract.md). - **CocoaPods**: `React-Core-prebuilt`'s `prepare_command` flattens `ReactNativeHeaders`' Headers (incl. the module map) into the pod, and the React-core pods are installed as dependency-only **facades** @@ -316,8 +321,8 @@ mtime + hermes presence). | R9 allowlist validation | private header removed/renamed, or bucket drifted | `validatePrivateReactHeaders` | | R10 umbrella check | a probed namespace loses all modular headers | `planFromInventory` | | R5 exemption assert | an invalid-module-identifier namespace gains a modular-candidate header | `planFromInventory` | -| Deps namespace guard (missing) | folly/glog/… not staged at compose time | `buildReactNativeHeadersXcframework` | -| Deps namespace guard (undeclared) | the deps artifact ships a namespace not in `DEPS_NAMESPACES` (new third-party dep) | `buildReactNativeHeadersXcframework` | +| Deps namespace guard (missing) | folly/glog/… not staged at compose time | `buildDepsHeadersXcframework` | +| Deps namespace guard (undeclared) | the deps artifact ships a namespace not in `DEPS_NAMESPACES` (new third-party dep) | `buildDepsHeadersXcframework` | | Include-health ratchet | a shipped header gains a `notShipped`/`unresolved`/quoted-unresolvable include not in the committed baseline | `headers-verify.js` | | Structural gate | composed module maps/umbrellas differ from the spec render; R9 headers or deps dirs absent | `headers-verify.js` | | Compile gates | the React module, any R5 namespace module, the R10 umbrella, the R9 textual (Expo-shape) surface, or Swift `RCTBridge.moduleRegistry` fails to compile | `headers-verify.js` (CI: prebuild compose job) | diff --git a/packages/react-native/scripts/spm/__docs__/README.md b/packages/react-native/scripts/spm/__docs__/README.md index caeb710be67a..b04da44fc81f 100644 --- a/packages/react-native/scripts/spm/__docs__/README.md +++ b/packages/react-native/scripts/spm/__docs__/README.md @@ -2,10 +2,9 @@ [🏠 Home](../../../../../__docs__/README.md) -> **Preview.** SwiftPM support is an early preview: the commands, flags, -> generated layout, and distribution model may change in future releases, and it -> is not yet recommended for production. CocoaPods remains the supported -> default. +> **Preview.** SwiftPM support is an early preview. The commands, flags, +> generated layout, and distribution model may change in future releases. It is +> not yet recommended for production. CocoaPods remains the supported default. The scripts in `scripts/spm/` let a React Native iOS app consume React Native through **Swift Package Manager** instead of CocoaPods, using prebuilt @@ -13,9 +12,9 @@ XCFrameworks. Support is opt-in and additive: `npx react-native spm` injects package references into the app's existing `.xcodeproj` in place, and `deinit` reverses exactly what it injected. -The motivation, staged migration plan, and open questions live in -[RFC0994](https://github.com/react-native-community/discussions-and-proposals/blob/main/proposals/0994-swift-package-manager-support-for-react-native-ios-projects.md). -The documents here describe how the implementation actually works. +[RFC0994](https://github.com/react-native-community/discussions-and-proposals/blob/main/proposals/0994-swift-package-manager-support-for-react-native-ios-projects.md) +holds the motivation, the staged migration plan, and the open questions. The +documents here describe how the implementation works. ## 🚀 Usage @@ -24,21 +23,10 @@ cd ios npx react-native spm # add on first run, update thereafter ``` -**If any autolinked dependency ships no `Package.swift`, this stops with -`error: Package.swift is missing for library ""` and exit code 2.** That -is deliberate — `add` and `update` never scaffold silently, so a missing -manifest is visible and fixed on purpose. Generate the manifests first, then -re-run setup: - -```bash -npx react-native spm scaffold # writes Package.swift into node_modules// -npx react-native spm # then inject as usual -``` - -Because `node_modules` isn't committed, persist each scaffolded manifest with -`npx patch-package ` and commit the patch — otherwise the same error -returns on every fresh install and in CI. Better still, contribute the manifest -upstream. See +**If an autolinked dependency ships no `Package.swift`, setup stops with +`error: Package.swift is missing for library ""` and exit code 2.** Run +`npx react-native spm scaffold`, persist the manifest with `patch-package`, and +re-run setup. See [Community packages without a Package.swift](./spm-scripts.md#community-packages-without-a-packageswift). See **[spm-scripts.md](./spm-scripts.md)** for the CLI actions and flags, @@ -52,20 +40,20 @@ Three documents cover the design, each owning one area: | Document | Covers | | -------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | [spm-scripts.md](./spm-scripts.md) | The tool itself: CLI surface, the six-step pipeline, [every file it creates or modifies](./spm-scripts.md#files-the-tool-touches), the two auto-sync hooks, and how Debug/Release flavor selection works. | -| [spm-header-paths-contract.md](./spm-header-paths-contract.md) | How headers and package references resolve. The contract is **zero-`-I`**: no header search paths and no `unsafeFlags` in any generated manifest. Also covers remote mode. | +| [spm-header-paths-contract.md](./spm-header-paths-contract.md) | How headers and package references resolve. The contract is **zero-`-I`**: no generated manifest carries a search path into React Native's own headers. Also covers remote mode. | | [spm-autolinking-plugins.md](./spm-autolinking-plugins.md) | The extension seam for frameworks with their own module system (Expo is the first consumer): discovery, the full context/return contract including `flavoredFrameworks`, `watchPaths` and `scriptPhases`, and failure behavior. | Two ideas explain most of the architecture: - **Headers go through SwiftPM; runtime binaries do not.** A `binaryTarget` - cannot vary by build configuration, but React Native ships flavored binaries - (a debug `React.framework` carries the dev menu and assertions; release strips - them). So the package graph vends headers only, and the flavored frameworks - are linked and embedded through generated Xcode build settings instead. + cannot vary by build configuration, but React Native ships flavored binaries. + So the package graph vends headers only, and generated Xcode build settings + link and embed the flavored frameworks. See + [Debug/Release flavor is automatic](./spm-scripts.md#debugrelease-flavor-is-automatic). - **Generated state is regenerable, and the injection is reversible.** Everything under `build/` is gitignored and rebuilt from the app's - `package.json`; everything written into the `.xcodeproj` is recorded in a - `.spm-injected.json` marker so `deinit` can undo precisely that. + `package.json`. Every edit to the `.xcodeproj` is recorded in a + `.spm-injected.json` marker, so `deinit` can undo exactly that. ## 🔗 Relationship with other systems @@ -83,7 +71,9 @@ Two ideas explain most of the architecture: local `React-GeneratedCode` package rather than a Pod. - **`@react-native-community/cli config`** — supplies the autolinking metadata (`autolinking.json`) that the SwiftPM autolinker turns into a `Package.swift`. - Overridable via `--configCommand`. + Override it with the `RCT_SPM_AUTOLINKING_CONFIG_COMMAND` environment + variable, or with `--config-command` when you run `setup-apple-spm.js` + directly. ### Uses this diff --git a/packages/react-native/scripts/spm/__docs__/spm-autolinking-plugins.md b/packages/react-native/scripts/spm/__docs__/spm-autolinking-plugins.md index be999741f8cd..5aba539f9aea 100644 --- a/packages/react-native/scripts/spm/__docs__/spm-autolinking-plugins.md +++ b/packages/react-native/scripts/spm/__docs__/spm-autolinking-plugins.md @@ -22,18 +22,18 @@ The documented extension points don't cover a framework: re-run autolinking on every dependency change. A framework's contribution must run _whenever autolinking runs_. -A plugin is exactly that. It is invoked from `generate-spm-autolinking.js`'s -`main()` — the single function that both `add` / `update` **and** the build-time -`sync` call — so the contribution is regenerated on every build and never goes -stale. +A plugin does exactly that. `generate-spm-autolinking.js`'s `main()` invokes it. +`add` / `update` **and** the build-time `sync` all call that one function. So +the contribution is regenerated on every build and never goes stale, and the +build-time path needs no separate hook. -(This is the SwiftPM analog of the seams CocoaPods gave Expo: the Podfile, -`use_expo_modules!`, and `react_native_post_install` hooks.) +This is the SwiftPM analog of the seams CocoaPods gave Expo: the Podfile, +`use_expo_modules!`, and `react_native_post_install` hooks. ## Discovery — transitive, zero app config A dependency opts in from its **own** package.json, so installing the framework -is enough (mirrors how CocoaPods pulls in `use_expo_modules!` transitively): +is enough. This mirrors how CocoaPods pulls in `use_expo_modules!` transitively: ```json // node_modules/expo/package.json @@ -42,12 +42,16 @@ is enough (mirrors how CocoaPods pulls in `use_expo_modules!` transitively): } ``` -The autolinker reads every dependency's SwiftPM settings; any that declares -`autolinkingPlugin` is `require`d and invoked. No app-level registration or +The autolinker reads every dependency's SwiftPM settings. It `require`s and +invokes each one that declares `autolinkingPlugin`. No app-level registration or allowlist is required. The deprecated `spm.autolinkingPlugin` in `react-native.config.js` is still read — see [Migrating from react-native.config.js](spm-scripts.md#where-swiftpm-settings-live). +A dependency that declares a plugin owns its native contribution. React Native +does not build it as an autolinked target, so it needs no `Package.swift` of its +own. + **Opt-out escape hatch.** An app can exclude a plugin from its own package.json: ```json @@ -117,6 +121,29 @@ module.exports = function plugin(context) { }; ``` +### `generatedSources` — sources wired into the app target + +The merge writes `.spm-plugin-generated-sources.json`. The `spm add`/`update` +xcodeproj injector (`generate-spm-xcodeproj.js`) reads it and wires each source +**into the app target**: a `PBXFileReference`, a `PBXBuildFile`, and a +Sources-build-phase entry, all under one "SPM Generated Sources" navigator +group. + +This is what makes an `@objc` class (e.g. Expo's `ExpoModulesProvider`) reach +the ObjC classlist. A class inside the static Autolinked aggregate never does, +so `NSClassFromString` discovery would fail. + +- Paths are stored SRCROOT-relative when they are under the app root (the usual + `build/generated/…` case), else absolute (`sourceTree = ""`). +- All UUIDs are namespaced on the normalized path, so injection is deterministic + and idempotent. They are recorded in the `.spm-injected.json` marker's + `generatedSources` map. `deinit` reverts them, and `update` reconciles entries + that left the manifest. +- A target without a Sources phase logs loudly and skips the wiring. The rest of + the injection succeeds. +- v1 targets only the injected app target and assumes `.swift` in practice. + `.m`/`.mm` are mapped as future-proofing. + ### `flavoredFrameworks` — per-configuration precompiled frameworks Each entry is @@ -127,37 +154,47 @@ agree across flavors. Static binaries, nested frameworks, duplicate IDs, and duplicate embedded framework names are fatal. The declarations are recorded to -`/.spm-plugin-flavored-frameworks.json`, normalized into the same -immutable app-local slots as React Native, and added to Xcode's exact linker and -embed settings. They are not emitted as SwiftPM product dependencies. Adding or -removing one requires `spm update`; the build-time `spm sync` intentionally does -not mutate runtime framework settings. +`/.spm-plugin-flavored-frameworks.json`. They are normalized into the +same immutable app-local slots as React Native and added to Xcode's exact linker +and embed settings. They are not emitted as SwiftPM product dependencies. Adding +or removing one requires `spm update`: the build-time `spm sync` intentionally +does not mutate runtime framework settings. ### `watchPaths` — plugin staleness inputs -`watchPaths` is an array of **absolute** paths (dirs **or** files) the Xcode -auto-sync hooks watch to decide whether they must re-sync. RN already watches -each module's source dir plus every npm dep's checked-in `Package.swift` and -`.react-native/` dir; a plugin adds the inputs only it knows about — e.g. -`packages/expo/Package.swift`, `expo-module.config.json`, and per-module -manifests. On the next build the phase re-syncs when a watched **file** is newer -than the last sync, a watched **dir** has a newer child, or a watched path has -**vanished** (a rename forces a re-sync so the config error surfaces). - -Unlike `flavoredFrameworks`, watch paths are best-effort: a non-array is ignored -with a warning (never fatal), and each non-string / empty / **relative** entry -is dropped with a warning. Absolute-only, because the generated phase tests -these paths with no cwd context. The kept paths are folded into +`watchPaths` is an array of **absolute** paths (dirs **or** files). The Xcode +auto-sync hooks watch them to decide whether they must re-sync. RN already +watches each module's source dir, plus every npm dep's checked-in +`Package.swift` and `.react-native/` dir. A plugin adds the inputs only it knows +about, e.g. `packages/expo/Package.swift`, `expo-module.config.json`, and +per-module manifests. + +On the next build, the phase re-syncs when: + +- a watched **file** is newer than the last sync, +- a watched **dir** has a newer child, or +- a watched path has **vanished**. A rename forces a re-sync, so the config + error surfaces. + +Unlike `flavoredFrameworks`, watch paths are best-effort. A non-array is ignored +with a warning (never fatal). Each non-string, empty, or **relative** entry is +dropped with a warning. Paths must be absolute because the generated phase tests +them with no cwd context. The kept paths are folded into `/.spm-sync-watch-paths` alongside RN's own, then deduped and sorted. +Only paths that exist when the sync runs are written; a missing path is dropped +silently. So the **vanished** check applies only to paths that existed at the +last sync. ### `scriptPhases` — build-time shell phases on the app target -SwiftPM has no equivalent of CocoaPods' `script_phase`, so a framework that must -run a script during the app's build — `expo-constants` writing -`EXConstants.bundle/app.config` is the first consumer — declares it here. Each -entry is recorded to `/.spm-plugin-script-phases.json`, which -`spm add` / `spm update` reads to emit one `PBXShellScriptBuildPhase` per entry -on the injected app target: +SwiftPM has no equivalent of CocoaPods' `script_phase`. A framework that must +run a script during the app's build declares it here. The first consumer is +`expo-constants`, which writes `EXConstants.bundle/app.config`. + +The merge always rewrites `/.spm-plugin-script-phases.json` — `[]` +when no plugin declares any, so removing a plugin clears stale entries. +`spm add` / `spm update` read that sidecar and emit one +`PBXShellScriptBuildPhase` per entry on the injected app target: | Key | Meaning | | ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | @@ -168,69 +205,80 @@ on the injected app target: | `inputPaths` / `outputPaths` | Optional Xcode input/output file lists, which is what lets Xcode skip an up-to-date phase. | | `alwaysOutOfDate` | Optional; when `true` the phase runs on every build regardless of its file lists. | -**Placement.** `'end'` appends at the true end of the target's `buildPhases` — -after the app's own JS-bundle phase. `'beforeCompile'` lands directly after the -"Sync SPM Autolinking" phase, which stays first because it regenerates the -content everything else reads, and always **before Sources**: React Native never -re-seats its own sync phase, so if you have dragged that below Sources your -`beforeCompile` phases are seated ahead of Sources instead of following it. -Phases sharing a position keep their declared order. +**Placement.** + +- `'end'` appends at the true end of the target's `buildPhases`, after the app's + own JS-bundle phase. +- `'beforeCompile'` lands directly after the "Sync SPM Autolinking" phase, and + always **before Sources**. The sync phase stays first because it regenerates + the content everything else reads. React Native never re-seats its own sync + phase. If you have dragged it below Sources, your `beforeCompile` phases sit + ahead of Sources instead of following it. + +Phases that share a position keep their declared order. **Position is enforced on every sync.** `add`/`update` compares where the plugin -phases actually sit in `buildPhases` against the declared placement and, **only -when the two differ**, lifts their membership lines and re-seats them in -declared order. So changing `position` — or swapping two phases that share one — -takes effect on the next `spm add`/`update`, with no remove + re-add. When they -agree nothing is rewritten, which is what keeps an unchanged declaration -re-syncing to a byte-identical project. The consequence worth knowing: a phase -you **drag somewhere else in Xcode is moved back** to its declared position on -the next sync, because the plugin's declaration is the source of truth. Only the -`id` behaves differently — it is a key, not a label, so renaming it is a remove - -- add. +phases sit in `buildPhases` with the declared placement. **Only when the two +differ** does it lift their membership lines and re-seat them in declared order. +So a change to `position`, or a swap of two phases that share one, takes effect +on the next `spm add`/`update`, with no remove + re-add. When they agree nothing +is rewritten, so an unchanged declaration re-syncs to a byte-identical project. +The plugin's declaration is the source of truth: a phase you **drag somewhere +else in Xcode is moved back** to its declared position on the next sync. Phases are injected by `spm add` / `spm update` **only**. The build-time `sync` -rewrites the sidecar but never touches the `.xcodeproj`, so a newly declared +rewrites the sidecar but never touches the `.xcodeproj`. So a newly declared phase appears on the next `add`/`update`, not on the next build. Each phase's UUID is derived from its `id` and recorded in the `.spm-injected.json` marker's -`scriptPhases` map, so a re-run refreshes the phase's `name`, `script`, path -lists, `alwaysOutOfDate` and placement in place, `update` removes phases that +`scriptPhases` map. So a re-run refreshes the phase's `name`, `script`, path +lists, `alwaysOutOfDate` and placement in place. `update` removes phases that left the sidecar, and `deinit` reverts all of them. -Validation is **fatal**, like `flavoredFrameworks` and unlike `watchPaths`: a -non-array `scriptPhases`, a malformed entry, or a duplicate `id` (within one -plugin or across plugins) aborts the run. A silently dropped phase would produce -a green build whose generated content was never written — a runtime failure with -no build-time signal — and two phases sharing an `id` would collapse onto one -ledger key. `__proto__`, `constructor`, and `prototype` are rejected as ids even -though the charset admits them: as keys of that ledger they never become own -properties, so the phase would look recorded, disappear when the marker is -serialized, and be unremovable by `deinit`. - -**Hostile names.** A `name` reaches the project file twice. In the `name` field -— what Xcode displays — it lands verbatim, escaped as an OpenStep string, so any -single-line string is expressible. Beside the phase's UUID, on the object's -definition line and on its `buildPhases` member line, it also becomes a -`/* … */` comment; those comments are cosmetic (Xcode regenerates them from the -`name` field) but the text around them is scanned by delimiter, so the name is -**normalized** there: `{}(),;="*/`, tabs and whitespace runs collapse to single -spaces (`spm-pbxproj.js`'s `commentSafe`), falling back to the phase `id` — -normalized the same way — and then to no comment at all if nothing survives -either. Without that, a `{` in a comment would make the injector read the next -object's body as this one's, and a `,` would make `deinit` delete the wrong line -— corruption with no error. Only a line break is therefore rejected outright; a -name is a display name, and no Xcode phase name spans lines. - -The injector's read of the sidecar is deliberately lenient — the file does not +Validation is **fatal**, like `flavoredFrameworks` and unlike `watchPaths`. +`invokePlugins` checks the `id` charset, a single-line `name`, a required +`script`, the `position` enum, and the optional path lists and +`alwaysOutOfDate`. A non-array `scriptPhases`, a malformed entry, or a duplicate +`id` (within one plugin or across plugins) aborts the run. + +- A silently dropped phase would produce a green build whose generated content + was never written — a runtime failure with no build-time signal. +- Two phases that share an `id` would collapse onto one ledger key. +- `__proto__`, `constructor`, and `prototype` are rejected as ids even though + the charset admits them. As keys of that ledger they never become own + properties. The phase would look recorded, disappear when the marker is + serialized, and be unremovable by `deinit`. + +**Hostile names.** A `name` reaches the project file twice: + +- In the `name` field, which Xcode displays, it lands verbatim, escaped as an + OpenStep string. Any single-line string is expressible there. +- Beside the phase's UUID, on the object's definition line and on its + `buildPhases` member line, it becomes a `/* … */` comment. Xcode regenerates + these comments from the `name` field, so they are cosmetic. But the injector + scans the text around them by delimiter, so the name is **normalized** there. + `{}(),;="*/`, tabs and whitespace runs collapse to single spaces + (`spm-pbxproj.js`'s `commentSafe`). If nothing survives, the comment falls + back to the phase `id`, normalized the same way, and then to no comment at + all. + +Without that normalization, a `{` in a comment would make the injector read the +next object's body as this one's. A `,` would make `deinit` delete the wrong +line. Both are corruption with no error. So only a line break is rejected +outright: a name is a display name, and no Xcode phase name spans lines. + +The injector's read of the sidecar is deliberately lenient. The file does not exist yet on a first `spm add`, and a stale or hand-edited copy must not break -injection. An absent file yields no phases silently, an unparseable one warns, -and a single entry failing the same checks (bad or reserved `id`, empty or -multi-line `name`, missing `script`, unknown `position`, duplicate `id`) is -**skipped, never coerced** — the sidecar is the only gate on a hand edit, so it -enforces exactly the rules the plugin contract does. +injection. + +- An absent file yields no phases, silently. +- An unparseable file warns. +- An entry that fails the same checks (bad or reserved `id`, empty or multi-line + `name`, missing `script`, unknown `position`, duplicate `id`) is **skipped, + never coerced**. The sidecar is the only gate on a hand edit, so it enforces + exactly the rules the plugin contract does. **Gating is the script's job.** The phase runs for every configuration and -platform the target builds; if it should be a no-op for some of them (Release +platform the target builds. If it should be a no-op for some of them (Release only, simulator only, …), the script must check `$CONFIGURATION` / `$PLATFORM_NAME` and exit early. @@ -248,9 +296,9 @@ only, simulator only, …), the script must check `$CONFIGURATION` / #### `context.react` — depending on React A plugin that emits its own `Package.swift` must declare React as a dependency. -Rather than re-deriving React Native's package path, identity, and product names -— which differ between local and remote mode and **move as RN repackages** — -take them from `context.react`: +React Native's package path, identity, and product names differ between local +and remote mode, and they **move as RN repackages**. So take them from +`context.react` rather than re-deriving them: ```js react: { @@ -266,19 +314,21 @@ react: { } ``` -Local vs remote is signalled by which `packageRef` keys are present (`path` xor -`url`+`version`). `packageRef.path` is **absolute** — always correct no matter -which subdirectory of `outputDir` the plugin writes its own manifest into (the -generated manifests are gitignored and regenerated every sync, so there's no -portability cost); `relPath` (relative to `outputDir`) is provided as a -convenience. `products` is the set React Native wires into **its own** -autolinked targets (so a plugin's target compiles against exactly RN's React -surface), filtered to those resolvable this run — every listed product is safe -to reference without guarding. Note the fourth entry: `ReactAppHeaders` lives in -the separate `React-GeneratedCode` package (per-app codegen), which a -hand-rolled plugin would miss, and which is omitted when that package is absent. -Because RN derives this list from one source of truth alongside its own product -wiring, it stays correct across repackaging. +- The `packageRef` keys signal local or remote mode: `path` xor `url`+`version`. +- `packageRef.path` is **absolute**, so it is correct in any subdirectory of + `outputDir` the plugin writes its manifest into. The generated manifests are + gitignored and regenerated every sync, so this has no portability cost. + `relPath` (relative to `outputDir`) is a convenience. +- `products` is the set React Native wires into **its own** autolinked targets, + so a plugin's target compiles against exactly RN's React surface. It is + filtered to the products resolvable this run, so every listed product is safe + to reference without a guard. +- The fourth entry, `ReactAppHeaders`, lives in the separate + `React-GeneratedCode` package (per-app codegen). A hand-rolled plugin would + miss it. It is omitted when that package is absent. + +RN derives this list from one source of truth alongside its own product wiring, +so it stays correct across repackaging. ### Return (contributions, all optional) @@ -290,10 +340,11 @@ wiring, it stays correct across repackaging. | `flavoredFrameworks` | Mandatory Debug/Release dynamic XCFramework pairs normalized outside SwiftPM. Malformed or incomplete entries are fatal. | | `scriptPhases` | Recorded for `spm add` / `update` to emit one `PBXShellScriptBuildPhase` per entry on the app target. Malformed entries and duplicate `id`s are fatal. | -The plugin returns **data** — it never writes into React Native's generated -tree. RN owns the merge, so a re-sync reproduces the same `Package.swift` -byte-for-byte (idempotent). Package and product contributions are **deduped by -name** across plugins. +The plugin returns **data**. It never writes into React Native's generated tree. +RN owns the merge, so a re-sync reproduces the same `Package.swift` +byte-for-byte (idempotent). Across plugins, package contributions are **deduped +by name** and product contributions by **package and name**. The first +contribution wins. ## Lifecycle @@ -307,51 +358,32 @@ Xcode "Sync SPM Autolinking" ──┘ │ └─ 4. merge results → aggregator Package.swift ``` -Because steps 1–4 run in the one `main()`, everything above shares the same seam -— there is no separate hook to wire for the build-time path. - ## Failure behavior -Fail-closed and **named**: a plugin that fails to load, doesn't export a -function, throws, or returns a malformed contribution aborts the run with a -message identifying the framework. A framework silently dropping its modules (a -green build missing native code) is worse than a loud stop. +Failures are fail-closed and **named**. A plugin that fails to load, doesn't +export a function, throws, or returns a malformed contribution aborts the run. +The message identifies the framework. A framework that silently drops its +modules (a green build missing native code) is worse than a loud stop. + +A plugin's host dependency is also a hard error when another library lists it in +`swiftpmConfig.dependencies` and that library has no `Package.swift` of its own +(shipped or scaffolded). React Native builds no target for the host, so there is +nothing to depend on. Remove the entry: the plugin already links its products +into the app. ## Status & open items (Preview) - **Implemented & tested:** discovery (transitive + deny-list), invocation, - package + product merge, fail-closed validation, and dual-flavor framework - normalization/link/embed outside SwiftPM. -- **Implemented & tested:** `generatedSources` **app-target wiring**. The merge - writes `.spm-plugin-generated-sources.json`; the `spm add`/`update` xcodeproj - injector (generate-spm-xcodeproj.js) reads it and wires each source **into the - app target** — a `PBXFileReference` + `PBXBuildFile` + a Sources-build-phase - entry, parented under one "SPM Generated Sources" navigator group. This is - what makes an `@objc` class (e.g. Expo's `ExpoModulesProvider`) reach the ObjC - classlist: a class inside the static Autolinked aggregate never does, so - `NSClassFromString` discovery would fail. Paths are stored SRCROOT-relative - when under the app root (the usual `build/generated/…` case), else absolute - (`sourceTree = ""`). All UUIDs are namespaced on the normalized path - (deterministic/idempotent) and recorded in the `.spm-injected.json` marker's - `generatedSources` map, so `deinit` reverts them and `update` reconciles - entries that left the manifest. A target without a Sources phase logs loudly - and skips the wiring (injection otherwise succeeds). v1 targets only the - injected app target and assumes `.swift` in practice (`.m`/`.mm` are mapped as - future-proofing). -- **Implemented & tested:** `scriptPhases`, contract through injection. - `invokePlugins` validates every entry fatally (`id` charset plus the reserved - `__proto__`/`constructor`/`prototype` names, a single-line `name`, a required - `script`, the `position` enum, optional path lists and `alwaysOutOfDate`, plus - duplicate `id`s), and the merge always rewrites - `.spm-plugin-script-phases.json` — `[]` when no plugin declares any, so - removing a plugin clears stale entries. The `spm add`/`update` xcodeproj - injector reads that sidecar and emits one `PBXShellScriptBuildPhase` per entry - on the app target at the requested position, recording the id→UUID map in the - `.spm-injected.json` marker so a re-run refreshes each phase's content in - place, re-seats it when its declared position or order changed, `update` - removes phases that left the sidecar, and `deinit` reverts them. Like - `flavoredFrameworks`, the build-time `sync` only rewrites the sidecar; it - never mutates the project. + package + product merge, and fail-closed validation. +- **Implemented & tested:** dual-flavor framework normalization/link/embed + outside SwiftPM + ([`flavoredFrameworks`](#flavoredframeworks--per-configuration-precompiled-frameworks)). +- **Implemented & tested:** + [`generatedSources`](#generatedsources--sources-wired-into-the-app-target) + **app-target wiring**. +- **Implemented & tested:** + [`scriptPhases`](#scriptphases--build-time-shell-phases-on-the-app-target), + from the contract through injection. - **Co-design with Expo (not final):** codegen **provider ordering** — codegen must consume the same discovered module set the plugin contributes — is intentionally left for the first real plugin to drive to a stable shape. diff --git a/packages/react-native/scripts/spm/__docs__/spm-header-paths-contract.md b/packages/react-native/scripts/spm/__docs__/spm-header-paths-contract.md index ff467c249b92..4055fed9f182 100644 --- a/packages/react-native/scripts/spm/__docs__/spm-header-paths-contract.md +++ b/packages/react-native/scripts/spm/__docs__/spm-header-paths-contract.md @@ -1,100 +1,131 @@ # SPM headers & package references — how they resolve -React Native's SPM consumption is **zero-I**: no `-I` / `-F` header search paths -and no `unsafeFlags` in any generated manifest. Headers are served by SPM -products/binary targets, and every generated `Package.swift` references the -React Native + codegen packages with plain, fixed-relative paths computed at -generation time (no runtime discovery). This document is the single source of -truth for how that resolves. - -> History: earlier iterations materialized two header trees and fed them to -> consumers as `-I` flags read from `spm-paths.json` / -> `.react-native/paths.json` via an inlined Swift loader. That whole mechanism -> (the loader `renderRNPathsLoader`, the `writeAppPathsJson` / -> `writeSharedPathsJson` writers, and both JSON files) has been **deleted** — -> manifests are now declarative. If you find a reference to those files, it is -> stale. +React Native's SPM consumption is **zero-I**: no generated manifest carries a +`-I` / `-F` header search path into React Native's own headers. SPM products and +binary targets serve those headers, with no clang VFS overlay. Every generated +`Package.swift` references the React Native and codegen packages by plain, +fixed-relative paths. This document is the single source of truth for how that +resolves. + +Generated manifests may still reach into their own tree: + +- Scaffolded manifests, local-module wrappers, and the codegen package use + `.headerSearchPath` into their own tree. +- Scaffolded manifests may use `.unsafeFlags` for podspec compiler flags and a + prefix header. + +Manifests are declarative. The files `spm-paths.json` and +`.react-native/paths.json`, their writers (`writeAppPathsJson`, +`writeSharedPathsJson`), and the Swift loader `renderRNPathsLoader` no longer +exist. A reference to any of them is stale. ## How headers resolve (no search paths) +React Native uses CocoaPods-style imports (`#import `) that +SwiftPM does not natively support. These products serve them: + | Namespace | Served by | Mechanism | | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | | Objective-C `` / Swift `import React` | `ReactHeaders` Clang source target | Canonical Debug/Release-identical React headers staged under `ReactHeadersTarget/include/React`, with a plain `module React` module map. | | Lowercase C++ `` and everything else: ``, ``, ``, ``, folly/glog/boost/fmt/double-conversion | `ReactNativeHeaders.xcframework` plus `ReactNativeDependenciesHeaders.xcframework` | Header-only invariant binary targets keep lowercase `react` separate from Objective-C `React` and propagate their search paths through product dependencies. | | ``, `ReactAppDependencyProvider`, this app's generated specs | `ReactAppHeaders` SPM target in the codegen package | SPM `publicHeadersPath` propagation — a real target dependency, not a flag. | +`ReactHeaders` stages its copy only after it proves that Debug and Release +expose identical public headers; its module map uses `React/`-prefixed paths. +`ReactNativeHeaders` is a headers-only (LIBRARY-type) binaryTarget, and the deps +sidecar uses the same mechanism: SwiftPM serves each slice's `Headers/` to +dependents. The deps sidecar also carries `fast_float/` and `SocketRocket/`. It +exists because the binary `ReactNativeDependencies.xcframework` is +framework-type and cannot expose those headers to SwiftPM. Targets that compile +against React take all four products in the table as product dependencies. + The one remaining materialized header tree is the per-app farm at -`/build/generated/ios/ReactAppHeaders` (built by -`buildPerAppHeaderTree` in `spm-utils.js`, called from the orchestrators). It is -vended as the `ReactAppHeaders` SPM target — consumers reach it through a -product dependency, never through `-I`. +`/build/generated/ios/ReactAppHeaders`. `buildPerAppHeaderTree` in +`spm-utils.js` builds it, called from the orchestrators. It is vended as the +`ReactAppHeaders` SPM target, so consumers reach it through a product +dependency, never through `-I`. `autolinking.json` (the `@react-native-community/cli config` output) is an INPUT -used to generate the manifests; it is never read by a manifest. +used to generate the manifests. No manifest reads it. ## How each manifest references the React + codegen packages Every generated manifest sits at a known depth inside the app and is regenerated -on every `react-native spm` run, so package references are plain fixed-relative -paths — no walk-up, no JSON, no `import Foundation`. - -| Manifest | Location | How it references the React + codegen packages | -| ------------------------ | ---------------------------------------------- | -------------------------------------------------------------------------------------------------------------------- | -| Autolinked aggregator | `build/generated/autolinking/Package.swift` | `.package(path: "../../xcframeworks")` + `"../ios"` (only when it has inline `spmModule` targets) | -| Per-dep synth wrapper | `build/generated/autolinking/packages//` | `.package(path: "../../../../xcframeworks")` + `"../../../ios"` | -| Codegen template | `build/generated/ios/Package.swift` | `.package(path: "../../xcframeworks")` (or the remote url) | -| App target (pbxproj) | `.xcodeproj` | local `XCLocalSwiftPackageReference` (or `XCRemoteSwiftPackageReference` in remote mode) | -| Scaffolded community lib | `node_modules//Package.swift` | scaffold-time relative paths to the app's xcframeworks + codegen packages (or `.package(url:exact:)` in remote mode) | +on every `react-native spm` run. So package references are computed at +generation time as plain fixed-relative paths — no runtime discovery, no +walk-up, no JSON, no `import Foundation`. + +| Manifest | Location | How it references the React + codegen packages | +| ------------------------ | ---------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- | +| Autolinked aggregator | `build/generated/autolinking/Package.swift` | Neither: it has no inline targets, and references only its dependencies' packages (`packages/`, `libs/`, plugin packages) | +| Local-module wrapper | `build/generated/autolinking/packages//` | `.package(path: "../../../../xcframeworks")` + `"../../../ios"` (only for `swiftpmConfig.modules` entries) | +| Codegen template | `build/generated/ios/Package.swift` | `.package(path: "../../xcframeworks")` (or the remote url) | +| App target (pbxproj) | `.xcodeproj` | local `XCLocalSwiftPackageReference` (or `XCRemoteSwiftPackageReference` in remote mode) | +| Scaffolded community lib | `node_modules//Package.swift` | scaffold-time relative paths to the app's xcframeworks + codegen packages (or `.package(url:exact:)` in remote mode) | ## Remote-package mode -Remote mode is gated by a **URL alone** — `RN_SPM_REMOTE_URL` (or the persisted -`url`). When set, the whole app graph flips to a single remote React Native -package identity: `.package(path: build/xcframeworks)` becomes -`.package(url:exact:)` everywhere (aggregator/synth/codegen template/pbxproj), -and the local artifact download + compose is skipped. SPM's -one-version-per-package rule then unifies app + every library on one resolved -React Native. The package identity is derived from the URL tail (swift-tools 6 -dropped `.package(name:url:)`) — nothing hardcodes a repo name. - -**Version is derived from npm, not pinned by hand.** The SPM-pinned RN version -is not a free parameter: the SPM graph must compile against the same React -Native the JS/native code uses, so the app (graph root) pins EXACT to the -_installed_ RN version, read from `node_modules/react-native/package.json`. +A **URL alone** turns remote mode on: `RN_SPM_REMOTE_URL`, or the persisted +`url`. The whole app graph then uses a single remote React Native package +identity. `.package(path: build/xcframeworks)` becomes `.package(url:exact:)` +everywhere it appears (synth, codegen template, pbxproj). The local artifact +download and the compose of `build/xcframeworks` still run. SPM's +one-version-per-package rule then unifies the app and every library on one +resolved React Native. + +The package identity comes from the URL tail, because swift-tools 6 dropped +`.package(name:url:)`. Nothing hardcodes a repo name. + +**Version is derived from npm, not pinned by hand.** The SPM graph must compile +against the same React Native that the JS and native code use. So the app (the +graph root) pins EXACT to the _installed_ React Native version, read from +`node_modules/react-native/package.json`. + `RN_SPM_REMOTE_VERSION` and the persisted `versionOverride` are **overrides**, -not the source of truth — they're only needed when the installed version isn't -publishable (e.g. the monorepo `1000.0.0` dev placeholder, which has no remote -tag). A _derived_ version is never persisted, so an `npm install` that upgrades -RN auto-re-pins the SPM graph on the next `spm` run; an _override_ is persisted -as `versionOverride` so it survives Xcode-phase re-syncs without the env. - -Persisted schema is `{url, versionOverride?}`. Legacy `{url, version}` is still -read, with `version` honored as an override (back-compat). If remote mode is on -but no usable version can be resolved — react-native isn't installed, or it's a -non-publishable dev placeholder and no override is set — the tooling errors -(exit 2, a hard Xcode build error) directing you to set `RN_SPM_REMOTE_VERSION` -or install a released react-native, rather than silently pinning an unpublished +not the source of truth. You need them only when the installed version is not +publishable, e.g. the monorepo `1000.0.0` dev placeholder, which has no remote tag. +- A _derived_ version is never persisted. An `npm install` that upgrades React + Native re-pins the SPM graph on the next `spm` run. +- An _override_ is persisted as `versionOverride`, so it survives Xcode-phase + re-syncs without the environment variable. + +The settings persist in `build/generated/autolinking/spm-remote.json`. The file +is written only when the settings come from the environment variables. It lives +under `build/`, so deleting `build/` turns remote mode off until you set the +variables again. The persisted schema is `{url, versionOverride?}`. The legacy +`{url, version}` is still read, with `version` honored as an override. + +If remote mode is on but no usable version resolves, the tooling stops with exit +2, a hard Xcode build error. This happens when react-native is not installed, or +when it is a non-publishable dev placeholder and no override is set. The error +tells you to set `RN_SPM_REMOTE_VERSION` or install a released react-native. The +tooling never silently pins an unpublished tag. + ## Hand-authored community library contract -A library that ships its own `Package.swift` (no scaffolder/autolinker marker) -is left untouched by the tooling. It needs only two things, and **no discovery -code**: +The tooling leaves untouched a library that ships its own `Package.swift` (no +scaffolder or autolinker marker). That library needs only two things, and **no +discovery code**: 1. Depend on the React Native SPM package and its products — in remote mode `.package(url: "", exact: "")` + - `.product(name: "ReactNative", …)` and - `.product(name: "ReactNativeHeaders", …)`. (Libraries should declare a - version RANGE in production; the consuming app pins EXACT.) + `.product(name: "ReactHeaders", package: "")`, + `.product(name: "ReactNativeHeaders", package: "")`, and + `.product(name: "ReactNativeDependenciesHeaders", package: "")`, + where `` is the URL tail, lowercased, without `.git`. A scaffolded + manifest depends on the same React Native products. Libraries should declare + a version RANGE in production; the consuming app pins EXACT. 2. Ship its own generated code: set `codegenConfig.includesGeneratedCode: true` and generate with `generate-codegen-artifacts.js --path . --targetPlatform ios --source library`. - Output lands at `/build/generated/ios/ReactCodegen/`, reachable - from the manifest with one safe `.headerSearchPath(...)` into the library's - own tree. The app-side codegen then skips the lib's spec (no duplicate - symbols). - -This makes the library self-contained — it carries no app-layout knowledge and -needs no per-app codegen headers from the consuming app. Proven with -`@chrfalch/react-native-calculator` (a hand-authored Fabric/TurboModule lib). + The output lands at `/build/generated/ios/ReactCodegen/`. The + manifest reaches it with one safe `.headerSearchPath(...)` into the library's + own tree. The app-side codegen then skips the library's spec, so there are no + duplicate symbols. + +The library is then self-contained. It carries no app-layout knowledge and needs +no per-app codegen headers from the consuming app. +`@chrfalch/react-native-calculator` (a hand-authored Fabric/TurboModule library) +proves this. diff --git a/packages/react-native/scripts/spm/__docs__/spm-scripts.md b/packages/react-native/scripts/spm/__docs__/spm-scripts.md index 47059cb79da2..d71608aa9d6d 100644 --- a/packages/react-native/scripts/spm/__docs__/spm-scripts.md +++ b/packages/react-native/scripts/spm/__docs__/spm-scripts.md @@ -1,14 +1,13 @@ # SwiftPM Scripts – React Native iOS via Swift Package Manager (Preview) -> **Preview.** SwiftPM support is an early preview: the commands, flags, -> generated layout, and distribution model may change in future releases, and it -> is not yet recommended for production. Feedback is welcome. CocoaPods remains -> the supported default. +> **Preview.** SwiftPM support is an early preview. The commands, flags, +> generated layout, and distribution model may change in future releases. It is +> not yet recommended for production. Feedback is welcome. CocoaPods remains the +> supported default. Build React Native iOS apps using **Swift Package Manager** with prebuilt -XCFrameworks, as an alternative to CocoaPods. It is **opt-in and additive** — -CocoaPods remains the default; `spm` injects into your existing `.xcodeproj` in -place and is fully reversible. +XCFrameworks, as an alternative to CocoaPods. It is **opt-in and additive**: +`spm` injects into your existing `.xcodeproj` in place and is fully reversible. ## Quick Start @@ -25,10 +24,10 @@ npx react-native spm add --deintegrate open MyApp.xcodeproj ``` -After the initial run, the project carries **auto-sync hooks** that detect +After the initial run, the project carries **auto-sync hooks**. They detect dependency changes and re-run autolinking before compilation (see -[Auto-Sync](#auto-sync)) — you don't re-invoke `react-native spm` manually for -day-to-day dependency changes. **On a fresh clone or CI checkout, run +[Auto-Sync](#auto-sync)), so you don't re-run `react-native spm` for day-to-day +dependency changes. **On a fresh clone or CI checkout, run `npx react-native spm` once before building** (see [Fresh clones & CI](#fresh-clones--ci)). @@ -42,35 +41,39 @@ day-to-day dependency changes. **On a fresh clone or CI checkout, run `spm add` injects into a project that is **not** CocoaPods-integrated. On a CocoaPods app it fails loud and points you at `--deintegrate`, which: -1. runs `pod deintegrate` — removes CocoaPods integration from the `.xcodeproj` - (Pods references, `[CP]` build phases, xcconfig links). Your `Podfile` is - left on disk. +1. runs `pod deintegrate`. This removes CocoaPods integration from the + `.xcodeproj` (Pods references, `[CP]` build phases, xcconfig links). Your + `Podfile` is left on disk. 2. strips **only** the React Native directives (`use_react_native!`, - `use_native_modules!`, `prepare_react_native_project!`) from the Podfile — - every other line, **including your own `pod '…'` entries, is preserved**. + `use_native_modules!`, `prepare_react_native_project!`) from the Podfile. + Every other line, **including your own `pod '…'` entries, is preserved**. The + strip is line-based: it deletes each line that contains one of those names, + and nothing else. The argument lines of a multi-line `use_react_native!(` + call and any `react_native_post_install(...)` call remain, so remove them by + hand before you run `pod install`. 3. injects SwiftPM into the `.xcodeproj`. -React Native now comes from SwiftPM; no pods are linked yet (deintegrate removed -the integration). +React Native now comes from SwiftPM. No pods are linked yet, because deintegrate +removed the integration. ### Keeping non-RN pods -Non-RN pods can stay side-by-side. After `spm add --deintegrate` your Podfile -still lists them (only the RN directives were removed) — re-integrate them with +Non-RN pods can stay side-by-side. Your Podfile still lists them. Remove the +leftover React Native lines (see step 2 above), then re-integrate the pods with a normal install: ```bash pod install # re-integrates the remaining (non-RN) pods; (re)creates the .xcworkspace ``` -Then **open the `.xcworkspace`** (not the `.xcodeproj`): the workspace includes +Then **open the `.xcworkspace`** (not the `.xcodeproj`). The workspace includes the SwiftPM-injected project, so React Native resolves through SwiftPM and your -other pods through CocoaPods, together. +other pods through CocoaPods. -> **Do not re-add `use_react_native!`.** React Native must be provided by -> _either_ SwiftPM _or_ CocoaPods, never both — they share `build/generated/`, -> so a dual-managed RN does not build. `spm add` refuses to run while the -> Podfile still declares `use_react_native!`. +> **Do not re-add `use_react_native!`.** React Native must come from _either_ +> SwiftPM _or_ CocoaPods, never both. They share `build/generated/`, so a +> dual-managed RN does not build. If only the Podfile still declares +> `use_react_native!`, `spm add` prints a warning and continues. The migration is fully reversible — see [Removing / resetting](#removing--resetting). @@ -78,23 +81,22 @@ The migration is fully reversible — see ## Brownfield apps `spm add` injects into your existing `.xcodeproj` in place, so an app that -embeds React Native works the same way — point it at the right project and +embeds React Native works the same way. Point it at the right project and target: ```bash npx react-native spm add --xcodeproj MyApp.xcodeproj --productName MyApp ``` -**Requirement:** the `.xcodeproj` must live **inside the React Native JS tree** -— i.e. the app's `package.json` is a parent directory of the project. Both setup -and the build-time sync locate React Native by walking up from the project to -the nearest `package.json`. The common "native project at the repo root with the -RN JS in a sibling/child subfolder" layout is **not supported yet** — there is -no way to point at a JS root outside the project's ancestors. +**Requirement:** the `.xcodeproj` must live **inside the React Native JS tree**. +The app's `package.json` must be in a parent directory of the project. Setup and +the build-time sync both find React Native by walking up from the project to the +nearest `package.json`. The common "native project at the repo root with the RN +JS in a sibling/child subfolder" layout is **not supported yet**. You cannot +point at a JS root outside the project's ancestors. -Brownfield apps that keep CocoaPods for their other native dependencies follow -the [coexistence rules above](#keeping-non-rn-pods): React Native from SwiftPM, -everything else from CocoaPods, and no `use_react_native!` in the Podfile. +Brownfield apps that keep CocoaPods for other native dependencies follow the +[coexistence rules above](#keeping-non-rn-pods). ## CLI Actions @@ -104,18 +106,18 @@ react-native spm [action] [options] With no action, the command **auto-resolves**: if SwiftPM has been injected (`.spm-injected.json` marker present) it routes to `update`; otherwise `add`. On -a freshly-scaffolded CocoaPods project (clean git tree, stock Podfile) the -zero-arg path additionally implies `--deintegrate` (the safe-gate), so +a freshly-scaffolded CocoaPods project (clean git tree, stock Podfile), the +zero-arg path also implies `--deintegrate` (the safe-gate). So `npx react-native spm` converts a brand-new app to SwiftPM in one command. -When invoked from the JS root of a standard RN app (sibling `ios/` subdir), the -command auto-redirects into `ios/` with a banner. +From the JS root of a standard RN app (sibling `ios/` subdir), the command +redirects into `ios/` and prints a banner. | Action | Description | | --------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `add` | Inject SwiftPM packages (package refs, build settings, the Sync build phase) into the existing `.xcodeproj`, in place. Idempotent. Default on first run. `--deintegrate` first runs `pod deintegrate` + strips React Native from the Podfile. | | `update` | Re-run the pipeline and refresh the existing injection. Default once a project is injected. | -| `deinit` | The inverse of `add`: surgically remove only what `add` injected (recorded in `.spm-injected.json`) and drop the marker. Git-recoverable; no prompt. Three things it does not undo — see [Files the tool touches](#files-the-tool-touches). | +| `deinit` | The inverse of `add`: surgically remove only what `add` injected (recorded in `.spm-injected.json`) and drop the marker. Git-recoverable; no prompt. Some edits it does not undo — see [Files the tool touches](#files-the-tool-touches). | | `scaffold` | Generate `Package.swift` into `node_modules//` for community RN libraries that ship only a podspec. | | `sync` (advanced) | Lightweight resync invoked by the Xcode auto-sync hooks. Regenerates invariant codegen and autolinking output only. Not for humans. | | `codegen` (advanced) | Run codegen and install the SwiftPM codegen template only. | @@ -123,115 +125,134 @@ command auto-redirects into `ios/` with a banner. ## CLI Options -Flags below use the `react-native spm` (camelCase) form. The raw script accepts -kebab-case equivalents (e.g. `--skip-codegen`). - -| Option | Description | -| -------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `--version ` | RN version. Resolved in this order: this flag, then the version a previous `--version` pinned into `.spm-injected.json`, then `node_modules/react-native/package.json`. Pass it once — later runs reuse the pin (see [Pinning the React Native version](#pinning-the-react-native-version)) | -| `--yes` | Skip the dirty-pbxproj confirmation prompt | -| `--xcodeproj ` | [add] Which `.xcodeproj` to inject into (when several exist) | -| `--productName ` | [add] Which app target to inject into (when several exist) | -| `--deintegrate` | [add] Run `pod deintegrate` + strip React Native from the Podfile before injecting | -| `--artifacts ` | [advanced] Local artifact root containing complete `debug/` and `release/` cache slots | -| `--download ` | [advanced] Artifact download policy (default: auto) | -| `--skipCodegen` | [advanced] Skip the codegen step | -| `--configCommand ` | [advanced] JSON array of the argv used to generate `autolinking.json`, overriding the default `@react-native-community/cli config` command. Also settable via the `RCT_SPM_AUTOLINKING_CONFIG_COMMAND` env var. Either way the value is remembered, so you pass it once. Example: `'["npx","expo-modules-autolinking","react-native-config","--json","--platform","ios"]'` | +Flags below use the `react-native spm` (camelCase) form. + +| Option | Description | +| -------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `--version ` | RN version. Resolved in this order: this flag, then the version a previous `--version` pinned into `.spm-injected.json`, then `node_modules/react-native/package.json`. Pass it once — later runs reuse the pin (see [Pinning the React Native version](#pinning-the-react-native-version)) | +| `--yes` | Skip the dirty-pbxproj confirmation prompt | +| `--xcodeproj ` | [add, update, scaffold, deinit] Which `.xcodeproj` to inject into or remove from (when several exist) | +| `--productName ` | [add, update, scaffold] Which app target to inject into (when several exist) | +| `--deintegrate` | [add] Run `pod deintegrate` + strip React Native from the Podfile before injecting | +| `--artifacts ` | [advanced] Local artifact root containing complete `debug/` and `release/` cache slots | +| `--download ` | [advanced] Artifact download policy (default: auto) | +| `--skipCodegen` | [advanced] Skip the codegen step | +| `--config-command ` | [advanced] **Raw script only** — `npx react-native spm` does not forward it; use the `RCT_SPM_AUTOLINKING_CONFIG_COMMAND` env var there. JSON array of the argv used to generate `autolinking.json`, overriding the default `@react-native-community/cli config` command. Either way the value is remembered, so you pass it once. Example: `'["npx","expo-modules-autolinking","react-native-config","--json","--platform","ios"]'` | ### The autolinking config command is remembered An app that replaces `@react-native-community/cli` autolinking (an Expo app, for -example) has to tell `spm` how to produce `autolinking.json`. Pass the command -once, on `add` or `update`: +example) must tell `spm` how to produce `autolinking.json`. Pass the command +once, on `add` or `update`. `npx react-native spm` does not forward a +`--configCommand` flag, so set the environment variable: + +```bash +RCT_SPM_AUTOLINKING_CONFIG_COMMAND='["npx","expo-modules-autolinking","react-native-config","--json","--platform","ios"]' \ + npx react-native spm add +``` + +or run the script directly with `--config-command`: ```bash -npx react-native spm add --configCommand '["npx","expo-modules-autolinking","react-native-config","--json","--platform","ios"]' +node node_modules/react-native/scripts/setup-apple-spm.js add \ + --config-command '["npx","expo-modules-autolinking","react-native-config","--json","--platform","ios"]' ``` Every action that needs `autolinking.json` — `add`, `update`, `scaffold`, and the build-time `sync` — resolves the command in this order: -1. `--configCommand` +1. `--config-command` (raw script only) 2. `RCT_SPM_AUTOLINKING_CONFIG_COMMAND` 3. the `configCommand` pinned in `MyApp.xcodeproj/.spm-injected.json` by an - earlier `add`/`update` + earlier `add`/`update`/`scaffold` 4. the default `@react-native-community/cli config` -`add`/`update` pin whichever of the first two routes supplied the command, -validated as an argv array; a later run that passes neither keeps the existing -pin, and passing `--configCommand` again replaces it. The pin exists because the -**Sync SPM Autolinking** build phase inherits neither your flag nor the shell -that exported the env var — without it, a successful `add` is followed by -failing builds, because the phase re-derives `autolinking.json` with the default -command. A pin never shadows the env var, so an override in your shell still -takes effect, and a pin that no longer parses is ignored in favor of the +`add`/`update`/`scaffold` pin the command from whichever of the first two routes +supplied it, validated as an argv array. A later run that passes neither keeps +the existing pin. A new command passed by either route replaces it. + +The pin exists because the **Sync SPM Autolinking** build phase inherits neither +your flag nor the shell that exported the env var. Without the pin, the phase +re-derives `autolinking.json` with the default command, and builds fail after a +successful `add`. A pin never shadows the env var, so an override in your shell +still takes effect. A pin that no longer parses is ignored in favor of the default. -`deinit` deletes `.spm-injected.json`, and the pin with it. A later `add` -therefore falls back to the default command unless you pass `--configCommand` -(or export the env var) again. +`deinit` deletes `.spm-injected.json`, and the pin with it. A later `add` then +falls back to the default command unless you export the env var (or pass +`--config-command` to the raw script) again. ### Pinning the React Native version The resolved version selects **which artifact slots the project is wired to**, -so it has to stay the same from one run to the next. `--version` is therefore -recorded in the `.spm-injected.json` marker (as `artifactsVersionOverride`) and -read back by later runs, which resolve the version in this order: +so it must stay the same from one run to the next. So `--version` is recorded in +the `.spm-injected.json` marker (as `artifactsVersionOverride`) and read back by +later runs, which resolve the version in this order: 1. an explicit `--version `, 2. the version a previous `--version` pinned into the marker, 3. `node_modules/react-native/package.json`. -So you pass the flag once, and a later flagless `add`/`update` stays on the -slots it selected. Without the pin, that flagless run falls back to -`package.json` and re-points the project at different artifact slots while the -marker still advertises the pinned version. +You pass the flag once, and a later flagless `add`/`update` stays on the slots +it selected. Without the pin, that flagless run would fall back to +`package.json`. It would re-point the project at different artifact slots while +the marker still advertises the pinned version. -`deinit` deletes the marker, and with it the pin — a later `add` resolves +`deinit` deletes the marker, and with it the pin. A later `add` resolves `node_modules/react-native/package.json` again unless you pass `--version`. ### Debug/Release flavor is automatic -React Native ships **flavored** prebuilt binaries: the _debug_ `React.framework` -(and `hermes-engine` / `ReactNativeDependencies`) carry the dev experience — dev -menu, assertions, `RN_DEBUG_STRING_CONVERTIBLE` — while _release_ strips them -for production. A Debug build must embed the debug binaries and a -Release/archive the release ones. +React Native ships **flavored** prebuilt binaries. The _debug_ `React.framework` +(and `hermes-engine` / `ReactNativeDependencies`) carry the dev experience: dev +menu, assertions, `RN_DEBUG_STRING_CONVERTIBLE`. The _release_ binaries strip +them for production. A Debug build must embed the debug binaries, and a +Release/archive build the release ones. SwiftPM `binaryTarget`s can't branch on the build configuration, so runtime -frameworks are deliberately kept out of the package graph. `spm add` downloads -and validates **both** flavors into immutable app-local slots. It injects -SDK/architecture-qualified Xcode settings that link the exact selected binaries, -plus one phase that copies and signs the selected frameworks into the app. +frameworks are deliberately kept out of the package graph. Instead: + +- `spm add` downloads and validates **both** flavors into immutable app-local + slots. +- It injects SDK/architecture-qualified Xcode settings that link the exact + selected binaries. +- It adds one phase that copies and signs the selected frameworks into the app. + Configurations containing `debug` or `development` select Debug; every other configuration selects Release. Selection uses only generated build settings and -standard macOS tools: builds do not run Node, mutate symlinks, regenerate the +standard macOS tools. Builds do not run Node, mutate symlinks, regenerate the package graph, or require a second build. -Those same debug-flavored configurations also get -`SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited) DEBUG"` — the only thing -that makes Swift's `#if DEBUG` true (`GCC_PREPROCESSOR_DEFINITIONS` reaches -C/ObjC/C++ only), and what `AppDelegate.swift`'s `bundleURL()` branches on to -load from Metro instead of a bundled `main.jsbundle`. CocoaPods injects it at -`pod install` time, so this keeps SwiftPM apps at parity. An existing value is -left alone. +Those debug-flavored configurations also get `DEBUG` in +`SWIFT_ACTIVE_COMPILATION_CONDITIONS`, injected as `("$(inherited)", DEBUG)` +when the setting is absent. Only this makes Swift's `#if DEBUG` true +(`GCC_PREPROCESSOR_DEFINITIONS` reaches C/ObjC/C++ only). `AppDelegate.swift`'s +`bundleURL()` branches on it to load from Metro instead of a bundled +`main.jsbundle`. CocoaPods injects it at `pod install` time, so this keeps +SwiftPM apps at parity. An existing value is left alone only if it already +contains `DEBUG`. Otherwise `DEBUG` is appended, and a scalar value is promoted +to an array (see [Files the tool touches](#files-the-tool-touches)). ### iOS deployment target -Every manifest React Native generates — the `Autolinked` aggregate, the synth -package per dependency, and each scaffolded community package — declares the -same platform floor: your app's `IPHONEOS_DEPLOYMENT_TARGET`, never below React -Native's own minimum (15.1). SwiftPM refuses to link a product whose minimum is -higher than the target depending on it, so a dependency that needs more (Expo's -packages need iOS 16.4) only resolves once the app asks for at least as much: -raise the deployment target in Xcode and re-run `react-native spm update`. - -A floor set in an `.xcconfig` your configuration is based on is honored, -`#include` chains included; one set through a build-setting variable -(`$(MY_FLOOR)`) is not, and falls back to React Native's minimum. `spm add` and -`spm update` also refresh the platform-floor line of existing scaffolded -manifests (they never create new ones) — if you persisted a scaffold with -`patch-package`, re-run `npx patch-package ` afterwards. +The `Autolinked` aggregate, the synth package per local module, and each +scaffolded community package declare the same platform floor: your app's +`IPHONEOS_DEPLOYMENT_TARGET`, never below React Native's own minimum (15.1). The +codegen package (`build/generated/ios/Package.swift`) always declares iOS 15. +`build/xcframeworks/Package.swift` declares no platforms. + +SwiftPM refuses to link a product whose minimum is higher than that of the +target depending on it. So a dependency that needs more (Expo's packages need +iOS 16.4) resolves only once the app asks for at least as much. Raise the +deployment target in Xcode and re-run `react-native spm update`. + +- A floor set in an `.xcconfig` your configuration is based on is honored, + `#include` chains included. +- A floor set through a build-setting variable (`$(MY_FLOOR)`) is not honored; + it falls back to React Native's minimum. +- `spm add` and `spm update` also refresh the platform-floor line of existing + scaffolded manifests. They never create new ones. If you persisted a scaffold + with `patch-package`, re-run `npx patch-package ` afterwards. ## Files the tool touches @@ -239,93 +260,110 @@ Paths are relative to the Xcode project directory (`ios/`) unless noted. ### In your repo — committed -| Path | Written by | What happens | Undone by `deinit`? | -| --------------------------------------------------- | ------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------ | -| `MyApp.xcodeproj/project.pbxproj` | `add`, `update` | SwiftPM package refs, the React build settings, the Sync build phase, and the flavored-framework embed phase are added. Purely additive; a re-run is a no-op. | Yes — exactly what was injected, per the marker (one exception below) | -| `MyApp.xcodeproj/.spm-injected.json` | `add`, `update` | Created. Two roles: it records every edit made — including the pre-injection value of any build setting rewritten — so removal is surgical and re-runs stay idempotent; and it **pins configuration** later runs and Xcode builds must reuse (see the two pins below). | Yes — deleted, and the pins go with it | -| `MyApp.xcodeproj/xcshareddata/xcschemes/*.xcscheme` | `add`, `update` | The sync pre-action is added to the scheme that builds your target; a shared scheme is created if there is none. Commit this or teammates lose the pre-action. | Yes — the scheme is deleted if `add` created it, otherwise only the pre-action is stripped | -| `.gitignore` | `add` only | Created if absent, else appended: a `# SPM – auto-generated at build time` block adding `Package.resolved`, `build/generated/`, `build/xcframeworks/`, `.build/`. | **No** — the block is left behind | -| `Podfile` | `add --deintegrate` | Only the React Native directives (`use_react_native!`, `use_native_modules!`, `prepare_react_native_project!`) are stripped. Your own `pod '…'` lines are preserved. | **No** — re-add the directives yourself to go back to CocoaPods | -| `Pods/`, `Pods-*.xcconfig`, `[CP]` phases | `add --deintegrate` | Removed by `pod deintegrate`. The `.xcworkspace` referencing them is left on disk. | **No** — run `pod install` to restore | +| Path | Written by | What happens | Undone by `deinit`? | +| --------------------------------------------------- | --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `MyApp.xcodeproj/project.pbxproj` | `add`, `update`, `scaffold` | SwiftPM package refs, the React build settings, the Sync build phase, and the flavored-framework embed phase are added; a re-run is a no-op. A `${PODS_ROOT}`-anchored `REACT_NATIVE_PATH` is replaced with the SwiftPM path. `add` also removes the template's unused `JavaScriptCore.framework` reference, and `add --deintegrate` removes an empty `Pods` group. | Yes — exactly what was injected, per the marker, including the original `REACT_NATIVE_PATH` (one exception below). The removed `JavaScriptCore.framework` reference and `Pods` group are **not** restored. | +| `MyApp.xcodeproj/.spm-injected.json` | `add`, `update`, `scaffold` | Created. Two roles: it records every edit made — including the pre-injection value of any build setting rewritten — so removal is surgical and re-runs stay idempotent; and it **pins configuration** later runs and Xcode builds must reuse (see the two pins below). | Yes — deleted, and the pins go with it | +| `MyApp.xcodeproj/xcshareddata/xcschemes/*.xcscheme` | `add`, `update`, `scaffold` | The sync pre-action is added to the scheme that builds your target; a shared scheme is created if there is none. Commit this or teammates lose the pre-action. | Yes — a scheme `add` created is deleted only if it is still byte-identical to the generated one; otherwise only the pre-action is stripped | +| `.gitignore` | `add` only | Created if absent, else appended: a `# SPM – auto-generated at build time` block adding `Package.resolved`, `build/generated/`, `build/xcframeworks/`, `.build/`. | **No** — the block is left behind | +| `Podfile` | `add --deintegrate` | Only the lines containing the React Native directives (`use_react_native!`, `use_native_modules!`, `prepare_react_native_project!`) are stripped. Your own `pod '…'` lines are preserved. The argument lines of a multi-line `use_react_native!(` call and `react_native_post_install(...)` remain — remove them by hand before `pod install`. | **No** — re-add the directives yourself to go back to CocoaPods | +| `Pods/`, `Pods-*.xcconfig`, `[CP]` phases | `add --deintegrate` | Removed by `pod deintegrate`. The `.xcworkspace` referencing them is left on disk. | **No** — run `pod install` to restore | -The two pinned settings are the `--version` pin (`artifactsVersionOverride`, see +The two pins are the `--version` pin (`artifactsVersionOverride`, see [Pinning the React Native version](#pinning-the-react-native-version)) and the [autolinking config command](#the-autolinking-config-command-is-remembered). -Because `deinit` drops the marker, it drops both. ### In your repo — generated, gitignored -| Path | Written by | Contents | -| ------------------------------ | ----------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `build/generated/ios/` | `add`, `update`, `sync`, `codegen` | Codegen output plus the SwiftPM codegen manifest (the `React-GeneratedCode` package). | -| `build/generated/autolinking/` | `add`, `update`, `sync` | `Package.swift`, `autolinking.json`, `packages/`, `libs/`, `headers/`, the `.spm-sync-stamp`, `.spm-sync-watch-paths`, and any `.spm-plugin-*.json` plugin manifests. | -| `build/xcframeworks/` | `add`, `update`, `sync`, `download` | The `debug/` and `release/` flavor slots (symlinks into the cache), `ReactHeadersTarget/`, the headers-only xcframeworks, `Package.swift`, `flavored-frameworks.json`, `.artifact-stamp`. | -| `.build/`, `Package.resolved` | Xcode / SwiftPM | SwiftPM's own build directory and resolution file. Machine-specific. | +| Path | Written by | Contents | +| ------------------------------ | ---------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `build/generated/ios/` | `add`, `update`, `scaffold`, `sync`, `codegen` | Codegen output plus the SwiftPM codegen manifest (the `React-GeneratedCode` package). | +| `build/generated/autolinking/` | `add`, `update`, `scaffold`, `sync` | `Package.swift`, `autolinking.json`, `packages/`, `libs/`, `headers/`, the `.spm-sync-stamp`, `.spm-sync-watch-paths`, and any `.spm-plugin-*.json` plugin manifests. | +| `build/xcframeworks/` | `add`, `update`, `scaffold` | The `debug/` and `release/` flavor slots (symlinks into the cache), `ReactHeadersTarget/`, the headers-only xcframeworks, `Package.swift`, `flavored-frameworks.json`, `.artifact-stamp`. | +| `.build/`, `Package.resolved` | Xcode / SwiftPM | SwiftPM's own build directory and resolution file. Machine-specific. | -`deinit` leaves all of the above in place — it is regenerable, and removing it -is `rm -rf build/ .build/` (see [Removing / resetting](#removing--resetting)). +`deinit` leaves all of the above in place. It is regenerable, and removing it is +`rm -rf build/ .build/` (see [Removing / resetting](#removing--resetting)). ### Outside your repo -| Path | Written by | Notes | -| ---------------------------------------------------------------- | ----------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `node_modules//Package.swift` | `scaffold` | A generated manifest for a dep that ships none. Not committed — persist with `patch-package` (see [Community packages without a Package.swift](#community-packages-without-a-packageswift)). | -| `~/Library/Caches/ReactNative/spm-artifacts///` | `add`, `update`, `sync`, `download` | The immutable artifact slots the `build/xcframeworks/` symlinks point at. Shared across apps on the machine. | -| `~/Library/Caches/ReactNative/` | `download` | Downloaded tarballs, shared with CocoaPods. `RCT_SKIP_CACHES=1` bypasses the cache. | - -Injection is **purely additive** and **idempotent**: every other byte of your -project — signing, capabilities, your own Build Phases — stays untouched, and a -re-run is a no-op. The injected refs point at three stable sub-package paths -under `build/`, so adding or removing community deps changes the sub-package -contents (gitignored) and never re-injects. `deinit` removes exactly what was -injected, leaving the project byte-identical to its pre-`add` state — with the -exceptions called out above, and one more described next. - -**Build settings that already exist** are edited in place. The four array -settings `add` merges into — `HEADER_SEARCH_PATHS`, `OTHER_LDFLAGS`, -`FRAMEWORK_SEARCH_PATHS`, `LD_RUNPATH_SEARCH_PATHS` — keep the shape they were -written in: Xcode's multi-line form as well as the compact one-line form hand -edits and other generators (XcodeGen, Tuist) emit. One that exists as a plain -_scalar_ is promoted to a `( … )` array — the shape an Xcode-authored target can -carry, e.g. a +| Path | Written by | Notes | +| ---------------------------------------------------------------- | --------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `node_modules//Package.swift` | `scaffold` | A generated manifest for a dep that ships none. Not committed — persist with `patch-package` (see [Community packages without a Package.swift](#community-packages-without-a-packageswift)). | +| `~/Library/Caches/ReactNative/spm-artifacts///` | `add`, `update`, `scaffold`, `download` | The immutable artifact slots the `build/xcframeworks/` symlinks point at. Shared across apps on the machine. | +| `~/Library/Caches/ReactNative/` | `download`, `add`, `update`, `scaffold` | Downloaded tarballs, shared with CocoaPods. `RCT_SKIP_CACHES=1` bypasses the cache. | + +Apart from the `project.pbxproj` edits listed above, injection is **additive**: +every other byte of your project — signing, capabilities, your own Build Phases +— stays untouched. The injected refs point at three stable sub-package paths +under `build/`. So adding or removing community deps changes only the +sub-package contents (gitignored) and never re-injects. `deinit` leaves the +project byte-identical to its pre-`add` state, with the exceptions above and one +more, described next. + +**Build settings that already exist** are edited in place. `add` merges into +five array settings: `HEADER_SEARCH_PATHS`, `OTHER_LDFLAGS`, +`FRAMEWORK_SEARCH_PATHS`, `LD_RUNPATH_SEARCH_PATHS`, and (on debug +configurations) `SWIFT_ACTIVE_COMPILATION_CONDITIONS`. Each keeps the shape it +was written in: Xcode's multi-line form, or the compact one-line form that hand +edits and other generators (XcodeGen, Tuist) emit. + +A setting that exists as a plain _scalar_ is promoted to a `( … )` array. An +Xcode-authored target can carry that shape, e.g. `LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";` written as a scalar rather than a list. `add` records the pre-injection value in the -marker and `deinit` restores it by rewriting the whole field — once folded -together, the injected members and your own are indistinguishable — so **members +marker. `deinit` restores it by rewriting the whole field, because once folded +together, the injected members and your own are indistinguishable. So **members you add to a promoted array by hand afterwards are lost**. That applies to `update` too, which reverts to the recorded baseline before re-injecting. +## Environment variables + +| Variable | Effect | +| -------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `RCT_SPM_AUTOLINKING_CONFIG_COMMAND` | JSON argv array that replaces the default `@react-native-community/cli config` command. `add`/`update`/`scaffold` pin it — see [The autolinking config command is remembered](#the-autolinking-config-command-is-remembered). | +| `RCT_SKIP_CACHES` | `1` bypasses the shared tarball cache in `~/Library/Caches/ReactNative/`. | +| `RN_CORE_TARBALL_PATH` | Local React core tarball, used instead of a download. Skips the shared cache. | +| `RN_HEADERS_TARBALL_PATH` | Local `ReactNativeHeaders` tarball, used instead of the copy inside the React core tarball. | +| `RN_DEPS_TARBALL_PATH` | Local `ReactNativeDependencies` tarball, used instead of a download. Skips the shared cache. | +| `RN_DEPS_HEADERS_TARBALL_PATH` | Local `ReactNativeDependenciesHeaders` tarball, used instead of the copy inside the dependencies tarball. | +| `RCT_REACT_NATIVE_MAVEN_MIRROR_ENABLED` | `false` or `0` turns off the React Native Maven mirror. The mirror is on by default and is tried before Maven Central. | +| `ENTERPRISE_REPOSITORY` | Maven repository URL. When set, it is the only repository consulted for releases; the snapshot fallback still uses Maven Central snapshots. | +| `RN_SPM_REMOTE_URL`, `RN_SPM_REMOTE_VERSION` | Turn on remote-package mode — see [Remote-package mode](./spm-header-paths-contract.md#remote-package-mode). | +| `RN_DEP_VERSION` | Version of the `ReactNativeDependencies` artifact, or `nightly`. Defaults to the React Native version. | +| `HERMES_VERSION` | Version of the Hermes artifact, `nightly`, or `latest-v1`. Defaults to the locally pinned `hermes-compiler` version, else `latest-v1`. | + +A tarball path that does not exist fails the download. Each tarball override +applies to both flavors, so the Debug and Release slots get the same file. + ## Fresh clones & CI -Everything under `build/` is gitignored, so a clean checkout has no resolvable +Everything under `build/` is gitignored. So a clean checkout has no resolvable Swift packages until they are regenerated. Xcode resolves the package graph before build phases **and** before scheme pre-actions, so neither -[auto-sync hook](#auto-sync) can rescue this: with `build/generated/autolinking` -missing, the build stops at _"Resolve Package Graph … doesn't exist"_ having run +[auto-sync hook](#auto-sync) can fix this. With `build/generated/autolinking` +missing, the build stops at _"Resolve Package Graph … doesn't exist"_ and runs neither hook. -Verified on Xcode 26.6 against a freshly-injected app with `build/` deleted: +Verified on Xcode 26.6, against a freshly-injected app with `build/` deleted: `xcodebuild -scheme … build` fails in nine lines of log, with -`Resolve Package Graph` as the first step and no trace of the pre-action; -`xcodebuild -resolvePackageDependencies` fails identically. Opening the project +`Resolve Package Graph` as the first step and no trace of the pre-action. +`xcodebuild -resolvePackageDependencies` fails the same way. Opening the project in Xcode also resolves the graph on load, before you press Build. -So run the setup command once after cloning, before building — the SwiftPM +So run the setup command once after cloning, before building. It is the SwiftPM analog of `pod install`: ```bash npx react-native spm # downloads artifacts (if missing) + regenerates build/ ``` -On an already-injected project this routes to `update`: it fetches the -xcframework artifacts into the shared cache if they aren't present and -regenerates `build/xcframeworks` + `build/generated`. After this first run, -incremental dependency changes are picked up automatically by the auto-sync -hooks. +On an already-injected project this routes to `update`. It fetches the +xcframework artifacts into the shared cache if they aren't present, and +regenerates `build/xcframeworks` + `build/generated`. -**Automate it** so nobody has to remember — add a `postinstall` hook, which runs -as part of the `npm install` / `yarn install` your CI already does before -`xcodebuild`: +**Automate it** with a `postinstall` hook. It runs as part of the `npm install` +/ `yarn install` your CI already does before `xcodebuild`: ```json { @@ -335,22 +373,22 @@ as part of the `npm install` / `yarn install` your CI already does before } ``` -`npx react-native spm` auto-redirects from the JS root into `ios/`, so the hook -works from the app root; in CI (non-interactive) it proceeds without prompting. -It re-runs the full pipeline (codegen + an idempotent re-inject that is a no-op -when nothing changed), so it is slightly heavier than the internal `sync` the -build phase calls — a fine trade for not having to remember a command. +The hook works from the app root, because `npx react-native spm` redirects from +the JS root into `ios/`. In CI (non-interactive) it proceeds without prompting. +It re-runs the full pipeline: codegen, plus an idempotent re-inject that is a +no-op when nothing changed. So it is slightly heavier than the internal `sync` +the build phase calls. > A future remote-package distribution (a tagged `Package.swift` repo + -> `binaryTarget(url:checksum:)`) removes this step entirely: SwiftPM resolves -> and fetches the artifacts itself during normal package resolution. Until then, -> the one-time setup run is required on clean machines. +> `binaryTarget(url:checksum:)`) removes this step: SwiftPM will resolve and +> fetch the artifacts itself during normal package resolution. Until then, clean +> machines need the one-time setup run. ## Local Native Modules -Modules not discovered via autolinking are declared in the app's package.json. -Each `path` is relative to the file that declares it — the project root for a -package.json there, the Xcode project directory for a config kept there: +Declare modules that autolinking does not discover in the app's package.json. +Each `path` is relative to the file that declares it: the project root for a +package.json there, or the Xcode project directory for a config kept there: ```json { @@ -359,16 +397,42 @@ package.json there, the Xcode project directory for a config kept there: { "name": "MyNativeModule", "path": "ios/MyNativeModule", - "exclude": ["*.podspec"] + "exclude": ["*.podspec"], + "sources": ["**/*.{h,m,mm}"] } ] } } ``` -Each entry becomes a target in `build/generated/autolinking/Package.swift`. -Sources outside `build/generated/autolinking/` are automatically mirrored with -file-level symlinks. +Each entry becomes its own package at +`build/generated/autolinking/packages//Package.swift`, with a `root` +directory symlink to the module directory. Its headers are mirrored with +file-level symlinks into `build/generated/autolinking/headers//`. The +aggregator `build/generated/autolinking/Package.swift` references each one as +`.package(path: "packages/")`. A module directory that ships its own +`Package.swift` is used as is. + +A module that mixes Swift and C-family (`.m`/`.mm`/`.c`/`.cpp`) sources is +rejected, because SwiftPM cannot compile both in one target. Split it into +single-language modules, or ship a hand-written `Package.swift`. Module names +get the same checks as [library names](#library-names): a name React Native +reserves, or one that collides with another module or an autolinked library, is +a hard error. + +`sources` is optional. It is a glob allowlist relative to `path`, like a +podspec's `source_files`. Without it, or when it matches no file, the target +gets every `.h`, `.hpp`, `.m`, `.mm`, `.c`, `.cpp` and `.swift` file under +`path`, minus `exclude`. Directories named `android`, `test`, `tests`, +`__tests__`, `__mocks__`, `jest` or `node_modules` are skipped at any depth, +also when `sources` is set. + +Modules reach one app target only: the one `spm add` links the `Autolinked` +aggregate to. Other targets in the project do not see them. Choose the target +with `--productName`. + +Add third-party Swift packages to the app target in Xcode (File > Add Package +Dependencies). Local modules cannot import them. ## Where SwiftPM settings live @@ -393,22 +457,22 @@ alongside `codegenConfig`. } ``` -A library's settings come from its own package.json. An **app's** are resolved -field by field, each from the directory holding its package.json — the JS root, -where codegen reads `codegenConfig` — falling back to the Xcode project -directory. An unrecognised field is ignored with a warning naming it, so a typo -does not pass silently. +A library's settings come from its own package.json. An **app's** settings are +resolved field by field. Each field comes from the directory that holds its +package.json (the JS root, where codegen reads `codegenConfig`), falling back to +the Xcode project directory. An unrecognised field is ignored with a warning +naming it, so a typo does not pass silently. -These settings used to live in an `spm` block in `react-native.config.js`. That -block is **deprecated** but still read, with the same field names, so nothing -breaks: move the keys across as they are, and package.json wins field by field. -`npx react-native spm scaffold` writes the `name` for you — see +The `spm` block in `react-native.config.js` is **deprecated** but still read, +with the same field names, so nothing breaks. Move the keys across as they are; +package.json wins field by field. `npx react-native spm scaffold` writes the +`name` for you — see [Community packages without a Package.swift](#community-packages-without-a-packageswift). ## Library names An autolinked library's SwiftPM target name is also the prefix its headers are -imported under (`#import `), so it is not cosmetic: it has to be the +imported under (`#import `). So it is not cosmetic: it must be the prefix the library's own sources and its dependents already use. It is resolved in this order: @@ -421,42 +485,51 @@ It is resolved in this order: 5. podspec name — `react-native-svg` → `RNSVG` 6. npm package name — `react-native-svg` → `ReactNativeSvg` -Steps 3–5 are a **migration path, not the destination**. They exist so that -libraries work unchanged today, a podspec being what they already ship, and -`npx react-native spm scaffold` closes them out: it records the name it derived -as `swiftpmConfig.name` in the library's package.json, after which the podspec -is never consulted for naming again. A library that declares its own name needs -no podspec for SwiftPM at all. - -Within those three steps, `header_dir` comes first because that is what a -library sets when its import prefix differs from its pod name, then -`module_name`, what CocoaPods compiles the module as and so what Swift and -`@import` consumers write. But a `header_dir` only a **subspec** declares names -that subspec's headers, not the library, so the pod name stands -(react-native-svg is `RNSVG`, not `rnsvg`; its subspec prefix resolves through -the header search paths instead) — unless the subspec reuses the parent's block -variable (`do |s|`), which hides which scope declared what, so the podspec is -read by CocoaPods or not at all. An unreadable podspec falls through rather than -failing the build — with a warning, since a machine that can read it (one with -CocoaPods installed) may resolve a different name. A prefix Swift cannot spell -is normalized — `Some.Pod` becomes `Some_Pod`, what SwiftPM would compile it as -anyway — with a warning naming `swiftpmConfig.name`. - -Two names are refused outright: one React Native reserves (`ReactNative`, -`ReactHeaders`, `ReactNativeHeaders`, `ReactNativeDependenciesHeaders`, -`ReactAppHeaders`, `React-GeneratedCode`, `ReactCodegen`, -`ReactAppDependencyProvider`, `Autolinked`), and one another library already -took. Both are **hard errors** naming `swiftpmConfig.name` — nothing is renamed -automatically, because a name the build invented is one no `#import` in your -sources can predict. Two names must differ by more than case or punctuation to -be two targets: `worklets` and `Worklets` share a headers directory, and -`foo-bar` and `foo_bar` are one module, since SwiftPM replaces every character -C99 rejects with `_`. +Steps 3–5 are a **migration path, not the destination**. They let libraries work +unchanged today, because a podspec is what they already ship. +`npx react-native spm scaffold` closes them out: it records the derived name as +`swiftpmConfig.name` in the library's package.json, and the podspec is then +never consulted for naming again. A library that declares its own name needs no +podspec for SwiftPM at all. + +Within those three steps: + +- `header_dir` comes first, because a library sets it when its import prefix + differs from its pod name. +- `module_name` comes next. It is what CocoaPods compiles the module as, and so + what Swift and `@import` consumers write. +- A `header_dir` that only a **subspec** declares names that subspec's headers, + not the library, so the pod name stands. react-native-svg is `RNSVG`, not + `rnsvg`; its subspec prefix resolves through the header search paths instead. + The exception is a subspec that reuses the parent's block variable (`do |s|`). + That hides which scope declared what, so the podspec is read by CocoaPods or + not at all. +- An unreadable podspec falls through rather than failing the build. It logs a + warning, because a machine that can read it (one with CocoaPods installed) may + resolve a different name. +- A prefix Swift cannot spell is normalized, e.g. `Some.Pod` becomes `Some_Pod`, + which is what SwiftPM would compile it as anyway. A warning names + `swiftpmConfig.name`. + +Two names are refused outright: + +- a name React Native reserves (`ReactNative`, `ReactHeaders`, + `ReactNativeHeaders`, `ReactNativeDependenciesHeaders`, `ReactAppHeaders`, + `React-GeneratedCode`, `ReactCodegen`, `ReactAppDependencyProvider`, + `Autolinked`), and +- a name another library already took. + +Both are **hard errors** that name `swiftpmConfig.name`. Nothing is renamed +automatically, because no `#import` in your sources can predict a name the build +invented. Two names must differ by more than case or punctuation to be two +targets. `worklets` and `Worklets` share a headers directory, and `foo-bar` and +`foo_bar` are one module, since SwiftPM replaces every character C99 rejects +with `_`. ## Dependencies between libraries -SwiftPM has no equivalent of a podspec's `s.dependency`, so a library that needs -another native library declares it explicitly in its **own** package.json — a +SwiftPM has no equivalent of a podspec's `s.dependency`. So a library that needs +another native library declares it explicitly in its **own** package.json, as a list of npm names: ```json @@ -467,15 +540,15 @@ list of npm names: ``` The autolinker follows these **recursively** from the directly-autolinked deps -and dedupes, so a transitive dependency joins the package graph even when the +and dedupes. So a transitive dependency joins the package graph even when the app never depends on it directly. ### Config module format `react-native.config.js` may be CommonJS or ESM, and both named and default -exports are read. A key defined twice — as a named export and on the default -export — resolves to the named one. Avoid that shape anyway: the Community CLI -has two loaders that disagree about it, a sync one that sees named exports and +exports are read. A key defined twice, as a named export and on the default +export, resolves to the named one. Avoid that shape anyway. The Community CLI +has two loaders that disagree about it: a sync one that sees named exports, and an async one that takes only the default export. For maximum compatibility, prefer the one-line CommonJS form: @@ -483,33 +556,32 @@ prefer the one-line CommonJS form: module.exports = {dependency: {platforms: {ios: {}}}}; ``` -If the config fails to load, a warning names the file and the reason — any -deprecated `spm` settings in it are ignored rather than silently applied. +If the config fails to load, a warning names the file and the reason. Any +deprecated `spm` settings in it are then ignored rather than silently applied. ## Self-managed community packages -A community library that ships its own `Package.swift` is referenced directly by -the autolinker instead of being wrapped. To keep SwiftPM's package identity -(which it derives from the path basename) unique across deps — even when several -libs put their manifest inside an `ios/` subdir — each self-managed dep is -exposed through a uniquely-named symlink at -`build/generated/autolinking/libs//`. The aggregator `Package.swift` -references that path, so two libs both shipping `/ios/Package.swift` never -collide on identity `"ios"`. +The autolinker references a community library that ships its own `Package.swift` +directly, instead of wrapping it. SwiftPM derives package identity from the path +basename, and several libs may put their manifest inside an `ios/` subdir. To +keep identity unique, each self-managed dep is exposed through a uniquely-named +symlink at `build/generated/autolinking/libs//`. The aggregator +`Package.swift` references that path, so two libs that both ship +`/ios/Package.swift` never collide on identity `"ios"`. -The `libs/` directory is wiped and recreated on every autolinker run, so -deleting a dep via `npm uninstall` cleans up the alias automatically on the next -build. +The `libs/` directory is not recreated on each autolinker run. An alias that +does not change keeps its inode, because Xcode holds each one as a loaded +package root. Aliases for deps that are no longer self-managed are pruned. So +after `npm uninstall` removes a dep, the next build cleans up its alias. ## Community packages without a Package.swift If an autolinked library ships **no `Package.swift`**, `spm add`/`update` stops with a per-dep error (`Package.swift is missing for library ""`) and exits -**2** — a distinct code from a generic failure, so CI and the Xcode sync hooks -can treat it as a hard error while staying lenient about transient sync -failures. +with code **2**. This code is distinct from a generic failure, so CI and the +Xcode sync hooks can treat it as a hard error (see [Auto-Sync](#auto-sync)). -`add` and `update` deliberately **never** scaffold on your behalf: +`add` and `update` deliberately **never** scaffold for you, because auto-scaffolding would hide a real gap in the dependency's SPM support. Generate the manifest from the library's podspec explicitly, then re-run setup: @@ -518,19 +590,21 @@ npx react-native spm scaffold # writes Package.swift into node_modules/ # then commit the generated patch ``` **Better: contribute the manifest upstream.** The generated `Package.swift` is a -normal, committable manifest — the ideal fix is for the library to ship it -itself, so every consumer gets SwiftPM support without a local patch. Please -**file an issue or open a PR on the library** with the scaffolded -`Package.swift` (mention it was generated by `react-native spm scaffold` for -React Native SwiftPM support). Until it lands upstream, the `patch-package` -workaround keeps your app building. - -> A library whose sources mix Swift **and** Objective-C/C++ in one target, or -> that ships neither a `Package.swift` nor a podspec, can't be scaffolded -> automatically — the error says so. Opt it out via `react-native.config.js` -> (`platforms.ios = null`) or ask the maintainer for a prebuilt xcframework. A +normal, committable manifest. The ideal fix is for the library to ship it, so +every consumer gets SwiftPM support without a local patch. Please **file an +issue or open a PR on the library** with the scaffolded `Package.swift`, and +mention that `react-native spm scaffold` generated it for React Native SwiftPM +support. Until it lands upstream, the `patch-package` workaround keeps your app +building. + +> A library can't be scaffolded automatically if its sources mix Swift **and** +> Objective-C/C++ in one target, or if it ships neither a `Package.swift` nor a +> podspec. The error says so. Opt it out via `react-native.config.js` +> (`platforms.ios = null`), or ask the maintainer for a prebuilt xcframework. A > library can also opt out of scaffolding alone with > `"swiftpmConfig": {"scaffold": false}`. ## Framework plugins (Preview) Frameworks with their own module system (e.g. Expo) contribute to the -autolinking graph through a **plugin** — a function invoked on every -regeneration (including the build-time sync) that adds SwiftPM package refs, -product dependencies, and generated sources. Discovery is transitive (installing -the framework is enough), and the plugin returns data that RN merges -idempotently. - -See **[spm-autolinking-plugins.md](./spm-autolinking-plugins.md)** for the -discovery mechanism, the full context/return contract, lifecycle, and failure -behavior. +autolinking graph through a **plugin**. See +**[spm-autolinking-plugins.md](./spm-autolinking-plugins.md)** for discovery, +the context/return contract, lifecycle, and failure behavior. ## Removing / resetting @@ -574,36 +642,36 @@ react-native spm deinit # surgically removes everything `add` injected pod install # then, to restore CocoaPods ``` -To reset the regenerable build state (without un-injecting), just delete the -gitignored dirs and re-run: +To reset the regenerable build state without un-injecting, delete the gitignored +dirs and re-run: ```bash rm -rf build/xcframeworks build/generated .build react-native spm update ``` -Xcode's "Clean Build Folder" (Cmd+Shift+K) only removes DerivedData — it does -not touch SwiftPM-generated directories. The cached xcframework slot is shared +Xcode's "Clean Build Folder" (Cmd+Shift+K) only removes DerivedData. It does not +touch SwiftPM-generated directories. The cached xcframework slot is shared across apps; refresh it with `react-native spm update --download force`. ## Troubleshooting -| Problem | Fix | -| ---------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `xcodebuild` fails: "Could not resolve package dependencies … `build/generated/autolinking` doesn't exist" | Fresh clone — run `npx react-native spm` once before building (see [Fresh clones & CI](#fresh-clones--ci)) | -| `spm add` fails: "CocoaPods-integrated project" | Re-run `spm add --deintegrate` (runs `pod deintegrate` + strips RN from the Podfile), or `pod deintegrate` yourself first. | -| `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`. | -| `spm add` fails: "multiple .xcodeproj found" | Pass `--xcodeproj ` (and `--product-name ` if multiple app targets). | -| `Package.swift is missing for library ""` (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) | -| `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) | -| Missing headers | Re-run `react-native spm` | -| "not contained in target" | Re-run setup (regenerates file-level symlinks) | -| Codegen fails | Use `--skipCodegen` to iterate on other parts | -| "SPM sync failed" warning | Check Xcode build log for details; node may not be in PATH — ensure `with-environment.sh` is present | -| "Sync SPM Autolinking" build phase fails: `'npx --no-install @react-native-community/cli config' exited with status 1` | This app replaces `@react-native-community/cli` autolinking (e.g. an Expo app). Re-run `spm add`/`update` with `--configCommand` (or with `RCT_SPM_AUTOLINKING_CONFIG_COMMAND` exported) so the working command is pinned for the build phase to reuse — see [The autolinking config command is remembered](#the-autolinking-config-command-is-remembered). | -| Autolinking not updating on build | Touch `package.json` to force a sync, or delete `build/generated/autolinking/.spm-sync-stamp` | -| Stale SwiftPM state or corrupted build | `rm -rf build/ .build/`, then `react-native spm update`, then reopen Xcode | -| Want to revert to CocoaPods | `react-native spm deinit`, then `pod install` | +| Problem | Fix | +| ---------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `xcodebuild` fails: "Could not resolve package dependencies … `build/generated/autolinking` doesn't exist" | Fresh clone — run `npx react-native spm` once before building (see [Fresh clones & CI](#fresh-clones--ci)) | +| `spm add` fails: "CocoaPods-integrated project" | Re-run `spm add --deintegrate` (runs `pod deintegrate` + strips RN from the Podfile), or `pod deintegrate` yourself first. | +| `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`. | +| `spm add` fails: "multiple .xcodeproj found" | Pass `--xcodeproj ` (and `--productName ` if multiple app targets). | +| `Package.swift is missing for library ""` (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) | +| `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) | +| Missing headers | Re-run `react-native spm` | +| "not contained in target" | Re-run setup (regenerates file-level symlinks) | +| Codegen fails | Use `--skipCodegen` to iterate on other parts | +| "SPM sync failed" warning | Check Xcode build log for details; node may not be in PATH — ensure `with-environment.sh` is present | +| "Sync SPM Autolinking" build phase fails: `'npx --no-install @react-native-community/cli config' exited with status 1` | This app replaces `@react-native-community/cli` autolinking (e.g. an Expo app). Re-run `spm add`/`update` with `RCT_SPM_AUTOLINKING_CONFIG_COMMAND` exported (or the raw script with `--config-command`) so the working command is pinned for the build phase to reuse — see [The autolinking config command is remembered](#the-autolinking-config-command-is-remembered). | +| Autolinking not updating on build | Touch `package.json` to force a sync, or delete `build/generated/autolinking/.spm-sync-stamp` | +| Stale SwiftPM state or corrupted build | `rm -rf build/ .build/`, then `react-native spm update`, then reopen Xcode | +| Want to revert to CocoaPods | `react-native spm deinit`, then `pod install` | --- @@ -628,13 +696,13 @@ across apps; refresh it with `react-native spm update --download force`. ```text my-app/ios/ MyApp.xcodeproj/ <-- committed (your project; SwiftPM injected in place, carries .spm-injected.json) - Podfile <-- present until `pod deintegrate` (CocoaPods coexistence is best-effort) + Podfile <-- kept; `--deintegrate` only strips the React Native lines (CocoaPods coexistence is best-effort) build/ generated/ autolinking/ <-- gitignored (regenerated at build time) Package.swift autolinking.json - packages/ <-- synth wrappers for autolinker-managed deps + packages/ <-- synth wrappers for swiftpmConfig.modules (local modules) libs/ <-- symlinks to self-managed deps' Package.swift dirs, named by Swift module so SwiftPM package identity stays unique @@ -658,44 +726,23 @@ my-app/ios/ ### Header Resolution -React Native uses CocoaPods-style imports (`#import `) that -SwiftPM doesn't natively support. The prebuilt artifacts serve them through -SwiftPM package products — no `-I` search-path flags, and no clang VFS overlay: - -1. **`` and `import React`** resolve through the invariant - **`ReactHeaders` Clang target**. It stages one canonical header copy after - proving Debug and Release expose identical public headers, and uses a plain - `module React` module map with `React/`-prefixed paths. -2. **Lowercase C++ `react/` and every other RN namespace** (`yoga/`, `jsi/`, - `jsinspector-modern`, …) comes from **`ReactNativeHeaders.xcframework`**, a - headers-only (LIBRARY-type) binaryTarget whose per-slice `Headers/` SwiftPM - auto-serves to dependents. -3. **Third-party dependency namespaces** (`folly/`, `glog/`, `boost/`, `fmt/`, - `double-conversion/`, `fast_float/`, `SocketRocket/`) come from - **`ReactNativeDependenciesHeaders.xcframework`**, the deps headers-only - sidecar (same mechanism — the binary `ReactNativeDependencies.xcframework` is - framework-type and can't expose those headers to SwiftPM). - -Targets that compile against React take these as product dependencies -(`ReactHeaders`, `ReactNativeHeaders`, `ReactNativeDependenciesHeaders`, plus -the app's `ReactAppHeaders`), so all of the above resolve with zero search-path -flags. +React Native's headers resolve through SwiftPM package products, with no `-I` +search-path flags and no clang VFS overlay. See +[How headers resolve](./spm-header-paths-contract.md#how-headers-resolve-no-search-paths) +for the products, the namespaces each one serves, and why. ### Auto-Sync -Autolinking is kept up to date without manual re-runs of `react-native spm` by -**two hooks running the same sync script**, injected by `add`/`update`: +`add`/`update` inject **two hooks that run the same sync script**. They keep +autolinking up to date without manual re-runs of `react-native spm`: | Hook | Where | Role | | ---------------------------------- | ------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- | | Scheme pre-action | The app's **shared** scheme (`xcshareddata/xcschemes/`), under `BuildAction` → `PreActions` | Fires earlier in the build than a build phase can, so it is the one that normally does the re-sync. | | `Sync SPM Autolinking` build phase | `.xcodeproj`, prepended before `Sources` | **Safety net** for builds that bypass the scheme (and for a scheme whose pre-action was stripped). | -Neither hook can bootstrap a clean checkout. Xcode resolves the Swift package -graph before build phases **and** before scheme pre-actions, so if the generated -packages are missing entirely, resolution fails and the build stops before -either hook runs — see [Fresh clones & CI](#fresh-clones--ci). The hooks keep an -_existing_ set of generated packages current; they do not create the first one. +The hooks keep an _existing_ set of generated packages current. They do not +create the first one: see [Fresh clones & CI](#fresh-clones--ci). **How the sync script works:** @@ -703,18 +750,25 @@ _existing_ set of generated packages current; they do not create the first one. `build/generated/autolinking/.spm-sync-stamp`: - `package.json` — dependency declarations - `react-native.config.js` — autolinking config + - lockfiles found walking up from the project root — `package-lock.json`, + `npm-shrinkwrap.json`, `yarn.lock`, `pnpm-lock.yaml`, `bun.lock`, + `bun.lockb`, `.pnp.cjs`, `.pnp.loader.mjs` - `node_modules/` directory mtime — updated by any package manager (npm, yarn, pnpm, bun); also checks parent `node_modules` for monorepo setups - - a missing `build/xcframeworks/` (e.g. after a manual clean) also marks - stale - every path in `.spm-sync-watch-paths` — RN's own inputs plus any [plugin](./spm-autolinking-plugins.md#watchpaths--plugin-staleness-inputs) `watchPaths`; a watched file that is newer, a watched dir with a newer - child, or a watched path that has **vanished** all mark stale + descendant (the scan skips `.swiftpm`), or a watched path that has + **vanished** all mark stale + - the commit time of the latest `git log` entry touching `*.js` / `*.ts`, + compared with the stamp's mtime 2. If any input is newer (or the stamp is missing): runs - `npx react-native spm sync`, which re-executes autolinking + package - generation (downloading artifacts if the cache slot is incomplete) and writes - the stamp file. + `"$NODE_BINARY" "$RN_DIR/scripts/setup-apple-spm.js" sync`. It falls back to + `npx react-native spm sync` only when that script or `NODE_BINARY` is + missing. `sync` regenerates `autolinking.json` (CLI config), runs codegen, + installs the codegen template, re-runs autolinking, rebuilds the header farm, + and writes the stamp file. It does not download artifacts or regenerate + `build/xcframeworks/`. 3. If all inputs are fresh: exits immediately (~1ms). **Ordering.** As observed in an `xcodebuild -scheme … build` log on Xcode 26.6: @@ -732,21 +786,23 @@ _existing_ set of generated packages current; they do not create the first one. | Resources (copy) | build phase 5 | | Build JS Bundle | build phase 6 | -Resolution coming first is what makes the one-time setup run necessary on a -clean checkout; it is not something either hook can work around. - -A sync failure is lenient by default but **not unconditionally**. The generated +A sync failure is lenient by default, but **not unconditionally**. The generated script branches on the exit code: -- **Exit 2** — an autolinked dependency ships no `Package.swift`. This **fails - the build** (`exit 1`), deliberately: the autolinker has already printed an - `error:` line per dep, and the fix needs a terminal (see - [Community packages without a Package.swift](#community-packages-without-a-packageswift)). +- **Exit 2** — **fails the build** (`exit 1`), whatever the cause. Three errors + exit with 2: + - an autolinked dependency ships no `Package.swift` — the autolinker has + already printed an `error:` line per dep, and the fix needs a terminal (see + [Community packages without a Package.swift](#community-packages-without-a-packageswift)); + - the autolinking config command fails (see + [The autolinking config command is remembered](#the-autolinking-config-command-is-remembered)); + - remote mode is on but no usable React Native version resolves (see + [Remote-package mode](./spm-header-paths-contract.md#remote-package-mode)). - **Any other non-zero exit** — emits `warning: SPM sync failed — build may use stale codegen/autolinking` and lets the build continue, so an already-generated package graph can still produce a successful build. -That split is the whole reason the missing-manifest case has its own exit code: -a transient sync hiccup should not break a build that could still succeed, while -a genuinely missing manifest should not pass silently. +These errors have their own exit code because a transient sync hiccup should not +break a build that could still succeed, while a missing manifest, a broken +config command, or an unresolvable remote version should not pass silently.