From 211813521bf88a149c8ac6c81f2c1abf5e51f78c Mon Sep 17 00:00:00 2001 From: Christian Falch Date: Wed, 19 Aug 2026 11:56:08 +0200 Subject: [PATCH 1/8] Rename scripts/spm/__doc__ to __docs__ to match repo convention Summary: `packages/react-native/scripts/spm/__doc__` was the only `__doc__` directory in the repo; every other subsystem uses `__docs__` (27+ directories, and the convention documented in `__docs__/GUIDELINES.md`). Pure directory rename, no content changes. This also stops shipping the SwiftPM design docs in the npm package: `packages/react-native/package.json` includes `scripts/spm` in `files` and excludes `!**/__docs__/**`, so the misspelled directory was being published. Changelog: [Internal] Test Plan: - `git grep __doc__` returns no hits: nothing referenced the path by name. - `npm pack --dry-run --ignore-scripts` in `packages/react-native` lists no `scripts/spm/__docs__/` entries while all `scripts/spm/*.js` remain. Co-Authored-By: Claude Opus 5 --- .../scripts/spm/{__doc__ => __docs__}/rfc-spm-xcframework.md | 0 .../scripts/spm/{__doc__ => __docs__}/spm-autolinking-plugins.md | 0 .../spm/{__doc__ => __docs__}/spm-header-paths-contract.md | 0 .../scripts/spm/{__doc__ => __docs__}/spm-plugins-assessment.md | 0 .../react-native/scripts/spm/{__doc__ => __docs__}/spm-scripts.md | 0 5 files changed, 0 insertions(+), 0 deletions(-) rename packages/react-native/scripts/spm/{__doc__ => __docs__}/rfc-spm-xcframework.md (100%) rename packages/react-native/scripts/spm/{__doc__ => __docs__}/spm-autolinking-plugins.md (100%) rename packages/react-native/scripts/spm/{__doc__ => __docs__}/spm-header-paths-contract.md (100%) rename packages/react-native/scripts/spm/{__doc__ => __docs__}/spm-plugins-assessment.md (100%) rename packages/react-native/scripts/spm/{__doc__ => __docs__}/spm-scripts.md (100%) diff --git a/packages/react-native/scripts/spm/__doc__/rfc-spm-xcframework.md b/packages/react-native/scripts/spm/__docs__/rfc-spm-xcframework.md similarity index 100% rename from packages/react-native/scripts/spm/__doc__/rfc-spm-xcframework.md rename to packages/react-native/scripts/spm/__docs__/rfc-spm-xcframework.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 100% rename from packages/react-native/scripts/spm/__doc__/spm-autolinking-plugins.md rename to packages/react-native/scripts/spm/__docs__/spm-autolinking-plugins.md diff --git a/packages/react-native/scripts/spm/__doc__/spm-header-paths-contract.md b/packages/react-native/scripts/spm/__docs__/spm-header-paths-contract.md similarity index 100% rename from packages/react-native/scripts/spm/__doc__/spm-header-paths-contract.md rename to packages/react-native/scripts/spm/__docs__/spm-header-paths-contract.md diff --git a/packages/react-native/scripts/spm/__doc__/spm-plugins-assessment.md b/packages/react-native/scripts/spm/__docs__/spm-plugins-assessment.md similarity index 100% rename from packages/react-native/scripts/spm/__doc__/spm-plugins-assessment.md rename to packages/react-native/scripts/spm/__docs__/spm-plugins-assessment.md diff --git a/packages/react-native/scripts/spm/__doc__/spm-scripts.md b/packages/react-native/scripts/spm/__docs__/spm-scripts.md similarity index 100% rename from packages/react-native/scripts/spm/__doc__/spm-scripts.md rename to packages/react-native/scripts/spm/__docs__/spm-scripts.md From f436bc0d1446e19683dbb59b3fff29ec95645ed5 Mon Sep 17 00:00:00 2001 From: Christian Falch Date: Wed, 19 Aug 2026 11:56:08 +0200 Subject: [PATCH 2/8] Remove two superseded SwiftPM design docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Summary: Both documented designs that no longer exist, and both are superseded elsewhere. `rfc-spm-xcframework.md` (707 lines) is a stale ancestor of RFC0994, maintained in react-native-community/discussions-and-proposals. It still described `spm init` and xcodeproj *generation* (the tool now injects in place via add/update/deinit), the `.xcodeproj.legacy` rename migration, a `spm clean` command that does not exist, stub `Package.swift` files, VFS overlays, a top-level `spmModules` key, and a sync step that self-heals a fresh clone (it cannot). `spm-plugins-assessment.md` evaluated whether SwiftPM plugins could replace the injected Xcode build phases. Its conclusion holds, but its detail did not: it listed a "Prepare VFS Overlay" phase and used the pre-rename `autolinked/` directory. The durable reasoning — plugin sandboxing, no access to CONFIGURATION/BUILT_PRODUCTS_DIR, no post-build stage — has been folded into RFC0994's Alternatives section, along with the content the deleted RFC uniquely carried. Changelog: [Internal] Test Plan: The only inbound link to either file was in the deleted RFC itself; a repo-wide grep for both filenames now returns nothing. Co-Authored-By: Claude Opus 5 --- .../spm/__docs__/rfc-spm-xcframework.md | 707 ------------------ .../spm/__docs__/spm-plugins-assessment.md | 128 ---- 2 files changed, 835 deletions(-) delete mode 100644 packages/react-native/scripts/spm/__docs__/rfc-spm-xcframework.md delete mode 100644 packages/react-native/scripts/spm/__docs__/spm-plugins-assessment.md diff --git a/packages/react-native/scripts/spm/__docs__/rfc-spm-xcframework.md b/packages/react-native/scripts/spm/__docs__/rfc-spm-xcframework.md deleted file mode 100644 index 86116637e3f7..000000000000 --- a/packages/react-native/scripts/spm/__docs__/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/__docs__/spm-plugins-assessment.md b/packages/react-native/scripts/spm/__docs__/spm-plugins-assessment.md deleted file mode 100644 index e20e4c93f672..000000000000 --- a/packages/react-native/scripts/spm/__docs__/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). From 0cecdf52643c00afac4319c94f8bf75041149f7c Mon Sep 17 00:00:00 2001 From: Christian Falch Date: Wed, 19 Aug 2026 11:56:42 +0200 Subject: [PATCH 3/8] Prettier-format the SwiftPM docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Summary: The SwiftPM markdown was never Prettier-formatted, even though the repo's `format-check` script globs `*.md`. These are the only unformatted markdown files in the repository, so `prettier --list-different "./**/*.md"` goes from three failures to clean — this fixes an existing red check rather than tidying one directory. Kept as its own commit, and placed before the content changes that follow, so the substantive edits read as content rather than reflow. Changelog: [Internal] Test Plan: - `npx prettier --check packages/react-native/scripts/spm/__docs__/*.md` passes. - `npx prettier --list-different "./**/*.md"` is empty for the whole repo; on the parent commit it listed exactly these three files. - Content-neutral: comparing `grep -oE '[A-Za-z0-9]+'` token streams before and after, all three files are identical word for word. Only wrapping, table padding, and Prettier's `*emphasis*` -> `_emphasis_` convention changed. Co-Authored-By: Claude Opus 5 --- .../spm/__docs__/spm-autolinking-plugins.md | 171 ++++----- .../spm/__docs__/spm-header-paths-contract.md | 81 ++--- .../scripts/spm/__docs__/spm-scripts.md | 328 +++++++++--------- 3 files changed, 296 insertions(+), 284 deletions(-) diff --git a/packages/react-native/scripts/spm/__docs__/spm-autolinking-plugins.md b/packages/react-native/scripts/spm/__docs__/spm-autolinking-plugins.md index 4280c6258d4e..a62b0fa554ba 100644 --- a/packages/react-native/scripts/spm/__docs__/spm-autolinking-plugins.md +++ b/packages/react-native/scripts/spm/__docs__/spm-autolinking-plugins.md @@ -17,15 +17,16 @@ 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 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 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 +67,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'}, ], @@ -117,11 +121,11 @@ module.exports = function plugin(context) { #### `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 @@ -141,11 +145,11 @@ 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 @@ -156,14 +160,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 +187,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 +208,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 +217,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 +232,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 +272,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 +283,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` @@ -303,51 +308,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 index c62c46e0b781..ff467c249b92 100644 --- a/packages/react-native/scripts/spm/__docs__/spm-header-paths-contract.md +++ b/packages/react-native/scripts/spm/__docs__/spm-header-paths-contract.md @@ -1,49 +1,50 @@ # 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. +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. +> 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. | +| 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. | +| ``, `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 +`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. +`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`. +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) | +| 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 @@ -59,12 +60,12 @@ 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`. +_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 +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 @@ -82,15 +83,17 @@ 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 + `.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). + 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 diff --git a/packages/react-native/scripts/spm/__docs__/spm-scripts.md b/packages/react-native/scripts/spm/__docs__/spm-scripts.md index fcb69bf55d47..5145a5a63e7c 100644 --- a/packages/react-native/scripts/spm/__docs__/spm-scripts.md +++ b/packages/react-native/scripts/spm/__docs__/spm-scripts.md @@ -1,14 +1,14 @@ # 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. +> 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. +CocoaPods remains the default; `spm` injects into your existing `.xcodeproj` in +place and is fully reversible. ## Quick Start @@ -26,39 +26,38 @@ 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 +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`). +> `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. +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). +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: +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 @@ -87,11 +86,11 @@ 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. +— 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, @@ -104,46 +103,46 @@ 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 +(`.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. +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. | +| 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"]'` | +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`: +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"]' @@ -160,12 +159,12 @@ the build-time `sync` — resolves the command in this order: `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 +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` @@ -174,8 +173,8 @@ therefore falls back to the default command unless you pass `--configCommand` ### 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 +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: @@ -183,21 +182,21 @@ read back by later runs, which resolve the version in this order: 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. +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. +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 @@ -219,19 +218,19 @@ 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 | +| 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 +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. @@ -241,15 +240,14 @@ 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 +_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. +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. @@ -258,8 +256,8 @@ resolvable Swift packages until they are regenerated — see the next section. 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"*. +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`: @@ -274,8 +272,8 @@ 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 +**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 @@ -293,13 +291,14 @@ 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, +> `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`: +Modules not discovered via autolinking can be declared in +`react-native.config.js`: ```js module.exports = { @@ -307,9 +306,9 @@ module.exports = { modules: [ { name: 'MyNativeModule', - path: 'ios/MyNativeModule', // relative to app root - exclude: ['*.podspec'], // optional - publicHeadersPath: '.', // optional + path: 'ios/MyNativeModule', // relative to app root + exclude: ['*.podspec'], // optional + publicHeadersPath: '.', // optional }, ], }, @@ -322,18 +321,18 @@ 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"`. +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. +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 @@ -352,8 +351,8 @@ install: 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 +**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 @@ -370,9 +369,9 @@ workaround keeps your app building. 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. +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 @@ -401,20 +400,20 @@ 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 | +| 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` | +| 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` | --- @@ -424,15 +423,15 @@ across apps; refresh it with `react-native spm update --download force`. `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 | +| 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 @@ -470,8 +469,8 @@ my-app/ios/ ## Header Resolution React Native uses CocoaPods-style imports (`#import `) that -SwiftPM doesn't natively support. The prebuilt artifacts serve them through SwiftPM -package products — no `-I` search-path flags, and no clang VFS overlay: +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 @@ -484,12 +483,12 @@ package products — no `-I` search-path flags, and no clang VFS overlay: 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). + 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 +(`ReactHeaders`, `ReactNativeHeaders`, `ReactNativeDependenciesHeaders`, plus +the app's `ReactAppHeaders`), so all of the above resolve with zero search-path flags. ## Auto-Sync Build Phase @@ -498,31 +497,36 @@ 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).) +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`: +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. + - `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. +| # | 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. From 92b2a3bc3108d8f08ea6b1dcbc6523e8bb3d784e Mon Sep 17 00:00:00 2001 From: Christian Falch Date: Wed, 19 Aug 2026 11:58:42 +0200 Subject: [PATCH 4/8] Correct SwiftPM docs to match the shipped implementation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Summary: Audited the docs against the code. Corrections: - **Auto-sync is two hooks, not one.** `add`/`update` inject a scheme pre-action (in the app's *shared* scheme) *and* the `Sync SPM Autolinking` build phase. The docs described only the phase. Rewrote the section around both. - **Neither hook can bootstrap a clean checkout.** The docs said the build phase "cannot bootstrap a fresh clone" and implied a pre-action would; measured on Xcode 26.6 with a freshly-injected app and `build/` deleted, `xcodebuild -scheme … build` fails in nine lines with `Resolve Package Graph` first and no trace of the pre-action. Resolution precedes *both* hooks, so the one-time setup run is genuinely required. The ordering table now shows the measured sequence, including where the pre-action really falls. - **A sync failure is not unconditionally non-fatal.** The section ended by saying a failure "emits a `warning:` and exits 0". The generated script branches: exit 2 — an autolinked dependency with no `Package.swift` — maps to `exit 1` and fails the build on purpose; only other non-zero codes warn. As written the doc also contradicted the exit-2 behaviour documented elsewhere in the same file. - `hermesvm` -> `hermes-engine`. That name appears nowhere in the code; `BUILTIN_FRAMEWORKS` in `flavored-frameworks.js` uses `hermes-engine`. - Added plugin `watchPaths` / `.spm-sync-watch-paths` to the staleness inputs. - Fixed the `#auto-sync-build-phase` anchors left dangling by the heading rename, here and in `spm-autolinking-plugins.md`, plus the subject/verb agreement that followed from "phase" becoming "hooks". Changelog: [Internal] Test Plan: Each claim traced to source: `injectOrCreateScheme` / `addPreActionToScheme` and the `RC -eq 2` branch of `buildSyncAutolinkingScript` in `generate-spm-xcodeproj.js`; `BUILTIN_FRAMEWORKS` in `flavored-frameworks.js`. The fresh-clone and ordering claims were measured against `private/helloworld` with both artifact flavors cached, and the app restored afterwards. Prettier clean. Co-Authored-By: Claude Opus 5 --- .../spm/__docs__/spm-autolinking-plugins.md | 13 +-- .../scripts/spm/__docs__/spm-scripts.md | 110 ++++++++++++------ 2 files changed, 80 insertions(+), 43 deletions(-) diff --git a/packages/react-native/scripts/spm/__docs__/spm-autolinking-plugins.md b/packages/react-native/scripts/spm/__docs__/spm-autolinking-plugins.md index a62b0fa554ba..350aaeb2fb8a 100644 --- a/packages/react-native/scripts/spm/__docs__/spm-autolinking-plugins.md +++ b/packages/react-native/scripts/spm/__docs__/spm-autolinking-plugins.md @@ -18,10 +18,9 @@ The documented extension points don't cover a framework: 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_. + 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 @@ -137,9 +136,9 @@ not mutate runtime framework settings. #### `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 diff --git a/packages/react-native/scripts/spm/__docs__/spm-scripts.md b/packages/react-native/scripts/spm/__docs__/spm-scripts.md index 5145a5a63e7c..cb84686eeb8a 100644 --- a/packages/react-native/scripts/spm/__docs__/spm-scripts.md +++ b/packages/react-native/scripts/spm/__docs__/spm-scripts.md @@ -25,11 +25,11 @@ npx react-native spm add --deintegrate 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 +After the initial run, the project carries **auto-sync hooks** that detects +dependency changes and re-runs 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 @@ -117,7 +117,7 @@ command auto-redirects into `ios/` with a banner. | `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. | +| `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. | @@ -193,7 +193,7 @@ marker still advertises the pinned version. ### Debug/Release flavor is automatic React Native ships **flavored** prebuilt binaries: the _debug_ `React.framework` -(and `hermesvm` / `ReactNativeDependencies`) carry the dev experience — dev +(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. @@ -254,13 +254,21 @@ 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"_. +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. -Run the setup command once after cloning, before building — the SwiftPM analog -of `pod install`: +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/ @@ -270,7 +278,7 @@ 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. +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 @@ -491,16 +499,23 @@ Targets that compile against React take these as product dependencies the app's `ReactAppHeaders`), so all of the above resolve with zero search-path flags. -## Auto-Sync Build Phase +## 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). | -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).) +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 it works:** +**How the sync script works:** 1. Compares timestamps of staleness inputs against `build/generated/autolinking/.spm-sync-stamp`: @@ -510,23 +525,46 @@ package graph before any phase runs; see 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). -**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. +**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. From ebac880e48440e44b5f6634a36c13c7369c1f2e3 Mon Sep 17 00:00:00 2001 From: Christian Falch Date: Wed, 19 Aug 2026 12:00:09 +0200 Subject: [PATCH 5/8] Add a "Files the tool touches" table MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Summary: There was no single answer to "what does `npx react-native spm` create or modify" — the information was spread across a commit-oriented table, an ASCII directory tree, and scattered prose. Replaced the "What to commit" section with one table grouped by committed / gitignored / outside-the-repo, each row naming the writing action and whether `deinit` undoes it. Writing it surfaced two things nothing documented: - `add` creates or appends to `ios/.gitignore` (a `# SPM – auto-generated at build time` block adding `Package.resolved`, `build/generated/`, `build/xcframeworks/`, `.build/`). A tracked file in the user's repo, mentioned nowhere. - `deinit` does **not** revert that block, nor anything `--deintegrate` changed. The docs called `deinit` "the exact inverse of `add`"; it is exact with respect to the `.spm-injected.json` marker, which is what the marker tracks, but these edits fall outside it. The marker's own description now states both of its roles — reversal record and configuration store — since the `--version` and autolinking-config-command pins live there too, and `deinit` drops them with it. The table also records the scheme behaviour worth knowing: `deinit` deletes the scheme when `add` created it, and otherwise strips only the pre-action. The promoted-array-setting caveat is kept as-is; it is the third thing `deinit` does not restore byte-for-byte, and the table now points at it. Changelog: [Internal] Test Plan: Every row traced to source: `ensureGitignoreSpmEntries` and its `action === 'add'` guard in `setup-apple-spm.js`; the scheme branch in `generate-spm-xcodeproj.js` (`scheme.created === true` deletes, else `removePreActionFromScheme`); the absence of any Podfile or `build/` handling in the deinit path; and the cache locations in `download-spm-artifacts.js` (`~/Library/Caches/ReactNative/spm-artifacts/`, plus the tarball cache shared with CocoaPods and `RCT_SKIP_CACHES`). Prettier clean. Co-Authored-By: Claude Opus 5 --- .../scripts/spm/__docs__/spm-scripts.md | 74 +++++++++++++------ 1 file changed, 50 insertions(+), 24 deletions(-) diff --git a/packages/react-native/scripts/spm/__docs__/spm-scripts.md b/packages/react-native/scripts/spm/__docs__/spm-scripts.md index cb84686eeb8a..0f1bed060aad 100644 --- a/packages/react-native/scripts/spm/__docs__/spm-scripts.md +++ b/packages/react-native/scripts/spm/__docs__/spm-scripts.md @@ -25,8 +25,8 @@ npx react-native spm add --deintegrate open MyApp.xcodeproj ``` -After the initial run, the project carries **auto-sync hooks** that detects -dependency changes and re-runs autolinking before compilation (see +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 @@ -115,7 +115,7 @@ command auto-redirects into `ios/` with a banner. | --------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `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. | +| `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. | @@ -216,24 +216,53 @@ 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. +## 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`, @@ -249,9 +278,6 @@ together, the injected members and your own are indistinguishable — so **membe 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 Everything under `build/` is gitignored, so a clean checkout has no resolvable From c7e3497030292bd8224fe96747eb7150c7cb6059 Mon Sep 17 00:00:00 2001 From: Christian Falch Date: Wed, 19 Aug 2026 12:01:16 +0200 Subject: [PATCH 6/8] Document spm.dependencies and the scaffold prerequisite MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Summary: Two things implemented in the code and absent from the docs. **`spm.dependencies`.** The SwiftPM analog of a podspec's `s.dependency` — how a library declares it needs another native library — is implemented in `expand-spm-dependencies.js` and was documented in no doc at all, so a library author porting a podspec had no way to find the mechanism replacing `s.dependency`. Added a section next to the other `react-native.config.js` surfaces, noting it resolves recursively and is a library-author surface rather than an app one. **The scaffold prerequisite.** "Community packages without a `Package.swift`" said the *build* fails. It is `spm add`/`update` that stops, during autolinking generation, with exit code **2** — deliberately distinct so the Xcode sync hook can treat it as a hard build error while staying lenient about transient sync failures. Neither the exit code nor the reason `add`/`update` refuse to auto-scaffold (it would hide a real gap in the dependency's SPM support) was written down. Also noted that `scaffold` runs codegen and autolinking but does *not* inject, so a first-time setup still needs the follow-up `npx react-native spm`, and added a troubleshooting row keyed on the error text since that is what people will search for. Changelog: [Internal] Test Plan: Traced to `expand-spm-dependencies.js` for the recursive expansion, and to `reportMissingManifests` in `generate-spm-autolinking.js` plus its handling in `setup-apple-spm.js`, where the `scaffold` action runs before the shared codegen/autolinking steps and injection stays gated on `action === 'add'`. Prettier clean. Co-Authored-By: Claude Opus 5 --- .../scripts/spm/__docs__/spm-scripts.md | 41 +++++++++++++++++-- 1 file changed, 38 insertions(+), 3 deletions(-) diff --git a/packages/react-native/scripts/spm/__docs__/spm-scripts.md b/packages/react-native/scripts/spm/__docs__/spm-scripts.md index 0f1bed060aad..11f513469200 100644 --- a/packages/react-native/scripts/spm/__docs__/spm-scripts.md +++ b/packages/react-native/scripts/spm/__docs__/spm-scripts.md @@ -353,6 +353,29 @@ 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 @@ -370,14 +393,25 @@ 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: +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: @@ -440,6 +474,7 @@ across apps; refresh it with `react-native spm update --download force`. | `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 | From 6c979b288de09f2eeae403cfce74a7bef00e7115 Mon Sep 17 00:00:00 2001 From: Christian Falch Date: Wed, 19 Aug 2026 12:02:11 +0200 Subject: [PATCH 7/8] Add a README index for the SwiftPM docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Summary: `__docs__/GUIDELINES.md` asks each subsystem to carry a `__docs__/README.md` entry point linking to its parent and children, so the technical docs can be navigated by following links. The SwiftPM docs had no entry point — you had to already know which file to open. Removing two of the five documents made that more obvious. The index orients the reader, points at RFC0994 for the motivation and migration plan (the in-repo copy of that RFC was stale and has been removed), states the two ideas that explain most of the architecture, and records the subsystem relationships: `ios-prebuild` produces the artifacts consumed here, codegen's output becomes a local package, and `@react-native-community/cli config` supplies the autolinking metadata. Also links SwiftPM from the root documentation index under Build system → iOS, which had no children. Changelog: [Internal] Test Plan: Every relative link resolves on disk, including the `../../../../../` hop to the root index and the root index's link back in. All 19 intra-doc anchor links across `__docs__/` verified against GitHub's slug rules: none dead. Prettier clean. Co-Authored-By: Claude Opus 5 --- __docs__/README.md | 1 + .../scripts/spm/__docs__/README.md | 92 +++++++++++++++++++ 2 files changed, 93 insertions(+) create mode 100644 packages/react-native/scripts/spm/__docs__/README.md 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/__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). From 39d7384640551ba17f99877b8bf77a1b77078c6f Mon Sep 17 00:00:00 2001 From: Christian Falch Date: Wed, 19 Aug 2026 12:16:25 +0200 Subject: [PATCH 8/8] Fix markdownlint errors the rename exposed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Summary: `markdownlint-cli2`'s glob is `**/__docs__/*.md`, so these files were never linted while they lived in `__doc__` — the same way the misspelling dodged the npm `files` exclusion. Renaming the directory pulled them into the lint job for the first time and surfaced four pre-existing violations: - `spm-autolinking-plugins.md` — MD001. `flavoredFrameworks`, `watchPaths` and `scriptPhases` were `h4` directly under an `h2`, a two-level jump. They are subsections of "The contract", so they are now `h3`. `context.react` stays `h4` under the `h3` it belongs to, which is now a legal single-level step. - `spm-scripts.md` — MD025. The "Reference / internals" divider was a second `h1`. It is now `h2`, and the four sections it introduces drop one level with it so the hierarchy still reads as "these are under Reference". - Both files — MD040. The lifecycle diagram and the directory-layout tree are unlabelled fences; both are now `text`. Demoting the headings does not move any anchors, since GitHub derives them from the heading text rather than its level. Changelog: [Internal] Test Plan: - `npx markdownlint-cli2` — the CI invocation, no path argument, so it lints all 38 files under the repo's `__docs__` glob: 0 errors, down from 4. - `npx prettier --check packages/react-native/scripts/spm/__docs__/*.md` passes. - `npx eslint --max-warnings 0 packages/react-native/scripts/spm/` passes. - All 20 intra-doc anchor links re-verified against GitHub's slug rules after the demotions: none dead. The config's own `link-fragments` rule agrees. Co-Authored-By: Claude Opus 5 --- .../scripts/spm/__docs__/spm-autolinking-plugins.md | 8 ++++---- .../react-native/scripts/spm/__docs__/spm-scripts.md | 12 ++++++------ 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/packages/react-native/scripts/spm/__docs__/spm-autolinking-plugins.md b/packages/react-native/scripts/spm/__docs__/spm-autolinking-plugins.md index 350aaeb2fb8a..3a23c378f91e 100644 --- a/packages/react-native/scripts/spm/__docs__/spm-autolinking-plugins.md +++ b/packages/react-native/scripts/spm/__docs__/spm-autolinking-plugins.md @@ -117,7 +117,7 @@ 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 @@ -133,7 +133,7 @@ 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 hooks watch to decide whether they must re-sync. RN already watches @@ -150,7 +150,7 @@ 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 @@ -297,7 +297,7 @@ name** across plugins. ## Lifecycle -``` +```text react-native spm add / update ─┐ ├─► generate-spm-autolinking main() Xcode "Sync SPM Autolinking" ──┘ │ diff --git a/packages/react-native/scripts/spm/__docs__/spm-scripts.md b/packages/react-native/scripts/spm/__docs__/spm-scripts.md index 11f513469200..fcf1ab32821d 100644 --- a/packages/react-native/scripts/spm/__docs__/spm-scripts.md +++ b/packages/react-native/scripts/spm/__docs__/spm-scripts.md @@ -486,9 +486,9 @@ across apps; refresh it with `react-native spm update --download force`. --- -# Reference / internals +## Reference / internals -## Pipeline +### Pipeline `react-native spm add` and `react-native spm update` orchestrate these steps: @@ -502,9 +502,9 @@ across apps; refresh it with `react-native spm update --download force`. | 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 +### 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) @@ -535,7 +535,7 @@ my-app/ios/ .artifact-stamp ``` -## Header Resolution +### Header Resolution React Native uses CocoaPods-style imports (`#import `) that SwiftPM doesn't natively support. The prebuilt artifacts serve them through @@ -560,7 +560,7 @@ Targets that compile against React take these as product dependencies the app's `ReactAppHeaders`), so all of the above resolve with zero search-path flags. -## Auto-Sync +### 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`: