diff --git a/__docs__/README.md b/__docs__/README.md index dd25a3fd248d..6ce5cdddef3e 100644 --- a/__docs__/README.md +++ b/__docs__/README.md @@ -80,6 +80,7 @@ TODO: Explain the different components of React Native at a high level. - Build system - Android - iOS + - [SwiftPM](../packages/react-native/scripts/spm/__docs__/README.md) - C++ - JavaScript - Metro diff --git a/packages/react-native/scripts/spm/__doc__/rfc-spm-xcframework.md b/packages/react-native/scripts/spm/__doc__/rfc-spm-xcframework.md deleted file mode 100644 index 86116637e3f7..000000000000 --- a/packages/react-native/scripts/spm/__doc__/rfc-spm-xcframework.md +++ /dev/null @@ -1,707 +0,0 @@ ---- -title: Swift Package Manager Support for React Native iOS -author: -- Christian Falch -date: 2026-03-17 ---- - -# RFC: Swift Package Manager Support for React Native iOS - -## Summary - -Add Swift Package Manager (SPM) as an officially supported build system for -React Native iOS apps, alongside CocoaPods. The approach uses **prebuilt -XCFrameworks** published to Maven, eliminating the need for source compilation -of React Native internals and enabling fast, reproducible builds. - -## Basic example - -### New project - -```bash -npx react-native init MyApp -cd MyApp -npx react-native spm # auto-detects first-run → init; prompts to rename legacy CocoaPods xcodeproj -npm run ios -``` - -A future CLI integration (e.g., an `--ios-build-system spm` flag on -`react-native init`) could run `react-native spm init` automatically as part -of project creation, eliminating the manual step. - -### Existing project - -```bash -cd MyApp -npx react-native spm -# Prompted: rename CocoaPods MyApp.xcodeproj → MyApp.xcodeproj.legacy? -# Accept (Y) — the SPM xcodeproj writes to the now-free MyApp.xcodeproj slot, -# `npm run ios` resolves to it unambiguously. The legacy stays on disk -# (git mv tracks the rename cleanly) for rollback via `spm clean --project`. -``` - -After initial setup, day-to-day development requires no extra commands. Adding -or removing JS dependencies that include native code is handled automatically -by a build-phase sync step (see [Auto-sync build phase](#auto-sync-build-phase)). - -## Motivation - -### Apple is moving away from CocoaPods - -SPM is Apple's endorsed dependency manager. Xcode's SPM integration improves -with every release — package resolution, build caching, and IDE features all -assume SPM as the primary workflow. CocoaPods is community-maintained and has -been officially sunsetted — the CocoaPods trunk will become permanently -read-only on **December 2, 2026**, after which no new pods or updates can be -published ([announcement](https://blog.cocoapods.org/CocoaPods-Specs-Repo/)). -Existing builds will continue to work, but the ecosystem is moving on. - -### Build speed - -Prebuilt XCFrameworks skip compilation of ~2000 C++/Objective-C files. A clean -SPM build of rn-tester compiles only app sources and codegen output. This is a -significant improvement for CI pipelines and developer iteration speed. - -### Reduced onboarding friction - -CocoaPods requires Ruby, Bundler, and a working gem environment — a frequent -source of setup issues, especially on new machines or in CI. SPM requires only -Xcode. Removing the Ruby toolchain dependency simplifies onboarding and reduces -the surface area for environment-related build failures. - -### Adoption barrier - -Many organizations mandate SPM for iOS dependencies. Teams in these -environments are currently blocked from adopting React Native, or must maintain -custom workarounds. First-class SPM support might help overcoming this barrier. - -### Compatibility - -The SPM workflow generates an `AppName.xcodeproj` that takes the same -filename slot as the legacy CocoaPods xcodeproj. On `init`, the script -prompts to rename the existing CocoaPods project to `AppName.xcodeproj.legacy` -— preserving it for rollback while letting the community CLI's -`findXcodeProject` resolve `npm run ios` to the SPM project unambiguously. -Teams can migrate at their own pace before CocoaPods trunk goes read-only -in December 2026, and `spm clean --project` reverses the migration when -needed. - -## Detailed design - -### Architecture - -``` -┌─────────────────────────────────────────────────┐ -│ Maven (artifacts) │ -│ ├── React.xcframework (~200 MB, debug) │ -│ ├── ReactNativeDependencies.xcframework │ -│ └── hermes-engine.xcframework │ -└──────────────────┬──────────────────────────────┘ - │ download + cache - ▼ -┌─────────────────────────────────────────────────┐ -│ ~/Library/Caches/com.facebook.ReactNative/ │ -│ └── spm-artifacts/{version}/{flavor}/ │ -└──────────────────┬──────────────────────────────┘ - │ symlink - ▼ -┌──────────────────────────────────────────────────┐ -│ App ios/ │ -│ ├── AppName.xcodeproj/ (committed) │ -│ │ └── .spm-managed (marker file) │ -│ ├── AppName.xcodeproj.legacy/ (committed if │ -│ │ rename was │ -│ │ accepted) │ -│ └── build/ │ -│ ├── generated/ │ -│ │ ├── autolinking/ (generated) │ -│ │ │ ├── Package.swift │ -│ │ │ ├── autolinking.json │ -│ │ │ ├── packages/ (synth wrappers) │ -│ │ │ └── libs/ (alias symlinks │ -│ │ │ for self-managed│ -│ │ │ deps; basename │ -│ │ │ = SwiftName) │ -│ │ └── ios/ (codegen) │ -│ └── xcframeworks/ (symlinks) │ -│ ├── Package.swift │ -│ ├── React.xcframework -> cache │ -│ ├── ReactNativeDependencies.xcframework │ -│ └── hermes-engine.xcframework │ -└──────────────────────────────────────────────────┘ -``` - -### Pipeline - -`react-native spm` orchestrates six steps (the underlying script is -`scripts/setup-apple-spm.js`): - -| # | Step | Script | Output | -|---|------|--------|--------| -| 1 | CLI config | `spm/generate-spm-autolinking-config.js` | `build/generated/autolinking/autolinking.json` | -| 2 | Codegen | `generate-codegen-artifacts.js` | `build/generated/ios/` | -| 3 | Autolinking | `spm/generate-spm-autolinking.js` | `build/generated/autolinking/Package.swift` + source symlinks | -| 4 | Download | `spm/download-spm-artifacts.js` | Cached xcframeworks | -| 5 | Package | `spm/generate-spm-package.js` | `build/xcframeworks/Package.swift` + symlinks | -| 6 | Xcodeproj | `spm/generate-spm-xcodeproj.js` | `AppName.xcodeproj` + `.spm-managed` marker (`init` only; create-if-missing on subsequent runs) | -| — | Sync (build-time) | `spm/sync-spm-autolinking.js` | Re-runs steps 1–5 when inputs change (downloads artifacts if missing) | - -The `init` action additionally (a) prompts to rename any existing -CocoaPods `.xcodeproj` to `.xcodeproj.legacy` before step 6, and -(b) appends SPM-specific entries to `.gitignore` -(`build/generated/`, `build/xcframeworks/`, `.build/`, `Package.resolved`). -Existing entries are not duplicated. - -### Auto-sync build phase - -After initial setup, developers shouldn't need to re-run `react-native spm` -manually when dependencies change. The generated `.xcodeproj` includes a -**Sync SPM Autolinking** pre-build phase (ordered first, before VFS overlay) -that: - -1. Checks whether xcframework artifacts are missing (`artifacts.json` or - `React.xcframework` absent). This covers fresh clones where no setup - script has been run yet. -2. Compares timestamps of `package.json`, `react-native.config.js`, and the - `node_modules` directory against `autolinked/.spm-sync-stamp`. In - monorepos where `node_modules` is hoisted, the parent directory is also - checked. -3. If any check triggers (or the stamp is missing): sources `with-environment.sh` - for node PATH, then runs `spm/sync-spm-autolinking.js` which re-executes - codegen, artifact download (if needed), autolinking, and package generation. -4. If all inputs are fresh: exits immediately (~1ms shell check). - -Failures emit `warning:` and exit 0 — the existing autolinking may still be -valid. The stamp file is written on successful sync. - -The sync step handles React Native version changes automatically: after -`npm install` pulls a new version, the `node_modules` mtime changes, the sync -step regenerates autolinking and recreates xcframework symlinks pointing to the -new version's cache directory. - -The sync step is **self-healing**: if xcframework artifacts are missing (e.g., -the local cache at `~/Library/Caches/com.facebook.ReactNative/` was deleted, -or the project was freshly cloned), it automatically downloads them before -proceeding with autolinking and package generation. This means `react-native spm` -is only strictly required for initial project scaffolding (`init`); subsequent -builds recover automatically. - -### Cleaning generated SPM state - -Xcode's "Clean Build Folder" (Cmd+Shift+K) only removes DerivedData — it does -not touch the project's `build/` or `.build/` directories. Xcode provides no -hook to run custom scripts during GUI clean actions. - -`react-native spm clean` is scoped by opt-in flags. The default removes only -generated dirs under `appRoot`: - -```bash -react-native spm clean # build/xcframeworks/, build/generated/, .build/ -react-native spm clean --project # also: delete SPM xcodeproj, restore .legacy backup -react-native spm clean --derived-data # also: this app's Xcode DerivedData entries -react-native spm clean --cache # also: cached xcframework slot for current version -react-native spm clean --all # = --project --derived-data --cache -``` - -Destructive scopes (`--project`, `--derived-data`, `--cache`, `--all`) prompt -for confirmation (bypass with `--yes`). `--project` is the reverse of the -init-time rename migration — deleting the SPM xcodeproj and restoring -`.xcodeproj.legacy` to its original filename if a backup exists. - -After a plain `clean`, run `react-native spm update` (or open the checked-in -`.xcodeproj` and build) to regenerate state. SPM package resolution is locked -for the duration of a build — if only stubs were left in place, Xcode would -resolve stubs and never pick up the real packages generated by the sync build -phase. - -### Stub packages for fresh clones - -Xcode resolves SPM packages **before** any build phase runs. On a fresh clone, -the referenced package directories (`build/xcframeworks`, `autolinked`, -`build/generated/ios`) may not exist yet, causing package resolution to fail. - -To solve this, `generate-spm-xcodeproj.js` writes **stub `Package.swift` -files** into each referenced sub-package directory that doesn't already have -one. Each stub defines the expected library products backed by a minimal -placeholder target (`.stub/Stub.swift`). This lets Xcode resolve packages -successfully even before the first build. On the first build, the auto-sync -build phase overwrites the stubs with real Package.swift files generated from -downloaded artifacts and autolinking output. - -### Caching and CI - -Xcframeworks are cached at -`~/Library/Caches/com.facebook.ReactNative/spm-artifacts/{version}/{flavor}/` -by default. The download step accepts a `--output` flag to write xcframeworks -to an explicit directory. - -For CI pipelines (GitHub Actions, CircleCI, etc.), cache the default path -keyed by the React Native version and flavor to avoid re-downloading -xcframeworks on every build. - -The Maven base URL can be overridden via the `ENTERPRISE_REPOSITORY` -environment variable for teams that mirror artifacts to an internal registry. - -**Planned:** A `RN_SPM_CACHE_DIR` environment variable to override the default -cache directory. This is not yet implemented in the current POC but is needed -for CI environments where a specific path must be persisted across builds. - -### Package graph - -The generated `.xcodeproj` references three local packages directly -via `XCLocalSwiftPackageReference` — no app-level `Package.swift` is required: - -``` -AppName.xcodeproj - ├── XCLocalSwiftPackageReference → build/xcframeworks/Package.swift - │ ├── ReactNative (product, wraps React binaryTarget) - │ ├── ReactNativeDependencies (binaryTarget) - │ └── hermes-engine (binaryTarget) - ├── XCLocalSwiftPackageReference → build/generated/ios/Package.swift - │ ├── ReactCodegen (target — codegen output) - │ └── ReactAppDependencyProvider (target) - └── XCLocalSwiftPackageReference → build/generated/autolinking/Package.swift - └── ... (targets — symlinked sources) -``` - -These three sub-package paths are **stable**: adding or removing community -deps changes the contents of `build/generated/autolinking/Package.swift` -(gitignored) but never the xcodeproj's references. That's why the -`.xcodeproj` is committed once and not regenerated on subsequent runs. - -The xcodeproj generation is **create-if-missing** on `update` (use -`--force-xcodeproj` for an explicit overwrite). This protects user-side -Xcode edits — signing, capabilities, Build Phases, scheme settings — from -being clobbered. Teammates can clone the repo and open Xcode immediately: -stub `Package.swift` files in each sub-package directory let SPM resolution -succeed before the first build, and the auto-sync build phase downloads -artifacts and writes the real sub-packages on first compile. - -### Header resolution - -React Native uses CocoaPods-style imports (`#import `) that -SPM does not natively support. Two mechanisms solve this: - -1. **XCFramework `Headers/` layout.** The prebuild step organizes headers by - `header_dir` (e.g., `Headers/React/`, `Headers/react/renderer/core/`). - Adding `-I Headers` to search paths resolves most imports directly. - -2. **VFS overlay.** A Clang virtual filesystem overlay (`React-VFS.yaml`) - remaps remaining edge cases — headers that appear in multiple pods or have - platform variants. The overlay is generated as a template at prebuild time - and resolved with local paths at setup time. - -### Local native modules - -Modules not discovered via autolinking (e.g., app-specific native modules) are -declared in `react-native.config.js`: - -```js -// react-native.config.js -module.exports = { - spmModules: [ - { - name: 'MyNativeModule', // SPM target name - path: 'ios/MyNativeModule', // path to source files - exclude: ['*.podspec'], // files to exclude from the target - publicHeadersPath: '.', // header search path for consumers - }, - ], -}; -``` - -Each entry becomes a target in `autolinked/Package.swift`. Sources outside the -autolinked directory are mirrored with **file-level symlinks** (SPM rejects -directory symlinks that resolve outside the package root). - -### Self-managed deps and package identity - -A community library that ships its own `Package.swift` (instead of being -wrapped by the autolinker) is referenced directly. SPM derives the package -identity for a `.package(path:)` dependency from the path's basename — and -a common convention is to ship the manifest inside an `ios/` subdir -(`/ios/Package.swift`). Two libs following that convention would both -have identity `"ios"`, and SPM rejects with `Conflicting identity for ios`. - -To make every reference globally unique by construction, the autolinker -materializes each self-managed dep as a symlink at -`build/generated/autolinking/libs//` pointing at the dep's real -manifest dir. The aggregator `Package.swift` then references the symlink -(`path: "libs/"`), and SPM uses the symlink basename — the -library's Swift module name — as the package identity. Swift module names -are already unique per dep (deriving from the npm package name), so this -sidesteps the collision in all cases, including against the codegen -package at `build/generated/ios/`. - -The `libs/` directory is wiped and recreated on every autolinker run, so -stale aliases for uninstalled deps disappear automatically. - -### Third-party library support - -The current implementation handles React Native's own frameworks and app-local -native modules. The primary goal for third-party libraries is to **build using -SPM**. Shipping prebuilt xcframeworks is the recommended approach for faster -builds, but it is not a requirement — libraries can also be compiled from -source via SPM targets. This ensures that library authors with limited -resources can support SPM without needing to set up a prebuild CI pipeline. - -#### Library metadata in `react-native.config.js` - -`react-native.config.js` is the canonical place for library SPM metadata. The -autolinking pipeline already scans `node_modules` for this file to discover -iOS and Android native modules. Adding SPM config alongside the existing -`dependency.platforms.ios` keeps a single source of truth, requires no new -discovery mechanism, and can express things `Package.swift` cannot — such as -Maven URL templates with version and flavor placeholders for downloading -prebuilt xcframeworks. Libraries may still ship a `Package.swift` for direct -SPM consumers outside the React Native ecosystem, but React Native autolinking -reads `react-native.config.js`. - -#### Prebuilt xcframeworks (primary path) - -React Native already prebuilds its core into xcframeworks and publishes them to -Maven. This is the model we want every library to follow. Libraries declare SPM -metadata in `react-native.config.js`: - -```js -// react-native-maps/react-native.config.js -module.exports = { - dependency: { - platforms: { - ios: { /* existing autolinking config */ }, - }, - }, - spm: { - // Primary: prebuilt xcframework (downloaded at setup time) - xcframework: { - name: 'ReactNativeMaps', - // URL template — {version}, {rn-version}, {flavor} resolved at download time - url: 'https://maven.example.com/.../react-native-maps-{version}-xcframework-{flavor}.tar.gz', - }, - // Fallback: source compilation (used during local development or when - // xcframework is unavailable) - source: { - name: 'ReactNativeMaps', - path: 'ios', - publicHeadersPath: '.', - exclude: ['*.podspec', 'Tests/**'], - dependencies: ['MapKit'], - resources: ['ios/Resources/**'], - }, - }, -}; -``` - -**Planned (Phase 2):** When `react-native spm` gains third-party library -support, it will: -1. If `spm.xcframework` is declared, download the prebuilt binary (fast path). -2. If the download fails or the `--source` flag is passed, fall back to - `spm.source` and compile from symlinked sources. -3. If neither is declared, the library requires a manual `spmModules` entry. - -Currently, only `spmModules` entries (see [Local native modules](#local-native-modules)) -are supported. The `spm.xcframework` and `spm.source` config fields — including -the `dependencies` field shown above — are not yet implemented. - -#### Source compilation (fallback) - -Source-level autolinking (`spmModules` / `spm.source`) remains available for: -- **Local development** — library authors iterating on native code -- **Libraries without prebuilt xcframeworks** — transitional state -- **App-specific native modules** — code that lives in the app repo - -This reuses the existing `spmModules` mechanism: sources are mirrored with -file-level symlinks into `autolinked/`, compiled as SPM targets with -appropriate header search paths. - -#### `react-native-prebuild` CLI - -React Native already has a mature prebuild pipeline (`scripts/ios-prebuild/`) -that produces signed, packaged xcframeworks published to Maven. Rather than -asking library authors to reinvent this, we can expose the same tooling as a -reusable CLI: - -```bash -npx react-native-prebuild \ - --podspec ios/MyLibrary.podspec \ - --react-native-version 0.80.0 \ - --platforms ios,ios-simulator \ - --flavor release \ - --output dist/ - -# Output: -# dist/MyLibrary.xcframework.tar.gz -# dist/MyLibrary.framework.dSYM.tar.gz -``` - -The tool would: - -1. **Download React Native xcframeworks** for the specified version. -2. **Parse the library's podspec** to discover source files, headers, - `header_dir`, dependencies, and compiler flags. -3. **Generate a temporary Package.swift** declaring the library as a target - with dependencies on the RN xcframeworks. -4. **Build** using `xcodebuild` for each platform slice. -5. **Compose** the xcframework with organized headers, module map, and - optional VFS overlay. -6. **Sign** the xcframework with the developer's code signing identity. -7. **Package** as `.tar.gz` with dSYM symbols. - -The tool includes code signing as a built-in step. Library authors provide -their own signing identity (Apple Developer certificate); the tool handles -the `codesign` invocation. Unsigned xcframeworks trigger macOS Gatekeeper -warnings, so signing is strongly recommended for distributed artifacts. -Documentation will cover how to create and manage a signing identity for -this purpose. - -Library authors can integrate this into CI to publish prebuilt artifacts on -every release, targeting a matrix of React Native versions and build flavors. - -#### Version compatibility - -A library's xcframework must be built against a compatible React Native -version. The prebuild tool embeds metadata (React Native version, library -version, build flavor, minimum iOS version) inside the xcframework. The -download step verifies compatibility at setup time, warning if a library was -built against a different React Native version than the app is using. - -## Drawbacks - -### Transition period: supporting both CocoaPods and SPM - -With CocoaPods trunk going read-only in December 2026, the migration to SPM is -necessary rather than optional. During the transition period, both build -systems must be supported in parallel. Bug fixes, new features, and build-phase -changes need to be tested against both CocoaPods and SPM until CocoaPods -support is eventually removed. - -### Download size - -Prebuilt xcframeworks for React Native core are compressed as tar.gz archives. -Individual library xcframeworks are typically 1–15 MB in debug mode including -dSYM bundles. Both debug and release flavors are needed, which doubles the -total. While artifacts are cached locally after the first download, CI -environments without persistent caches will re-download on every build. - -### Ecosystem adoption takes time - -Third-party libraries must opt in to the prebuild workflow. During the -transition period, many libraries will only support CocoaPods. Apps that depend -on these libraries cannot fully migrate to SPM until the libraries catch up. -This creates a chicken-and-egg problem that may slow adoption. - -### SPM limitations require `.xcodeproj` generation - -SPM does not support build script phases, `post_install` hooks, or the kind of -build-time customization that CocoaPods provides via its Podfile DSL. The -current design works around this by generating an `.xcodeproj` with explicit -build phases for JS bundling, Hermes engine copying, VFS overlay setup, and -autolinking sync. This is a known limitation of the current approach. If Apple -expands SPM's plugin API to support arbitrary script execution with file I/O -and network access, the `.xcodeproj` could be eliminated in favor of a purely -SPM-native workflow — but this is a future direction that depends on Apple's -roadmap, not something this proposal can resolve. - -### Committed xcodeproj edits - -The generated `.xcodeproj` is committed and may carry user edits — -signing, capabilities, Build Phases, custom schemes. The `update` action is -**create-if-missing** to protect those edits, which means the script does -not propagate generator improvements into existing projects automatically. -Bug fixes that change the emitted pbxproj need an explicit -`--force-xcodeproj` run to take effect. A future improvement could -preserve user-side edits through a merge step rather than full overwrite — -see "Hardening `update --force-xcodeproj`" in unresolved questions. - -## Alternatives - -### Compile React Native from source as SPM targets - -Compiling React Native's C++/Objective-C sources from source as SPM targets is -not the default path due to the ~2000 source files and complex header layout, -which makes clean build times significantly longer. However, source -compilation support is a goal for specific use cases: - -- **Debugging React Native internals** — developers investigating bugs or - contributing fixes to React Native itself need to build from source with - debug symbols. -- **Apps requiring source patches** — projects like Expo Go that need to modify - React Native source code to build successfully, or apps that apply patches - via tools like `patch-package`. - -The source compilation path would reuse the same SPM package structure but -replace binary xcframework targets with source targets. This is planned as a -`--source` flag to `react-native spm`. - -### SPM build tool plugins - -SPM plugins were evaluated as a way to eliminate the `.xcodeproj` (see -[SPM Plugins Assessment](spm-plugins-assessment.md) for details). The key -findings: - -- **Post-build phases are impossible.** JS bundling and Hermes engine copying - run after linking to place artifacts in the `.app` bundle. SPM has no - post-build plugin capability — this is a deliberate design choice for build - reproducibility. -- **Sandbox restrictions.** Build tool plugins cannot write to the source tree, - run `node`, or access `node_modules`. Pre-build phases like autolinking sync - require all of these. -- **No Xcode build settings.** SPM plugins do not receive `CONFIGURATION`, - `BUILT_PRODUCTS_DIR`, or other settings that the JS bundling script relies on. - -A hybrid approach (some SPM plugins + some Xcode build phases) would be harder -to reason about than the current uniform approach of all Xcode build phases. -SPM plugins are not a viable alternative today. - -## Adoption strategy - -This proposal introduces SPM as an **additional** build system. It is not a -breaking change. CocoaPods continues to work exactly as before. The two -workflows coexist — an app can have both `Podfile` and `Package.swift` in the -same directory. - -### Phase 1: React Native core (current) - -SPM works for React Native core frameworks and app-local native modules -declared as `spmModules` in `react-native.config.js`. No third-party library -support. This phase validates the architecture and developer experience with -rn-tester and the helloworld template. - -### Phase 2: Library ecosystem tooling - -Ship the `react-native-prebuild` CLI. Library authors can prebuild and publish -xcframeworks for their libraries. The autolinking step reads `spm.xcframework` -from installed libraries and downloads artifacts automatically. Libraries -without xcframeworks fall back to `spm.source` (source compilation) or manual -`spmModules` entries. - -### Phase 3: Ecosystem-wide adoption - -Popular libraries ship prebuilt xcframeworks from CI. App developers get -near-zero-compilation iOS builds — only app code and codegen output are -compiled. React Native provides clear documentation and tooling -(`react-native-prebuild`) to help library authors build and publish -xcframeworks — for example, CI workflow templates and guidance on publishing to -Maven or GitHub Releases. Prebuilt xcframeworks are recommended but not -required; libraries that don't provide them fall back to source compilation. - -### Migration path for existing apps - -1. Run `npx react-native spm` from the project root (auto-redirects into - `ios/`). -2. Accept the rename prompt — your existing `AppName.xcodeproj` becomes - `AppName.xcodeproj.legacy` (preserved for rollback). -3. Commit the new `AppName.xcodeproj/` (SPM-managed) and the renamed - `AppName.xcodeproj.legacy/`. `git mv` tracks the rename cleanly. -4. Run `npm run ios` and verify the SPM build. -5. Once validated, optionally delete the `.legacy` backup, `Podfile`, - `Pods/`, and `.xcworkspace`. - -To roll back: `npx react-native spm clean --project` deletes the SPM -xcodeproj and renames `.legacy` back to the canonical filename. - -No changes to JavaScript code, Metro configuration, or Android setup are -required. - -### Upgrading React Native - -After upgrading `react-native` in `package.json` and running `npm install`, -the auto-sync build phase detects the `node_modules` mtime change on the next -Xcode build and re-runs the sync step automatically. This downloads the new -version's xcframeworks, regenerates the sub-packages, and updates autolinking. -No manual edits are needed — the xcodeproj's sub-package references are -stable, and those sub-packages are fully regenerated each run. Developers -can also run `react-native spm` manually to trigger the update before -building. - -## How we teach this - -### Documentation - -- Add a **"Building with SPM"** guide to the React Native docs, parallel to the - existing CocoaPods setup guide. -- Update the **"Getting Started"** guide to present SPM as an option alongside - CocoaPods, with SPM as the recommended path for new projects once Phase 2 is - stable. -- Add a **library author guide** explaining how to use `react-native-prebuild` - and publish xcframeworks. - -### CLI discoverability - -- `react-native spm --help` should provide clear usage instructions and - explain each step. -- Error messages should include actionable suggestions (e.g., "Run - `react-native spm init` for first-time setup"). -- The auto-sync build phase should surface warnings in Xcode's issue navigator - when autolinking state is stale. - -### Community template - -- The `react-native init` template should include SPM as an option (e.g., - `--pm spm` flag or interactive prompt). -- The template should generate the initial `Package.swift` and `.xcodeproj` so - that new projects work with SPM out of the box. - -### Naming and terminology - -- **"SPM build"** or **"Swift Package Manager build"** to distinguish from the - CocoaPods-based workflow. -- **"xcframeworks"** when referring to the prebuilt binary artifacts. -- Avoid the term "pods" when discussing the SPM workflow to prevent confusion. - -## Unresolved questions - -1. **How should version compatibility be enforced?** A library's xcframework - must be built against a compatible React Native version. Should the download - step enforce strict version matching, accept semver-compatible ranges, or - simply warn on mismatch? - -2. **Where should library xcframeworks be hosted?** Maven Central (consistent - with React Native core), GitHub Releases (simpler for library authors), or a - dedicated registry (better discovery and compatibility metadata). Each has - different trade-offs for discoverability, reliability, and maintenance - burden. - -3. **Debug symbol (dSYM) distribution.** The prebuild pipeline produces dSYM - bundles alongside xcframeworks, but the best way to distribute and consume - them is not yet defined. Open questions include: should dSYMs be downloaded - alongside xcframeworks automatically or on demand? How should they integrate - with crash reporting services (Sentry, Crashlytics) that need dSYM UUIDs - for symbolication? Should the download step place dSYMs in a location that - Xcode's archive workflow picks up automatically? - -4. **How should library authors validate SPM compatibility?** A validation - command (`react-native-prebuild --validate`) could verify that a library's - sources compile as an SPM target without producing a full release artifact. - This would be useful for CI checks on pull requests. - -5. **Hardening `update --force-xcodeproj`.** The default `update` is - create-if-missing, which preserves user edits but means generator - improvements don't propagate to existing projects automatically. Passing - `--force-xcodeproj` clobbers everything. A future improvement could read - the existing pbxproj, merge changes (signing, capabilities, custom Build - Phases), and write back — likely via a proper Xcode project - parser/generator (e.g., `@bacons/xcode`) rather than the current - template-based approach. Planned work for production readiness. - -6. **Auto-sync failure visibility.** The sync build phase currently emits - `warning:` and exits 0 on failure, which means a broken autolinking state - can persist silently across builds. Planned improvements include a strict - mode (e.g., `RN_SPM_STRICT_SYNC=1` that exits non-zero on failure) and - generating a `#warning` directive in a source file when sync fails, so - Xcode surfaces the issue in the issue navigator even when build log - warnings are missed. - -7. **Monorepo and package manager compatibility.** The auto-sync build phase - uses `node_modules` mtime to detect dependency changes. This has been - tested with npm in the React Native monorepo but not yet with Yarn - workspaces (hoisted or PnP), pnpm (symlinked `node_modules`), or Bun. - These package managers structure `node_modules` differently and may require - adjustments to the mtime detection logic. Validating and fixing - compatibility across package managers is planned work. - -## References - -- [RFC0508: Out-of-NPM Artifacts](https://github.com/react-native-community/discussions-and-proposals/blob/main/proposals/0508-out-of-npm-artifacts.md) — established the Maven-based artifact distribution pattern this proposal builds on -- [Apple: Creating Swift Packages](https://developer.apple.com/documentation/xcode/creating-a-standalone-swift-package-with-xcode) -- [SE-0272: Package Manager Binary Dependencies](https://github.com/swiftlang/swift-evolution/blob/main/proposals/0272-swiftpm-binary-dependencies.md) diff --git a/packages/react-native/scripts/spm/__doc__/spm-header-paths-contract.md b/packages/react-native/scripts/spm/__doc__/spm-header-paths-contract.md deleted file mode 100644 index c62c46e0b781..000000000000 --- a/packages/react-native/scripts/spm/__doc__/spm-header-paths-contract.md +++ /dev/null @@ -1,97 +0,0 @@ -# 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. - -## How headers resolve (no search paths) - -| 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. | - -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`. - -`autolinking.json` (the `@react-native-community/cli config` output) is an -INPUT used to generate the manifests; it is never read by a manifest. - -## 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) | - -## 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`. -`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 -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**: - -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.) -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). diff --git a/packages/react-native/scripts/spm/__doc__/spm-plugins-assessment.md b/packages/react-native/scripts/spm/__doc__/spm-plugins-assessment.md deleted file mode 100644 index e20e4c93f672..000000000000 --- a/packages/react-native/scripts/spm/__doc__/spm-plugins-assessment.md +++ /dev/null @@ -1,128 +0,0 @@ -# SPM Build Plugins Assessment - -An evaluation of whether Swift Package Manager plugins can replace the Xcode build -phase scripts currently injected by `generate-spm-xcodeproj.js`. - -## Current Build Phases (6 total) - -| # | Phase | Timing | SPM Plugin Feasible? | -|---|-------|--------|----------------------| -| 1 | Sync SPM Autolinking | Pre-build | Partially | -| 2 | Prepare VFS Overlay | Pre-build | Partially | -| 3 | Sources (compile) | Build | N/A (standard) | -| 4 | Frameworks (link) | Build | N/A (standard) | -| 5 | Resources (copy) | Build | N/A (standard) | -| 6 | Build JS Bundle | **Post-build** | **See below** | - -> **Removed:** The "Copy Hermes Framework" phase was removed — it was a no-op. -> The underlying `copy-hermes-xcode.sh` script has been empty since Dec 2022. -> Hermes is already properly linked as an xcframework SPM dependency. - -## SPM Plugin Types - -SPM offers two plugin types: - -1. **Build Tool Plugins** (`BuildToolPlugin`) — run pre-build, can generate source - files/resources via `prebuildCommands` or per-file `buildCommands`. -2. **Command Plugins** (`CommandPlugin`) — run on-demand via - `swift package `. - -## Key Constraints - -### Sandbox restrictions - -SPM plugins run sandboxed by default — no network access, limited filesystem access. -The current scripts need to: - -- Run `node` (not on the sandbox-allowed path) -- Write to the source tree (`autolinked/`, `build/`) -- Access `node_modules/` -- Read git state - -Command plugins can request `--allow-writing-to-package-directory`, but build tool -plugins can only write to a designated plugin work directory, not the source tree. - -### No Xcode build settings - -SPM plugins do not receive Xcode build settings such as `CONFIGURATION`, -`PLATFORM_NAME`, `BUILT_PRODUCTS_DIR`, or `DERIVED_FILE_DIR`. The JS bundling script -relies heavily on these to decide debug-vs-release behavior and output paths. - -## JS Bundle Phase — Could Move to Pre-build - -The JS bundle has no dependency on native compilation. It only needs JS source files, -Metro, and knowledge of debug vs release. The current post-build placement is -historical — the script writes directly into `BUILT_PRODUCTS_DIR`. - -A potential restructuring: - -1. **Generate the bundle pre-build** into a known location (e.g. `build/jsbundle/`) -2. **Declare it as an SPM resource** so it gets copied into the app automatically - -Challenges: -- **Debug builds skip bundling** (app loads from Metro dev server). The script checks - `CONFIGURATION == Debug`, which is unavailable to SPM plugins. -- **Hermes bytecode compilation** also happens in this phase for release builds. -- Making it a command plugin (`swift package bundle-js --configuration release`) would - lose the automatic behavior — developers would need to run it explicitly. - -## What Could Theoretically Work - -### Codegen as a Command Plugin - -A Swift command plugin could shell out to `node` to run codegen: - -```swift -@main struct CodegenPlugin: CommandPlugin { - func performCommand(context: PluginContext, arguments: [String]) throws { - let process = Process() - process.executableURL = URL(fileURLWithPath: "/usr/bin/env") - process.arguments = ["node", "scripts/codegen/generate-codegen-artifacts.js"] - try process.run() - process.waitUntilExit() - } -} -``` - -Invoked as `swift package codegen`. This is essentially wrapping a shell script in -Swift with no real benefit over the current approach. - -### Autolinking sync as a Prebuild Command - -A `prebuildCommand` runs before every build, similar to Phase 1. But: - -- Output can only go to the plugin work directory (not `autolinked/`) -- Would need to restructure the package graph to consume generated files from the - plugin work directory -- Still needs to shell out to `node` - -This is a significant architectural rework for marginal benefit. - -## Recommendation - -**Do not invest in SPM plugins for this use case.** Reasons: - -1. **Pre-build phases already work well** as Xcode build phase scripts. Moving them - to SPM plugins adds Swift boilerplate around `Process()` calls to `node`, while - losing access to Xcode build settings. - -2. **The ROI is poor** — a hybrid (some SPM plugins + some Xcode build phases) is - harder to reason about than the current uniform approach of all Xcode build phases. - -3. **SPM plugins shine for pure Swift source generation** (SwiftGen, SwiftProtobuf) - where the plugin generates `.swift` files that feed into compilation. React - Native's build steps are fundamentally different — they orchestrate a JS toolchain - and copy runtime artifacts. - -4. **The JS bundle phase could move pre-build** but would lose automatic - debug/release detection without Xcode build settings. Worth revisiting if SPM - gains access to build configuration in a future Swift version. - -## Alternatives Worth Exploring - -- **Xcode Build Tool Plug-ins** (the Xcode-specific variant, not SPM) have access to - build settings and can run post-build, but require a different packaging model. -- **Move auto-sync to a `prepare` script** in `package.json` so it runs at - `yarn install` time instead of every build, reducing build-time overhead. -- **Pre-build JS bundling** with the bundle declared as an SPM resource, removing the - need for a post-build phase entirely (release builds only). diff --git a/packages/react-native/scripts/spm/__doc__/spm-scripts.md b/packages/react-native/scripts/spm/__doc__/spm-scripts.md deleted file mode 100644 index fcb69bf55d47..000000000000 --- a/packages/react-native/scripts/spm/__doc__/spm-scripts.md +++ /dev/null @@ -1,528 +0,0 @@ -# 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. - -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. - -## Quick Start - -```bash -cd ios - -# First-time setup: injects SwiftPM packages into your existing MyApp.xcodeproj, -# in place. `npx react-native spm` with no action auto-resolves to `add` (or -# `update` once injected); on a fresh CocoaPods app it converts in one command -# (implies --deintegrate). To do it explicitly: -npx react-native spm add --deintegrate - -# Open in Xcode (or `npm run ios`). Incremental dep changes auto-sync on build. -open MyApp.xcodeproj -``` - -After the initial run, the `.xcodeproj` includes an **auto-sync build phase** -that detects dependency changes and re-runs autolinking before compilation -(see [Auto-Sync](#auto-sync-build-phase)) — you don't re-invoke -`react-native spm` manually 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)). - -> **Note:** `react-native spm` is a thin wrapper over -> `node node_modules/react-native/scripts/setup-apple-spm.js`. If the CLI -> alias is unavailable in your environment, invoke the script directly with -> the same actions and the kebab-case flag equivalents (e.g. -> `--skip-codegen`). - -## CocoaPods → SwiftPM migration - -`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. -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**. -3. injects SwiftPM into the `.xcodeproj`. - -React Native now comes from SwiftPM; no pods are linked yet (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 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 -the SwiftPM-injected project, so React Native resolves through SwiftPM and your -other pods through CocoaPods, together. - -> **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!`. - -The migration is fully reversible — see -[Removing / resetting](#removing--resetting). - -## 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 -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. - -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. - -## CLI Actions - -```bash -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 -`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. - -| 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 exact inverse of `add`: surgically remove only what `add` injected (recorded in `.spm-injected.json`) and drop the marker. Git-recoverable; no prompt. | -| `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 build phase. Regenerates invariant codegen and autolinking output only. Not for humans. | -| `codegen` (advanced) | Run codegen and install the SwiftPM codegen template only. | -| `download` (advanced) | Download/check xcframework artifacts only. | - -## 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"]'` | - -### 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`: - -```bash -npx react-native spm add --configCommand '["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` -2. `RCT_SPM_AUTOLINKING_CONFIG_COMMAND` -3. the `configCommand` pinned in `MyApp.xcodeproj/.spm-injected.json` by an - earlier `add`/`update` -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 -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. - -### 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: - -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. - -`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 `hermesvm` / `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. - -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. -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 -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. - -## What to commit - -| Path | Commit? | Why | -|------|---------|-----| -| `MyApp.xcodeproj/` | Yes | Your project, with SwiftPM injected in place. Holds your signing, capabilities, Build Phases — `add` only adds SwiftPM refs/settings, additively. | -| `MyApp.xcodeproj/.spm-injected.json` | Yes | Marker recording every edit `add` made — plus the pre-injection value of any build setting it rewrote — so `deinit` can surgically reverse it and re-runs stay idempotent. Also pins settings later runs and Xcode builds must reuse: the `--version` pin (`artifactsVersionOverride`) that keeps later runs on the same artifact slots, and the [autolinking config command](#the-autolinking-config-command-is-remembered). | -| `build/generated/` | No | Codegen/autolinking output; regenerated | -| `build/xcframeworks/` | No | Symlinks to the machine-local artifact cache | -| `Package.resolved` | No | SwiftPM resolution file; machine-specific | - -Injection is **purely additive** and **idempotent**: `add`/`update` insert only -SwiftPM package refs, the React build settings, the Sync build phase, and a scheme -pre-action — every other byte (your signing / capabilities / Build Phases) -stays untouched, and a re-run is a no-op. The injected refs point at three -stable sub-package paths under `build/`; adding or removing community deps -changes the sub-package contents (gitignored) and never re-injects. `deinit` -removes exactly what was injected (using the marker), leaving the project -byte-identical to its pre-`add` state — with one exception, 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 -`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 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. - -Because everything under `build/` is gitignored, a clean checkout has no -resolvable Swift packages until they are regenerated — see the next section. - -## Fresh clones & CI - -Xcode resolves the Swift package graph **before any build phase runs**, so on a -clean checkout (where the gitignored `build/` packages don't exist yet) the -auto-sync build phase can't regenerate them in time — a bare `xcodebuild` -fails at *"Resolve Package Graph … build/generated/autolinking doesn't exist"*. - -Run the setup command once after cloning, before building — 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 -build phase. - -**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`: - -```json -{ - "scripts": { - "postinstall": "react-native spm" - } -} -``` - -`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. - -> 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. - -## Local Native Modules - -Modules not discovered via autolinking can be declared in `react-native.config.js`: - -```js -module.exports = { - spm: { - modules: [ - { - name: 'MyNativeModule', - path: 'ios/MyNativeModule', // relative to app root - exclude: ['*.podspec'], // optional - publicHeadersPath: '.', // optional - }, - ], - }, -}; -``` - -Each entry becomes a target in `build/generated/autolinking/Package.swift`. -Sources outside `build/generated/autolinking/` are automatically mirrored with -file-level symlinks. - -## 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 `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. - -## Community packages without a Package.swift - -If an autolinked library ships **no `Package.swift`**, the build fails with a -clear per-dep error (`Package.swift is missing for library ""`). Generate -one from the library's podspec: - -```bash -npx react-native spm scaffold # writes Package.swift into node_modules// -``` - -Because `node_modules/` isn't committed, persist it so it survives the next -install: - -```bash -npx patch-package # 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. - -## 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. - -## Removing / resetting - -To remove SwiftPM entirely, use `deinit` (the inverse of `add`): - -```bash -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: - -```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 -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). | -| 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` | - ---- - -# Reference / internals - -## Pipeline - -`react-native spm add` and `react-native spm update` orchestrate these steps: - -| Step | Script | Output | -|------|--------|--------| -| 1. CLI config | `spm/generate-spm-autolinking-config.js` | `build/generated/autolinking/autolinking.json` | -| 2. Codegen | `generate-codegen-artifacts.js` | `build/generated/ios/` | -| 3. Autolinking | `spm/generate-spm-autolinking.js` | `build/generated/autolinking/Package.swift` | -| 4. Download | `spm/download-spm-artifacts.js` | Complete Debug and Release cache slots | -| 5. Package | `spm/generate-spm-package.js` | Immutable flavor slots, central manifest, canonical `ReactHeaders`, and invariant `Package.swift` | -| 6. Inject | `spm/generate-spm-xcodeproj.js` | Invariant SwiftPM products plus configuration-qualified linker settings and the embed/sign phase | -| Auto-sync | `spm/sync-spm-autolinking.js` | Re-runs invariant codegen/autolinking output only at Xcode build time | - -## Directory Layout - -``` -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) - build/ - generated/ - autolinking/ <-- gitignored (regenerated at build time) - Package.swift - autolinking.json - packages/ <-- synth wrappers for autolinker-managed deps - libs/ <-- symlinks to self-managed deps' Package.swift - dirs, named by Swift module so SwiftPM - package identity stays unique - headers/ <-- generated header symlinks - ios/ <-- gitignored, codegen output - xcframeworks/ <-- gitignored, immutable runtime flavor slots + invariant package - debug/ - React.xcframework -> ~/Library/Caches/.../debug/React.xcframework - ReactNativeDependencies.xcframework -> ... - hermes-engine.xcframework -> ... - release/ - React.xcframework -> ~/Library/Caches/.../release/React.xcframework - ReactNativeDependencies.xcframework -> ... - hermes-engine.xcframework -> ... - ReactHeadersTarget/ <-- canonical Objective-C React headers + module map - ReactNativeHeaders.xcframework -> ... - ReactNativeDependenciesHeaders.xcframework -> ... - flavored-frameworks.json - .artifact-stamp -``` - -## 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. - -## Auto-Sync Build Phase - -The generated `.xcodeproj` includes a **Sync SPM Autolinking** shell script -build phase. It keeps `build/generated/autolinking/Package.swift` up to date -without requiring manual re-runs of `react-native spm` for incremental -dependency changes. (It cannot bootstrap a fresh clone — Xcode resolves the -package graph before any phase runs; see [Fresh clones & CI](#fresh-clones--ci).) - -**How it works:** - -1. Compares timestamps of staleness inputs against `build/generated/autolinking/.spm-sync-stamp`: - - `package.json` — dependency declarations - - `react-native.config.js` — `spm.modules` config - - `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 -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. -3. If all inputs are fresh: exits immediately (~1ms). - -**Build phase ordering:** - -| # | Phase | -|---|-------| -| 0 | Resolve Package Graph (Xcode — runs before all build phases) | -| 1 | Sync SPM Autolinking | -| 2 | Sources (compile) | -| 3 | Frameworks (link) | -| 4 | Embed React Native Flavored Frameworks | -| 5 | Resources (copy) | -| 6 | Build JS Bundle | - -Failures in the sync phase are non-fatal — it emits a `warning:` and exits 0, -so an already-generated package graph can still produce a successful build. diff --git a/packages/react-native/scripts/spm/__docs__/README.md b/packages/react-native/scripts/spm/__docs__/README.md new file mode 100644 index 000000000000..caeb710be67a --- /dev/null +++ b/packages/react-native/scripts/spm/__docs__/README.md @@ -0,0 +1,92 @@ +# SwiftPM (Apple platforms) — Preview + +[🏠 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. + +The scripts in `scripts/spm/` let a React Native iOS app consume React Native +through **Swift Package Manager** instead of CocoaPods, using prebuilt +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. + +## 🚀 Usage + +```bash +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 +[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, +CocoaPods migration, brownfield apps, what to commit, fresh clones and CI, and +troubleshooting. + +## 📐 Design + +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-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. +- **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. + +## 🔗 Relationship with other systems + +### Part of + +- iOS build system — the alternative to the CocoaPods integration in + [`scripts/cocoapods/`](../../cocoapods). + +### Used by this + +- **Prebuilt XCFrameworks** from [`scripts/ios-prebuild/`](../../ios-prebuild) — + produces the `React`, `ReactNativeDependencies`, `hermes-engine`, and + headers-only artifacts that these scripts download, stage, and link. +- **Codegen** (`generate-codegen-artifacts.js`) — its output is installed as a + 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`. + +### Uses this + +- Apps opting into SwiftPM, via the `spm` React Native CLI command. +- Frameworks layering their own module system on top of React Native, via + [autolinking plugins](./spm-autolinking-plugins.md). diff --git a/packages/react-native/scripts/spm/__doc__/spm-autolinking-plugins.md b/packages/react-native/scripts/spm/__docs__/spm-autolinking-plugins.md similarity index 57% rename from packages/react-native/scripts/spm/__doc__/spm-autolinking-plugins.md rename to packages/react-native/scripts/spm/__docs__/spm-autolinking-plugins.md index 4280c6258d4e..3a23c378f91e 100644 --- a/packages/react-native/scripts/spm/__doc__/spm-autolinking-plugins.md +++ b/packages/react-native/scripts/spm/__docs__/spm-autolinking-plugins.md @@ -17,15 +17,15 @@ The documented extension points don't cover a framework: `node_modules`), generates a **module registry**, and ships mixed Swift/ObjC/C++ modules (e.g. `ExpoModulesCore`) that `spm scaffold` can't handle. -- A one-shot **post-process** of the generated `Package.swift` is **clobbered - on the next sync**: the Xcode [auto-sync build phase](./spm-scripts.md#auto-sync-build-phase) - re-runs autolinking on every dependency change. A framework's contribution - must run *whenever autolinking runs*. +- A one-shot **post-process** of the generated `Package.swift` is **clobbered on + the next sync**: the Xcode [auto-sync hooks](./spm-scripts.md#auto-sync) + 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. +`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. (This is the SwiftPM analog of the seams CocoaPods gave Expo: the Podfile, `use_expo_modules!`, and `react_native_post_install` hooks.) @@ -66,7 +66,10 @@ module.exports = function plugin(context) { return { packageDependencies: [ // Local package (e.g. a scanned module dir) … - {name: 'ExpoModulesCore', path: '../../../node_modules/expo-modules-core/ios'}, + { + name: 'ExpoModulesCore', + path: '../../../node_modules/expo-modules-core/ios', + }, // … or a remote/published package: // {name: 'SomePkg', url: 'https://…/SomePkg.git', version: '1.2.3'}, ], @@ -114,14 +117,14 @@ module.exports = function plugin(context) { }; ``` -#### `flavoredFrameworks` — per-configuration precompiled frameworks +### `flavoredFrameworks` — per-configuration precompiled frameworks Each entry is -`{id, frameworkName, linkage: 'dynamic', flavors: {debug, release}}`. -Both flavor paths must be absolute and present when `spm add` or `spm update` -runs. The framework and executable names, public headers, and platform slices -must agree across flavors. Static binaries, nested frameworks, duplicate IDs, -and duplicate embedded framework names are fatal. +`{id, frameworkName, linkage: 'dynamic', flavors: {debug, release}}`. Both +flavor paths must be absolute and present when `spm add` or `spm update` runs. +The framework and executable names, public headers, and platform slices must +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 @@ -130,24 +133,24 @@ 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` — plugin staleness inputs `watchPaths` is an array of **absolute** paths (dirs **or** files) the Xcode -auto-sync build phase watches to decide whether it 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. +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 `/.spm-sync-watch-paths` -alongside RN's own, then deduped and sorted. +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 +`/.spm-sync-watch-paths` alongside RN's own, then deduped and sorted. -#### `scriptPhases` — build-time shell phases on the app target +### `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 @@ -156,14 +159,14 @@ 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: -| Key | Meaning | -|---|---| -| `id` | **Stable key** — the ledger entry and the deterministic UUID seed. Charset `/^[@A-Za-z0-9_./-]+$/`, so a scoped npm name like `@expo/log-box` is a valid id; a `:` is not, because the id is hashed into the UUID seed as `plugin:` and the separator must stay unambiguous. Renaming it is a *remove + add*, not a rename. | -| `name` | The phase's display name in Xcode. Any non-empty single-line string — see **Hostile names** below. | -| `script` | The shell body. | -| `position` | `'beforeCompile'` or `'end'`. Optional, default `'end'` — see **Placement** below. | -| `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. | +| Key | Meaning | +| ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `id` | **Stable key** — the ledger entry and the deterministic UUID seed. Charset `/^[@A-Za-z0-9_./-]+$/`, so a scoped npm name like `@expo/log-box` is a valid id; a `:` is not, because the id is hashed into the UUID seed as `plugin:` and the separator must stay unambiguous. Renaming it is a _remove + add_, not a rename. | +| `name` | The phase's display name in Xcode. Any non-empty single-line string — see **Hostile names** below. | +| `script` | The shell body. | +| `position` | `'beforeCompile'` or `'end'`. Optional, default `'end'` — see **Placement** below. | +| `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 @@ -183,7 +186,8 @@ 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. + +- add. 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 @@ -203,8 +207,8 @@ 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 +**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 @@ -212,10 +216,10 @@ definition line and on its `buildPhases` member line, it also becomes a **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. +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 exist yet on a first `spm add`, and a stale or hand-edited copy must not break @@ -227,26 +231,26 @@ 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 -only, simulator only, …), the script must check `$CONFIGURATION` / `$PLATFORM_NAME` -and exit early. +only, simulator only, …), the script must check `$CONFIGURATION` / +`$PLATFORM_NAME` and exit early. ### Context (input) -| Field | Meaning | -|---|---| -| `appRoot` | The Xcode project directory (`/ios`) being injected — **not** the app package root. Deriving package-root-relative paths from it (e.g. `path.join(appRoot, 'node_modules')`) silently breaks; use `projectRoot` for that. | -| `projectRoot` | The JS root (nearest `package.json`) — where the framework scans `node_modules`. | -| `reactNativeRoot` | Resolved `react-native` package root. | -| `autolinking` | Parsed `autolinking.json` — RN's already-discovered deps, so the plugin can react to them. | -| `outputDir` | `build/generated/autolinking` — where generated artifacts land. | -| `react` | How to depend on React (see below). `null` when there is no resolvable React dependency. | +| Field | Meaning | +| ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `appRoot` | The Xcode project directory (`/ios`) being injected — **not** the app package root. Deriving package-root-relative paths from it (e.g. `path.join(appRoot, 'node_modules')`) silently breaks; use `projectRoot` for that. | +| `projectRoot` | The JS root (nearest `package.json`) — where the framework scans `node_modules`. | +| `reactNativeRoot` | Resolved `react-native` package root. | +| `autolinking` | Parsed `autolinking.json` — RN's already-discovered deps, so the plugin can react to them. | +| `outputDir` | `build/generated/autolinking` — where generated artifacts land. | +| `react` | How to depend on React (see below). `null` when there is no resolvable React dependency. | #### `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`: +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`: ```js react: { @@ -267,10 +271,10 @@ Local vs remote is signalled by which `packageRef` keys are present (`path` xor 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 +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 @@ -278,13 +282,13 @@ wiring, it stays correct across repackaging. ### Return (contributions, all optional) -| Field | Merged into | -|---|---| -| `packageDependencies` | The aggregator's `.package(…)` list (`path`, or `url` + `version`). | -| `productDependencies` | The `AutolinkedAggregate` target's `dependencies:` (`.product(name:package:)`). | -| `generatedSources` | Recorded for the codegen step to register (e.g. a module-registry `.swift`). | -| `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. | +| Field | Merged into | +| --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `packageDependencies` | The aggregator's `.package(…)` list (`path`, or `url` + `version`). | +| `productDependencies` | The `AutolinkedAggregate` target's `dependencies:` (`.product(name:package:)`). | +| `generatedSources` | Recorded for the codegen step to register (e.g. a module-registry `.swift`). | +| `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` @@ -293,7 +297,7 @@ name** across plugins. ## Lifecycle -``` +```text react-native spm add / update ─┐ ├─► generate-spm-autolinking main() Xcode "Sync SPM Autolinking" ──┘ │ @@ -303,51 +307,51 @@ 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. +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. +message identifying the framework. A framework silently dropping its modules (a +green build missing native code) is worse than a loud stop. ## 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:** `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 + 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. + 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. - **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 new file mode 100644 index 000000000000..ff467c249b92 --- /dev/null +++ b/packages/react-native/scripts/spm/__docs__/spm-header-paths-contract.md @@ -0,0 +1,100 @@ +# 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. + +## How headers resolve (no search paths) + +| 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. | + +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`. + +`autolinking.json` (the `@react-native-community/cli config` output) is an INPUT +used to generate the manifests; it is never read by a manifest. + +## 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) | + +## 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`. +`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 +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**: + +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.) +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). diff --git a/packages/react-native/scripts/spm/__docs__/spm-scripts.md b/packages/react-native/scripts/spm/__docs__/spm-scripts.md new file mode 100644 index 000000000000..fcf1ab32821d --- /dev/null +++ b/packages/react-native/scripts/spm/__docs__/spm-scripts.md @@ -0,0 +1,631 @@ +# 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. + +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. + +## Quick Start + +```bash +cd ios + +# First-time setup: injects SwiftPM packages into your existing MyApp.xcodeproj, +# in place. `npx react-native spm` with no action auto-resolves to `add` (or +# `update` once injected); on a fresh CocoaPods app it converts in one command +# (implies --deintegrate). To do it explicitly: +npx react-native spm add --deintegrate + +# Open in Xcode (or `npm run ios`). Incremental dep changes auto-sync on build. +open MyApp.xcodeproj +``` + +After the initial run, the project carries **auto-sync hooks** that 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 +`npx react-native spm` once before building** (see +[Fresh clones & CI](#fresh-clones--ci)). + +> **Note:** `react-native spm` is a thin wrapper over +> `node node_modules/react-native/scripts/setup-apple-spm.js`. If the CLI alias +> is unavailable in your environment, invoke the script directly with the same +> actions and the kebab-case flag equivalents (e.g. `--skip-codegen`). + +## CocoaPods → SwiftPM migration + +`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. +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**. +3. injects SwiftPM into the `.xcodeproj`. + +React Native now comes from SwiftPM; no pods are linked yet (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 +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 +the SwiftPM-injected project, so React Native resolves through SwiftPM and your +other pods through CocoaPods, together. + +> **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!`. + +The migration is fully reversible — see +[Removing / resetting](#removing--resetting). + +## 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 +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. + +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. + +## CLI Actions + +```bash +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 +`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. + +| 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). | +| `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. | +| `download` (advanced) | Download/check xcframework artifacts only. | + +## 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"]'` | + +### 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`: + +```bash +npx react-native spm add --configCommand '["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` +2. `RCT_SPM_AUTOLINKING_CONFIG_COMMAND` +3. the `configCommand` pinned in `MyApp.xcodeproj/.spm-injected.json` by an + earlier `add`/`update` +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 +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. + +### 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: + +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. + +`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. + +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. +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 +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. + +## Files the tool touches + +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 | + +The two pinned settings 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. | + +`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 +`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 +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. + +## Fresh clones & CI + +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 +neither hook. + +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 +in Xcode also resolves the graph on load, before you press Build. + +So run the setup command once after cloning, before building — 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. + +**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`: + +```json +{ + "scripts": { + "postinstall": "react-native spm" + } +} +``` + +`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. + +> 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. + +## Local Native Modules + +Modules not discovered via autolinking can be declared in +`react-native.config.js`: + +```js +module.exports = { + spm: { + modules: [ + { + name: 'MyNativeModule', + path: 'ios/MyNativeModule', // relative to app root + exclude: ['*.podspec'], // optional + publicHeadersPath: '.', // optional + }, + ], + }, +}; +``` + +Each entry becomes a target in `build/generated/autolinking/Package.swift`. +Sources outside `build/generated/autolinking/` are automatically mirrored with +file-level symlinks. + +## Dependencies between libraries + +SwiftPM has no equivalent of a podspec's `s.dependency`, so a library that needs +another native library declares it explicitly with `spm.dependencies` in its +**own** `react-native.config.js` — a list of npm names: + +```js +// react-native-reanimated/react-native.config.js +module.exports = { + dependency: {platforms: {ios: {}}}, + spm: {dependencies: ['react-native-worklets']}, +}; +``` + +The autolinker starts from the directly-autolinked deps, follows each one's +`spm.dependencies` **recursively**, and dedupes the result, so a transitive +dependency is pulled into the package graph even when the app never depends on +it directly. Declared names are mapped to Swift target names, so the dependent +library's target can import it. + +This is a **library-author** surface, like the podspec dependency it replaces — +apps don't normally set it. + +## 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 `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. + +## 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. + +`add` and `update` deliberately **never** scaffold on your behalf: +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: + +```bash +npx react-native spm scaffold # writes Package.swift into node_modules// +npx react-native spm # then inject/update as usual +``` + +(`scaffold` also runs codegen and regenerates the autolinking package, but it +does not inject into the `.xcodeproj` — so on a first-time setup you still +follow it with `npx react-native spm`.) + +Because `node_modules/` isn't committed, persist it so it survives the next +install: + +```bash +npx patch-package # 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. + +## 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. + +## Removing / resetting + +To remove SwiftPM entirely, use `deinit` (the inverse of `add`): + +```bash +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: + +```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 +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) | +| 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` | + +--- + +## Reference / internals + +### Pipeline + +`react-native spm add` and `react-native spm update` orchestrate these steps: + +| Step | Script | Output | +| -------------- | ---------------------------------------- | ------------------------------------------------------------------------------------------------- | +| 1. CLI config | `spm/generate-spm-autolinking-config.js` | `build/generated/autolinking/autolinking.json` | +| 2. Codegen | `generate-codegen-artifacts.js` | `build/generated/ios/` | +| 3. Autolinking | `spm/generate-spm-autolinking.js` | `build/generated/autolinking/Package.swift` | +| 4. Download | `spm/download-spm-artifacts.js` | Complete Debug and Release cache slots | +| 5. Package | `spm/generate-spm-package.js` | Immutable flavor slots, central manifest, canonical `ReactHeaders`, and invariant `Package.swift` | +| 6. Inject | `spm/generate-spm-xcodeproj.js` | Invariant SwiftPM products plus configuration-qualified linker settings and the embed/sign phase | +| Auto-sync | `spm/sync-spm-autolinking.js` | Re-runs invariant codegen/autolinking output only at Xcode build time | + +### Directory Layout + +```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) + build/ + generated/ + autolinking/ <-- gitignored (regenerated at build time) + Package.swift + autolinking.json + packages/ <-- synth wrappers for autolinker-managed deps + libs/ <-- symlinks to self-managed deps' Package.swift + dirs, named by Swift module so SwiftPM + package identity stays unique + headers/ <-- generated header symlinks + ios/ <-- gitignored, codegen output + xcframeworks/ <-- gitignored, immutable runtime flavor slots + invariant package + debug/ + React.xcframework -> ~/Library/Caches/.../debug/React.xcframework + ReactNativeDependencies.xcframework -> ... + hermes-engine.xcframework -> ... + release/ + React.xcframework -> ~/Library/Caches/.../release/React.xcframework + ReactNativeDependencies.xcframework -> ... + hermes-engine.xcframework -> ... + ReactHeadersTarget/ <-- canonical Objective-C React headers + module map + ReactNativeHeaders.xcframework -> ... + ReactNativeDependenciesHeaders.xcframework -> ... + flavored-frameworks.json + .artifact-stamp +``` + +### 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. + +### 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`: + +| 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. + +**How the sync script works:** + +1. Compares timestamps of staleness inputs against + `build/generated/autolinking/.spm-sync-stamp`: + - `package.json` — dependency declarations + - `react-native.config.js` — `spm.modules` config + - `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 +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. +3. If all inputs are fresh: exits immediately (~1ms). + +**Ordering.** As observed in an `xcodebuild -scheme … build` log on Xcode 26.6: + +| Step | Owner | +| ----------------------------------------------- | ----------------- | +| Resolve Package Graph | Xcode | +| **Sync SPM Autolinking** | scheme pre-action | +| Prepare packages / ComputeTargetDependencyGraph | Xcode | +| CreateBuildDescription | Xcode | +| **Sync SPM Autolinking** (safety net) | build phase 1 | +| Sources (compile) | build phase 2 | +| Frameworks (link) | build phase 3 | +| Embed React Native Flavored Frameworks | build phase 4 | +| 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 +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)). +- **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.