diff --git a/packages/community-cli-plugin/src/commands/spm.js b/packages/community-cli-plugin/src/commands/spm.js index 181ad4ff812e..b0579adf3589 100644 --- a/packages/community-cli-plugin/src/commands/spm.js +++ b/packages/community-cli-plugin/src/commands/spm.js @@ -25,8 +25,8 @@ const spmCommand: Command = { name: 'spm [action]', description: 'Set up or maintain Swift Package Manager support for the iOS/macOS app. ' + - 'Actions: add, update, deinit, scaffold. With no action: add (or update ' + - 'if SPM is already set up).', + 'Actions: add, update, deinit, scaffold, sync. With no action: add (or ' + + 'update if SPM is already set up).', options: [ { name: '--version ', diff --git a/packages/react-native/scripts/ios-prebuild/__docs__/README.md b/packages/react-native/scripts/ios-prebuild/__docs__/README.md index 70411582212b..0e1431f876ba 100644 --- a/packages/react-native/scripts/ios-prebuild/__docs__/README.md +++ b/packages/react-native/scripts/ios-prebuild/__docs__/README.md @@ -126,11 +126,14 @@ overlay**. The layout contract is defined and validated in code: the `React/` and bare-aliased headers into every slice's `React.framework/Headers`, and `buildReactNativeHeadersXcframework()` assembles the headers-only `ReactNativeHeaders.xcframework` carrying every - other namespace (incl. `react/`) plus the third-party dependency namespaces - (`folly`, `glog`, `boost`, `fmt`, `double-conversion`, `fast_float`). The - Hermes public headers (``) are folded in only on the SwiftPM - consumer side (`ensureHeadersLayout`); the published prebuild artifact does - not yet carry them (TODO in `xcframework.js`). + other React Native namespace (incl. `react/`). The third-party dependency + namespaces (`folly`, `glog`, `boost`, `fmt`, `double-conversion`, + `fast_float`, `SocketRocket`) ship in the separate headers-only + `ReactNativeDependenciesHeaders.xcframework` sidecar, built by the + dependencies prebuild. The Hermes public headers (``) are folded + into the published `ReactNativeHeaders` when the hermes-ios headers are + staged; without them the compose ships no `hermes/`, unless `--require-hermes` + is set, which makes it fail closed. ### Artifacts @@ -139,15 +142,15 @@ The prebuild (`xcframework.js`) always produces: - `React.xcframework` — the compiled React core. Each slice's `React.framework` carries the headers-spec layout (every `` header + the framework module map). CocoaPods consumes that layout directly, through - `FRAMEWORK_SEARCH_PATHS`. SwiftPM consumes the same headers indirectly: the - XCFramework is not a member of the Swift package graph, so the consumer side - stages a copy of `React.framework/Headers` into - `ReactHeadersTarget/include/React` and rewrites `framework module React` to a - plain `module React`, vended as the `ReactHeaders` target (see - `spm-header-paths-contract.md` in the SwiftPM docs). -- `ReactNativeHeaders.xcframework` — headers-only; carries every other - namespace. Consumed by SwiftPM as a `binaryTarget` and by CocoaPods via the - `React-Core-prebuilt` pod (headers flattened onto the header search path). + `FRAMEWORK_SEARCH_PATHS`. SwiftPM consumes the same headers indirectly, + because the XCFramework is not a member of the Swift package graph. The + consumer side stages a copy of `React.framework/Headers` into + `ReactHeadersTarget/include/React`, rewrites `framework module React` to a + plain `module React`, and vends it as the `ReactHeaders` target (see + [spm-header-paths-contract.md](../../spm/__docs__/spm-header-paths-contract.md)). +- `ReactNativeHeaders.xcframework` — headers-only; carries every other React + Native namespace. Consumed by SwiftPM as a `binaryTarget` and by CocoaPods via + the `React-Core-prebuilt` pod (headers flattened onto the header search path). ### CocoaPods consumption diff --git a/packages/react-native/scripts/ios-prebuild/__docs__/headers-rules.md b/packages/react-native/scripts/ios-prebuild/__docs__/headers-rules.md index 3c4de65e246a..950c8dee386a 100644 --- a/packages/react-native/scripts/ios-prebuild/__docs__/headers-rules.md +++ b/packages/react-native/scripts/ios-prebuild/__docs__/headers-rules.md @@ -120,13 +120,14 @@ resolving `` through `React.framework` requires case-folding `react.framework` → `React.framework`, which only works on case-insensitive filesystems. The header-search-path route (R2) is exact everywhere. -**R2 — every other namespace ships in ONE headers-only xcframework, +**R2 — every other React Native namespace ships in ONE headers-only xcframework, `ReactNativeHeaders`.** Namespace dirs at its `Headers/` root: `react/`, -`yoga/`, `jsi/`, `cxxreact/`, `React_RCTAppDelegate/`, … **including the -third-party deps namespaces** (folly/glog/boost/fmt/double-conversion/ -fast_float, copied out of the ReactNativeDependencies artifact — which thereby -becomes binary-only). SPM/Xcode auto-serve a binaryTarget's `Headers/` on the -consumer's search path, so everything resolves with zero flags. +`yoga/`, `jsi/`, `cxxreact/`, `React_RCTAppDelegate/`, … The third-party deps +namespaces (folly, glog, boost, fmt, double-conversion, fast_float, +SocketRocket) are **not** here: they ship in the headers-only +`ReactNativeDependenciesHeaders` sidecar built by the deps prebuild. SPM/Xcode +auto-serve a binaryTarget's `Headers/` on the consumer's search path, so +everything resolves with zero flags. **R3 — NO include rewriting, anywhere.** Shipped headers are byte-identical to the repo. If a header's includes don't work in the packaged layout, the fix is @@ -249,11 +250,11 @@ declarations stay single-owned. ### `buildReactNativeHeadersXcframework` -1. Stage all R2 entries, then copy the six deps namespaces from - `third-party/ReactNativeDependencies.xcframework/Headers`. A declared deps - namespace that is missing is a **hard error** — previously a warn-and-ship, - which once produced a silently deps-less artifact (1.6 MB instead of 11 MB). -2. Optionally fold in the `hermes/` public headers (consumer-side compose path). +1. Stage all R2 entries. The deps namespaces are not copied here; they ship in + the `ReactNativeDependenciesHeaders` sidecar, whose namespace guards live in + `buildDepsHeadersXcframework` (`headers-xcframework.js`). +2. Fold in the `hermes/` public headers when they are staged (both the prebuild + compose and the consumer-side compose path). 3. Write the R10 umbrella files, then the R5 module map. 4. Compile a stub static archive per slice (headers-only artifacts still need a library for `xcodebuild -create-xcframework`) and compose the xcframework. @@ -274,13 +275,17 @@ mtime + hermes presence). | `#import ` | same, **textual** via the R9 entry | R9 | | `#import ` (C++) | `ReactNativeHeaders/Headers` search path, textual | R2 | | `#import ` | same, **modular** via the yoga R5 module | R2, R5 | -| `#import ` | same (deps namespaces relocated here) | R2 | +| `#import ` | SwiftPM: the `ReactNativeDependenciesHeaders` sidecar's `Headers/` (deps namespaces are not in `ReactNativeHeaders`) | R2 | | `` | same, modular | R10 | | `#import "RCTFabricComponentsPlugins.h"` (quoted, community Fabric pods) | CocoaPods only: re-vended by the `React-RCTFabric` facade into the pod header map (`rncore_facades.rb`, `FACADE_REEXPOSED_HEADERS`) | — | -- **SwiftPM**: both xcframeworks are plain `.binaryTarget`s; Xcode auto-serves - `React.framework`'s `Headers/`+`Modules/` and `ReactNativeHeaders`' `Headers/` - (incl. its `module.modulemap`) to dependents. Zero flags. +- **SwiftPM**: `React.xcframework` is not in the package graph. Its headers are + copied into `ReactHeadersTarget/include/React` with the module map rewritten + to a plain `module React`, vended as the `ReactHeaders` target. The only + `.binaryTarget`s are `ReactNativeHeaders` and + `ReactNativeDependenciesHeaders`; Xcode auto-serves their `Headers/` (incl. + `module.modulemap`) to dependents. Zero flags. See + [spm-header-paths-contract.md](../../spm/__docs__/spm-header-paths-contract.md). - **CocoaPods**: `React-Core-prebuilt`'s `prepare_command` flattens `ReactNativeHeaders`' Headers (incl. the module map) into the pod, and the React-core pods are installed as dependency-only **facades** @@ -294,8 +299,8 @@ mtime + hermes presence). | R9 allowlist validation | private header removed/renamed, or bucket drifted | `validatePrivateReactHeaders` | | R10 umbrella check | a probed namespace loses all modular headers | `planFromInventory` | | R5 exemption assert | an invalid-module-identifier namespace gains a modular-candidate header | `planFromInventory` | -| Deps namespace guard (missing) | folly/glog/… not staged at compose time | `buildReactNativeHeadersXcframework` | -| Deps namespace guard (undeclared) | the deps artifact ships a namespace not in `DEPS_NAMESPACES` (new third-party dep) | `buildReactNativeHeadersXcframework` | +| Deps namespace guard (missing) | folly/glog/… not staged at compose time | `buildDepsHeadersXcframework` | +| Deps namespace guard (undeclared) | the deps artifact ships a namespace not in `DEPS_NAMESPACES` (new third-party dep) | `buildDepsHeadersXcframework` | | Include-health ratchet | a shipped header gains a `notShipped`/`unresolved`/quoted-unresolvable include not in the committed baseline | `headers-verify.js` | | Structural gate | composed module maps/umbrellas differ from the spec render; R9 headers or deps dirs absent | `headers-verify.js` | | Compile gates | the React module, any R5 namespace module, the R10 umbrella, the R9 textual (Expo-shape) surface, or Swift `RCTBridge.moduleRegistry` fails to compile | `headers-verify.js` (CI: prebuild compose job) | diff --git a/packages/react-native/scripts/setup-apple-spm.js b/packages/react-native/scripts/setup-apple-spm.js index 0ace9683a03a..b37a872d768f 100644 --- a/packages/react-native/scripts/setup-apple-spm.js +++ b/packages/react-native/scripts/setup-apple-spm.js @@ -34,9 +34,10 @@ * .spm-injected.json) and drop the marker. * scaffold Generate Package.swift for community deps that * lack SPM support. - * sync / codegen / download Advanced/internal: `sync` is invoked by the - * generated Xcode build phase; `codegen` and - * `download` run a single pipeline step. + * sync Re-run the build-time autolinking sync and its + * checks. The generated Xcode build phase runs + * it; run it by hand after precompiling a module. + * codegen / download Advanced/internal: run a single pipeline step. * * Zero-arg `npx react-native spm` auto-detects: a freshly-scaffolded CocoaPods * project (clean tree, stock Podfile) → `add --deintegrate`; an injected @@ -93,6 +94,7 @@ const { const {main: generatePackage} = require('./spm/generate-spm-package'); const {findSourcePath} = require('./spm/generate-spm-package'); const { + PluginFrameworkMismatchError, SPM_INJECTED_MARKER, cleanupDanglingJavaScriptCoreRef, cleanupLeftoverPodsGroup, @@ -1200,8 +1202,11 @@ async function main(argv /*:: ?: Array */) /*: Promise */ { iosDeploymentTarget, ]); } catch (e) { - if (e instanceof MissingManifestError) { - // The per-dep `error:` lines were already printed by the autolinker. + if ( + e instanceof MissingManifestError || + e instanceof PluginFrameworkMismatchError + ) { + // The `error:` lines were already printed. // Exit 2 (distinct from generic failure) so the Xcode build phase can // turn this into a hard build error while staying lenient on transient // sync failures. diff --git a/packages/react-native/scripts/spm/__docs__/README.md b/packages/react-native/scripts/spm/__docs__/README.md index caeb710be67a..b04da44fc81f 100644 --- a/packages/react-native/scripts/spm/__docs__/README.md +++ b/packages/react-native/scripts/spm/__docs__/README.md @@ -2,10 +2,9 @@ [🏠 Home](../../../../../__docs__/README.md) -> **Preview.** SwiftPM support is an early preview: the commands, flags, -> generated layout, and distribution model may change in future releases, and it -> is not yet recommended for production. CocoaPods remains the supported -> default. +> **Preview.** SwiftPM support is an early preview. The commands, flags, +> generated layout, and distribution model may change in future releases. It is +> not yet recommended for production. CocoaPods remains the supported default. The scripts in `scripts/spm/` let a React Native iOS app consume React Native through **Swift Package Manager** instead of CocoaPods, using prebuilt @@ -13,9 +12,9 @@ XCFrameworks. Support is opt-in and additive: `npx react-native spm` injects package references into the app's existing `.xcodeproj` in place, and `deinit` reverses exactly what it injected. -The motivation, staged migration plan, and open questions live in -[RFC0994](https://github.com/react-native-community/discussions-and-proposals/blob/main/proposals/0994-swift-package-manager-support-for-react-native-ios-projects.md). -The documents here describe how the implementation actually works. +[RFC0994](https://github.com/react-native-community/discussions-and-proposals/blob/main/proposals/0994-swift-package-manager-support-for-react-native-ios-projects.md) +holds the motivation, the staged migration plan, and the open questions. The +documents here describe how the implementation works. ## 🚀 Usage @@ -24,21 +23,10 @@ cd ios npx react-native spm # add on first run, update thereafter ``` -**If any autolinked dependency ships no `Package.swift`, this stops with -`error: Package.swift is missing for library ""` and exit code 2.** That -is deliberate — `add` and `update` never scaffold silently, so a missing -manifest is visible and fixed on purpose. Generate the manifests first, then -re-run setup: - -```bash -npx react-native spm scaffold # writes Package.swift into node_modules// -npx react-native spm # then inject as usual -``` - -Because `node_modules` isn't committed, persist each scaffolded manifest with -`npx patch-package ` and commit the patch — otherwise the same error -returns on every fresh install and in CI. Better still, contribute the manifest -upstream. See +**If an autolinked dependency ships no `Package.swift`, setup stops with +`error: Package.swift is missing for library ""` and exit code 2.** Run +`npx react-native spm scaffold`, persist the manifest with `patch-package`, and +re-run setup. See [Community packages without a Package.swift](./spm-scripts.md#community-packages-without-a-packageswift). See **[spm-scripts.md](./spm-scripts.md)** for the CLI actions and flags, @@ -52,20 +40,20 @@ Three documents cover the design, each owning one area: | Document | Covers | | -------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | [spm-scripts.md](./spm-scripts.md) | The tool itself: CLI surface, the six-step pipeline, [every file it creates or modifies](./spm-scripts.md#files-the-tool-touches), the two auto-sync hooks, and how Debug/Release flavor selection works. | -| [spm-header-paths-contract.md](./spm-header-paths-contract.md) | How headers and package references resolve. The contract is **zero-`-I`**: no header search paths and no `unsafeFlags` in any generated manifest. Also covers remote mode. | +| [spm-header-paths-contract.md](./spm-header-paths-contract.md) | How headers and package references resolve. The contract is **zero-`-I`**: no generated manifest carries a search path into React Native's own headers. Also covers remote mode. | | [spm-autolinking-plugins.md](./spm-autolinking-plugins.md) | The extension seam for frameworks with their own module system (Expo is the first consumer): discovery, the full context/return contract including `flavoredFrameworks`, `watchPaths` and `scriptPhases`, and failure behavior. | Two ideas explain most of the architecture: - **Headers go through SwiftPM; runtime binaries do not.** A `binaryTarget` - cannot vary by build configuration, but React Native ships flavored binaries - (a debug `React.framework` carries the dev menu and assertions; release strips - them). So the package graph vends headers only, and the flavored frameworks - are linked and embedded through generated Xcode build settings instead. + cannot vary by build configuration, but React Native ships flavored binaries. + So the package graph vends headers only, and generated Xcode build settings + link and embed the flavored frameworks. See + [Debug/Release flavor is automatic](./spm-scripts.md#debugrelease-flavor-is-automatic). - **Generated state is regenerable, and the injection is reversible.** Everything under `build/` is gitignored and rebuilt from the app's - `package.json`; everything written into the `.xcodeproj` is recorded in a - `.spm-injected.json` marker so `deinit` can undo precisely that. + `package.json`. Every edit to the `.xcodeproj` is recorded in a + `.spm-injected.json` marker, so `deinit` can undo exactly that. ## 🔗 Relationship with other systems @@ -83,7 +71,9 @@ Two ideas explain most of the architecture: local `React-GeneratedCode` package rather than a Pod. - **`@react-native-community/cli config`** — supplies the autolinking metadata (`autolinking.json`) that the SwiftPM autolinker turns into a `Package.swift`. - Overridable via `--configCommand`. + Override it with the `RCT_SPM_AUTOLINKING_CONFIG_COMMAND` environment + variable, or with `--config-command` when you run `setup-apple-spm.js` + directly. ### Uses this diff --git a/packages/react-native/scripts/spm/__docs__/spm-autolinking-plugins.md b/packages/react-native/scripts/spm/__docs__/spm-autolinking-plugins.md index be999741f8cd..878d738f2804 100644 --- a/packages/react-native/scripts/spm/__docs__/spm-autolinking-plugins.md +++ b/packages/react-native/scripts/spm/__docs__/spm-autolinking-plugins.md @@ -22,18 +22,18 @@ The documented extension points don't cover a framework: re-run autolinking on every dependency change. A framework's contribution must run _whenever autolinking runs_. -A plugin is exactly that. It is invoked from `generate-spm-autolinking.js`'s -`main()` — the single function that both `add` / `update` **and** the build-time -`sync` call — so the contribution is regenerated on every build and never goes -stale. +A plugin does exactly that. `generate-spm-autolinking.js`'s `main()` invokes it. +`add` / `update` **and** the build-time `sync` all call that one function. So +the contribution is regenerated on every build and never goes stale, and the +build-time path needs no separate hook. -(This is the SwiftPM analog of the seams CocoaPods gave Expo: the Podfile, -`use_expo_modules!`, and `react_native_post_install` hooks.) +This is the SwiftPM analog of the seams CocoaPods gave Expo: the Podfile, +`use_expo_modules!`, and `react_native_post_install` hooks. ## Discovery — transitive, zero app config A dependency opts in from its **own** package.json, so installing the framework -is enough (mirrors how CocoaPods pulls in `use_expo_modules!` transitively): +is enough. This mirrors how CocoaPods pulls in `use_expo_modules!` transitively: ```json // node_modules/expo/package.json @@ -42,12 +42,16 @@ is enough (mirrors how CocoaPods pulls in `use_expo_modules!` transitively): } ``` -The autolinker reads every dependency's SwiftPM settings; any that declares -`autolinkingPlugin` is `require`d and invoked. No app-level registration or +The autolinker reads every dependency's SwiftPM settings. It `require`s and +invokes each one that declares `autolinkingPlugin`. No app-level registration or allowlist is required. The deprecated `spm.autolinkingPlugin` in `react-native.config.js` is still read — see [Migrating from react-native.config.js](spm-scripts.md#where-swiftpm-settings-live). +A dependency that declares a plugin owns its native contribution. React Native +does not build it as an autolinked target, so it needs no `Package.swift` of its +own. + **Opt-out escape hatch.** An app can exclude a plugin from its own package.json: ```json @@ -117,6 +121,29 @@ module.exports = function plugin(context) { }; ``` +### `generatedSources` — sources wired into the app target + +The merge writes `.spm-plugin-generated-sources.json`. The `spm add`/`update` +xcodeproj injector (`generate-spm-xcodeproj.js`) reads it and wires each source +**into the app target**: a `PBXFileReference`, a `PBXBuildFile`, and a +Sources-build-phase entry, all under one "SPM Generated Sources" navigator +group. + +This is what makes an `@objc` class (e.g. Expo's `ExpoModulesProvider`) reach +the ObjC classlist. A class inside the static Autolinked aggregate never does, +so `NSClassFromString` discovery would fail. + +- Paths are stored SRCROOT-relative when they are under the app root (the usual + `build/generated/…` case), else absolute (`sourceTree = ""`). +- All UUIDs are namespaced on the normalized path, so injection is deterministic + and idempotent. They are recorded in the `.spm-injected.json` marker's + `generatedSources` map. `deinit` reverts them, and `update` reconciles entries + that left the manifest. +- A target without a Sources phase logs loudly and skips the wiring. The rest of + the injection succeeds. +- v1 targets only the injected app target and assumes `.swift` in practice. + `.m`/`.mm` are mapped as future-proofing. + ### `flavoredFrameworks` — per-configuration precompiled frameworks Each entry is @@ -127,37 +154,56 @@ agree across flavors. Static binaries, nested frameworks, duplicate IDs, and duplicate embedded framework names are fatal. The declarations are recorded to -`/.spm-plugin-flavored-frameworks.json`, normalized into the same -immutable app-local slots as React Native, and added to Xcode's exact linker and -embed settings. They are not emitted as SwiftPM product dependencies. Adding or -removing one requires `spm update`; the build-time `spm sync` intentionally does -not mutate runtime framework settings. +`/.spm-plugin-flavored-frameworks.json`. They are normalized into the +same immutable app-local slots as React Native and added to Xcode's exact linker +and embed settings. They are not emitted as SwiftPM product dependencies. Adding +or removing one requires `spm update`: the build-time `spm sync` intentionally +does not mutate runtime framework settings. When the frameworks that plugins +provide and the frameworks that the project links disagree, sync fails the +build. An `error:` line names each framework and the command to run. ### `watchPaths` — plugin staleness inputs -`watchPaths` is an array of **absolute** paths (dirs **or** files) the Xcode -auto-sync hooks watch to decide whether they must re-sync. RN already watches -each module's source dir plus every npm dep's checked-in `Package.swift` and -`.react-native/` dir; a plugin adds the inputs only it knows about — e.g. -`packages/expo/Package.swift`, `expo-module.config.json`, and per-module -manifests. On the next build the phase re-syncs when a watched **file** is newer -than the last sync, a watched **dir** has a newer child, or a watched path has -**vanished** (a rename forces a re-sync so the config error surfaces). - -Unlike `flavoredFrameworks`, watch paths are best-effort: a non-array is ignored -with a warning (never fatal), and each non-string / empty / **relative** entry -is dropped with a warning. Absolute-only, because the generated phase tests -these paths with no cwd context. The kept paths are folded into -`/.spm-sync-watch-paths` alongside RN's own, then deduped and sorted. +`watchPaths` is an array of **absolute** paths (dirs **or** files). The Xcode +auto-sync hooks watch them to decide whether they must re-sync. RN already +watches each module's source dir, plus every npm dep's checked-in +`Package.swift` and `.react-native/` dir. A plugin adds the inputs only it knows +about, e.g. `packages/expo/Package.swift`, `expo-module.config.json`, and +per-module manifests. + +On the next build, the phase re-syncs when: + +- a watched **file** is newer than the last sync, +- a watched **dir** has a newer child, or +- a watched path has **vanished**. A rename forces a re-sync, so the config + error surfaces. + +A watched path that does not exist yet is watched for **appearing**. When it is +created, the next build re-syncs. So a plugin that builds precompiled modules +should list their output folder, even when a fresh clone does not have it yet. +List only folders that the Xcode build itself does not write to, or every build +re-syncs. + +Unlike `flavoredFrameworks`, watch paths are best-effort. A non-array is ignored +with a warning (never fatal). Each non-string, empty, or **relative** entry is +dropped with a warning. Paths must be absolute because the generated phase tests +them with no cwd context. The kept paths are folded in with RN's own, deduped, +and sorted: paths that exist go to `/.spm-sync-watch-paths`, and +paths that do not exist yet go to `/.spm-sync-watch-absent`. Only +paths that exist when the sync runs are written; a missing path is dropped +silently. So the **vanished** check applies only to paths that existed at the +last sync. ### `scriptPhases` — build-time shell phases on the app target -SwiftPM has no equivalent of CocoaPods' `script_phase`, so a framework that must -run a script during the app's build — `expo-constants` writing -`EXConstants.bundle/app.config` is the first consumer — declares it here. Each -entry is recorded to `/.spm-plugin-script-phases.json`, which -`spm add` / `spm update` reads to emit one `PBXShellScriptBuildPhase` per entry -on the injected app target: +SwiftPM has no equivalent of CocoaPods' `script_phase`. A framework that must +run a script during the app's build declares it here. The first consumer is +`expo-constants`, which writes `EXConstants.bundle/app.config`. + +The merge always rewrites `/.spm-plugin-script-phases.json` — `[]` +when no plugin declares any, so removing a plugin clears stale entries. +`spm add` / `spm update` read that sidecar and emit one +`PBXShellScriptBuildPhase` per entry on the injected app target: | Key | Meaning | | ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | @@ -168,69 +214,80 @@ on the injected app target: | `inputPaths` / `outputPaths` | Optional Xcode input/output file lists, which is what lets Xcode skip an up-to-date phase. | | `alwaysOutOfDate` | Optional; when `true` the phase runs on every build regardless of its file lists. | -**Placement.** `'end'` appends at the true end of the target's `buildPhases` — -after the app's own JS-bundle phase. `'beforeCompile'` lands directly after the -"Sync SPM Autolinking" phase, which stays first because it regenerates the -content everything else reads, and always **before Sources**: React Native never -re-seats its own sync phase, so if you have dragged that below Sources your -`beforeCompile` phases are seated ahead of Sources instead of following it. -Phases sharing a position keep their declared order. +**Placement.** + +- `'end'` appends at the true end of the target's `buildPhases`, after the app's + own JS-bundle phase. +- `'beforeCompile'` lands directly after the "Sync SPM Autolinking" phase, and + always **before Sources**. The sync phase stays first because it regenerates + the content everything else reads. React Native never re-seats its own sync + phase. If you have dragged it below Sources, your `beforeCompile` phases sit + ahead of Sources instead of following it. + +Phases that share a position keep their declared order. **Position is enforced on every sync.** `add`/`update` compares where the plugin -phases actually sit in `buildPhases` against the declared placement and, **only -when the two differ**, lifts their membership lines and re-seats them in -declared order. So changing `position` — or swapping two phases that share one — -takes effect on the next `spm add`/`update`, with no remove + re-add. When they -agree nothing is rewritten, which is what keeps an unchanged declaration -re-syncing to a byte-identical project. The consequence worth knowing: a phase -you **drag somewhere else in Xcode is moved back** to its declared position on -the next sync, because the plugin's declaration is the source of truth. Only the -`id` behaves differently — it is a key, not a label, so renaming it is a remove - -- add. +phases sit in `buildPhases` with the declared placement. **Only when the two +differ** does it lift their membership lines and re-seat them in declared order. +So a change to `position`, or a swap of two phases that share one, takes effect +on the next `spm add`/`update`, with no remove + re-add. When they agree nothing +is rewritten, so an unchanged declaration re-syncs to a byte-identical project. +The plugin's declaration is the source of truth: a phase you **drag somewhere +else in Xcode is moved back** to its declared position on the next sync. Phases are injected by `spm add` / `spm update` **only**. The build-time `sync` -rewrites the sidecar but never touches the `.xcodeproj`, so a newly declared +rewrites the sidecar but never touches the `.xcodeproj`. So a newly declared phase appears on the next `add`/`update`, not on the next build. Each phase's UUID is derived from its `id` and recorded in the `.spm-injected.json` marker's -`scriptPhases` map, so a re-run refreshes the phase's `name`, `script`, path -lists, `alwaysOutOfDate` and placement in place, `update` removes phases that +`scriptPhases` map. So a re-run refreshes the phase's `name`, `script`, path +lists, `alwaysOutOfDate` and placement in place. `update` removes phases that left the sidecar, and `deinit` reverts all of them. -Validation is **fatal**, like `flavoredFrameworks` and unlike `watchPaths`: a -non-array `scriptPhases`, a malformed entry, or a duplicate `id` (within one -plugin or across plugins) aborts the run. A silently dropped phase would produce -a green build whose generated content was never written — a runtime failure with -no build-time signal — and two phases sharing an `id` would collapse onto one -ledger key. `__proto__`, `constructor`, and `prototype` are rejected as ids even -though the charset admits them: as keys of that ledger they never become own -properties, so the phase would look recorded, disappear when the marker is -serialized, and be unremovable by `deinit`. - -**Hostile names.** A `name` reaches the project file twice. In the `name` field -— what Xcode displays — it lands verbatim, escaped as an OpenStep string, so any -single-line string is expressible. Beside the phase's UUID, on the object's -definition line and on its `buildPhases` member line, it also becomes a -`/* … */` comment; those comments are cosmetic (Xcode regenerates them from the -`name` field) but the text around them is scanned by delimiter, so the name is -**normalized** there: `{}(),;="*/`, tabs and whitespace runs collapse to single -spaces (`spm-pbxproj.js`'s `commentSafe`), falling back to the phase `id` — -normalized the same way — and then to no comment at all if nothing survives -either. Without that, a `{` in a comment would make the injector read the next -object's body as this one's, and a `,` would make `deinit` delete the wrong line -— corruption with no error. Only a line break is therefore rejected outright; a -name is a display name, and no Xcode phase name spans lines. - -The injector's read of the sidecar is deliberately lenient — the file does not +Validation is **fatal**, like `flavoredFrameworks` and unlike `watchPaths`. +`invokePlugins` checks the `id` charset, a single-line `name`, a required +`script`, the `position` enum, and the optional path lists and +`alwaysOutOfDate`. A non-array `scriptPhases`, a malformed entry, or a duplicate +`id` (within one plugin or across plugins) aborts the run. + +- A silently dropped phase would produce a green build whose generated content + was never written — a runtime failure with no build-time signal. +- Two phases that share an `id` would collapse onto one ledger key. +- `__proto__`, `constructor`, and `prototype` are rejected as ids even though + the charset admits them. As keys of that ledger they never become own + properties. The phase would look recorded, disappear when the marker is + serialized, and be unremovable by `deinit`. + +**Hostile names.** A `name` reaches the project file twice: + +- In the `name` field, which Xcode displays, it lands verbatim, escaped as an + OpenStep string. Any single-line string is expressible there. +- Beside the phase's UUID, on the object's definition line and on its + `buildPhases` member line, it becomes a `/* … */` comment. Xcode regenerates + these comments from the `name` field, so they are cosmetic. But the injector + scans the text around them by delimiter, so the name is **normalized** there. + `{}(),;="*/`, tabs and whitespace runs collapse to single spaces + (`spm-pbxproj.js`'s `commentSafe`). If nothing survives, the comment falls + back to the phase `id`, normalized the same way, and then to no comment at + all. + +Without that normalization, a `{` in a comment would make the injector read the +next object's body as this one's. A `,` would make `deinit` delete the wrong +line. Both are corruption with no error. So only a line break is rejected +outright: a name is a display name, and no Xcode phase name spans lines. + +The injector's read of the sidecar is deliberately lenient. The file does not exist yet on a first `spm add`, and a stale or hand-edited copy must not break -injection. An absent file yields no phases silently, an unparseable one warns, -and a single entry failing the same checks (bad or reserved `id`, empty or -multi-line `name`, missing `script`, unknown `position`, duplicate `id`) is -**skipped, never coerced** — the sidecar is the only gate on a hand edit, so it -enforces exactly the rules the plugin contract does. +injection. + +- An absent file yields no phases, silently. +- An unparseable file warns. +- An entry that fails the same checks (bad or reserved `id`, empty or multi-line + `name`, missing `script`, unknown `position`, duplicate `id`) is **skipped, + never coerced**. The sidecar is the only gate on a hand edit, so it enforces + exactly the rules the plugin contract does. **Gating is the script's job.** The phase runs for every configuration and -platform the target builds; if it should be a no-op for some of them (Release +platform the target builds. If it should be a no-op for some of them (Release only, simulator only, …), the script must check `$CONFIGURATION` / `$PLATFORM_NAME` and exit early. @@ -248,9 +305,9 @@ only, simulator only, …), the script must check `$CONFIGURATION` / #### `context.react` — depending on React A plugin that emits its own `Package.swift` must declare React as a dependency. -Rather than re-deriving React Native's package path, identity, and product names -— which differ between local and remote mode and **move as RN repackages** — -take them from `context.react`: +React Native's package path, identity, and product names differ between local +and remote mode, and they **move as RN repackages**. So take them from +`context.react` rather than re-deriving them: ```js react: { @@ -266,19 +323,21 @@ react: { } ``` -Local vs remote is signalled by which `packageRef` keys are present (`path` xor -`url`+`version`). `packageRef.path` is **absolute** — always correct no matter -which subdirectory of `outputDir` the plugin writes its own manifest into (the -generated manifests are gitignored and regenerated every sync, so there's no -portability cost); `relPath` (relative to `outputDir`) is provided as a -convenience. `products` is the set React Native wires into **its own** -autolinked targets (so a plugin's target compiles against exactly RN's React -surface), filtered to those resolvable this run — every listed product is safe -to reference without guarding. Note the fourth entry: `ReactAppHeaders` lives in -the separate `React-GeneratedCode` package (per-app codegen), which a -hand-rolled plugin would miss, and which is omitted when that package is absent. -Because RN derives this list from one source of truth alongside its own product -wiring, it stays correct across repackaging. +- The `packageRef` keys signal local or remote mode: `path` xor `url`+`version`. +- `packageRef.path` is **absolute**, so it is correct in any subdirectory of + `outputDir` the plugin writes its manifest into. The generated manifests are + gitignored and regenerated every sync, so this has no portability cost. + `relPath` (relative to `outputDir`) is a convenience. +- `products` is the set React Native wires into **its own** autolinked targets, + so a plugin's target compiles against exactly RN's React surface. It is + filtered to the products resolvable this run, so every listed product is safe + to reference without a guard. +- The fourth entry, `ReactAppHeaders`, lives in the separate + `React-GeneratedCode` package (per-app codegen). A hand-rolled plugin would + miss it. It is omitted when that package is absent. + +RN derives this list from one source of truth alongside its own product wiring, +so it stays correct across repackaging. ### Return (contributions, all optional) @@ -289,11 +348,13 @@ wiring, it stays correct across repackaging. | `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. | +| `watchPaths` | The auto-sync staleness inputs (see [`watchPaths`](#watchpaths--plugin-staleness-inputs)). Invalid entries are dropped with a warning, not fatal. | -The plugin returns **data** — it never writes into React Native's generated -tree. RN owns the merge, so a re-sync reproduces the same `Package.swift` -byte-for-byte (idempotent). Package and product contributions are **deduped by -name** across plugins. +The plugin returns **data**. It never writes into React Native's generated tree. +RN owns the merge, so a re-sync reproduces the same `Package.swift` +byte-for-byte (idempotent). Across plugins, package contributions are **deduped +by name** and product contributions by **package and name**. The first +contribution wins. ## Lifecycle @@ -307,51 +368,32 @@ Xcode "Sync SPM Autolinking" ──┘ │ └─ 4. merge results → aggregator Package.swift ``` -Because steps 1–4 run in the one `main()`, everything above shares the same seam -— there is no separate hook to wire for the build-time path. - ## Failure behavior -Fail-closed and **named**: a plugin that fails to load, doesn't export a -function, throws, or returns a malformed contribution aborts the run with a -message identifying the framework. A framework silently dropping its modules (a -green build missing native code) is worse than a loud stop. +Failures are fail-closed and **named**. A plugin that fails to load, doesn't +export a function, throws, or returns a malformed contribution aborts the run. +The message identifies the framework. A framework that silently drops its +modules (a green build missing native code) is worse than a loud stop. + +A plugin's host dependency is also a hard error when another library lists it in +`swiftpmConfig.dependencies` and that library has no `Package.swift` of its own +(shipped or scaffolded). React Native builds no target for the host, so there is +nothing to depend on. Remove the entry: the plugin already links its products +into the app. ## Status & open items (Preview) - **Implemented & tested:** discovery (transitive + deny-list), invocation, - package + product merge, fail-closed validation, and dual-flavor framework - normalization/link/embed outside SwiftPM. -- **Implemented & tested:** `generatedSources` **app-target wiring**. The merge - writes `.spm-plugin-generated-sources.json`; the `spm add`/`update` xcodeproj - injector (generate-spm-xcodeproj.js) reads it and wires each source **into the - app target** — a `PBXFileReference` + `PBXBuildFile` + a Sources-build-phase - entry, parented under one "SPM Generated Sources" navigator group. This is - what makes an `@objc` class (e.g. Expo's `ExpoModulesProvider`) reach the ObjC - classlist: a class inside the static Autolinked aggregate never does, so - `NSClassFromString` discovery would fail. Paths are stored SRCROOT-relative - when under the app root (the usual `build/generated/…` case), else absolute - (`sourceTree = ""`). All UUIDs are namespaced on the normalized path - (deterministic/idempotent) and recorded in the `.spm-injected.json` marker's - `generatedSources` map, so `deinit` reverts them and `update` reconciles - entries that left the manifest. A target without a Sources phase logs loudly - and skips the wiring (injection otherwise succeeds). v1 targets only the - injected app target and assumes `.swift` in practice (`.m`/`.mm` are mapped as - future-proofing). -- **Implemented & tested:** `scriptPhases`, contract through injection. - `invokePlugins` validates every entry fatally (`id` charset plus the reserved - `__proto__`/`constructor`/`prototype` names, a single-line `name`, a required - `script`, the `position` enum, optional path lists and `alwaysOutOfDate`, plus - duplicate `id`s), and the merge always rewrites - `.spm-plugin-script-phases.json` — `[]` when no plugin declares any, so - removing a plugin clears stale entries. The `spm add`/`update` xcodeproj - injector reads that sidecar and emits one `PBXShellScriptBuildPhase` per entry - on the app target at the requested position, recording the id→UUID map in the - `.spm-injected.json` marker so a re-run refreshes each phase's content in - place, re-seats it when its declared position or order changed, `update` - removes phases that left the sidecar, and `deinit` reverts them. Like - `flavoredFrameworks`, the build-time `sync` only rewrites the sidecar; it - never mutates the project. + package + product merge, and fail-closed validation. +- **Implemented & tested:** dual-flavor framework normalization/link/embed + outside SwiftPM + ([`flavoredFrameworks`](#flavoredframeworks--per-configuration-precompiled-frameworks)). +- **Implemented & tested:** + [`generatedSources`](#generatedsources--sources-wired-into-the-app-target) + **app-target wiring**. +- **Implemented & tested:** + [`scriptPhases`](#scriptphases--build-time-shell-phases-on-the-app-target), + from the contract through injection. - **Co-design with Expo (not final):** codegen **provider ordering** — codegen must consume the same discovered module set the plugin contributes — is intentionally left for the first real plugin to drive to a stable shape. diff --git a/packages/react-native/scripts/spm/__docs__/spm-header-paths-contract.md b/packages/react-native/scripts/spm/__docs__/spm-header-paths-contract.md index ff467c249b92..4055fed9f182 100644 --- a/packages/react-native/scripts/spm/__docs__/spm-header-paths-contract.md +++ b/packages/react-native/scripts/spm/__docs__/spm-header-paths-contract.md @@ -1,100 +1,131 @@ # SPM headers & package references — how they resolve -React Native's SPM consumption is **zero-I**: no `-I` / `-F` header search paths -and no `unsafeFlags` in any generated manifest. Headers are served by SPM -products/binary targets, and every generated `Package.swift` references the -React Native + codegen packages with plain, fixed-relative paths computed at -generation time (no runtime discovery). This document is the single source of -truth for how that resolves. - -> History: earlier iterations materialized two header trees and fed them to -> consumers as `-I` flags read from `spm-paths.json` / -> `.react-native/paths.json` via an inlined Swift loader. That whole mechanism -> (the loader `renderRNPathsLoader`, the `writeAppPathsJson` / -> `writeSharedPathsJson` writers, and both JSON files) has been **deleted** — -> manifests are now declarative. If you find a reference to those files, it is -> stale. +React Native's SPM consumption is **zero-I**: no generated manifest carries a +`-I` / `-F` header search path into React Native's own headers. SPM products and +binary targets serve those headers, with no clang VFS overlay. Every generated +`Package.swift` references the React Native and codegen packages by plain, +fixed-relative paths. This document is the single source of truth for how that +resolves. + +Generated manifests may still reach into their own tree: + +- Scaffolded manifests, local-module wrappers, and the codegen package use + `.headerSearchPath` into their own tree. +- Scaffolded manifests may use `.unsafeFlags` for podspec compiler flags and a + prefix header. + +Manifests are declarative. The files `spm-paths.json` and +`.react-native/paths.json`, their writers (`writeAppPathsJson`, +`writeSharedPathsJson`), and the Swift loader `renderRNPathsLoader` no longer +exist. A reference to any of them is stale. ## How headers resolve (no search paths) +React Native uses CocoaPods-style imports (`#import `) that +SwiftPM does not natively support. These products serve them: + | Namespace | Served by | Mechanism | | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | | Objective-C `` / Swift `import React` | `ReactHeaders` Clang source target | Canonical Debug/Release-identical React headers staged under `ReactHeadersTarget/include/React`, with a plain `module React` module map. | | Lowercase C++ `` and everything else: ``, ``, ``, ``, folly/glog/boost/fmt/double-conversion | `ReactNativeHeaders.xcframework` plus `ReactNativeDependenciesHeaders.xcframework` | Header-only invariant binary targets keep lowercase `react` separate from Objective-C `React` and propagate their search paths through product dependencies. | | ``, `ReactAppDependencyProvider`, this app's generated specs | `ReactAppHeaders` SPM target in the codegen package | SPM `publicHeadersPath` propagation — a real target dependency, not a flag. | +`ReactHeaders` stages its copy only after it proves that Debug and Release +expose identical public headers; its module map uses `React/`-prefixed paths. +`ReactNativeHeaders` is a headers-only (LIBRARY-type) binaryTarget, and the deps +sidecar uses the same mechanism: SwiftPM serves each slice's `Headers/` to +dependents. The deps sidecar also carries `fast_float/` and `SocketRocket/`. It +exists because the binary `ReactNativeDependencies.xcframework` is +framework-type and cannot expose those headers to SwiftPM. Targets that compile +against React take all four products in the table as product dependencies. + The one remaining materialized header tree is the per-app farm at -`/build/generated/ios/ReactAppHeaders` (built by -`buildPerAppHeaderTree` in `spm-utils.js`, called from the orchestrators). It is -vended as the `ReactAppHeaders` SPM target — consumers reach it through a -product dependency, never through `-I`. +`/build/generated/ios/ReactAppHeaders`. `buildPerAppHeaderTree` in +`spm-utils.js` builds it, called from the orchestrators. It is vended as the +`ReactAppHeaders` SPM target, so consumers reach it through a product +dependency, never through `-I`. `autolinking.json` (the `@react-native-community/cli config` output) is an INPUT -used to generate the manifests; it is never read by a manifest. +used to generate the manifests. No manifest reads it. ## How each manifest references the React + codegen packages Every generated manifest sits at a known depth inside the app and is regenerated -on every `react-native spm` run, so package references are plain fixed-relative -paths — no walk-up, no JSON, no `import Foundation`. - -| Manifest | Location | How it references the React + codegen packages | -| ------------------------ | ---------------------------------------------- | -------------------------------------------------------------------------------------------------------------------- | -| Autolinked aggregator | `build/generated/autolinking/Package.swift` | `.package(path: "../../xcframeworks")` + `"../ios"` (only when it has inline `spmModule` targets) | -| Per-dep synth wrapper | `build/generated/autolinking/packages//` | `.package(path: "../../../../xcframeworks")` + `"../../../ios"` | -| Codegen template | `build/generated/ios/Package.swift` | `.package(path: "../../xcframeworks")` (or the remote url) | -| App target (pbxproj) | `.xcodeproj` | local `XCLocalSwiftPackageReference` (or `XCRemoteSwiftPackageReference` in remote mode) | -| Scaffolded community lib | `node_modules//Package.swift` | scaffold-time relative paths to the app's xcframeworks + codegen packages (or `.package(url:exact:)` in remote mode) | +on every `react-native spm` run. So package references are computed at +generation time as plain fixed-relative paths — no runtime discovery, no +walk-up, no JSON, no `import Foundation`. + +| Manifest | Location | How it references the React + codegen packages | +| ------------------------ | ---------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- | +| Autolinked aggregator | `build/generated/autolinking/Package.swift` | Neither: it has no inline targets, and references only its dependencies' packages (`packages/`, `libs/`, plugin packages) | +| Local-module wrapper | `build/generated/autolinking/packages//` | `.package(path: "../../../../xcframeworks")` + `"../../../ios"` (only for `swiftpmConfig.modules` entries) | +| Codegen template | `build/generated/ios/Package.swift` | `.package(path: "../../xcframeworks")` (or the remote url) | +| App target (pbxproj) | `.xcodeproj` | local `XCLocalSwiftPackageReference` (or `XCRemoteSwiftPackageReference` in remote mode) | +| Scaffolded community lib | `node_modules//Package.swift` | scaffold-time relative paths to the app's xcframeworks + codegen packages (or `.package(url:exact:)` in remote mode) | ## Remote-package mode -Remote mode is gated by a **URL alone** — `RN_SPM_REMOTE_URL` (or the persisted -`url`). When set, the whole app graph flips to a single remote React Native -package identity: `.package(path: build/xcframeworks)` becomes -`.package(url:exact:)` everywhere (aggregator/synth/codegen template/pbxproj), -and the local artifact download + compose is skipped. SPM's -one-version-per-package rule then unifies app + every library on one resolved -React Native. The package identity is derived from the URL tail (swift-tools 6 -dropped `.package(name:url:)`) — nothing hardcodes a repo name. - -**Version is derived from npm, not pinned by hand.** The SPM-pinned RN version -is not a free parameter: the SPM graph must compile against the same React -Native the JS/native code uses, so the app (graph root) pins EXACT to the -_installed_ RN version, read from `node_modules/react-native/package.json`. +A **URL alone** turns remote mode on: `RN_SPM_REMOTE_URL`, or the persisted +`url`. The whole app graph then uses a single remote React Native package +identity. `.package(path: build/xcframeworks)` becomes `.package(url:exact:)` +everywhere it appears (synth, codegen template, pbxproj). The local artifact +download and the compose of `build/xcframeworks` still run. SPM's +one-version-per-package rule then unifies the app and every library on one +resolved React Native. + +The package identity comes from the URL tail, because swift-tools 6 dropped +`.package(name:url:)`. Nothing hardcodes a repo name. + +**Version is derived from npm, not pinned by hand.** The SPM graph must compile +against the same React Native that the JS and native code use. So the app (the +graph root) pins EXACT to the _installed_ React Native version, read from +`node_modules/react-native/package.json`. + `RN_SPM_REMOTE_VERSION` and the persisted `versionOverride` are **overrides**, -not the source of truth — they're only needed when the installed version isn't -publishable (e.g. the monorepo `1000.0.0` dev placeholder, which has no remote -tag). A _derived_ version is never persisted, so an `npm install` that upgrades -RN auto-re-pins the SPM graph on the next `spm` run; an _override_ is persisted -as `versionOverride` so it survives Xcode-phase re-syncs without the env. - -Persisted schema is `{url, versionOverride?}`. Legacy `{url, version}` is still -read, with `version` honored as an override (back-compat). If remote mode is on -but no usable version can be resolved — react-native isn't installed, or it's a -non-publishable dev placeholder and no override is set — the tooling errors -(exit 2, a hard Xcode build error) directing you to set `RN_SPM_REMOTE_VERSION` -or install a released react-native, rather than silently pinning an unpublished +not the source of truth. You need them only when the installed version is not +publishable, e.g. the monorepo `1000.0.0` dev placeholder, which has no remote tag. +- A _derived_ version is never persisted. An `npm install` that upgrades React + Native re-pins the SPM graph on the next `spm` run. +- An _override_ is persisted as `versionOverride`, so it survives Xcode-phase + re-syncs without the environment variable. + +The settings persist in `build/generated/autolinking/spm-remote.json`. The file +is written only when the settings come from the environment variables. It lives +under `build/`, so deleting `build/` turns remote mode off until you set the +variables again. The persisted schema is `{url, versionOverride?}`. The legacy +`{url, version}` is still read, with `version` honored as an override. + +If remote mode is on but no usable version resolves, the tooling stops with exit +2, a hard Xcode build error. This happens when react-native is not installed, or +when it is a non-publishable dev placeholder and no override is set. The error +tells you to set `RN_SPM_REMOTE_VERSION` or install a released react-native. The +tooling never silently pins an unpublished tag. + ## Hand-authored community library contract -A library that ships its own `Package.swift` (no scaffolder/autolinker marker) -is left untouched by the tooling. It needs only two things, and **no discovery -code**: +The tooling leaves untouched a library that ships its own `Package.swift` (no +scaffolder or autolinker marker). That library needs only two things, and **no +discovery code**: 1. Depend on the React Native SPM package and its products — in remote mode `.package(url: "", exact: "")` + - `.product(name: "ReactNative", …)` and - `.product(name: "ReactNativeHeaders", …)`. (Libraries should declare a - version RANGE in production; the consuming app pins EXACT.) + `.product(name: "ReactHeaders", package: "")`, + `.product(name: "ReactNativeHeaders", package: "")`, and + `.product(name: "ReactNativeDependenciesHeaders", package: "")`, + where `` is the URL tail, lowercased, without `.git`. A scaffolded + manifest depends on the same React Native products. Libraries should declare + a version RANGE in production; the consuming app pins EXACT. 2. Ship its own generated code: set `codegenConfig.includesGeneratedCode: true` and generate with `generate-codegen-artifacts.js --path . --targetPlatform ios --source library`. - Output lands at `/build/generated/ios/ReactCodegen/`, reachable - from the manifest with one safe `.headerSearchPath(...)` into the library's - own tree. The app-side codegen then skips the lib's spec (no duplicate - symbols). - -This makes the library self-contained — it carries no app-layout knowledge and -needs no per-app codegen headers from the consuming app. Proven with -`@chrfalch/react-native-calculator` (a hand-authored Fabric/TurboModule lib). + The output lands at `/build/generated/ios/ReactCodegen/`. The + manifest reaches it with one safe `.headerSearchPath(...)` into the library's + own tree. The app-side codegen then skips the library's spec, so there are no + duplicate symbols. + +The library is then self-contained. It carries no app-layout knowledge and needs +no per-app codegen headers from the consuming app. +`@chrfalch/react-native-calculator` (a hand-authored Fabric/TurboModule library) +proves this. diff --git a/packages/react-native/scripts/spm/__docs__/spm-scripts.md b/packages/react-native/scripts/spm/__docs__/spm-scripts.md index 47059cb79da2..f3c1fa885c42 100644 --- a/packages/react-native/scripts/spm/__docs__/spm-scripts.md +++ b/packages/react-native/scripts/spm/__docs__/spm-scripts.md @@ -1,14 +1,13 @@ # SwiftPM Scripts – React Native iOS via Swift Package Manager (Preview) -> **Preview.** SwiftPM support is an early preview: the commands, flags, -> generated layout, and distribution model may change in future releases, and it -> is not yet recommended for production. Feedback is welcome. CocoaPods remains -> the supported default. +> **Preview.** SwiftPM support is an early preview. The commands, flags, +> generated layout, and distribution model may change in future releases. It is +> not yet recommended for production. Feedback is welcome. CocoaPods remains the +> supported default. Build React Native iOS apps using **Swift Package Manager** with prebuilt -XCFrameworks, as an alternative to CocoaPods. It is **opt-in and additive** — -CocoaPods remains the default; `spm` injects into your existing `.xcodeproj` in -place and is fully reversible. +XCFrameworks, as an alternative to CocoaPods. It is **opt-in and additive**: +`spm` injects into your existing `.xcodeproj` in place and is fully reversible. ## Quick Start @@ -25,10 +24,10 @@ npx react-native spm add --deintegrate open MyApp.xcodeproj ``` -After the initial run, the project carries **auto-sync hooks** that detect +After the initial run, the project carries **auto-sync hooks**. They detect dependency changes and re-run autolinking before compilation (see -[Auto-Sync](#auto-sync)) — you don't re-invoke `react-native spm` manually for -day-to-day dependency changes. **On a fresh clone or CI checkout, run +[Auto-Sync](#auto-sync)), so you don't re-run `react-native spm` for day-to-day +dependency changes. **On a fresh clone or CI checkout, run `npx react-native spm` once before building** (see [Fresh clones & CI](#fresh-clones--ci)). @@ -42,35 +41,39 @@ day-to-day dependency changes. **On a fresh clone or CI checkout, run `spm add` injects into a project that is **not** CocoaPods-integrated. On a CocoaPods app it fails loud and points you at `--deintegrate`, which: -1. runs `pod deintegrate` — removes CocoaPods integration from the `.xcodeproj` - (Pods references, `[CP]` build phases, xcconfig links). Your `Podfile` is - left on disk. +1. runs `pod deintegrate`. This removes CocoaPods integration from the + `.xcodeproj` (Pods references, `[CP]` build phases, xcconfig links). Your + `Podfile` is left on disk. 2. strips **only** the React Native directives (`use_react_native!`, - `use_native_modules!`, `prepare_react_native_project!`) from the Podfile — - every other line, **including your own `pod '…'` entries, is preserved**. + `use_native_modules!`, `prepare_react_native_project!`) from the Podfile. + Every other line, **including your own `pod '…'` entries, is preserved**. The + strip is line-based: it deletes each line that contains one of those names, + and nothing else. The argument lines of a multi-line `use_react_native!(` + call and any `react_native_post_install(...)` call remain, so remove them by + hand before you run `pod install`. 3. injects SwiftPM into the `.xcodeproj`. -React Native now comes from SwiftPM; no pods are linked yet (deintegrate removed -the integration). +React Native now comes from SwiftPM. No pods are linked yet, because deintegrate +removed the integration. ### Keeping non-RN pods -Non-RN pods can stay side-by-side. After `spm add --deintegrate` your Podfile -still lists them (only the RN directives were removed) — re-integrate them with +Non-RN pods can stay side-by-side. Your Podfile still lists them. Remove the +leftover React Native lines (see step 2 above), then re-integrate the pods with a normal install: ```bash pod install # re-integrates the remaining (non-RN) pods; (re)creates the .xcworkspace ``` -Then **open the `.xcworkspace`** (not the `.xcodeproj`): the workspace includes +Then **open the `.xcworkspace`** (not the `.xcodeproj`). The workspace includes the SwiftPM-injected project, so React Native resolves through SwiftPM and your -other pods through CocoaPods, together. +other pods through CocoaPods. -> **Do not re-add `use_react_native!`.** React Native must be provided by -> _either_ SwiftPM _or_ CocoaPods, never both — they share `build/generated/`, -> so a dual-managed RN does not build. `spm add` refuses to run while the -> Podfile still declares `use_react_native!`. +> **Do not re-add `use_react_native!`.** React Native must come from _either_ +> SwiftPM _or_ CocoaPods, never both. They share `build/generated/`, so a +> dual-managed RN does not build. If only the Podfile still declares +> `use_react_native!`, `spm add` prints a warning and continues. The migration is fully reversible — see [Removing / resetting](#removing--resetting). @@ -78,23 +81,22 @@ The migration is fully reversible — see ## Brownfield apps `spm add` injects into your existing `.xcodeproj` in place, so an app that -embeds React Native works the same way — point it at the right project and +embeds React Native works the same way. Point it at the right project and target: ```bash npx react-native spm add --xcodeproj MyApp.xcodeproj --productName MyApp ``` -**Requirement:** the `.xcodeproj` must live **inside the React Native JS tree** -— i.e. the app's `package.json` is a parent directory of the project. Both setup -and the build-time sync locate React Native by walking up from the project to -the nearest `package.json`. The common "native project at the repo root with the -RN JS in a sibling/child subfolder" layout is **not supported yet** — there is -no way to point at a JS root outside the project's ancestors. +**Requirement:** the `.xcodeproj` must live **inside the React Native JS tree**. +The app's `package.json` must be in a parent directory of the project. Setup and +the build-time sync both find React Native by walking up from the project to the +nearest `package.json`. The common "native project at the repo root with the RN +JS in a sibling/child subfolder" layout is **not supported yet**. You cannot +point at a JS root outside the project's ancestors. -Brownfield apps that keep CocoaPods for their other native dependencies follow -the [coexistence rules above](#keeping-non-rn-pods): React Native from SwiftPM, -everything else from CocoaPods, and no `use_react_native!` in the Podfile. +Brownfield apps that keep CocoaPods for other native dependencies follow the +[coexistence rules above](#keeping-non-rn-pods). ## CLI Actions @@ -104,134 +106,158 @@ react-native spm [action] [options] With no action, the command **auto-resolves**: if SwiftPM has been injected (`.spm-injected.json` marker present) it routes to `update`; otherwise `add`. On -a freshly-scaffolded CocoaPods project (clean git tree, stock Podfile) the -zero-arg path additionally implies `--deintegrate` (the safe-gate), so +a freshly-scaffolded CocoaPods project (clean git tree, stock Podfile), the +zero-arg path also implies `--deintegrate` (the safe-gate). So `npx react-native spm` converts a brand-new app to SwiftPM in one command. -When invoked from the JS root of a standard RN app (sibling `ios/` subdir), the -command auto-redirects into `ios/` with a banner. +From the JS root of a standard RN app (sibling `ios/` subdir), the command +redirects into `ios/` and prints a banner. -| Action | Description | -| --------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `add` | Inject SwiftPM packages (package refs, build settings, the Sync build phase) into the existing `.xcodeproj`, in place. Idempotent. Default on first run. `--deintegrate` first runs `pod deintegrate` + strips React Native from the Podfile. | -| `update` | Re-run the pipeline and refresh the existing injection. Default once a project is injected. | -| `deinit` | The inverse of `add`: surgically remove only what `add` injected (recorded in `.spm-injected.json`) and drop the marker. Git-recoverable; no prompt. Three things it does not undo — see [Files the tool touches](#files-the-tool-touches). | -| `scaffold` | Generate `Package.swift` into `node_modules//` for community RN libraries that ship only a podspec. | -| `sync` (advanced) | Lightweight resync invoked by the Xcode auto-sync hooks. Regenerates invariant codegen and autolinking output only. Not for humans. | -| `codegen` (advanced) | Run codegen and install the SwiftPM codegen template only. | -| `download` (advanced) | Download/check xcframework artifacts only. | +| Action | Description | +| --------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `add` | Inject SwiftPM packages (package refs, the base reference to `ReactNativeSPM/ReactNativeSPM.xcconfig`, the Sync build phase) into the existing `.xcodeproj`, in place. Idempotent. Default on first run. `--deintegrate` first runs `pod deintegrate` + strips React Native from the Podfile. | +| `update` | Re-run the pipeline and refresh the existing injection. Default once a project is injected. | +| `deinit` | The inverse of `add`: surgically remove only what `add` injected (recorded in `.spm-injected.json`) and drop the marker. Git-recoverable; no prompt. Some edits it does not undo — see [Files the tool touches](#files-the-tool-touches). | +| `scaffold` | Generate `Package.swift` into `node_modules//` for community RN libraries that ship only a podspec. | +| `sync` (advanced) | Lightweight resync invoked by the Xcode auto-sync hooks. Regenerates invariant codegen and autolinking output only. Not for humans. | +| `codegen` (advanced) | Run codegen and install the SwiftPM codegen template only. | +| `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. + +| Option | Description | +| -------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `--version ` | RN version. Resolved in this order: this flag, then the version a previous `--version` pinned into `.spm-injected.json`, then `node_modules/react-native/package.json`. Pass it once — later runs reuse the pin (see [Pinning the React Native version](#pinning-the-react-native-version)) | +| `--yes` | Skip the dirty-pbxproj confirmation prompt | +| `--xcodeproj ` | [add, update, scaffold, deinit] Which `.xcodeproj` to inject into or remove from (when several exist) | +| `--productName ` | [add, update, scaffold] Which app target to inject into (when several exist) | +| `--deintegrate` | [add] Run `pod deintegrate` + strip React Native from the Podfile before injecting | +| `--artifacts ` | [advanced] Local artifact root containing complete `debug/` and `release/` cache slots | +| `--download ` | [advanced] Artifact download policy (default: auto) | +| `--skipCodegen` | [advanced] Skip the codegen step | +| `--config-command ` | [advanced] **Raw script only** — `npx react-native spm` does not forward it; use the `RCT_SPM_AUTOLINKING_CONFIG_COMMAND` env var there. JSON array of the argv used to generate `autolinking.json`, overriding the default `@react-native-community/cli config` command. Either way the value is remembered, so you pass it once. Example: `'["npx","expo-modules-autolinking","react-native-config","--json","--platform","ios"]'` | ### The autolinking config command is remembered An app that replaces `@react-native-community/cli` autolinking (an Expo app, for -example) has to tell `spm` how to produce `autolinking.json`. Pass the command -once, on `add` or `update`: +example) must tell `spm` how to produce `autolinking.json`. Pass the command +once, on `add` or `update`. `npx react-native spm` does not forward a +`--configCommand` flag, so set the environment variable: ```bash -npx react-native spm add --configCommand '["npx","expo-modules-autolinking","react-native-config","--json","--platform","ios"]' +RCT_SPM_AUTOLINKING_CONFIG_COMMAND='["npx","expo-modules-autolinking","react-native-config","--json","--platform","ios"]' \ + npx react-native spm add +``` + +or run the script directly with `--config-command`: + +```bash +node node_modules/react-native/scripts/setup-apple-spm.js add \ + --config-command '["npx","expo-modules-autolinking","react-native-config","--json","--platform","ios"]' ``` Every action that needs `autolinking.json` — `add`, `update`, `scaffold`, and the build-time `sync` — resolves the command in this order: -1. `--configCommand` +1. `--config-command` (raw script only) 2. `RCT_SPM_AUTOLINKING_CONFIG_COMMAND` 3. the `configCommand` pinned in `MyApp.xcodeproj/.spm-injected.json` by an - earlier `add`/`update` + earlier `add`/`update`/`scaffold` 4. the default `@react-native-community/cli config` -`add`/`update` pin whichever of the first two routes supplied the command, -validated as an argv array; a later run that passes neither keeps the existing -pin, and passing `--configCommand` again replaces it. The pin exists because the -**Sync SPM Autolinking** build phase inherits neither your flag nor the shell -that exported the env var — without it, a successful `add` is followed by -failing builds, because the phase re-derives `autolinking.json` with the default -command. A pin never shadows the env var, so an override in your shell still -takes effect, and a pin that no longer parses is ignored in favor of the +`add`/`update`/`scaffold` pin the command from whichever of the first two routes +supplied it, validated as an argv array. A later run that passes neither keeps +the existing pin. A new command passed by either route replaces it. + +The pin exists because the **Sync SPM Autolinking** build phase inherits neither +your flag nor the shell that exported the env var. Without the pin, the phase +re-derives `autolinking.json` with the default command, and builds fail after a +successful `add`. A pin never shadows the env var, so an override in your shell +still takes effect. A pin that no longer parses is ignored in favor of the default. -`deinit` deletes `.spm-injected.json`, and the pin with it. A later `add` -therefore falls back to the default command unless you pass `--configCommand` -(or export the env var) again. +`deinit` deletes `.spm-injected.json`, and the pin with it. A later `add` then +falls back to the default command unless you export the env var (or pass +`--config-command` to the raw script) again. ### Pinning the React Native version The resolved version selects **which artifact slots the project is wired to**, -so it has to stay the same from one run to the next. `--version` is therefore -recorded in the `.spm-injected.json` marker (as `artifactsVersionOverride`) and -read back by later runs, which resolve the version in this order: +so it must stay the same from one run to the next. So `--version` is recorded in +the `.spm-injected.json` marker (as `artifactsVersionOverride`) and read back by +later runs, which resolve the version in this order: 1. an explicit `--version `, 2. the version a previous `--version` pinned into the marker, 3. `node_modules/react-native/package.json`. -So you pass the flag once, and a later flagless `add`/`update` stays on the -slots it selected. Without the pin, that flagless run falls back to -`package.json` and re-points the project at different artifact slots while the -marker still advertises the pinned version. +You pass the flag once, and a later flagless `add`/`update` stays on the slots +it selected. Without the pin, that flagless run would fall back to +`package.json`. It would re-point the project at different artifact slots while +the marker still advertises the pinned version. -`deinit` deletes the marker, and with it the pin — a later `add` resolves +`deinit` deletes the marker, and with it the pin. A later `add` resolves `node_modules/react-native/package.json` again unless you pass `--version`. ### Debug/Release flavor is automatic -React Native ships **flavored** prebuilt binaries: the _debug_ `React.framework` -(and `hermes-engine` / `ReactNativeDependencies`) carry the dev experience — dev -menu, assertions, `RN_DEBUG_STRING_CONVERTIBLE` — while _release_ strips them -for production. A Debug build must embed the debug binaries and a -Release/archive the release ones. +React Native ships **flavored** prebuilt binaries. The _debug_ `React.framework` +(and `hermes-engine` / `ReactNativeDependencies`) carry the dev experience: dev +menu, assertions, `RN_DEBUG_STRING_CONVERTIBLE`. The _release_ binaries strip +them for production. A Debug build must embed the debug binaries, and a +Release/archive build the release ones. SwiftPM `binaryTarget`s can't branch on the build configuration, so runtime -frameworks are deliberately kept out of the package graph. `spm add` downloads -and validates **both** flavors into immutable app-local slots. It injects -SDK/architecture-qualified Xcode settings that link the exact selected binaries, -plus one phase that copies and signs the selected frameworks into the app. +frameworks are deliberately kept out of the package graph. Instead: + +- `spm add` downloads and validates **both** flavors into immutable app-local + slots. +- It writes SDK/architecture-qualified Xcode settings to + `ReactNativeSPM/ReactNativeSPM.xcconfig`. They link the exact selected + binaries. +- It adds one phase that copies and signs the selected frameworks into the app. + Configurations containing `debug` or `development` select Debug; every other configuration selects Release. Selection uses only generated build settings and -standard macOS tools: builds do not run Node, mutate symlinks, regenerate the +standard macOS tools. Builds do not run Node, mutate symlinks, regenerate the package graph, or require a second build. -Those same debug-flavored configurations also get -`SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited) DEBUG"` — the only thing -that makes Swift's `#if DEBUG` true (`GCC_PREPROCESSOR_DEFINITIONS` reaches -C/ObjC/C++ only), and what `AppDelegate.swift`'s `bundleURL()` branches on to -load from Metro instead of a bundled `main.jsbundle`. CocoaPods injects it at -`pod install` time, so this keeps SwiftPM apps at parity. An existing value is -left alone. +The xcconfig sets `RN_SPM_FLAVOR` for each configuration by name. A +configuration added later, e.g. duplicated in Xcode, falls back to release until +you run `npx react-native spm update`. + +Those debug-flavored configurations also get `DEBUG` in +`SWIFT_ACTIVE_COMPILATION_CONDITIONS`, set in the xcconfig as +`$(inherited) DEBUG`. Only this makes Swift's `#if DEBUG` true +(`GCC_PREPROCESSOR_DEFINITIONS` reaches C/ObjC/C++ only). `AppDelegate.swift`'s +`bundleURL()` branches on it to load from Metro instead of a bundled +`main.jsbundle`. CocoaPods injects it at `pod install` time, so this keeps +SwiftPM apps at parity. `add` and `update` refuse a target value without +`$(inherited)`, because it would hide this one (see +[Build settings](#build-settings)). ### iOS deployment target -Every manifest React Native generates — the `Autolinked` aggregate, the synth -package per dependency, and each scaffolded community package — declares the -same platform floor: your app's `IPHONEOS_DEPLOYMENT_TARGET`, never below React -Native's own minimum (15.1). SwiftPM refuses to link a product whose minimum is -higher than the target depending on it, so a dependency that needs more (Expo's -packages need iOS 16.4) only resolves once the app asks for at least as much: -raise the deployment target in Xcode and re-run `react-native spm update`. - -A floor set in an `.xcconfig` your configuration is based on is honored, -`#include` chains included; one set through a build-setting variable -(`$(MY_FLOOR)`) is not, and falls back to React Native's minimum. `spm add` and -`spm update` also refresh the platform-floor line of existing scaffolded -manifests (they never create new ones) — if you persisted a scaffold with -`patch-package`, re-run `npx patch-package ` afterwards. +The `Autolinked` aggregate, the synth package per local module, and each +scaffolded community package declare the same platform floor: your app's +`IPHONEOS_DEPLOYMENT_TARGET`, never below React Native's own minimum (15.1). The +codegen package (`build/generated/ios/Package.swift`) always declares iOS 15. +`build/xcframeworks/Package.swift` declares no platforms. + +SwiftPM refuses to link a product whose minimum is higher than that of the +target depending on it. So a dependency that needs more (Expo's packages need +iOS 16.4) resolves only once the app asks for at least as much. Raise the +deployment target in Xcode and re-run `react-native spm update`. + +- A floor set in an `.xcconfig` your configuration is based on is honored, + `#include` chains included. +- A floor set through a build-setting variable (`$(MY_FLOOR)`) is not honored; + it falls back to React Native's minimum. +- `spm add` and `spm update` also refresh the platform-floor line of existing + scaffolded manifests. They never create new ones. If you persisted a scaffold + with `patch-package`, re-run `npx patch-package ` afterwards. ## Files the tool touches @@ -239,93 +265,125 @@ 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 +| Path | Written by | What happens | Undone by `deinit`? | +| --------------------------------------------------- | --------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `MyApp.xcodeproj/project.pbxproj` | `add`, `update`, `scaffold` | SwiftPM package refs, a reference to `ReactNativeSPM/ReactNativeSPM.xcconfig` set as the base configuration of each app configuration, the Sync build phase, and the flavored-framework embed phase are added. The two phases run their scripts from `ReactNativeSPM/Scripts/`. A re-run is a no-op. A `${PODS_ROOT}`-anchored `REACT_NATIVE_PATH` is removed, so the xcconfig's SwiftPM path applies. `add` also removes the template's unused `JavaScriptCore.framework` reference, and `add --deintegrate` removes an empty `Pods` group. | Yes — exactly what was injected, per the marker. The removed `REACT_NATIVE_PATH`, `JavaScriptCore.framework` reference, and `Pods` group are **not** restored. | +| `MyApp.xcodeproj/.spm-injected.json` | `add`, `update`, `scaffold` | Created. Three roles: it records every edit made — including which configurations it based on the generated xcconfig — so removal is surgical and re-runs stay idempotent; it records the frameworks the project links (`linkedFrameworks`), which the build-time sync compares with the frameworks plugins provide; and it **pins configuration** later runs and Xcode builds must reuse (see the two pins below). | Yes — deleted, and the pins go with it | +| `ReactNativeSPM/ReactNativeSPM.xcconfig` | `add`, `update`, `scaffold` | Regenerated on each run and written when its content changes. Holds the React build settings: search paths, linker flags, `REACT_NATIVE_PATH`, the C++ standard, each configuration's flavor (`RN_SPM_FLAVOR`), and `DEBUG` for Swift on debug configurations (see [Build settings](#build-settings)). **Commit it.** Xcode reads it before any build phase runs, so a build cannot regenerate it. If it is missing, run `npx react-native spm update` and commit it. | Yes — deleted, unless a configuration is based on an xcconfig of your own, which may `#include` it. Then the file is kept, and `deinit` names those configurations. | +| `ReactNativeSPM/Scripts/` | `add`, `update`, `scaffold` | Regenerated on each run and written when their content changes: `sync-autolinking.sh` and `embed-flavored-frameworks.sh`, the scripts the Sync and embed phases run. Plugin script phases keep their scripts in `project.pbxproj`. **Commit it.** When a script is missing, its phase fails the build with `error: is missing. Run 'npx react-native spm update' to generate it.` | Yes — both scripts are deleted | +| `MyApp.xcodeproj/xcshareddata/xcschemes/*.xcscheme` | `add`, `update`, `scaffold` | The sync pre-action is added to the scheme that builds your target; a shared scheme is created if there is none. Commit this or teammates lose the pre-action. | Yes — a scheme `add` created is deleted only if it is still byte-identical to the generated one; otherwise only the pre-action is stripped | +| `.gitignore` | `add` only | Created if absent, else appended: a `# SPM – auto-generated at build time` block adding `Package.resolved`, `build/generated/`, `build/xcframeworks/`, `.build/`. | **No** — the block is left behind | +| `Podfile` | `add --deintegrate` | Only the lines containing the React Native directives (`use_react_native!`, `use_native_modules!`, `prepare_react_native_project!`) are stripped. Your own `pod '…'` lines are preserved. The argument lines of a multi-line `use_react_native!(` call and `react_native_post_install(...)` remain — remove them by hand before `pod install`. | **No** — re-add the directives yourself to go back to CocoaPods | +| `Pods/`, `Pods-*.xcconfig`, `[CP]` phases | `add --deintegrate` | Removed by `pod deintegrate`. The `.xcworkspace` referencing them is left on disk. | **No** — run `pod install` to restore | + +The two pins are the `--version` pin (`artifactsVersionOverride`, see [Pinning the React Native version](#pinning-the-react-native-version)) and the [autolinking config command](#the-autolinking-config-command-is-remembered). -Because `deinit` drops the marker, it drops both. ### In your repo — generated, gitignored -| Path | Written by | Contents | -| ------------------------------ | ----------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `build/generated/ios/` | `add`, `update`, `sync`, `codegen` | Codegen output plus the SwiftPM codegen manifest (the `React-GeneratedCode` package). | -| `build/generated/autolinking/` | `add`, `update`, `sync` | `Package.swift`, `autolinking.json`, `packages/`, `libs/`, `headers/`, the `.spm-sync-stamp`, `.spm-sync-watch-paths`, and any `.spm-plugin-*.json` plugin manifests. | -| `build/xcframeworks/` | `add`, `update`, `sync`, `download` | The `debug/` and `release/` flavor slots (symlinks into the cache), `ReactHeadersTarget/`, the headers-only xcframeworks, `Package.swift`, `flavored-frameworks.json`, `.artifact-stamp`. | -| `.build/`, `Package.resolved` | Xcode / SwiftPM | SwiftPM's own build directory and resolution file. Machine-specific. | +| Path | Written by | Contents | +| ------------------------------ | ---------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `build/generated/ios/` | `add`, `update`, `scaffold`, `sync`, `codegen` | Codegen output plus the SwiftPM codegen manifest (the `React-GeneratedCode` package). | +| `build/generated/autolinking/` | `add`, `update`, `scaffold`, `sync` | `Package.swift`, `autolinking.json`, `packages/`, `libs/`, `headers/`, the `.spm-sync-stamp`, `.spm-sync-watch-paths`, `.spm-sync-watch-absent`, `.spm-plugin-mismatch` (the errors of a failed plugin framework check), and any `.spm-plugin-*.json` plugin manifests. | +| `build/xcframeworks/` | `add`, `update`, `scaffold` | The `debug/` and `release/` flavor slots (symlinks into the cache), `ReactHeadersTarget/`, the headers-only xcframeworks, `Package.swift`, `flavored-frameworks.json`, `.artifact-stamp`. | +| `.build/`, `Package.resolved` | Xcode / SwiftPM | SwiftPM's own build directory and resolution file. Machine-specific. | -`deinit` leaves all of the above in place — it is regenerable, and removing it -is `rm -rf build/ .build/` (see [Removing / resetting](#removing--resetting)). +`deinit` leaves all of the above in place. It is regenerable, and removing it is +`rm -rf build/ .build/` (see [Removing / resetting](#removing--resetting)). ### Outside your repo -| Path | Written by | Notes | -| ---------------------------------------------------------------- | ----------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `node_modules//Package.swift` | `scaffold` | A generated manifest for a dep that ships none. Not committed — persist with `patch-package` (see [Community packages without a Package.swift](#community-packages-without-a-packageswift)). | -| `~/Library/Caches/ReactNative/spm-artifacts///` | `add`, `update`, `sync`, `download` | The immutable artifact slots the `build/xcframeworks/` symlinks point at. Shared across apps on the machine. | -| `~/Library/Caches/ReactNative/` | `download` | Downloaded tarballs, shared with CocoaPods. `RCT_SKIP_CACHES=1` bypasses the cache. | - -Injection is **purely additive** and **idempotent**: every other byte of your -project — signing, capabilities, your own Build Phases — stays untouched, and a -re-run is a no-op. The injected refs point at three stable sub-package paths -under `build/`, so adding or removing community deps changes the sub-package -contents (gitignored) and never re-injects. `deinit` removes exactly what was -injected, leaving the project byte-identical to its pre-`add` state — with the -exceptions called out above, and one more described next. - -**Build settings that already exist** are edited in place. The four array -settings `add` merges into — `HEADER_SEARCH_PATHS`, `OTHER_LDFLAGS`, -`FRAMEWORK_SEARCH_PATHS`, `LD_RUNPATH_SEARCH_PATHS` — keep the shape they were -written in: Xcode's multi-line form as well as the compact one-line form hand -edits and other generators (XcodeGen, Tuist) emit. One that exists as a plain -_scalar_ is promoted to a `( … )` array — the shape an Xcode-authored target can -carry, e.g. a -`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. +| Path | Written by | Notes | +| ---------------------------------------------------------------- | --------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `node_modules//Package.swift` | `scaffold` | A generated manifest for a dep that ships none. Not committed — persist with `patch-package` (see [Community packages without a Package.swift](#community-packages-without-a-packageswift)). | +| `~/Library/Caches/ReactNative/spm-artifacts///` | `add`, `update`, `scaffold`, `download` | The immutable artifact slots the `build/xcframeworks/` symlinks point at. Shared across apps on the machine. | +| `~/Library/Caches/ReactNative/` | `download`, `add`, `update`, `scaffold` | Downloaded tarballs, shared with CocoaPods. `RCT_SKIP_CACHES=1` bypasses the cache. | + +Apart from the `project.pbxproj` edits listed above, injection is **additive**: +every other byte of your project — signing, capabilities, your own Build Phases +— stays untouched. The injected refs point at three stable sub-package paths +under `build/`. So adding or removing community deps changes only the +sub-package contents (gitignored) and never re-injects. `deinit` leaves the +project byte-identical to its pre-`add` state, with the exceptions above. + +### Build settings + +The React build settings live in `ReactNativeSPM/ReactNativeSPM.xcconfig`, not +in `project.pbxproj`. Each app configuration is based on that file. Its list +settings start with `$(inherited)`. `add` and `update` refuse two setups, and +the error names the fix: + +- **A configuration already based on an xcconfig of your own.** React Native + does not edit xcconfig files it does not own. Add + `#include "/ReactNativeSPM.xcconfig"` to your file instead, with the + path relative to the folder that holds your file, and re-run. `#include` + chains are followed. The configuration then keeps your file as its base. +- **A list setting the target sets without `$(inherited)`.** These are + `HEADER_SEARCH_PATHS`, `OTHER_LDFLAGS`, `FRAMEWORK_SEARCH_PATHS`, + `LD_RUNPATH_SEARCH_PATHS`, and on debug configurations + `SWIFT_ACTIVE_COMPILATION_CONDITIONS`. A target's own value outranks the + xcconfig, so it would hide React Native's values. Add `$(inherited)` and + re-run. + +A project set up by an earlier version carries these settings in +`project.pbxproj`. The next `update` or `deinit` reverses the edits the marker +recorded. It removes the settings that version added and puts back any value it +replaced. An array that version promoted from a scalar goes back to its original +value, so **members you added to it by hand are lost**. + +## Environment variables + +| Variable | Effect | +| -------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `RCT_SPM_AUTOLINKING_CONFIG_COMMAND` | JSON argv array that replaces the default `@react-native-community/cli config` command. `add`/`update`/`scaffold` pin it — see [The autolinking config command is remembered](#the-autolinking-config-command-is-remembered). | +| `RCT_SKIP_CACHES` | `1` bypasses the shared tarball cache in `~/Library/Caches/ReactNative/`. | +| `RN_CORE_TARBALL_PATH` | Local React core tarball, used instead of a download. Skips the shared cache. | +| `RN_HEADERS_TARBALL_PATH` | Local `ReactNativeHeaders` tarball, used instead of the copy inside the React core tarball. | +| `RN_DEPS_TARBALL_PATH` | Local `ReactNativeDependencies` tarball, used instead of a download. Skips the shared cache. | +| `RN_DEPS_HEADERS_TARBALL_PATH` | Local `ReactNativeDependenciesHeaders` tarball, used instead of the copy inside the dependencies tarball. | +| `RCT_REACT_NATIVE_MAVEN_MIRROR_ENABLED` | `false` or `0` turns off the React Native Maven mirror. The mirror is on by default and is tried before Maven Central. | +| `ENTERPRISE_REPOSITORY` | Maven repository URL. When set, it is the only repository consulted for releases; the snapshot fallback still uses Maven Central snapshots. | +| `RN_SPM_REMOTE_URL`, `RN_SPM_REMOTE_VERSION` | Turn on remote-package mode — see [Remote-package mode](./spm-header-paths-contract.md#remote-package-mode). | +| `RN_DEP_VERSION` | Version of the `ReactNativeDependencies` artifact, or `nightly`. Defaults to the React Native version. | +| `HERMES_VERSION` | Version of the Hermes artifact, `nightly`, or `latest-v1`. Defaults to the locally pinned `hermes-compiler` version, else `latest-v1`. | + +A tarball path that does not exist fails the download. Each tarball override +applies to both flavors, so the Debug and Release slots get the same file. ## Fresh clones & CI -Everything under `build/` is gitignored, so a clean checkout has no resolvable +Everything under `build/` is gitignored. So a clean checkout has no resolvable Swift packages until they are regenerated. Xcode resolves the package graph before build phases **and** before scheme pre-actions, so neither -[auto-sync hook](#auto-sync) can rescue this: with `build/generated/autolinking` -missing, the build stops at _"Resolve Package Graph … doesn't exist"_ having run +[auto-sync hook](#auto-sync) can fix this. With `build/generated/autolinking` +missing, the build stops at _"Resolve Package Graph … doesn't exist"_ and runs neither hook. -Verified on Xcode 26.6 against a freshly-injected app with `build/` deleted: +Verified on Xcode 26.6, against a freshly-injected app with `build/` deleted: `xcodebuild -scheme … build` fails in nine lines of log, with -`Resolve Package Graph` as the first step and no trace of the pre-action; -`xcodebuild -resolvePackageDependencies` fails identically. Opening the project +`Resolve Package Graph` as the first step and no trace of the pre-action. +`xcodebuild -resolvePackageDependencies` fails the same way. Opening the project in Xcode also resolves the graph on load, before you press Build. -So run the setup command once after cloning, before building — the SwiftPM +So run the setup command once after cloning, before building. It is the SwiftPM analog of `pod install`: ```bash npx react-native spm # downloads artifacts (if missing) + regenerates build/ ``` -On an already-injected project this routes to `update`: it fetches the -xcframework artifacts into the shared cache if they aren't present and -regenerates `build/xcframeworks` + `build/generated`. After this first run, -incremental dependency changes are picked up automatically by the auto-sync -hooks. +On an already-injected project this routes to `update`. It fetches the +xcframework artifacts into the shared cache if they aren't present, and +regenerates `build/xcframeworks` + `build/generated`. + +`ReactNativeSPM/` is not under `build/`. It is committed with the project, so a +fresh clone already has it (see +[In your repo — committed](#in-your-repo--committed)). `update` rewrites it when +its content changes. -**Automate it** so nobody has to remember — add a `postinstall` hook, which runs -as part of the `npm install` / `yarn install` your CI already does before -`xcodebuild`: +**Automate it** with a `postinstall` hook. It runs as part of the `npm install` +/ `yarn install` your CI already does before `xcodebuild`: ```json { @@ -335,22 +393,22 @@ as part of the `npm install` / `yarn install` your CI already does before } ``` -`npx react-native spm` auto-redirects from the JS root into `ios/`, so the hook -works from the app root; in CI (non-interactive) it proceeds without prompting. -It re-runs the full pipeline (codegen + an idempotent re-inject that is a no-op -when nothing changed), so it is slightly heavier than the internal `sync` the -build phase calls — a fine trade for not having to remember a command. +The hook works from the app root, because `npx react-native spm` redirects from +the JS root into `ios/`. In CI (non-interactive) it proceeds without prompting. +It re-runs the full pipeline: codegen, plus an idempotent re-inject that is a +no-op when nothing changed. So it is slightly heavier than the internal `sync` +the build phase calls. > A future remote-package distribution (a tagged `Package.swift` repo + -> `binaryTarget(url:checksum:)`) removes this step entirely: SwiftPM resolves -> and fetches the artifacts itself during normal package resolution. Until then, -> the one-time setup run is required on clean machines. +> `binaryTarget(url:checksum:)`) removes this step: SwiftPM will resolve and +> fetch the artifacts itself during normal package resolution. Until then, clean +> machines need the one-time setup run. ## Local Native Modules -Modules not discovered via autolinking are declared in the app's package.json. -Each `path` is relative to the file that declares it — the project root for a -package.json there, the Xcode project directory for a config kept there: +Declare modules that autolinking does not discover in the app's package.json. +Each `path` is relative to the file that declares it: the project root for a +package.json there, or the Xcode project directory for a config kept there: ```json { @@ -359,16 +417,42 @@ package.json there, the Xcode project directory for a config kept there: { "name": "MyNativeModule", "path": "ios/MyNativeModule", - "exclude": ["*.podspec"] + "exclude": ["*.podspec"], + "sources": ["**/*.{h,m,mm}"] } ] } } ``` -Each entry becomes a target in `build/generated/autolinking/Package.swift`. -Sources outside `build/generated/autolinking/` are automatically mirrored with -file-level symlinks. +Each entry becomes its own package at +`build/generated/autolinking/packages//Package.swift`, with a `root` +directory symlink to the module directory. Its headers are mirrored with +file-level symlinks into `build/generated/autolinking/headers//`. The +aggregator `build/generated/autolinking/Package.swift` references each one as +`.package(path: "packages/")`. A module directory that ships its own +`Package.swift` is used as is. + +A module that mixes Swift and C-family (`.m`/`.mm`/`.c`/`.cpp`) sources is +rejected, because SwiftPM cannot compile both in one target. Split it into +single-language modules, or ship a hand-written `Package.swift`. Module names +get the same checks as [library names](#library-names): a name React Native +reserves, or one that collides with another module or an autolinked library, is +a hard error. + +`sources` is optional. It is a glob allowlist relative to `path`, like a +podspec's `source_files`. Without it, or when it matches no file, the target +gets every `.h`, `.hpp`, `.m`, `.mm`, `.c`, `.cpp` and `.swift` file under +`path`, minus `exclude`. Directories named `android`, `test`, `tests`, +`__tests__`, `__mocks__`, `jest` or `node_modules` are skipped at any depth, +also when `sources` is set. + +Modules reach one app target only: the one `spm add` links the `Autolinked` +aggregate to. Other targets in the project do not see them. Choose the target +with `--productName`. + +Add third-party Swift packages to the app target in Xcode (File > Add Package +Dependencies). Local modules cannot import them. ## Where SwiftPM settings live @@ -393,22 +477,22 @@ alongside `codegenConfig`. } ``` -A library's settings come from its own package.json. An **app's** are resolved -field by field, each from the directory holding its package.json — the JS root, -where codegen reads `codegenConfig` — falling back to the Xcode project -directory. An unrecognised field is ignored with a warning naming it, so a typo -does not pass silently. +A library's settings come from its own package.json. An **app's** settings are +resolved field by field. Each field comes from the directory that holds its +package.json (the JS root, where codegen reads `codegenConfig`), falling back to +the Xcode project directory. An unrecognised field is ignored with a warning +naming it, so a typo does not pass silently. -These settings used to live in an `spm` block in `react-native.config.js`. That -block is **deprecated** but still read, with the same field names, so nothing -breaks: move the keys across as they are, and package.json wins field by field. -`npx react-native spm scaffold` writes the `name` for you — see +The `spm` block in `react-native.config.js` is **deprecated** but still read, +with the same field names, so nothing breaks. Move the keys across as they are; +package.json wins field by field. `npx react-native spm scaffold` writes the +`name` for you — see [Community packages without a Package.swift](#community-packages-without-a-packageswift). ## Library names An autolinked library's SwiftPM target name is also the prefix its headers are -imported under (`#import `), so it is not cosmetic: it has to be the +imported under (`#import `). So it is not cosmetic: it must be the prefix the library's own sources and its dependents already use. It is resolved in this order: @@ -421,42 +505,51 @@ It is resolved in this order: 5. podspec name — `react-native-svg` → `RNSVG` 6. npm package name — `react-native-svg` → `ReactNativeSvg` -Steps 3–5 are a **migration path, not the destination**. They exist so that -libraries work unchanged today, a podspec being what they already ship, and -`npx react-native spm scaffold` closes them out: it records the name it derived -as `swiftpmConfig.name` in the library's package.json, after which the podspec -is never consulted for naming again. A library that declares its own name needs -no podspec for SwiftPM at all. - -Within those three steps, `header_dir` comes first because that is what a -library sets when its import prefix differs from its pod name, then -`module_name`, what CocoaPods compiles the module as and so what Swift and -`@import` consumers write. But a `header_dir` only a **subspec** declares names -that subspec's headers, not the library, so the pod name stands -(react-native-svg is `RNSVG`, not `rnsvg`; its subspec prefix resolves through -the header search paths instead) — unless the subspec reuses the parent's block -variable (`do |s|`), which hides which scope declared what, so the podspec is -read by CocoaPods or not at all. An unreadable podspec falls through rather than -failing the build — with a warning, since a machine that can read it (one with -CocoaPods installed) may resolve a different name. A prefix Swift cannot spell -is normalized — `Some.Pod` becomes `Some_Pod`, what SwiftPM would compile it as -anyway — with a warning naming `swiftpmConfig.name`. - -Two names are refused outright: one React Native reserves (`ReactNative`, -`ReactHeaders`, `ReactNativeHeaders`, `ReactNativeDependenciesHeaders`, -`ReactAppHeaders`, `React-GeneratedCode`, `ReactCodegen`, -`ReactAppDependencyProvider`, `Autolinked`), and one another library already -took. Both are **hard errors** naming `swiftpmConfig.name` — nothing is renamed -automatically, because a name the build invented is one no `#import` in your -sources can predict. Two names must differ by more than case or punctuation to -be two targets: `worklets` and `Worklets` share a headers directory, and -`foo-bar` and `foo_bar` are one module, since SwiftPM replaces every character -C99 rejects with `_`. +Steps 3–5 are a **migration path, not the destination**. They let libraries work +unchanged today, because a podspec is what they already ship. +`npx react-native spm scaffold` closes them out: it records the derived name as +`swiftpmConfig.name` in the library's package.json, and the podspec is then +never consulted for naming again. A library that declares its own name needs no +podspec for SwiftPM at all. + +Within those three steps: + +- `header_dir` comes first, because a library sets it when its import prefix + differs from its pod name. +- `module_name` comes next. It is what CocoaPods compiles the module as, and so + what Swift and `@import` consumers write. +- A `header_dir` that only a **subspec** declares names that subspec's headers, + not the library, so the pod name stands. react-native-svg is `RNSVG`, not + `rnsvg`; its subspec prefix resolves through the header search paths instead. + The exception is a subspec that reuses the parent's block variable (`do |s|`). + That hides which scope declared what, so the podspec is read by CocoaPods or + not at all. +- An unreadable podspec falls through rather than failing the build. It logs a + warning, because a machine that can read it (one with CocoaPods installed) may + resolve a different name. +- A prefix Swift cannot spell is normalized, e.g. `Some.Pod` becomes `Some_Pod`, + which is what SwiftPM would compile it as anyway. A warning names + `swiftpmConfig.name`. + +Two names are refused outright: + +- a name React Native reserves (`ReactNative`, `ReactHeaders`, + `ReactNativeHeaders`, `ReactNativeDependenciesHeaders`, `ReactAppHeaders`, + `React-GeneratedCode`, `ReactCodegen`, `ReactAppDependencyProvider`, + `Autolinked`), and +- a name another library already took. + +Both are **hard errors** that name `swiftpmConfig.name`. Nothing is renamed +automatically, because no `#import` in your sources can predict a name the build +invented. Two names must differ by more than case or punctuation to be two +targets. `worklets` and `Worklets` share a headers directory, and `foo-bar` and +`foo_bar` are one module, since SwiftPM replaces every character C99 rejects +with `_`. ## Dependencies between libraries -SwiftPM has no equivalent of a podspec's `s.dependency`, so a library that needs -another native library declares it explicitly in its **own** package.json — a +SwiftPM has no equivalent of a podspec's `s.dependency`. So a library that needs +another native library declares it explicitly in its **own** package.json, as a list of npm names: ```json @@ -467,15 +560,15 @@ list of npm names: ``` The autolinker follows these **recursively** from the directly-autolinked deps -and dedupes, so a transitive dependency joins the package graph even when the +and dedupes. So a transitive dependency joins the package graph even when the app never depends on it directly. ### Config module format `react-native.config.js` may be CommonJS or ESM, and both named and default -exports are read. A key defined twice — as a named export and on the default -export — resolves to the named one. Avoid that shape anyway: the Community CLI -has two loaders that disagree about it, a sync one that sees named exports and +exports are read. A key defined twice, as a named export and on the default +export, resolves to the named one. Avoid that shape anyway. The Community CLI +has two loaders that disagree about it: a sync one that sees named exports, and an async one that takes only the default export. For maximum compatibility, prefer the one-line CommonJS form: @@ -483,33 +576,32 @@ prefer the one-line CommonJS form: module.exports = {dependency: {platforms: {ios: {}}}}; ``` -If the config fails to load, a warning names the file and the reason — any -deprecated `spm` settings in it are ignored rather than silently applied. +If the config fails to load, a warning names the file and the reason. Any +deprecated `spm` settings in it are then ignored rather than silently applied. ## Self-managed community packages -A community library that ships its own `Package.swift` is referenced directly by -the autolinker instead of being wrapped. To keep SwiftPM's package identity -(which it derives from the path basename) unique across deps — even when several -libs put their manifest inside an `ios/` subdir — each self-managed dep is -exposed through a uniquely-named symlink at -`build/generated/autolinking/libs//`. The aggregator `Package.swift` -references that path, so two libs both shipping `/ios/Package.swift` never -collide on identity `"ios"`. +The autolinker references a community library that ships its own `Package.swift` +directly, instead of wrapping it. SwiftPM derives package identity from the path +basename, and several libs may put their manifest inside an `ios/` subdir. To +keep identity unique, each self-managed dep is exposed through a uniquely-named +symlink at `build/generated/autolinking/libs//`. The aggregator +`Package.swift` references that path, so two libs that both ship +`/ios/Package.swift` never collide on identity `"ios"`. -The `libs/` directory is wiped and recreated on every autolinker run, so -deleting a dep via `npm uninstall` cleans up the alias automatically on the next -build. +The `libs/` directory is not recreated on each autolinker run. An alias that +does not change keeps its inode, because Xcode holds each one as a loaded +package root. Aliases for deps that are no longer self-managed are pruned. So +after `npm uninstall` removes a dep, the next build cleans up its alias. ## Community packages without a Package.swift If an autolinked library ships **no `Package.swift`**, `spm add`/`update` stops with a per-dep error (`Package.swift is missing for library ""`) and exits -**2** — a distinct code from a generic failure, so CI and the Xcode sync hooks -can treat it as a hard error while staying lenient about transient sync -failures. +with code **2**. This code is distinct from a generic failure, so CI and the +Xcode sync hooks can treat it as a hard error (see [Auto-Sync](#auto-sync)). -`add` and `update` deliberately **never** scaffold on your behalf: +`add` and `update` deliberately **never** scaffold for you, because auto-scaffolding would hide a real gap in the dependency's SPM support. Generate the manifest from the library's podspec explicitly, then re-run setup: @@ -518,19 +610,21 @@ npx react-native spm scaffold # writes Package.swift into node_modules/ # then commit the generated patch ``` **Better: contribute the manifest upstream.** The generated `Package.swift` is a -normal, committable manifest — the ideal fix is for the library to ship it -itself, so every consumer gets SwiftPM support without a local patch. Please -**file an issue or open a PR on the library** with the scaffolded -`Package.swift` (mention it was generated by `react-native spm scaffold` for -React Native SwiftPM support). Until it lands upstream, the `patch-package` -workaround keeps your app building. - -> A library whose sources mix Swift **and** Objective-C/C++ in one target, or -> that ships neither a `Package.swift` nor a podspec, can't be scaffolded -> automatically — the error says so. Opt it out via `react-native.config.js` -> (`platforms.ios = null`) or ask the maintainer for a prebuilt xcframework. A +normal, committable manifest. The ideal fix is for the library to ship it, so +every consumer gets SwiftPM support without a local patch. Please **file an +issue or open a PR on the library** with the scaffolded `Package.swift`, and +mention that `react-native spm scaffold` generated it for React Native SwiftPM +support. Until it lands upstream, the `patch-package` workaround keeps your app +building. + +> A library can't be scaffolded automatically if its sources mix Swift **and** +> Objective-C/C++ in one target, or if it ships neither a `Package.swift` nor a +> podspec. The error says so. Opt it out via `react-native.config.js` +> (`platforms.ios = null`), or ask the maintainer for a prebuilt xcframework. A > library can also opt out of scaffolding alone with > `"swiftpmConfig": {"scaffold": false}`. ## Framework plugins (Preview) Frameworks with their own module system (e.g. Expo) contribute to the -autolinking graph through a **plugin** — a function invoked on every -regeneration (including the build-time sync) that adds SwiftPM package refs, -product dependencies, and generated sources. Discovery is transitive (installing -the framework is enough), and the plugin returns data that RN merges -idempotently. - -See **[spm-autolinking-plugins.md](./spm-autolinking-plugins.md)** for the -discovery mechanism, the full context/return contract, lifecycle, and failure -behavior. +autolinking graph through a **plugin**. See +**[spm-autolinking-plugins.md](./spm-autolinking-plugins.md)** for discovery, +the context/return contract, lifecycle, and failure behavior. ## Removing / resetting @@ -574,36 +662,45 @@ react-native spm deinit # surgically removes everything `add` injected pod install # then, to restore CocoaPods ``` -To reset the regenerable build state (without un-injecting), just delete the -gitignored dirs and re-run: +`deinit` removes each configuration's base reference to +`ReactNativeSPM/ReactNativeSPM.xcconfig` and deletes the generated scripts. It +deletes the xcconfig too, unless a configuration is based on an xcconfig of your +own. That file may `#include` it, so `deinit` keeps it and names those +configurations. Remove the `#include` and the file yourself. + +To reset the regenerable build state without un-injecting, delete the gitignored +dirs and re-run: ```bash rm -rf build/xcframeworks build/generated .build react-native spm update ``` -Xcode's "Clean Build Folder" (Cmd+Shift+K) only removes DerivedData — it does -not touch SwiftPM-generated directories. The cached xcframework slot is shared +Xcode's "Clean Build Folder" (Cmd+Shift+K) only removes DerivedData. It does not +touch SwiftPM-generated directories. The cached xcframework slot is shared across apps; refresh it with `react-native spm update --download force`. ## Troubleshooting -| Problem | Fix | -| ---------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `xcodebuild` fails: "Could not resolve package dependencies … `build/generated/autolinking` doesn't exist" | Fresh clone — run `npx react-native spm` once before building (see [Fresh clones & CI](#fresh-clones--ci)) | -| `spm add` fails: "CocoaPods-integrated project" | Re-run `spm add --deintegrate` (runs `pod deintegrate` + strips RN from the Podfile), or `pod deintegrate` yourself first. | -| `spm add` fails: "no .xcodeproj found" | Create an app first (`npx @react-native-community/cli init`) or make a project in Xcode, then `spm add`. | -| `spm add` fails: "multiple .xcodeproj found" | Pass `--xcodeproj ` (and `--product-name ` if multiple app targets). | -| `Package.swift is missing for library ""` (exit 2) | The dep ships no SwiftPM support. `npx react-native spm scaffold`, then re-run setup; persist with `patch-package`. See [Community packages without a Package.swift](#community-packages-without-a-packageswift) | -| `SPM Swift name collision` | Two libraries resolved to one Swift name, or one took a name React Native reserves. Set `swiftpmConfig.name` in the library's package.json — see [Library names](#library-names) | -| Missing headers | Re-run `react-native spm` | -| "not contained in target" | Re-run setup (regenerates file-level symlinks) | -| Codegen fails | Use `--skipCodegen` to iterate on other parts | -| "SPM sync failed" warning | Check Xcode build log for details; node may not be in PATH — ensure `with-environment.sh` is present | -| "Sync SPM Autolinking" build phase fails: `'npx --no-install @react-native-community/cli config' exited with status 1` | This app replaces `@react-native-community/cli` autolinking (e.g. an Expo app). Re-run `spm add`/`update` with `--configCommand` (or with `RCT_SPM_AUTOLINKING_CONFIG_COMMAND` exported) so the working command is pinned for the build phase to reuse — see [The autolinking config command is remembered](#the-autolinking-config-command-is-remembered). | -| Autolinking not updating on build | Touch `package.json` to force a sync, or delete `build/generated/autolinking/.spm-sync-stamp` | -| Stale SwiftPM state or corrupted build | `rm -rf build/ .build/`, then `react-native spm update`, then reopen Xcode | -| Want to revert to CocoaPods | `react-native spm deinit`, then `pod install` | +| Problem | Fix | +| ---------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `xcodebuild` fails: "Could not resolve package dependencies … `build/generated/autolinking` doesn't exist" | Fresh clone — run `npx react-native spm` once before building (see [Fresh clones & CI](#fresh-clones--ci)) | +| Build fails: `error: …/ReactNativeSPM/Scripts/.sh is missing` | `ReactNativeSPM/` was not committed. Run `npx react-native spm update`, then commit `ReactNativeSPM/` | +| `spm add` fails: "CocoaPods-integrated project" | Re-run `spm add --deintegrate` (runs `pod deintegrate` + strips RN from the Podfile), or `pod deintegrate` yourself first. | +| `spm add` fails: "no .xcodeproj found" | Create an app first (`npx @react-native-community/cli init`) or make a project in Xcode, then `spm add`. | +| `spm add` fails: "multiple .xcodeproj found" | Pass `--xcodeproj ` (and `--productName ` if multiple app targets). | +| `Package.swift is missing for library ""` (exit 2) | The dep ships no SwiftPM support. `npx react-native spm scaffold`, then re-run setup; persist with `patch-package`. See [Community packages without a Package.swift](#community-packages-without-a-packageswift) | +| ` () is a precompiled framework from an autolinking plugin, but the Xcode project does not link it` (exit 2) | A plugin provides a framework on this machine that the committed project does not link. Run `npx react-native spm update` and commit the project: every machine on the team then has to precompile the framework. | +| `The Xcode project links .framework, but no autolinking plugin provides it on this machine` (exit 2) | The committed project links a framework that this machine has not precompiled. Running `npx react-native spm update` removes the framework from the committed project for the whole team. Instead, precompile it, then run `npx react-native spm sync`. | +| `SPM Swift name collision` | Two libraries resolved to one Swift name, or one took a name React Native reserves. Set `swiftpmConfig.name` in the library's package.json — see [Library names](#library-names) | +| Missing headers | Re-run `react-native spm` | +| "not contained in target" | Re-run setup (regenerates file-level symlinks) | +| Codegen fails | Use `--skipCodegen` to iterate on other parts | +| "SPM sync failed" warning | Check Xcode build log for details; node may not be in PATH — ensure `with-environment.sh` is present | +| "Sync SPM Autolinking" build phase fails: `'npx --no-install @react-native-community/cli config' exited with status 1` | This app replaces `@react-native-community/cli` autolinking (e.g. an Expo app). Re-run `spm add`/`update` with `RCT_SPM_AUTOLINKING_CONFIG_COMMAND` exported (or the raw script with `--config-command`) so the working command is pinned for the build phase to reuse — see [The autolinking config command is remembered](#the-autolinking-config-command-is-remembered). | +| Autolinking not updating on build | Touch `package.json` to force a sync, or delete `build/generated/autolinking/.spm-sync-stamp` | +| Stale SwiftPM state or corrupted build | `rm -rf build/ .build/`, then `react-native spm update`, then reopen Xcode | +| Want to revert to CocoaPods | `react-native spm deinit`, then `pod install` | --- @@ -613,28 +710,31 @@ 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 and the embed/sign phase in the project, plus `ReactNativeSPM/` with the configuration-qualified linker settings and the phase scripts | +| Auto-sync | `spm/sync-spm-autolinking.js` | Re-runs invariant codegen/autolinking output only at Xcode build time | ### Directory Layout ```text my-app/ios/ MyApp.xcodeproj/ <-- committed (your project; SwiftPM injected in place, carries .spm-injected.json) - Podfile <-- present until `pod deintegrate` (CocoaPods coexistence is best-effort) + ReactNativeSPM/ <-- committed, rewritten by add/update + ReactNativeSPM.xcconfig <-- React build settings; base configuration of the app configurations + Scripts/ <-- scripts the Sync and embed phases run + Podfile <-- kept; `--deintegrate` only strips the React Native lines (CocoaPods coexistence is best-effort) build/ generated/ autolinking/ <-- gitignored (regenerated at build time) Package.swift autolinking.json - packages/ <-- synth wrappers for autolinker-managed deps + packages/ <-- synth wrappers for swiftpmConfig.modules (local modules) libs/ <-- symlinks to self-managed deps' Package.swift dirs, named by Swift module so SwiftPM package identity stays unique @@ -658,44 +758,23 @@ my-app/ios/ ### Header Resolution -React Native uses CocoaPods-style imports (`#import `) that -SwiftPM doesn't natively support. The prebuilt artifacts serve them through -SwiftPM package products — no `-I` search-path flags, and no clang VFS overlay: - -1. **`` and `import React`** resolve through the invariant - **`ReactHeaders` Clang target**. It stages one canonical header copy after - proving Debug and Release expose identical public headers, and uses a plain - `module React` module map with `React/`-prefixed paths. -2. **Lowercase C++ `react/` and every other RN namespace** (`yoga/`, `jsi/`, - `jsinspector-modern`, …) comes from **`ReactNativeHeaders.xcframework`**, a - headers-only (LIBRARY-type) binaryTarget whose per-slice `Headers/` SwiftPM - auto-serves to dependents. -3. **Third-party dependency namespaces** (`folly/`, `glog/`, `boost/`, `fmt/`, - `double-conversion/`, `fast_float/`, `SocketRocket/`) come from - **`ReactNativeDependenciesHeaders.xcframework`**, the deps headers-only - sidecar (same mechanism — the binary `ReactNativeDependencies.xcframework` is - framework-type and can't expose those headers to SwiftPM). - -Targets that compile against React take these as product dependencies -(`ReactHeaders`, `ReactNativeHeaders`, `ReactNativeDependenciesHeaders`, plus -the app's `ReactAppHeaders`), so all of the above resolve with zero search-path -flags. +React Native's headers resolve through SwiftPM package products, with no `-I` +search-path flags and no clang VFS overlay. See +[How headers resolve](./spm-header-paths-contract.md#how-headers-resolve-no-search-paths) +for the products, the namespaces each one serves, and why. ### Auto-Sync -Autolinking is kept up to date without manual re-runs of `react-native spm` by -**two hooks running the same sync script**, injected by `add`/`update`: +`add`/`update` inject **two hooks that run the same sync script**. They keep +autolinking up to date without manual re-runs of `react-native spm`: | Hook | Where | Role | | ---------------------------------- | ------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- | | Scheme pre-action | The app's **shared** scheme (`xcshareddata/xcschemes/`), under `BuildAction` → `PreActions` | Fires earlier in the build than a build phase can, so it is the one that normally does the re-sync. | -| `Sync SPM Autolinking` build phase | `.xcodeproj`, prepended before `Sources` | **Safety net** for builds that bypass the scheme (and for a scheme whose pre-action was stripped). | +| `Sync SPM Autolinking` build phase | `.xcodeproj`, prepended before `Sources`; runs `ReactNativeSPM/Scripts/sync-autolinking.sh` | **Safety net** for builds that bypass the scheme (and for a scheme whose pre-action was stripped). | -Neither hook can bootstrap a clean checkout. Xcode resolves the Swift package -graph before build phases **and** before scheme pre-actions, so if the generated -packages are missing entirely, resolution fails and the build stops before -either hook runs — see [Fresh clones & CI](#fresh-clones--ci). The hooks keep an -_existing_ set of generated packages current; they do not create the first one. +The hooks keep an _existing_ set of generated packages current. They do not +create the first one: see [Fresh clones & CI](#fresh-clones--ci). **How the sync script works:** @@ -703,19 +782,37 @@ _existing_ set of generated packages current; they do not create the first one. `build/generated/autolinking/.spm-sync-stamp`: - `package.json` — dependency declarations - `react-native.config.js` — autolinking config + - `MyApp.xcodeproj/.spm-injected.json` — the frameworks the project links, so + a pull that changes them re-checks them + - lockfiles found walking up from the project root — `package-lock.json`, + `npm-shrinkwrap.json`, `yarn.lock`, `pnpm-lock.yaml`, `bun.lock`, + `bun.lockb`, `.pnp.cjs`, `.pnp.loader.mjs` - `node_modules/` directory mtime — updated by any package manager (npm, yarn, pnpm, bun); also checks parent `node_modules` for monorepo setups - - a missing `build/xcframeworks/` (e.g. after a manual clean) also marks - stale - every path in `.spm-sync-watch-paths` — RN's own inputs plus any [plugin](./spm-autolinking-plugins.md#watchpaths--plugin-staleness-inputs) `watchPaths`; a watched file that is newer, a watched dir with a newer - child, or a watched path that has **vanished** all mark stale -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). + descendant (the scan skips `.swiftpm`), or a watched path that has + **vanished** all mark stale + - the commit time of the latest `git log` entry touching `*.js` / `*.ts`, + compared with the stamp's mtime + - every path in `.spm-sync-watch-absent` — watched paths that did not exist + at the last sync; one that **appears** marks stale. This includes each + dependency's root `Package.swift` and `.react-native/`: one that appears + later (e.g. after `spm scaffold`) changes self-managed detection. Projects + get this check after running `npx react-native spm update` once +2. If the last sync recorded a plugin framework mismatch in + `build/generated/autolinking/.spm-plugin-mismatch` and no input changed since + (the marker and the plugin manifest included): prints the recorded `error:` + lines and fails the build without re-running sync. +3. If any input is newer (or the stamp is missing): runs + `"$NODE_BINARY" "$RN_DIR/scripts/setup-apple-spm.js" sync`. It falls back to + `npx react-native spm sync` only when that script or `NODE_BINARY` is + missing. `sync` regenerates `autolinking.json` (CLI config), runs codegen, + installs the codegen template, re-runs autolinking, rebuilds the header farm, + and writes the stamp file. It does not download artifacts or regenerate + `build/xcframeworks/`. +4. If all inputs are fresh: exits immediately (~1ms). **Ordering.** As observed in an `xcodebuild -scheme … build` log on Xcode 26.6: @@ -732,21 +829,33 @@ _existing_ set of generated packages current; they do not create the first one. | Resources (copy) | build phase 5 | | Build JS Bundle | build phase 6 | -Resolution coming first is what makes the one-time setup run necessary on a -clean checkout; it is not something either hook can work around. - -A sync failure is lenient by default but **not unconditionally**. The generated +A sync failure is lenient by default, but **not unconditionally**. The generated script branches on the exit code: -- **Exit 2** — an autolinked dependency ships no `Package.swift`. This **fails - the build** (`exit 1`), deliberately: the autolinker has already printed an - `error:` line per dep, and the fix needs a terminal (see - [Community packages without a Package.swift](#community-packages-without-a-packageswift)). +- **Exit 2** — **fails the build** (`exit 1`), whatever the cause. Four errors + exit with 2: + - an autolinked dependency ships no `Package.swift` — the autolinker has + already printed an `error:` line per dep, and the fix needs a terminal (see + [Community packages without a Package.swift](#community-packages-without-a-packageswift)); + - the autolinking config command fails (see + [The autolinking config command is remembered](#the-autolinking-config-command-is-remembered)); + - remote mode is on but no usable React Native version resolves (see + [Remote-package mode](./spm-header-paths-contract.md#remote-package-mode)); + - The precompiled frameworks that autolinking plugins provide differ from the + frameworks that the Xcode project links (see + [Troubleshooting](#troubleshooting)). Sync writes the stamp and records the + `error:` lines in `.spm-plugin-mismatch`. When the scheme pre-action ran + that sync, the build phase prints the recorded lines instead of running sync + again. Later builds also print them without a sync, until an input changes. + `spm update` changes inputs, so the next build syncs. If precompiling a + framework changes no watched input, run `npx react-native spm sync` after + it. A sync that passes the check deletes the file. - **Any other non-zero exit** — emits `warning: SPM sync failed — build may use stale codegen/autolinking` and lets the build continue, so an already-generated package graph can still produce a successful build. -That split is the whole reason the missing-manifest case has its own exit code: -a transient sync hiccup should not break a build that could still succeed, while -a genuinely missing manifest should not pass silently. +These errors have their own exit code because a transient sync hiccup should not +break a build that could still succeed, while a missing manifest, an out-of-date +project, a broken config command, or an unresolvable remote version should not +pass silently. diff --git a/packages/react-native/scripts/spm/__tests__/generate-spm-autolinking-test.js b/packages/react-native/scripts/spm/__tests__/generate-spm-autolinking-test.js index aa2d2170ec73..245b51a800e3 100644 --- a/packages/react-native/scripts/spm/__tests__/generate-spm-autolinking-test.js +++ b/packages/react-native/scripts/spm/__tests__/generate-spm-autolinking-test.js @@ -1879,7 +1879,7 @@ describe('main() — scriptPhases sidecar', () => { // and deduped/sorted: (1) each module's source DIR; (2) each npm dep's // checked-in root Package.swift (FILE) and .react-native/ (DIR) — threaded from // the autolinking model's dep root, not derived by walking up; (3) plugin -// watchPaths. Nonexistent paths are filtered at emission time. +// watchPaths. Nonexistent paths go to .spm-sync-watch-absent instead. // --------------------------------------------------------------------------- describe('main() — .spm-sync-watch-paths emission', () => { @@ -1920,8 +1920,8 @@ describe('main() — .spm-sync-watch-paths emission', () => { fs.writeFileSync(path.join(fooDir, 'Foo.swift'), '// src\n'); // (C) A plugin-host dep contributing watchPaths: one existing absolute path - // (kept), one absent absolute path (filtered at emission), one relative - // path (dropped by invokePlugins). + // (kept), one absent absolute path (listed as absent), one relative path + // (dropped by invokePlugins). const expoDir = path.join(appRoot, 'node_modules', 'expo'); fs.mkdirSync(path.join(expoDir, 'ios'), {recursive: true}); fs.writeFileSync(path.join(expoDir, 'ios', 'Expo.mm'), '// native\n'); @@ -1940,7 +1940,7 @@ describe('main() — .spm-sync-watch-paths emission', () => { " productDependencies: [{name: 'ExpoModulesCore', package: 'ExpoModulesCore'}],", ' watchPaths: [', " path.join(__dirname, 'Package.swift'),", // exists → kept - " path.join(__dirname, 'MISSING.swift'),", // absent → filtered at emission + " path.join(__dirname, 'MISSING.swift'),", // absent → listed as absent " 'rel/manifest',", // relative → dropped by invokePlugins ' ],', ' };', @@ -1948,6 +1948,14 @@ describe('main() — .spm-sync-watch-paths emission', () => { ].join('\n') + '\n', ); + // (D) The app's project file: Xcode rewrites it on every IDE edit, so + // watching it would re-sync after each one. + fs.mkdirSync(path.join(appRoot, 'App.xcodeproj')); + fs.writeFileSync( + path.join(appRoot, 'App.xcodeproj', 'project.pbxproj'), + '\n', + ); + const autolinkDir = path.join(appRoot, 'build', 'generated', 'autolinking'); fs.mkdirSync(autolinkDir, {recursive: true}); fs.writeFileSync( @@ -1972,6 +1980,9 @@ describe('main() — .spm-sync-watch-paths emission', () => { // (C) plugin's existing absolute watchPath is kept. expect(lines).toContain(path.join(expoDir, 'Package.swift')); + // (D) the app's project file is not watched. + expect(lines.some(l => l.includes('project.pbxproj'))).toBe(false); + // Filtered / dropped entries never reach the file. expect(lines.some(l => l.includes('MISSING.swift'))).toBe(false); expect(lines.some(l => l.includes('rel/manifest'))).toBe(false); @@ -1979,6 +1990,54 @@ describe('main() — .spm-sync-watch-paths emission', () => { // Deduped and sorted (stable, deterministic output). expect(new Set(lines).size).toBe(lines.length); expect([...lines].sort()).toEqual(lines); + + expect( + fs.readFileSync(path.join(autolinkDir, '.spm-sync-watch-absent'), 'utf8'), + ).toBe(path.join(expoDir, 'MISSING.swift') + '\n'); + }); + + it('lists a dep root without Package.swift or .react-native/ as absent', () => { + const appRoot = fs.realpathSync( + fs.mkdtempSync(path.join(os.tmpdir(), 'spm-watch-absent-')), + ); + created.push(appRoot); + const rnRoot = path.join(appRoot, 'rn'); + fs.mkdirSync(rnRoot, {recursive: true}); + fs.writeFileSync( + path.join(appRoot, 'package.json'), + JSON.stringify({name: 'app'}), + ); + + // Self-managed through ios/Package.swift, so nothing exists at the root. + const barDir = path.join(appRoot, 'node_modules', 'react-native-bar'); + fs.mkdirSync(path.join(barDir, 'ios'), {recursive: true}); + fs.writeFileSync( + path.join(barDir, 'ios', 'Package.swift'), + '// swift-tools-version:5.9\n// hand-authored\n', + ); + fs.writeFileSync(path.join(barDir, 'ios', 'Bar.swift'), '// src\n'); + + const autolinkDir = path.join(appRoot, 'build', 'generated', 'autolinking'); + fs.mkdirSync(autolinkDir, {recursive: true}); + fs.writeFileSync( + path.join(autolinkDir, 'autolinking.json'), + JSON.stringify({ + dependencies: { + 'react-native-bar': {root: barDir, platforms: {ios: {}}}, + }, + }), + ); + + main(['--app-root', appRoot, '--react-native-root', rnRoot]); + + expect( + fs.readFileSync(path.join(autolinkDir, '.spm-sync-watch-absent'), 'utf8'), + ).toBe( + [ + path.join(barDir, '.react-native'), + path.join(barDir, 'Package.swift'), + ].join('\n') + '\n', + ); }); }); diff --git a/packages/react-native/scripts/spm/__tests__/generate-spm-xcodeproj-test.js b/packages/react-native/scripts/spm/__tests__/generate-spm-xcodeproj-test.js index f5b1145edf3a..1aa0e6d55d5d 100644 --- a/packages/react-native/scripts/spm/__tests__/generate-spm-xcodeproj-test.js +++ b/packages/react-native/scripts/spm/__tests__/generate-spm-xcodeproj-test.js @@ -17,10 +17,12 @@ const { buildSyncAutolinkingScript, flavorForBuildConfiguration, frameworkConditionalSettings, + generateReactNativeXcconfig, generateXcscheme, readScriptPhasesManifest, } = require('../generate-spm-xcodeproj'); -const {execFileSync} = require('node:child_process'); +const {DOMParser} = require('@xmldom/xmldom'); +const {execFileSync, spawnSync} = require('node:child_process'); const fs = require('node:fs'); const os = require('node:os'); const path = require('node:path'); @@ -137,6 +139,40 @@ describe('scheme pre-action', () => { expect(result).toContain('<'); }); + function parsedSyncScript(xml) { + return new DOMParser() + .parseFromString(xml, 'text/xml') + .getElementsByTagName('ActionContent')[0] + .getAttribute('scriptText'); + } + + const MULTILINE_SCRIPT = 'set -e\n\tif [ "$A" ]; then\r\n a && b < c\nfi'; + + it('keeps line breaks and tabs when an XML parser reads the script', () => { + const result = generateXcscheme( + 'MyApp', + 'TARGET_UUID', + 'MyApp', + MULTILINE_SCRIPT, + ); + expect(parsedSyncScript(result)).toBe(MULTILINE_SCRIPT); + }); + + it('encodes line breaks when it refreshes a script written with raw ones', () => { + const legacy = generateXcscheme( + 'MyApp', + 'TARGET_UUID', + 'MyApp', + 'OLD', + ).replace('scriptText = "OLD"', 'scriptText = "old\n# line 2"'); + const updated = addPreActionToScheme( + legacy, + 'TARGET_UUID', + MULTILINE_SCRIPT, + ); + expect(parsedSyncScript(updated)).toBe(MULTILINE_SCRIPT); + }); + it('refreshes stale script text and is idempotent', () => { const first = generateXcscheme( 'MyApp', @@ -298,6 +334,217 @@ describe('sync scripts', () => { }); }); + it('re-syncs when a path listed as absent appears', () => { + const loop = /^ABSENT_FILE=[\s\S]*?^fi$/m.exec(script)?.[0]; + expect(loop).toBeDefined(); + const probe = srcRoot => + execFileSync( + '/bin/bash', + [ + '-c', + `set -euo pipefail\nSRCROOT="$1"\nSTALE=0\n${String(loop)}\necho "$STALE"\n`, + 'probe', + srcRoot, + ], + {encoding: 'utf8'}, + ).trim(); + + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'spm-sync-absent-')); + try { + const autolinkDir = path.join(root, 'build/generated/autolinking'); + fs.mkdirSync(autolinkDir, {recursive: true}); + const watched = path.join(root, 'precompiled'); + fs.mkdirSync(watched); + expect(probe(root)).toBe('0'); + + fs.writeFileSync( + path.join(autolinkDir, '.spm-sync-watch-absent'), + `${watched}\n`, + ); + expect(probe(root)).toBe('1'); + + fs.rmdirSync(watched); + expect(probe(root)).toBe('0'); + } finally { + fs.rmSync(root, {recursive: true, force: true}); + } + }); + + describe('stale inputs and a recorded plugin framework mismatch', () => { + const MISMATCH = + 'error: The Xcode project links ExpoCore.framework, but no autolinking plugin provides it on this machine.'; + + let root; + let srcRoot; + let xcodeprojPath; + let generatedDir; + let fakeNode; + let rnDir; + let syncLog; + + // Does to the generated dir what a real sync does: on a mismatch it prints + // the errors, records them, refreshes the stamp, and exits 2. + const FAKE_NODE = `#!/bin/bash +if [ "$1" = "--print" ]; then + echo "$FAKE_RN_DIR" + exit 0 +fi +echo sync >> "$FAKE_SYNC_LOG" +DIR="$SRCROOT/build/generated/autolinking" +date > "$DIR/.spm-sync-stamp" +if [ -n "$FAKE_MISMATCH" ]; then + echo "$FAKE_MISMATCH" >&2 + echo "$FAKE_MISMATCH" > "$DIR/.spm-plugin-mismatch" + exit 2 +fi +rm -f "$DIR/.spm-plugin-mismatch" +`; + + const setTime = (file, secondsFromNow) => { + const time = Date.now() / 1000 + secondsFromNow; + fs.utimesSync(file, time, time); + }; + + beforeEach(() => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'spm-sync-mismatch-')); + srcRoot = path.join(root, 'app'); + xcodeprojPath = path.join(srcRoot, 'MyApp.xcodeproj'); + generatedDir = path.join(srcRoot, 'build', 'generated', 'autolinking'); + rnDir = path.join(root, 'react-native'); + fs.mkdirSync(xcodeprojPath, {recursive: true}); + fs.mkdirSync(generatedDir, {recursive: true}); + fs.mkdirSync(path.join(rnDir, 'scripts'), {recursive: true}); + fs.writeFileSync(path.join(rnDir, 'scripts', 'setup-apple-spm.js'), ''); + fs.writeFileSync(path.join(srcRoot, 'package.json'), '{}\n'); + fs.writeFileSync(path.join(xcodeprojPath, '.spm-injected.json'), '{}\n'); + fs.writeFileSync( + path.join(generatedDir, '.spm-plugin-flavored-frameworks.json'), + '[]\n', + ); + for (const file of [ + path.join(srcRoot, 'package.json'), + path.join(xcodeprojPath, '.spm-injected.json'), + path.join(generatedDir, '.spm-plugin-flavored-frameworks.json'), + ]) { + setTime(file, -100); + } + fakeNode = path.join(root, 'node'); + fs.writeFileSync(fakeNode, FAKE_NODE, {mode: 0o755}); + syncLog = path.join(root, 'syncs.log'); + }); + + afterEach(() => { + fs.rmSync(root, {recursive: true, force: true}); + }); + + function build({mismatch}) { + fs.writeFileSync(syncLog, ''); + const result = spawnSync('/bin/bash', ['-c', script], { + encoding: 'utf8', + env: { + PATH: process.env.PATH, + SRCROOT: srcRoot, + PROJECT_FILE_PATH: xcodeprojPath, + NODE_BINARY: fakeNode, + FAKE_RN_DIR: rnDir, + FAKE_SYNC_LOG: syncLog, + FAKE_MISMATCH: mismatch ? MISMATCH : '', + }, + }); + const output = result.stdout + result.stderr; + return { + status: result.status, + errors: output.split(MISMATCH).length - 1, + syncs: fs.readFileSync(syncLog, 'utf8').split('sync').length - 1, + }; + } + + function writeStamp(secondsFromNow) { + const stamp = path.join(generatedDir, '.spm-sync-stamp'); + fs.writeFileSync(stamp, 'earlier sync\n'); + setTime(stamp, secondsFromNow); + } + + it('syncs when the marker is newer than the stamp', () => { + writeStamp(-50); + setTime(path.join(xcodeprojPath, '.spm-injected.json'), -10); + expect(build({mismatch: false})).toEqual({ + status: 0, + errors: 0, + syncs: 1, + }); + }); + + it('does not sync when only project.pbxproj is newer than the stamp', () => { + writeStamp(-50); + const pbxprojPath = path.join(xcodeprojPath, 'project.pbxproj'); + fs.writeFileSync(pbxprojPath, '// !$*UTF8*$!\n'); + setTime(pbxprojPath, -10); + expect(build({mismatch: false})).toEqual({ + status: 0, + errors: 0, + syncs: 0, + }); + }); + + it('passes the next build after the recovery sync clears the mismatch', () => { + build({mismatch: true}); + // `npx react-native spm sync`, run after precompiling the framework. + spawnSync(fakeNode, ['setup-apple-spm.js', 'sync'], { + env: {SRCROOT: srcRoot, FAKE_SYNC_LOG: syncLog, FAKE_MISMATCH: ''}, + }); + expect(build({mismatch: false})).toEqual({ + status: 0, + errors: 0, + syncs: 0, + }); + }); + + it('fails the first build and prints the errors once', () => { + expect(build({mismatch: true})).toEqual({ + status: 1, + errors: 1, + syncs: 1, + }); + }); + + it('fails later builds with the recorded errors without re-running sync', () => { + build({mismatch: true}); + expect(build({mismatch: true})).toEqual({ + status: 1, + errors: 1, + syncs: 0, + }); + }); + + it.each([ + [ + 'the marker changes', + () => path.join(xcodeprojPath, '.spm-injected.json'), + ], + [ + 'spm update rewrites the plugin manifest', + () => path.join(generatedDir, '.spm-plugin-flavored-frameworks.json'), + ], + ['a dependency input changes', () => path.join(srcRoot, 'package.json')], + ])('re-runs sync when %s', (_, changedFile) => { + build({mismatch: true}); + setTime(path.join(generatedDir, '.spm-sync-stamp'), -50); + setTime(path.join(generatedDir, '.spm-plugin-mismatch'), -50); + setTime(changedFile(), -10); + expect(build({mismatch: false})).toEqual({ + status: 0, + errors: 0, + syncs: 1, + }); + expect(build({mismatch: false})).toEqual({ + status: 0, + errors: 0, + syncs: 0, + }); + }); + }); + it('is deterministic, shared with the pre-action, and valid POSIX shell', () => { expect(buildSyncAutolinkingScript(baked)).toBe(script); expect(buildSchemePreActionScript(baked)).toBe(script); @@ -358,6 +605,99 @@ describe('configuration-driven framework settings', () => { }); }); +describe('generateReactNativeXcconfig', () => { + const DEVICE_ONLY = {...FRAMEWORK, slices: [FRAMEWORK.slices[0]]}; + + it('writes every setting the app target needs, extending inherited lists', () => { + expect( + generateReactNativeXcconfig( + ['Debug', 'Release'], + '../node_modules/react-native', + [DEVICE_ONLY], + ), + ).toBe( + `// Generated by React Native on every \`spm add\` and \`spm update\`. +// Do not edit: changes are overwritten. + +CLANG_CXX_LANGUAGE_STANDARD = c++20 +REACT_NATIVE_PATH = $(SRCROOT)/../node_modules/react-native + +HEADER_SEARCH_PATHS = $(inherited) "$(SRCROOT)/build/generated/autolinking/headers" +OTHER_LDFLAGS = $(inherited) -ObjC "$(RN_SPM_REACT_BINARY)" +FRAMEWORK_SEARCH_PATHS = $(inherited) "$(RN_SPM_REACT_SEARCH_PATH)" +LD_RUNPATH_SEARCH_PATHS = $(inherited) @executable_path/Frameworks + +RN_SPM_FLAVOR = release +RN_SPM_FLAVOR[config=Debug] = debug +SWIFT_ACTIVE_COMPILATION_CONDITIONS[config=Debug] = $(inherited) DEBUG +RN_SPM_FLAVOR[config=Release] = release + +RN_SPM_REACT_FRAMEWORK[sdk=iphoneos*] = $(SRCROOT)/build/xcframeworks/$(RN_SPM_FLAVOR)/React.xcframework/ios-arm64/React.framework +RN_SPM_REACT_BINARY[sdk=iphoneos*] = $(SRCROOT)/build/xcframeworks/$(RN_SPM_FLAVOR)/React.xcframework/ios-arm64/React.framework/React +RN_SPM_REACT_SEARCH_PATH[sdk=iphoneos*] = $(SRCROOT)/build/xcframeworks/$(RN_SPM_FLAVOR)/React.xcframework/ios-arm64 +`, + ); + }); + + it('anchors REACT_NATIVE_PATH on SRCROOT itself when react-native sits there', () => { + expect(generateReactNativeXcconfig(['Debug'], '', [FRAMEWORK])).toContain( + '\nREACT_NATIVE_PATH = $(SRCROOT)\n', + ); + }); + + it("conditions the flavor on the project's own configuration names", () => { + const xcconfig = generateReactNativeXcconfig( + ['Development', 'Staging'], + '../node_modules/react-native', + [FRAMEWORK], + ); + expect(xcconfig).toContain('RN_SPM_FLAVOR[config=Development] = debug\n'); + expect(xcconfig).toContain( + 'SWIFT_ACTIVE_COMPILATION_CONDITIONS[config=Development] = $(inherited) DEBUG\n', + ); + expect(xcconfig).toContain('RN_SPM_FLAVOR[config=Staging] = release\n'); + expect(xcconfig).not.toContain( + 'SWIFT_ACTIVE_COMPILATION_CONDITIONS[config=Staging]', + ); + expect(xcconfig).not.toMatch(/\[config=(Debug|Release)\]/); + }); + + // A configuration duplicated in Xcode keeps the base reference but has no + // line of its own until the next sync, and an unset flavor would resolve + // every framework path to nothing. + it('falls back to the release flavor for a configuration it does not list', () => { + const lines = generateReactNativeXcconfig( + ['Debug', 'Release'], + '../node_modules/react-native', + [FRAMEWORK], + ).split('\n'); + const fallback = lines.indexOf('RN_SPM_FLAVOR = release'); + expect(fallback).toBeGreaterThan(-1); + expect(fallback).toBeLessThan( + lines.findIndex(line => line.startsWith('RN_SPM_FLAVOR[config=')), + ); + expect( + lines.some(line => + line.startsWith('SWIFT_ACTIVE_COMPILATION_CONDITIONS ='), + ), + ).toBe(false); + }); + + it('writes no plist quoting around conditional keys or path values', () => { + const xcconfig = generateReactNativeXcconfig( + ['Debug'], + '../node_modules/react-native', + [FRAMEWORK], + ); + expect(xcconfig).toContain( + '\nRN_SPM_REACT_BINARY[sdk=iphonesimulator*] = $(SRCROOT)/', + ); + expect(xcconfig).toContain('\nRN_SPM_REACT_SEARCH_PATH[sdk=macosx*] = '); + expect(xcconfig).not.toMatch(/^"/m); + expect(xcconfig).not.toContain('= "$(SRCROOT)/build/xcframeworks'); + }); +}); + describe('embed framework phase script', () => { const script = buildEmbedFrameworksScript([FRAMEWORK]); diff --git a/packages/react-native/scripts/spm/__tests__/inject-spm-xcodeproj-test.js b/packages/react-native/scripts/spm/__tests__/inject-spm-xcodeproj-test.js index 3d4093c9334c..85840bd0a247 100644 --- a/packages/react-native/scripts/spm/__tests__/inject-spm-xcodeproj-test.js +++ b/packages/react-native/scripts/spm/__tests__/inject-spm-xcodeproj-test.js @@ -12,11 +12,15 @@ const { buildPhaseOrder, + buildSyncAutolinkingScript, + generateReactNativeXcconfig, injectSpmIntoPbxproj, planInjection, } = require('../generate-spm-xcodeproj'); +const {findField, findObjectByUuid, quoteIfNeeded} = require('../spm-pbxproj'); const {isBalanced} = require('./pbxproj-oracles'); const fs = require('node:fs'); +const os = require('node:os'); const path = require('node:path'); const PLAIN = fs.readFileSync( @@ -56,17 +60,41 @@ function buildSettingsOf(text, configUuid) { ); return text.slice(open, text.indexOf('};', open)); } -// Derive a variant whose app-target configs already carry HEADER_SEARCH_PATHS, -// set to any valid pbxproj value: a plain scalar (which injection promotes to an -// array) or an array injection appends to. -function withHeaderSearchPaths(value) { +// Seed a whole `KEY = value;` field into both app-target configs. +function withAppSetting(field) { return PLAIN.replaceAll( 'PRODUCT_BUNDLE_IDENTIFIER = com.example.MyApp;', - `HEADER_SEARCH_PATHS = ${value};\n\t\t\t\tPRODUCT_BUNDLE_IDENTIFIER = com.example.MyApp;`, + `${field}\n\t\t\t\tPRODUCT_BUNDLE_IDENTIFIER = com.example.MyApp;`, ); } +function withHeaderSearchPaths(value) { + return withAppSetting(`HEADER_SEARCH_PATHS = ${value};`); +} + +// The app target's Debug config already based on an xcconfig of the user's own. +const FOREIGN_XCCONFIG = PLAIN.replace( + DEBUG_CONFIG_HEAD, + 'AA0000000000000000000901 /* Debug */ = {\n\t\t\tisa = XCBuildConfiguration;\n\t\t\tbaseConfigurationReference = CC0000000000000000000001 /* App.xcconfig */;\n\t\t\tbuildSettings = {', +).replace( + '/* End PBXFileReference section */', + '\t\tCC0000000000000000000001 /* App.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Config/App.xcconfig; sourceTree = ""; };\n/* End PBXFileReference section */', +); + +const XCCONFIG_PATH = 'ReactNativeSPM/ReactNativeSPM.xcconfig'; +const SYNC_SCRIPT_PATH = 'ReactNativeSPM/Scripts/sync-autolinking.sh'; +const EMBED_SCRIPT_PATH = 'ReactNativeSPM/Scripts/embed-flavored-frameworks.sh'; + +// The raw (still plist-quoted) shellScript value of the phase labelled `label`. +function shellScriptOf(text, label) { + const uuid = new RegExp(`([0-9A-F]{24}) /\\* ${label} \\*/ = \\{`).exec( + text, + )[1]; + return findField(text, findObjectByUuid(text, uuid), 'shellScript').value; +} + const RN_PATH = '../node_modules/react-native'; +const RN_PATHS = {fromAppRoot: RN_PATH, fromSrcRoot: RN_PATH}; const TEST_FRAMEWORKS = [ { id: 'react', @@ -117,7 +145,7 @@ function inject( frameworksPhaseUuid: plan.frameworksPhaseUuid, sourcesPhaseUuid: plan.sourcesPhaseUuid, }, - RN_PATH, + RN_PATHS, remote, generatedSources, TEST_FRAMEWORKS, @@ -179,6 +207,233 @@ describe('planInjection', () => { expect(plan.ok).toBe(false); expect(plan.reason).toMatch(/no application target/); }); + + // The generated xcconfig sits below the target's own settings, so a list + // setting the target sets without $(inherited) hides React Native's. + it.each([ + ['HEADER_SEARCH_PATHS', '"$(SRCROOT)/Vendor"'], + ['OTHER_LDFLAGS', '(\n\t\t\t\t\t"-lc++",\n\t\t\t\t)'], + ['FRAMEWORK_SEARCH_PATHS', '""'], + ['LD_RUNPATH_SEARCH_PATHS', '"@executable_path/Frameworks"'], + ])('refuses %s set without $(inherited), naming it', (key, value) => { + const plan = planInjection(withAppSetting(`${key} = ${value};`), {}); + expect(plan.ok).toBe(false); + expect(plan.reason).toContain(`${key} (Debug, Release)`); + expect(plan.reason).toContain('$(inherited)'); + }); + + it('refuses SWIFT_ACTIVE_COMPILATION_CONDITIONS without $(inherited) on a debug configuration', () => { + const plan = planInjection(withDebugCondition(PLAIN, 'MY_FLAG'), {}); + expect(plan.ok).toBe(false); + expect(plan.reason).toContain( + 'SWIFT_ACTIVE_COMPILATION_CONDITIONS (Debug)', + ); + }); + + it('accepts SWIFT_ACTIVE_COMPILATION_CONDITIONS without $(inherited) on a release configuration, which gets none', () => { + const releaseHead = DEBUG_CONFIG_HEAD.replace( + 'AA0000000000000000000901 /* Debug */', + 'AA00000000000000000000A2 /* Release */', + ); + const text = PLAIN.replace( + releaseHead, + `${releaseHead}\n\t\t\t\tSWIFT_ACTIVE_COMPILATION_CONDITIONS = MY_FLAG;`, + ); + expect(text).not.toBe(PLAIN); + expect(planInjection(text, {}).ok).toBe(true); + }); + + it.each([ + ['a scalar', '"$(inherited) $(SRCROOT)/Vendor"'], + [ + 'an array', + '(\n\t\t\t\t\t"$(inherited)",\n\t\t\t\t\t"$(SRCROOT)/Vendor",\n\t\t\t\t)', + ], + ['the brace form', '"${inherited} $(SRCROOT)/Vendor"'], + ])( + 'accepts a list setting that keeps $(inherited) in %s', + (_label, value) => { + expect(planInjection(withHeaderSearchPaths(value), {}).ok).toBe(true); + }, + ); + + it("refuses a configuration already based on the user's own xcconfig", () => { + const plan = planInjection(FOREIGN_XCCONFIG, {}); + expect(plan.ok).toBe(false); + expect(plan.reason).toContain('Debug'); + expect(plan.reason).toContain('Config/App.xcconfig'); + expect(plan.reason).toContain('#include'); + expect(plan.reason).toContain(XCCONFIG_PATH); + }); + + it('accepts a project it already based on the generated xcconfig', () => { + expect(planInjection(inject(PLAIN).text, {}).ok).toBe(true); + }); +}); + +// FOREIGN_XCCONFIG with the reference inside a `Config` group, the way Xcode +// files an xcconfig dragged into a folder of the navigator. +const FOREIGN_XCCONFIG_IN_GROUP = FOREIGN_XCCONFIG.replace( + 'path = Config/App.xcconfig;', + 'path = App.xcconfig;', +) + .replace( + '\t\t\t\tAA00000000000000000000F1 /* Products */,\n', + '\t\t\t\tAA00000000000000000000F1 /* Products */,\n\t\t\t\tCC0000000000000000000002 /* Config */,\n', + ) + .replace( + '/* End PBXGroup section */', + '\t\tCC0000000000000000000002 /* Config */ = {\n\t\t\tisa = PBXGroup;\n\t\t\tchildren = (\n\t\t\t\tCC0000000000000000000001 /* App.xcconfig */,\n\t\t\t);\n\t\t\tpath = Config;\n\t\t\tsourceTree = "";\n\t\t};\n/* End PBXGroup section */', + ); + +describe("planInjection — a configuration based on the user's own xcconfig", () => { + let roots = []; + afterEach(() => { + for (const root of roots) { + fs.rmSync(root, {recursive: true, force: true}); + } + roots = []; + }); + + // An app root (== SRCROOT) holding `files`, app-root-relative path → content. + function appRootWith(files) { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'spm-include-')); + roots.push(root); + for (const [relPath, content] of Object.entries(files)) { + fs.mkdirSync(path.dirname(path.join(root, relPath)), {recursive: true}); + fs.writeFileSync(path.join(root, relPath), content, 'utf8'); + } + return root; + } + + function plan(files, text = FOREIGN_XCCONFIG) { + const root = appRootWith(files); + return planInjection(text, {appRoot: root, srcRoot: root}); + } + + const INCLUDES_GENERATED = `#include "../${XCCONFIG_PATH}"\n`; + + it('accepts it when the xcconfig #includes the generated one', () => { + const result = plan({'Config/App.xcconfig': INCLUDES_GENERATED}); + expect(result.ok).toBe(true); + expect(result.includedByConfigUuids).toEqual([APP_DEBUG_CONFIG]); + }); + + it('includes no configuration it bases on the generated xcconfig itself', () => { + expect(planInjection(PLAIN, {}).includedByConfigUuids).toEqual([]); + }); + + it('finds an xcconfig filed under a navigator group', () => { + expect( + plan( + {'Config/App.xcconfig': INCLUDES_GENERATED}, + FOREIGN_XCCONFIG_IN_GROUP, + ).ok, + ).toBe(true); + }); + + it('follows #include? chains, each path relative to the file that holds it', () => { + expect( + plan({ + 'Config/App.xcconfig': + '// Shared settings\n#include? "Shared/Base.xcconfig"\n', + 'Config/Shared/Base.xcconfig': `#include "../../${XCCONFIG_PATH}"\n`, + }).ok, + ).toBe(true); + }); + + it.each([ + [ + 'a path relative to the project directory', + `#include "${XCCONFIG_PATH}"\n`, + ], + ['a commented-out include', `// ${INCLUDES_GENERATED}`], + ['an include of a file that is missing', '#include? "Missing.xcconfig"\n'], + ])('refuses %s', (_label, content) => { + expect(plan({'Config/App.xcconfig': content}).ok).toBe(false); + }); + + it('refuses an include cycle that never reaches the generated xcconfig', () => { + const result = plan({ + 'Config/App.xcconfig': '#include "Base.xcconfig"\n', + 'Config/Base.xcconfig': '#include "App.xcconfig"\n', + }); + expect(result.ok).toBe(false); + }); + + it('still refuses a list setting without $(inherited) on it', () => { + const result = plan( + {'Config/App.xcconfig': INCLUDES_GENERATED}, + FOREIGN_XCCONFIG.replaceAll( + 'PRODUCT_BUNDLE_IDENTIFIER = com.example.MyApp;', + 'HEADER_SEARCH_PATHS = /vendor;\n\t\t\t\tPRODUCT_BUNDLE_IDENTIFIER = com.example.MyApp;', + ), + ); + expect(result.ok).toBe(false); + expect(result.reason).toContain('HEADER_SEARCH_PATHS (Debug, Release)'); + }); + + it('names the exact line to add, relative to the xcconfig that needs it', () => { + const result = plan({'Config/App.xcconfig': 'MY_SETTING = 1\n'}); + expect(result.ok).toBe(false); + expect(result.reason).toContain( + `#include "../${XCCONFIG_PATH}" to Config/App.xcconfig`, + ); + expect(result.reason).not.toContain('project directory'); + }); + + it('says the path is relative to the xcconfig when it cannot find the file', () => { + const result = plan({}); + expect(result.ok).toBe(false); + expect(result.reason).toContain( + `#include ${XCCONFIG_PATH} from Config/App.xcconfig`, + ); + expect(result.reason).toContain( + 'relative to the folder that holds Config/App.xcconfig', + ); + expect(result.reason).not.toContain('project directory'); + }); + + it("keeps the user's base and records the configuration as including the generated xcconfig", () => { + const root = appRootWith({'Config/App.xcconfig': INCLUDES_GENERATED}); + const accepted = planInjection(FOREIGN_XCCONFIG, { + appRoot: root, + srcRoot: root, + }); + const {text, xcconfig, generatedFiles} = injectSpmIntoPbxproj( + FOREIGN_XCCONFIG, + { + rootUuid: accepted.rootUuid, + targetUuid: accepted.target.uuid, + configUuids: accepted.configUuids, + includedByConfigUuids: accepted.includedByConfigUuids, + frameworksPhaseUuid: accepted.frameworksPhaseUuid, + sourcesPhaseUuid: accepted.sourcesPhaseUuid, + }, + RN_PATHS, + null, + [], + TEST_FRAMEWORKS, + ); + const baseOf = configUuid => + findField( + text, + findObjectByUuid(text, configUuid), + 'baseConfigurationReference', + )?.value; + expect(baseOf(APP_DEBUG_CONFIG)).toBe( + 'CC0000000000000000000001 /* App.xcconfig */', + ); + expect(baseOf(APP_RELEASE_CONFIG)).toBe( + `${xcconfig.fileRefUuid} /* ReactNativeSPM.xcconfig */`, + ); + expect(xcconfig.configUuids).toEqual([APP_RELEASE_CONFIG]); + expect(xcconfig.includedByConfigUuids).toEqual([APP_DEBUG_CONFIG]); + expect(text).toContain(`path = ${XCCONFIG_PATH};`); + expect(generatedFiles[XCCONFIG_PATH]).toContain( + 'RN_SPM_FLAVOR[config=Debug] = debug\n', + ); + }); }); describe('injectSpmIntoPbxproj — Tier 1 (SPM graph)', () => { @@ -225,71 +480,153 @@ describe('injectSpmIntoPbxproj — Tier 1 (SPM graph)', () => { }); describe('injectSpmIntoPbxproj — Tier 2 (build settings + phase)', () => { - it('merges React build settings into BOTH build configurations', () => { + const PROJECT_DEBUG_CONFIG = 'AA0000000000000000000701'; + const PROJECT_RELEASE_CONFIG = 'AA0000000000000000000801'; + const ALL_CONFIGS = [ + PROJECT_DEBUG_CONFIG, + PROJECT_RELEASE_CONFIG, + APP_DEBUG_CONFIG, + APP_RELEASE_CONFIG, + ]; + const appSettingsOf = text => + [APP_DEBUG_CONFIG, APP_RELEASE_CONFIG].map(config => + buildSettingsOf(text, config), + ); + // React Native installed elsewhere, with `frameworks` in place of the + // default ones — what a later `spm update` sees. + const injectRelocated = frameworks => { + const plan = planInjection(PLAIN, {}); + const relocated = '../../elsewhere/react-native'; + return injectSpmIntoPbxproj( + PLAIN, + { + rootUuid: plan.rootUuid, + targetUuid: plan.target.uuid, + configUuids: plan.configUuids, + frameworksPhaseUuid: plan.frameworksPhaseUuid, + sourcesPhaseUuid: plan.sourcesPhaseUuid, + }, + {fromAppRoot: relocated, fromSrcRoot: relocated}, + null, + [], + frameworks, + [], + ); + }; + + // A routine `spm update` must not touch project.pbxproj, so none of these may + // live in it — they are the ones whose values follow the installed frameworks. + it('writes no React build setting into any configuration', () => { const {text} = inject(PLAIN); - expect(text.match(/-ObjC/g)).toHaveLength(2); - expect(text.match(/REACT_NATIVE_PATH = /g)).toHaveLength(2); - expect(text).not.toContain('fmodule-map-file='); - expect(text).toContain('build/generated/autolinking/headers'); - expect(text.match(/CLANG_CXX_LANGUAGE_STANDARD = "c\+\+20"/g)).toHaveLength( - 2, + for (const config of ALL_CONFIGS) { + const settings = buildSettingsOf(text, config); + expect(settings).toBe(buildSettingsOf(PLAIN, config)); + expect(settings).not.toMatch( + /RN_SPM_|OTHER_LDFLAGS|FRAMEWORK_SEARCH_PATHS|HEADER_SEARCH_PATHS|LD_RUNPATH_SEARCH_PATHS|SWIFT_ACTIVE_COMPILATION_CONDITIONS|CLANG_CXX_LANGUAGE_STANDARD|REACT_NATIVE_PATH/, + ); + } + }); + + it('bases every app configuration on the generated xcconfig, next to isa', () => { + const {text, injectedUuids, xcconfig} = inject(PLAIN); + const ref = + /baseConfigurationReference = ([0-9A-F]{24}) \/\* ReactNativeSPM\.xcconfig \*\/;/.exec( + text, + )[1]; + for (const [config, name] of [ + [APP_DEBUG_CONFIG, 'Debug'], + [APP_RELEASE_CONFIG, 'Release'], + ]) { + expect(text).toContain( + `${config} /* ${name} */ = {\n\t\t\tisa = XCBuildConfiguration;\n` + + `\t\t\tbaseConfigurationReference = ${ref} /* ReactNativeSPM.xcconfig */;\n` + + '\t\t\tbuildSettings = {', + ); + } + expect(text.match(/baseConfigurationReference = /g)).toHaveLength(2); + expect(xcconfig.configUuids).toEqual([ + APP_DEBUG_CONFIG, + APP_RELEASE_CONFIG, + ]); + + const fileRef = findObjectByUuid(text, ref); + const field = key => findField(text, fileRef, key)?.value; + expect(field('isa')).toBe('PBXFileReference'); + expect(field('lastKnownFileType')).toBe('text.xcconfig'); + expect(field('name')).toBe('ReactNativeSPM.xcconfig'); + expect(field('path')).toBe(XCCONFIG_PATH); + expect(field('sourceTree')).toBe('SOURCE_ROOT'); + expect(injectedUuids).toContain(ref); + // Visible in the navigator, at the top of the project. + const mainGroup = findObjectByUuid(text, 'AA00000000000000000000E1'); + expect(findField(text, mainGroup, 'children').value).toContain( + `${ref} /* ReactNativeSPM.xcconfig */,`, + ); + }); + + it('returns the xcconfig generated for the configurations based on it', () => { + const {generatedFiles} = inject(PLAIN); + expect(generatedFiles[XCCONFIG_PATH]).toBe( + generateReactNativeXcconfig( + ['Debug', 'Release'], + RN_PATH, + TEST_FRAMEWORKS, + ), ); - expect(text).toContain('RN_SPM_FLAVOR = debug'); - expect(text).toContain('RN_SPM_FLAVOR = release'); - expect(text).toContain('RN_SPM_REACT_BINARY[sdk=iphoneos*]'); - expect(text).toContain('RN_SPM_REACT_BINARY[sdk=iphonesimulator*]'); - expect(text).toContain('$(RN_SPM_REACT_BINARY)'); }); // An absolute hermesc path is machine-specific, and the app commits its // project.pbxproj. react-native-xcode.sh resolves hermesc through // react-native's own dependency graph at build time instead. - it('never writes HERMES_CLI_PATH into either configuration', () => { - const {text, buildSettingChanges} = inject(PLAIN); + it('never writes HERMES_CLI_PATH, into the project or a generated file', () => { + const {text, generatedFiles} = inject(PLAIN); expect(text).not.toContain('HERMES_CLI_PATH'); - expect( - buildSettingChanges.flatMap(change => change.createdScalars ?? []), - ).not.toContain('HERMES_CLI_PATH'); + for (const content of Object.values(generatedFiles)) { + expect(content).not.toContain('HERMES_CLI_PATH'); + } }); - // Swift's `#if DEBUG` — which AppDelegate.swift's bundleURL() uses to pick the - // Metro URL — is gated by this setting alone. CocoaPods injects it at `pod - // install`; an SPM app has to get it here or a Debug build looks for a - // main.jsbundle it never built. - it('sets SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG on the debug config only', () => { - const {text} = inject(PLAIN); - const debugSettings = buildSettingsOf(text, APP_DEBUG_CONFIG); - expect(debugSettings).toMatch( - /SWIFT_ACTIVE_COMPILATION_CONDITIONS = \(\s*"\$\(inherited\)",\s*DEBUG,\s*\)/, - ); - expect(buildSettingsOf(text, APP_RELEASE_CONFIG)).not.toContain( + it.each([ + ['HEADER_SEARCH_PATHS', withHeaderSearchPaths('"$(inherited)"')], + [ + 'HEADER_SEARCH_PATHS', + withHeaderSearchPaths('(\n\t\t\t\t\t"$(inherited)",\n\t\t\t\t)'), + ], + [ 'SWIFT_ACTIVE_COMPILATION_CONDITIONS', - ); + withDebugCondition(PLAIN, '"$(inherited) MY_DEBUG_UI"'), + ], + ])("leaves the configuration's own %s untouched", (_key, before) => { + expect(appSettingsOf(inject(before).text)).toEqual(appSettingsOf(before)); }); - it('leaves a config that already sets DEBUG (scalar form) untouched', () => { - const {text} = inject(withDebugCondition(PLAIN, '"$(inherited) DEBUG"')); - // Not promoted to an array, not re-appended — DEBUG is already there. - expect(buildSettingsOf(text, APP_DEBUG_CONFIG)).toContain( - 'SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited) DEBUG";', + // The CocoaPods template anchors REACT_NATIVE_PATH on ${PODS_ROOT}, which + // resolves empty once CocoaPods is gone, and a target's own value outranks + // any xcconfig — so it has to leave the project for the xcconfig's to apply. + it('removes a ${PODS_ROOT}-anchored REACT_NATIVE_PATH so the xcconfig value applies', () => { + const before = withAppSetting( + 'REACT_NATIVE_PATH = "${PODS_ROOT}/../../node_modules/react-native";', + ); + const {text, xcconfig, generatedFiles} = inject(before); + expect(appSettingsOf(text)).toEqual(appSettingsOf(PLAIN)); + expect(xcconfig.removedPodsRootReactNativePath).toBe(true); + expect(generatedFiles[XCCONFIG_PATH]).toContain( + `\nREACT_NATIVE_PATH = $(SRCROOT)/${RN_PATH}\n`, ); - expect(text.match(/\bDEBUG\b/g)).toHaveLength(1); }); - it("adds DEBUG alongside the user's own compilation conditions", () => { - const {text} = inject( - withDebugCondition(PLAIN, '"$(inherited) MY_DEBUG_UI"'), + it("keeps a REACT_NATIVE_PATH of the project's own", () => { + const before = withAppSetting( + 'REACT_NATIVE_PATH = ../../node_modules/react-native;', ); - // MY_DEBUG_UI must not be mistaken for DEBUG by a substring check. - const debugSettings = buildSettingsOf(text, APP_DEBUG_CONFIG); - expect(debugSettings).toContain('"$(inherited) MY_DEBUG_UI"'); - expect(debugSettings).toMatch(/^\s*DEBUG,$/m); + const {text, xcconfig} = inject(before); + expect(appSettingsOf(text)).toEqual(appSettingsOf(before)); + expect(xcconfig.removedPodsRootReactNativePath).toBe(false); }); it('prepends the Sync SPM Autolinking build phase', () => { const {text} = inject(PLAIN); expect(text).toContain('Sync SPM Autolinking'); - expect(text).toContain('npx react-native spm sync'); // It runs before Sources. const syncIdx = text.indexOf('Sync SPM Autolinking */,'); const sourcesIdx = text.indexOf('Sources */,'); @@ -297,6 +634,57 @@ describe('injectSpmIntoPbxproj — Tier 2 (build settings + phase)', () => { expect(syncIdx).toBeLessThan(sourcesIdx); }); + // The script bodies change whenever React Native's do, so they live in + // generated files; the phase only carries a wrapper that never changes. + it.each([ + ['Sync SPM Autolinking', SYNC_SCRIPT_PATH, 'npx react-native spm sync'], + [ + 'Embed React Native Flavored Frameworks', + EMBED_SCRIPT_PATH, + 'copy_and_sign "${RN_SPM_REACT_FRAMEWORK:-}" "React.framework"', + ], + ])('runs the %s script from %s', (label, scriptPath, bodyLine) => { + const {text, generatedFiles} = inject(PLAIN); + const wrapper = shellScriptOf(text, label); + expect(wrapper).toContain(`$SRCROOT/${scriptPath}`); + expect(wrapper).toContain('spm update'); + expect(text).not.toContain(quoteIfNeeded(bodyLine).slice(1, -1)); + expect(text).not.toContain('set -euo pipefail'); + expect(generatedFiles[scriptPath]).toContain(bodyLine); + }); + + it('keeps the script wrappers identical across React Native versions and frameworks', () => { + const {text} = inject(PLAIN); + const other = injectRelocated([]).text; + for (const label of [ + 'Sync SPM Autolinking', + 'Embed React Native Flavored Frameworks', + ]) { + expect(shellScriptOf(other, label)).toBe(shellScriptOf(text, label)); + } + }); + + // A routine `spm update` must leave project.pbxproj alone while the + // frameworks keep their ids and names: slice paths and architectures land + // only in the generated xcconfig. + it('produces a byte-identical project.pbxproj across a version bump (path + slice content change)', () => { + const bumped = TEST_FRAMEWORKS.map(framework => ({ + ...framework, + slices: framework.slices.map(slice => ({ + ...slice, + architectures: [...slice.architectures, 'arm64e'], + libraryIdentifier: `${slice.libraryIdentifier}_arm64e`, + binaryPath: 'React.framework/Versions/Current/React', + })), + })); + const before = inject(PLAIN); + const after = injectRelocated(bumped); + expect(after.generatedFiles[XCCONFIG_PATH]).not.toBe( + before.generatedFiles[XCCONFIG_PATH], + ); + expect(after.text).toBe(before.text); + }); + it('runs every injected shell-script build phase under bash, not /bin/sh', () => { // Their bodies start with `set -euo pipefail`, which a non-bash /bin/sh // (e.g. dash) rejects at runtime. @@ -326,50 +714,6 @@ describe('injectSpmIntoPbxproj — Tier 2 (build settings + phase)', () => { ); }); - it.each([ - [ - '"$(inherited)"', - ['"$(inherited)"', '"$(SRCROOT)/build/generated/autolinking/headers"'], - ], - [ - '"$(inherited) $(SRCROOT)/vendor/include"', - [ - '"$(inherited)"', - '"$(inherited) $(SRCROOT)/vendor/include"', - '"$(SRCROOT)/build/generated/autolinking/headers"', - ], - ], - ])( - 'promotes a pre-existing HEADER_SEARCH_PATHS scalar (%s) to an array, keeping its value and one $(inherited)', - (scalar, expectedMembers) => { - const {text} = inject(withHeaderSearchPaths(scalar)); - const arrays = [ - ...text.matchAll(/HEADER_SEARCH_PATHS = \(\n([\s\S]*?)\t+\);/g), - ].map(m => - m[1] - .split('\n') - .map(line => line.trim().replace(/,$/, '')) - .filter(member => member.length > 0), - ); - // Both app-target configs (Debug + Release). - expect(arrays).toEqual([expectedMembers, expectedMembers]); - }, - ); - - it('appends to a pre-existing ONE-LINE HEADER_SEARCH_PATHS array in place', () => { - const {text} = inject(withHeaderSearchPaths('("$(inherited)", )')); - expect(isBalanced(text)).toBe(true); - const arrays = [ - ...text.matchAll(/HEADER_SEARCH_PATHS = \(([^\n]*)\);/g), - ].map(m => m[1]); - // Both app-target configs, each keeping the one-line shape it was written in. - expect(arrays).toEqual( - Array(2).fill( - '"$(inherited)", "$(SRCROOT)/build/generated/autolinking/headers", ', - ), - ); - }); - it('adds one generated embed phase immediately after Frameworks', () => { const {text} = inject(PLAIN); expect(text).not.toContain('Fix SPM Embedded Flavor'); @@ -386,6 +730,19 @@ describe('injectSpmIntoPbxproj — Tier 2 (build settings + phase)', () => { '$(TARGET_BUILD_DIR)/$(FRAMEWORKS_FOLDER_PATH)/React.framework', ); }); + + // The phase only runs the script file, so the file has to be an input for + // Xcode to re-run the phase when the file alone changes. + it('lists its script file among the embed phase inputs', () => { + const {text} = inject(PLAIN); + const uuid = + /([0-9A-F]{24}) \/\* Embed React Native Flavored Frameworks \*\/ = \{/.exec( + text, + )[1]; + expect( + findField(text, findObjectByUuid(text, uuid), 'inputPaths').value, + ).toContain(`${quoteIfNeeded(`$(SRCROOT)/${EMBED_SCRIPT_PATH}`)},`); + }); }); describe('injectSpmIntoPbxproj — Tier 3 (plugin generated sources)', () => { @@ -445,7 +802,7 @@ describe('injectSpmIntoPbxproj — Tier 3 (plugin generated sources)', () => { frameworksPhaseUuid: plan.frameworksPhaseUuid, sourcesPhaseUuid: plan.sourcesPhaseUuid, }, - RN_PATH, + RN_PATHS, null, [PROVIDER_SOURCE], TEST_FRAMEWORKS, @@ -484,7 +841,7 @@ describe('injectSpmIntoPbxproj — Tier 3 (plugin generated sources)', () => { frameworksPhaseUuid: plan.frameworksPhaseUuid, sourcesPhaseUuid: plan.sourcesPhaseUuid, }, - RN_PATH, + RN_PATHS, null, [PROVIDER_SOURCE], TEST_FRAMEWORKS, @@ -1118,22 +1475,24 @@ describe('injectSpmIntoPbxproj — invariants', () => { }); it('is idempotent — a second injection is a byte-for-byte no-op', () => { - const first = inject(PLAIN).text; - const plan = planInjection(first, {}); + const first = inject(PLAIN); + const plan = planInjection(first.text, {}); const second = injectSpmIntoPbxproj( - first, + first.text, { rootUuid: plan.rootUuid, targetUuid: plan.target.uuid, configUuids: plan.configUuids, frameworksPhaseUuid: plan.frameworksPhaseUuid, }, - RN_PATH, + RN_PATHS, null, [], TEST_FRAMEWORKS, - ).text; - expect(second).toBe(first); + ); + expect(second.text).toBe(first.text); + expect(second.generatedFiles).toEqual(first.generatedFiles); + expect(second.xcconfig).toEqual(first.xcconfig); }); it('keeps the diff small — only adds lines, never removes original ones', () => { @@ -1150,15 +1509,13 @@ describe('injectSpmIntoPbxproj — invariants', () => { expect(added).toBeLessThan(220); }); - it('refreshes a stale shellScript on re-injection', () => { + it('replaces an inline script body from an earlier version with the wrapper', () => { const first = inject(PLAIN).text; - // Simulate an earlier run whose generated script has since changed (e.g. - // fixed dispatch logic) by corrupting a substring of the baked-in script. const stale = first.replace( - 'npx react-native spm sync', - 'STALE_OLD_SYNC_COMMAND', + shellScriptOf(first, 'Sync SPM Autolinking'), + quoteIfNeeded(buildSyncAutolinkingScript(RN_PATH)), ); - expect(stale).not.toBe(first); + expect(stale).toContain('npx react-native spm sync'); const plan = planInjection(stale, {}); const second = injectSpmIntoPbxproj( stale, @@ -1168,14 +1525,11 @@ describe('injectSpmIntoPbxproj — invariants', () => { configUuids: plan.configUuids, frameworksPhaseUuid: plan.frameworksPhaseUuid, }, - RN_PATH, + RN_PATHS, null, [], TEST_FRAMEWORKS, ).text; - // The stale marker is gone and the current script is restored. - expect(second).not.toContain('STALE_OLD_SYNC_COMMAND'); - expect(second).toContain('npx react-native spm sync'); expect(second).toBe(first); }); diff --git a/packages/react-native/scripts/spm/__tests__/plugin-framework-mismatch-test.js b/packages/react-native/scripts/spm/__tests__/plugin-framework-mismatch-test.js new file mode 100644 index 000000000000..0cd7897e788e --- /dev/null +++ b/packages/react-native/scripts/spm/__tests__/plugin-framework-mismatch-test.js @@ -0,0 +1,439 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @format + * @noflow + */ + +'use strict'; + +const {PLUGIN_FRAMEWORKS_MANIFEST} = require('../flavored-frameworks'); +const { + PluginFrameworkMismatchError, + SPM_INJECTED_MARKER, + assertPluginFrameworksLinked, + injectSpmIntoExistingXcodeproj, +} = require('../generate-spm-xcodeproj'); +const {main: syncAutolinking} = require('../sync-spm-autolinking'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); + +const PLAIN = fs.readFileSync( + path.join(__dirname, '__fixtures__', 'plain-app.pbxproj'), + 'utf8', +); + +function frameworkEntry(id, frameworkName, artifactRelativePath) { + return { + id, + frameworkName, + executableName: frameworkName, + linkage: 'dynamic', + artifactRelativePath, + slices: [ + { + sdk: 'iphoneos*', + platform: 'ios', + variant: null, + architectures: ['arm64'], + libraryIdentifier: 'ios-arm64', + libraryPath: `${frameworkName}.framework`, + binaryPath: `${frameworkName}.framework/${frameworkName}`, + }, + ], + }; +} + +const REACT = frameworkEntry('react', 'React', 'React.xcframework'); +const HERMES = frameworkEntry('hermes', 'hermes', 'hermes-engine.xcframework'); +const EXPO = frameworkEntry( + 'expo-core', + 'ExpoCore', + 'plugins/expo-core.xcframework', +); + +let appRoots = []; +let errorSpy; +let warnSpy; + +beforeEach(() => { + errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); + jest.spyOn(console, 'log').mockImplementation(() => {}); +}); + +afterEach(() => { + jest.restoreAllMocks(); + for (const appRoot of appRoots) { + fs.rmSync(appRoot, {recursive: true, force: true}); + } + appRoots = []; +}); + +function scaffoldApp() { + const appRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'spm-plugin-check-')); + appRoots.push(appRoot); + const xcodeprojPath = path.join(appRoot, 'MyApp.xcodeproj'); + fs.mkdirSync(xcodeprojPath, {recursive: true}); + fs.writeFileSync(path.join(xcodeprojPath, 'project.pbxproj'), PLAIN); + const rnRoot = path.join(appRoot, 'node_modules', 'react-native'); + fs.mkdirSync(rnRoot, {recursive: true}); + const artifactRoot = path.join(appRoot, 'build', 'xcframeworks'); + fs.mkdirSync(artifactRoot, {recursive: true}); + fs.writeFileSync(path.join(artifactRoot, '.artifact-stamp'), 'test\n'); + return {appRoot, xcodeprojPath, rnRoot}; +} + +// What `spm add` / `spm update` would have linked. +function inject({appRoot, xcodeprojPath, rnRoot}, frameworks) { + fs.writeFileSync( + path.join(appRoot, 'build', 'xcframeworks', 'flavored-frameworks.json'), + JSON.stringify({version: 1, frameworks}), + ); + injectSpmIntoExistingXcodeproj({ + appRoot, + reactNativeRoot: rnRoot, + xcodeprojPath, + }); +} + +// What the build-time sync's plugins pair on this machine. +function writeSidecar(appRoot, frameworks) { + const sidecarPath = path.join(appRoot, PLUGIN_FRAMEWORKS_MANIFEST); + fs.mkdirSync(path.dirname(sidecarPath), {recursive: true}); + fs.writeFileSync( + sidecarPath, + JSON.stringify( + frameworks.map(({id, frameworkName}) => ({ + id, + frameworkName, + linkage: 'dynamic', + flavors: { + debug: `/plugins/debug/${frameworkName}.xcframework`, + release: `/plugins/release/${frameworkName}.xcframework`, + }, + })), + ), + ); +} + +function projectFiles(xcodeprojPath) { + return ['project.pbxproj', SPM_INJECTED_MARKER].map(name => { + const file = path.join(xcodeprojPath, name); + return fs.existsSync(file) ? fs.readFileSync(file, 'utf8') : null; + }); +} + +// Runs the check and proves it wrote neither the pbxproj nor the marker. +// Returns the printed lines and the thrown error, if any. +function check(app, env = {}) { + const before = projectFiles(app.xcodeprojPath); + let error = null; + try { + assertPluginFrameworksLinked(app.appRoot, env); + } catch (e) { + error = e; + } + expect(projectFiles(app.xcodeprojPath)).toEqual(before); + return {error, lines: errorSpy.mock.calls.map(call => call.join(' '))}; +} + +function readLinkedFrameworks({xcodeprojPath}) { + return JSON.parse( + fs.readFileSync(path.join(xcodeprojPath, SPM_INJECTED_MARKER), 'utf8'), + ).linkedFrameworks; +} + +// What a marker written before `linkedFrameworks` was recorded looks like. +function injectWithOlderMarker(app, frameworks) { + inject(app, frameworks); + const markerPath = path.join(app.xcodeprojPath, SPM_INJECTED_MARKER); + const {linkedFrameworks, ...olderMarker} = JSON.parse( + fs.readFileSync(markerPath, 'utf8'), + ); + expect(linkedFrameworks).toBeDefined(); + fs.writeFileSync(markerPath, JSON.stringify(olderMarker, null, 2) + '\n'); +} + +const EXPO_NOT_LINKED = + 'error: ExpoCore (expo-core) is a precompiled framework from an autolinking plugin, but the Xcode project does not link it. Run `npx react-native spm update` to add ExpoCore to the committed project for the whole team.'; +const EXPO_NOT_PROVIDED = + 'error: The Xcode project links ExpoCore.framework, but no autolinking plugin provides it on this machine. Running `npx react-native spm update` removes ExpoCore from the committed project for the whole team. Instead, precompile ExpoCore, then run `npx react-native spm sync`.'; +const LINKED_CHECK_SKIPPED = + 'warning: Skipped the check for frameworks that the Xcode project links but no plugin provides. The project does not record them in .spm-injected.json, and build/xcframeworks/flavored-frameworks.json is missing. Run `npx react-native spm update` to record them.'; + +describe.each([ + ['the marker', inject], + ['the project, for an older marker', injectWithOlderMarker], +])('assertPluginFrameworksLinked, linked frameworks from %s', (_, link) => { + it('fails when a plugin pairs a framework the project does not link', () => { + const app = scaffoldApp(); + link(app, [REACT]); + writeSidecar(app.appRoot, [EXPO]); + + const {error, lines} = check(app); + + expect(error).toBeInstanceOf(PluginFrameworkMismatchError); + expect(lines).toEqual([EXPO_NOT_LINKED]); + expect(error.problems).toEqual(lines); + }); + + it('fails when the project links a plugin framework no plugin provides', () => { + const app = scaffoldApp(); + link(app, [REACT, EXPO]); + writeSidecar(app.appRoot, []); + + const {error, lines} = check(app); + + expect(error).toBeInstanceOf(PluginFrameworkMismatchError); + expect(lines).toEqual([EXPO_NOT_PROVIDED]); + }); + + it('fails when a plugin renames its framework but keeps its id', () => { + const app = scaffoldApp(); + link(app, [REACT, EXPO]); + writeSidecar(app.appRoot, [{id: 'expo-core', frameworkName: 'NewCore'}]); + + const {error, lines} = check(app); + + expect(error).toBeInstanceOf(PluginFrameworkMismatchError); + expect(lines).toEqual([ + 'error: NewCore (expo-core) is a precompiled framework from an autolinking plugin, but the Xcode project does not link it. Run `npx react-native spm update` to add NewCore to the committed project for the whole team.', + EXPO_NOT_PROVIDED, + ]); + }); + + it('checks the project Xcode is building, not the first injected one', () => { + const app = scaffoldApp(); + const stale = { + ...app, + xcodeprojPath: path.join(app.appRoot, 'A.xcodeproj'), + }; + fs.mkdirSync(stale.xcodeprojPath); + fs.writeFileSync(path.join(stale.xcodeprojPath, 'project.pbxproj'), PLAIN); + link(stale, [REACT]); + link(app, [REACT, EXPO]); + writeSidecar(app.appRoot, [EXPO]); + + expect(check(app, {PROJECT_FILE_PATH: app.xcodeprojPath})).toEqual({ + error: null, + lines: [], + }); + }); + + it('passes when the project links exactly the paired plugin frameworks', () => { + const app = scaffoldApp(); + link(app, [REACT, HERMES, EXPO]); + writeSidecar(app.appRoot, [EXPO]); + + expect(check(app)).toEqual({error: null, lines: []}); + }); + + it('reads framework names that contain a space', () => { + const app = scaffoldApp(); + const spaced = frameworkEntry('my-fw', 'My Fw', 'My Fw.xcframework'); + link(app, [REACT, HERMES, spaced]); + writeSidecar(app.appRoot, [spaced]); + + expect(check(app)).toEqual({error: null, lines: []}); + }); + + it('skips a project that was never injected', () => { + const app = scaffoldApp(); + writeSidecar(app.appRoot, [EXPO]); + + expect(check(app)).toEqual({error: null, lines: []}); + }); +}); + +describe('assertPluginFrameworksLinked, linked frameworks from the marker', () => { + it('records every framework the embed phase links', () => { + const app = scaffoldApp(); + inject(app, [REACT, HERMES, EXPO]); + + expect(readLinkedFrameworks(app)).toEqual([ + {id: 'react', frameworkName: 'React'}, + {id: 'hermes', frameworkName: 'hermes'}, + {id: 'expo-core', frameworkName: 'ExpoCore'}, + ]); + }); + + it('reports an unprovided framework when the frameworks manifest is missing', () => { + const app = scaffoldApp(); + inject(app, [REACT, HERMES, EXPO]); + writeSidecar(app.appRoot, []); + fs.rmSync( + path.join(app.appRoot, 'build/xcframeworks/flavored-frameworks.json'), + ); + + expect(check(app).lines).toEqual([EXPO_NOT_PROVIDED]); + }); + + it('reads the marker, not the project', () => { + const app = scaffoldApp(); + inject(app, [REACT, EXPO]); + writeSidecar(app.appRoot, []); + fs.writeFileSync(path.join(app.xcodeprojPath, 'project.pbxproj'), PLAIN); + + expect(check(app).lines).toEqual([EXPO_NOT_PROVIDED]); + }); + + it('fails when plugins swap the ids of two linked frameworks', () => { + const app = scaffoldApp(); + inject(app, [ + REACT, + frameworkEntry('a', 'Foo', 'plugins/a.xcframework'), + frameworkEntry('b', 'Bar', 'plugins/b.xcframework'), + ]); + writeSidecar(app.appRoot, [ + {id: 'a', frameworkName: 'Bar'}, + {id: 'b', frameworkName: 'Foo'}, + ]); + + const {error, lines} = check(app); + + expect(error).toBeInstanceOf(PluginFrameworkMismatchError); + expect(lines).toEqual([ + 'error: The Xcode project links Foo.framework (a), but the autolinking plugin that provides Foo gives it a different id. Run `npx react-native spm update` to relink Foo in the committed project for the whole team.', + 'error: The Xcode project links Bar.framework (b), but the autolinking plugin that provides Bar gives it a different id. Run `npx react-native spm update` to relink Bar in the committed project for the whole team.', + ]); + }); + + it('fails once when a plugin changes the id of a linked framework', () => { + const app = scaffoldApp(); + inject(app, [REACT, frameworkEntry('a', 'Foo', 'plugins/a.xcframework')]); + writeSidecar(app.appRoot, [{id: 'c', frameworkName: 'Foo'}]); + + expect(check(app).lines).toEqual([ + 'error: The Xcode project links Foo.framework (a), but the autolinking plugin that provides Foo gives it a different id. Run `npx react-native spm update` to relink Foo in the committed project for the whole team.', + ]); + }); + + it('reports a new framework and swapped ids once each', () => { + const app = scaffoldApp(); + inject(app, [ + REACT, + frameworkEntry('a', 'Foo', 'plugins/a.xcframework'), + frameworkEntry('b', 'Bar', 'plugins/b.xcframework'), + ]); + writeSidecar(app.appRoot, [ + {id: 'a', frameworkName: 'Bar'}, + {id: 'b', frameworkName: 'Foo'}, + {id: 'c', frameworkName: 'Baz'}, + ]); + + expect(check(app).lines).toEqual([ + 'error: Baz (c) is a precompiled framework from an autolinking plugin, but the Xcode project does not link it. Run `npx react-native spm update` to add Baz to the committed project for the whole team.', + 'error: The Xcode project links Foo.framework (a), but the autolinking plugin that provides Foo gives it a different id. Run `npx react-native spm update` to relink Foo in the committed project for the whole team.', + 'error: The Xcode project links Bar.framework (b), but the autolinking plugin that provides Bar gives it a different id. Run `npx react-native spm update` to relink Bar in the committed project for the whole team.', + ]); + }); +}); + +describe('assertPluginFrameworksLinked, linked frameworks from the project, for an older marker', () => { + it('compares setting prefixes and names separately', () => { + const app = scaffoldApp(); + injectWithOlderMarker(app, [ + REACT, + frameworkEntry('a', 'Foo', 'plugins/a.xcframework'), + frameworkEntry('b', 'Bar', 'plugins/b.xcframework'), + ]); + writeSidecar(app.appRoot, [ + {id: 'a', frameworkName: 'Bar'}, + {id: 'b', frameworkName: 'Foo'}, + ]); + + expect(check(app)).toEqual({error: null, lines: []}); + }); + + it('compares only setting prefixes when the frameworks manifest is missing', () => { + const app = scaffoldApp(); + injectWithOlderMarker(app, [REACT, HERMES, EXPO]); + writeSidecar(app.appRoot, [EXPO]); + fs.rmSync( + path.join(app.appRoot, 'build/xcframeworks/flavored-frameworks.json'), + ); + + expect(check(app)).toEqual({error: null, lines: []}); + }); + + it('warns that it skipped the unprovided check when the frameworks manifest is missing', () => { + const app = scaffoldApp(); + injectWithOlderMarker(app, [REACT, HERMES, EXPO]); + writeSidecar(app.appRoot, []); + fs.rmSync( + path.join(app.appRoot, 'build/xcframeworks/flavored-frameworks.json'), + ); + + expect(check(app)).toEqual({error: null, lines: []}); + expect(warnSpy.mock.calls).toEqual([[LINKED_CHECK_SKIPPED]]); + }); + + it('does not warn when the frameworks manifest is present', () => { + const app = scaffoldApp(); + injectWithOlderMarker(app, [REACT, HERMES, EXPO]); + writeSidecar(app.appRoot, [EXPO]); + + expect(check(app)).toEqual({error: null, lines: []}); + expect(warnSpy).not.toHaveBeenCalled(); + }); + + it('skips a project without the embed phase', () => { + const app = scaffoldApp(); + injectWithOlderMarker(app, [REACT]); + writeSidecar(app.appRoot, [EXPO]); + fs.writeFileSync(path.join(app.xcodeprojPath, 'project.pbxproj'), PLAIN); + + expect(check(app)).toEqual({error: null, lines: []}); + }); +}); + +describe('recovering from an unprovided framework', () => { + // A build-time sync whose plugins provide `provided` on this machine. + function sync(app, provided) { + return syncAutolinking( + ['--app-root', app.appRoot, '--react-native-root', app.rnRoot], + { + runCodegenAndInstallTemplate: jest.fn(), + installSpmCodegenTemplate: jest.fn(), + buildPerAppHeaderTree: jest.fn(), + findProjectRoot: () => app.appRoot, + generateAutolinking: () => writeSidecar(app.appRoot, provided), + }, + ); + } + + const mismatchPath = app => + path.join(app.appRoot, 'build/generated/autolinking/.spm-plugin-mismatch'); + + it('passes the sync the error names once the framework is precompiled', async () => { + const app = scaffoldApp(); + inject(app, [REACT, EXPO]); + await expect(sync(app, [])).rejects.toBeInstanceOf( + PluginFrameworkMismatchError, + ); + const recorded = fs.readFileSync(mismatchPath(app), 'utf8'); + expect(recorded).toBe(EXPO_NOT_PROVIDED + '\n'); + expect(recorded.trim()).toMatch(/run `npx react-native spm sync`\.$/); + + // The developer precompiles ExpoCore, then runs `npx react-native spm sync`. + await sync(app, [EXPO]); + + expect(fs.existsSync(mismatchPath(app))).toBe(false); + }); +}); + +it('injects a plugin framework idempotently', () => { + const app = scaffoldApp(); + inject(app, [REACT, EXPO]); + const pbxprojPath = path.join(app.xcodeprojPath, 'project.pbxproj'); + const once = fs.readFileSync(pbxprojPath, 'utf8'); + + inject(app, [REACT, EXPO]); + + expect(fs.readFileSync(pbxprojPath, 'utf8')).toBe(once); +}); diff --git a/packages/react-native/scripts/spm/__tests__/remove-spm-injection-test.js b/packages/react-native/scripts/spm/__tests__/remove-spm-injection-test.js index 2974e310af4e..628b5219c6c6 100644 --- a/packages/react-native/scripts/spm/__tests__/remove-spm-injection-test.js +++ b/packages/react-native/scripts/spm/__tests__/remove-spm-injection-test.js @@ -76,6 +76,11 @@ function scaffoldApp(pbxproj /*: string */ = PLAIN) { ); const rnRoot = path.join(appRoot, 'node_modules', 'react-native'); fs.mkdirSync(rnRoot, {recursive: true}); + seedArtifacts(appRoot); + return {appRoot, xcodeprojPath, rnRoot}; +} + +function seedArtifacts(appRoot /*: string */) { const artifactRoot = path.join(appRoot, 'build', 'xcframeworks'); fs.mkdirSync(artifactRoot, {recursive: true}); fs.writeFileSync( @@ -83,7 +88,6 @@ function scaffoldApp(pbxproj /*: string */ = PLAIN) { JSON.stringify({version: 1, frameworks: []}), ); fs.writeFileSync(path.join(artifactRoot, '.artifact-stamp'), 'test\n'); - return {appRoot, xcodeprojPath, rnRoot}; } // The hoisted `hermes-compiler` layout a real installed app has: the package @@ -167,6 +171,57 @@ function readMarker(xcodeprojPath) { return JSON.parse(markerTextOf(xcodeprojPath)); } +const XCCONFIG_REL = 'ReactNativeSPM/ReactNativeSPM.xcconfig'; +const GENERATED_FILES = [ + XCCONFIG_REL, + 'ReactNativeSPM/Scripts/sync-autolinking.sh', + 'ReactNativeSPM/Scripts/embed-flavored-frameworks.sh', +]; + +function generatedFileContents(appRoot) { + return GENERATED_FILES.map(file => + fs.existsSync(path.join(appRoot, file)) + ? fs.readFileSync(path.join(appRoot, file), 'utf8') + : null, + ); +} + +const APP_DEBUG_CONFIG = 'AA0000000000000000000901'; + +// The app target's Debug configuration based on Config/App.xcconfig, an +// xcconfig of the user's own. +const FOREIGN_XCCONFIG = PLAIN.replace( + `${APP_DEBUG_CONFIG} /* Debug */ = {\n\t\t\tisa = XCBuildConfiguration;\n`, + `${APP_DEBUG_CONFIG} /* Debug */ = {\n\t\t\tisa = XCBuildConfiguration;\n\t\t\tbaseConfigurationReference = CC0000000000000000000001 /* App.xcconfig */;\n`, +).replace( + '/* End PBXFileReference section */', + '\t\tCC0000000000000000000001 /* App.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Config/App.xcconfig; sourceTree = ""; };\n/* End PBXFileReference section */', +); + +function writeUserXcconfig(appRoot, content) { + fs.mkdirSync(path.join(appRoot, 'Config'), {recursive: true}); + fs.writeFileSync(path.join(appRoot, 'Config', 'App.xcconfig'), content); +} + +// What re-basing Debug on the user's own xcconfig in Xcode does after `add`. +function rebaseDebugOnUserXcconfig(xcodeprojPath) { + const text = pbxprojOf(xcodeprojPath); + const start = text.indexOf(`${APP_DEBUG_CONFIG} /* Debug */ = {`); + const end = text.indexOf('buildSettings', start); + fs.writeFileSync( + path.join(xcodeprojPath, 'project.pbxproj'), + text.slice(0, start) + + text + .slice(start, end) + .replace( + /baseConfigurationReference = [0-9A-F]{24} \/\* ReactNativeSPM\.xcconfig \*\//, + 'baseConfigurationReference = CC0000000000000000000001 /* App.xcconfig */', + ) + + text.slice(end), + 'utf8', + ); +} + function schemePathOf(xcodeprojPath) { return path.join( xcodeprojPath, @@ -204,11 +259,120 @@ describe('injectSpmIntoExistingXcodeproj — HERMES_CLI_PATH', () => { expect(pbxprojOf(xcodeprojPath)).not.toContain('HERMES_CLI_PATH'); expect(pbxprojOf(xcodeprojPath)).not.toContain(hermesc); + for (const file of GENERATED_FILES) { + expect(fs.readFileSync(path.join(appRoot, file), 'utf8')).not.toContain( + 'HERMES_CLI_PATH', + ); + } + expect(markerTextOf(xcodeprojPath)).not.toContain('HERMES_CLI_PATH'); + }); +}); + +// pnpm's layout: the project's node_modules/react-native links into a store +// directory named after the version and dependency hash, and the Xcode project +// sits one level down, in ios/. Config commands report the store path. +function scaffoldPnpmApp() { + const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'spm-pnpm-')); + scaffoldedAppRoots.push(projectRoot); + const nodeModules = path.join(projectRoot, 'node_modules'); + const store = path.join( + nodeModules, + '.pnpm', + 'react-native@0.87.0_abc123', + 'node_modules', + 'react-native', + ); + fs.mkdirSync(store, {recursive: true}); + fs.symlinkSync( + path.relative(nodeModules, store), + path.join(nodeModules, 'react-native'), + ); + const appRoot = path.join(projectRoot, 'ios'); + const xcodeprojPath = path.join(appRoot, 'MyApp.xcodeproj'); + fs.mkdirSync(xcodeprojPath, {recursive: true}); + fs.writeFileSync(path.join(xcodeprojPath, 'project.pbxproj'), PLAIN, 'utf8'); + seedArtifacts(appRoot); + return {appRoot, xcodeprojPath, rnRoot: fs.realpathSync(store)}; +} + +// The xcconfig, scripts and scheme are committed, so they must name +// react-native the way the app resolves it, not by its store location. +describe('injectSpmIntoExistingXcodeproj — REACT_NATIVE_PATH', () => { + it('writes the node_modules link, not the pnpm store path', () => { + const {appRoot, xcodeprojPath, rnRoot} = scaffoldPnpmApp(); + expect(rnRoot).toContain('.pnpm'); + expect( - readMarker(xcodeprojPath).buildSettingChanges.flatMap( - change => change.createdScalars ?? [], - ), - ).not.toContain('HERMES_CLI_PATH'); + injectSpmIntoExistingXcodeproj({ + appRoot, + reactNativeRoot: rnRoot, + xcodeprojPath, + }).status, + ).toBe('injected'); + + const generated = generatedFileContents(appRoot); + const [xcconfig, syncScript] = generated; + expect(xcconfig).toContain( + '\nREACT_NATIVE_PATH = $(SRCROOT)/../node_modules/react-native\n', + ); + expect(syncScript).toContain('RN_DIR="../node_modules/react-native"'); + for (const content of [ + ...generated, + fs.readFileSync(schemePathOf(xcodeprojPath), 'utf8'), + ]) { + expect(content).not.toContain('.pnpm'); + } + }); + + it('prefers the nearest node_modules link to react-native', () => { + const {appRoot, xcodeprojPath, rnRoot} = scaffoldPnpmApp(); + fs.mkdirSync(path.join(appRoot, 'node_modules')); + fs.symlinkSync(rnRoot, path.join(appRoot, 'node_modules', 'react-native')); + + injectSpmIntoExistingXcodeproj({ + appRoot, + reactNativeRoot: rnRoot, + xcodeprojPath, + }); + + expect(fs.readFileSync(path.join(appRoot, XCCONFIG_REL), 'utf8')).toContain( + '\nREACT_NATIVE_PATH = $(SRCROOT)/node_modules/react-native\n', + ); + }); + + it('skips a nearer node_modules/react-native that is another install', () => { + const {appRoot, xcodeprojPath, rnRoot} = scaffoldPnpmApp(); + fs.mkdirSync(path.join(appRoot, 'node_modules', 'react-native'), { + recursive: true, + }); + + injectSpmIntoExistingXcodeproj({ + appRoot, + reactNativeRoot: rnRoot, + xcodeprojPath, + }); + + expect(fs.readFileSync(path.join(appRoot, XCCONFIG_REL), 'utf8')).toContain( + '\nREACT_NATIVE_PATH = $(SRCROOT)/../node_modules/react-native\n', + ); + }); + + it('falls back to the given path when no node_modules link resolves to it', () => { + const {appRoot, xcodeprojPath} = scaffoldApp(); + const vendored = path.join(appRoot, 'vendor', 'react-native'); + fs.mkdirSync(vendored, {recursive: true}); + + injectSpmIntoExistingXcodeproj({ + appRoot, + reactNativeRoot: vendored, + xcodeprojPath, + }); + + const [xcconfig, syncScript] = generatedFileContents(appRoot); + expect(xcconfig).toContain( + '\nREACT_NATIVE_PATH = $(SRCROOT)/vendor/react-native\n', + ); + expect(syncScript).toContain('RN_DIR="vendor/react-native"'); }); }); @@ -228,11 +392,15 @@ describe('removeSpmInjection — the surgical inverse of add', () => { expect(fs.existsSync(path.join(xcodeprojPath, SPM_INJECTED_MARKER))).toBe( true, ); + expect(generatedFileContents(appRoot)).not.toContain(null); const removed = removeSpmInjection({appRoot, xcodeprojPath}); expect(removed.status).toBe('removed'); // Byte-identical to the pre-add pbxproj. expect(pbxprojOf(xcodeprojPath)).toBe(before); + expect(generatedFileContents(appRoot)).toEqual( + GENERATED_FILES.map(() => null), + ); // Marker is gone. expect(fs.existsSync(path.join(xcodeprojPath, SPM_INJECTED_MARKER))).toBe( false, @@ -258,12 +426,14 @@ describe('removeSpmInjection — the surgical inverse of add', () => { sync(); const injected = pbxprojOf(xcodeprojPath); const marker = markerTextOf(xcodeprojPath); + const files = generatedFileContents(appRoot); const schemePath = schemePathOf(xcodeprojPath); expect(fs.existsSync(schemePath)).toBe(true); sync(); - // The re-sync changed neither the project… + // The re-sync changed neither the project, nor the files it generates… expect(pbxprojOf(xcodeprojPath)).toBe(injected); + expect(generatedFileContents(appRoot)).toEqual(files); // …nor the record of what has to be undone. expect(markerTextOf(xcodeprojPath)).toBe(marker); @@ -374,6 +544,267 @@ describe('removeSpmInjection — the surgical inverse of add', () => { expect(removed.status).toBe('removed'); expect(pbxprojOf(xcodeprojPath)).toBe(before); }); + + it('keeps a base configuration the user re-pointed after add, and the xcconfig it may include', () => { + const {appRoot, xcodeprojPath, rnRoot} = scaffoldApp(); + injectSpmIntoExistingXcodeproj({ + appRoot, + reactNativeRoot: rnRoot, + xcodeprojPath, + }); + const repointed = pbxprojOf(xcodeprojPath).replace( + /baseConfigurationReference = [0-9A-F]{24} \/\* ReactNativeSPM\.xcconfig \*\//g, + 'baseConfigurationReference = CC0000000000000000000001 /* App.xcconfig */', + ); + fs.writeFileSync( + path.join(xcodeprojPath, 'project.pbxproj'), + repointed, + 'utf8', + ); + + expect(removeSpmInjection({appRoot, xcodeprojPath}).status).toBe('removed'); + expect( + pbxprojOf(xcodeprojPath).match( + /baseConfigurationReference = CC0000000000000000000001 \/\* App\.xcconfig \*\/;/g, + ), + ).toHaveLength(2); + expect(fs.existsSync(path.join(appRoot, XCCONFIG_REL))).toBe(true); + }); + + it('keeps the xcconfig while one configuration is re-based on a user xcconfig, removing the scripts', () => { + const {appRoot, xcodeprojPath, rnRoot} = scaffoldApp(); + injectSpmIntoExistingXcodeproj({ + appRoot, + reactNativeRoot: rnRoot, + xcodeprojPath, + }); + rebaseDebugOnUserXcconfig(xcodeprojPath); + const logSpy = jest.spyOn(console, 'log').mockImplementation(() => {}); + + removeSpmInjection({appRoot, xcodeprojPath}); + + const logged = logSpy.mock.calls.map(([line]) => line).join('\n'); + logSpy.mockRestore(); + expect(pbxprojOf(xcodeprojPath)).toContain( + 'baseConfigurationReference = CC0000000000000000000001 /* App.xcconfig */;', + ); + expect(pbxprojOf(xcodeprojPath)).not.toContain('ReactNativeSPM.xcconfig'); + expect( + generatedFileContents(appRoot).map(content => content != null), + ).toEqual([true, false, false]); + expect(logged).toContain(`Kept ${XCCONFIG_REL}`); + }); + + it('accepts a user xcconfig that #includes the generated one, across syncs and deinit', () => { + const {appRoot, xcodeprojPath, rnRoot} = scaffoldApp(FOREIGN_XCCONFIG); + writeUserXcconfig(appRoot, `#include "../${XCCONFIG_REL}"\n`); + const sync = () => + injectSpmIntoExistingXcodeproj({ + appRoot, + reactNativeRoot: rnRoot, + xcodeprojPath, + }); + + expect(sync().status).toBe('injected'); + const injected = pbxprojOf(xcodeprojPath); + expect(readMarker(xcodeprojPath).xcconfig.includedByConfigUuids).toEqual([ + APP_DEBUG_CONFIG, + ]); + expect(sync().status).toBe('injected'); + expect(pbxprojOf(xcodeprojPath)).toBe(injected); + + removeSpmInjection({appRoot, xcodeprojPath}); + expect(pbxprojOf(xcodeprojPath)).toBe(FOREIGN_XCCONFIG); + expect(fs.existsSync(path.join(appRoot, XCCONFIG_REL))).toBe(true); + }); + + it('keeps the xcconfig a recorded configuration included, even once its base is gone', () => { + const {appRoot, xcodeprojPath, rnRoot} = scaffoldApp(FOREIGN_XCCONFIG); + writeUserXcconfig(appRoot, `#include "../${XCCONFIG_REL}"\n`); + injectSpmIntoExistingXcodeproj({ + appRoot, + reactNativeRoot: rnRoot, + xcodeprojPath, + }); + fs.writeFileSync( + path.join(xcodeprojPath, 'project.pbxproj'), + pbxprojOf(xcodeprojPath).replace( + '\t\t\tbaseConfigurationReference = CC0000000000000000000001 /* App.xcconfig */;\n', + '', + ), + 'utf8', + ); + + removeSpmInjection({appRoot, xcodeprojPath}); + expect(fs.existsSync(path.join(appRoot, XCCONFIG_REL))).toBe(true); + }); + + it('deletes the xcconfig for a marker written before includes were recorded', () => { + const {appRoot, xcodeprojPath, rnRoot} = scaffoldApp(); + injectSpmIntoExistingXcodeproj({ + appRoot, + reactNativeRoot: rnRoot, + xcodeprojPath, + }); + const marker = readMarker(xcodeprojPath); + delete marker.xcconfig.includedByConfigUuids; + fs.writeFileSync( + path.join(xcodeprojPath, SPM_INJECTED_MARKER), + JSON.stringify(marker, null, 2) + '\n', + ); + + removeSpmInjection({appRoot, xcodeprojPath}); + expect(pbxprojOf(xcodeprojPath)).toBe(PLAIN); + expect(generatedFileContents(appRoot)).toEqual( + GENERATED_FILES.map(() => null), + ); + }); + + // A ${PODS_ROOT}-anchored value resolves empty without CocoaPods, so putting + // it back would only restore a broken setting. + it('does not restore the ${PODS_ROOT}-anchored REACT_NATIVE_PATH it removed', () => { + const {appRoot, xcodeprojPath, rnRoot} = scaffoldApp( + withSetting( + 'REACT_NATIVE_PATH = "${PODS_ROOT}/../../node_modules/react-native";', + ), + ); + injectSpmIntoExistingXcodeproj({ + appRoot, + reactNativeRoot: rnRoot, + xcodeprojPath, + }); + expect(pbxprojOf(xcodeprojPath)).not.toContain('PODS_ROOT'); + expect( + readMarker(xcodeprojPath).xcconfig.removedPodsRootReactNativePath, + ).toBe(true); + + // Sticky: the update that follows no longer sees the value it removed. + injectSpmIntoExistingXcodeproj({ + appRoot, + reactNativeRoot: rnRoot, + xcodeprojPath, + }); + expect( + readMarker(xcodeprojPath).xcconfig.removedPodsRootReactNativePath, + ).toBe(true); + + removeSpmInjection({appRoot, xcodeprojPath}); + expect(pbxprojOf(xcodeprojPath)).toBe(PLAIN); + }); + + it('refuses a setting without $(inherited) and leaves the project untouched', () => { + const {appRoot, xcodeprojPath, rnRoot} = scaffoldApp( + withSetting('HEADER_SEARCH_PATHS = ;'), + ); + const before = pbxprojOf(xcodeprojPath); + const result = injectSpmIntoExistingXcodeproj({ + appRoot, + reactNativeRoot: rnRoot, + xcodeprojPath, + }); + expect(result.status).toBe('refused'); + expect(result.reason).toContain('HEADER_SEARCH_PATHS (Debug, Release)'); + expect(pbxprojOf(xcodeprojPath)).toBe(before); + expect(fs.existsSync(path.join(xcodeprojPath, SPM_INJECTED_MARKER))).toBe( + false, + ); + expect(generatedFileContents(appRoot)).toEqual( + GENERATED_FILES.map(() => null), + ); + }); +}); + +// --------------------------------------------------------------------------- +// A project injected before the settings moved to the generated xcconfig +// carries them in project.pbxproj, recorded per configuration in the marker's +// `buildSettingChanges`. The next sync reverses that record first, so the +// project moves over on its own. +// --------------------------------------------------------------------------- +describe('a project injected with settings in project.pbxproj', () => { + const DEBUG = 'AA0000000000000000000901'; + const RELEASE = 'AA00000000000000000000A2'; + + function scaffoldLegacyApp(fields, change) { + const app = scaffoldApp( + fields.length > 0 ? withSetting(fields.join('\n\t\t\t\t')) : PLAIN, + ); + fs.writeFileSync( + path.join(app.xcodeprojPath, SPM_INJECTED_MARKER), + JSON.stringify({ + target: 'MyApp', + targetUuid: 'AA0000000000000000000301', + injectedUuids: [], + createdArrayFields: [], + buildSettingChanges: [DEBUG, RELEASE].map(configUuid => ({ + configUuid, + createdArrayKeys: [], + appendedArrayValues: {}, + createdScalars: [], + ...change, + })), + }), + ); + return app; + } + + it('moves them into the generated xcconfig on the next sync', () => { + const {appRoot, xcodeprojPath, rnRoot} = scaffoldLegacyApp( + [ + 'OTHER_LDFLAGS = (\n\t\t\t\t\t"$(inherited)",\n\t\t\t\t\t"-ObjC",\n\t\t\t\t);', + 'REACT_NATIVE_PATH = ../node_modules/react-native;', + 'RN_SPM_FLAVOR = debug;', + ], + { + createdArrayKeys: ['OTHER_LDFLAGS'], + createdScalars: ['REACT_NATIVE_PATH', 'RN_SPM_FLAVOR'], + }, + ); + expect( + injectSpmIntoExistingXcodeproj({ + appRoot, + reactNativeRoot: rnRoot, + xcodeprojPath, + }).status, + ).toBe('injected'); + + const migrated = pbxprojOf(xcodeprojPath); + expect(migrated).not.toMatch( + /OTHER_LDFLAGS|REACT_NATIVE_PATH|RN_SPM_FLAVOR/, + ); + expect(migrated.match(/baseConfigurationReference = /g)).toHaveLength(2); + expect(readMarker(xcodeprojPath)).not.toHaveProperty('buildSettingChanges'); + + removeSpmInjection({appRoot, xcodeprojPath}); + expect(pbxprojOf(xcodeprojPath)).toBe(PLAIN); + }); + + // The setting the user had before the promotion lacks $(inherited), which + // only shows once the promotion is reversed. + it('refuses when reversing a promotion leaves a setting without $(inherited)', () => { + const {appRoot, xcodeprojPath, rnRoot} = scaffoldLegacyApp( + [ + 'OTHER_LDFLAGS = (\n\t\t\t\t\t"$(inherited)",\n\t\t\t\t\t"-lc++",\n\t\t\t\t\t"-ObjC",\n\t\t\t\t);', + ], + {promotedArrayScalars: {OTHER_LDFLAGS: '"-lc++"'}}, + ); + const result = injectSpmIntoExistingXcodeproj({ + appRoot, + reactNativeRoot: rnRoot, + xcodeprojPath, + }); + expect(result.status).toBe('refused'); + expect(result.reason).toContain('OTHER_LDFLAGS (Debug, Release)'); + }); + + it('does not resurrect a promoted setting the user has since deleted', () => { + const {appRoot, xcodeprojPath} = scaffoldLegacyApp([], { + promotedArrayScalars: { + HEADER_SEARCH_PATHS: '"$(inherited) $(SRCROOT)/vendor/include"', + }, + }); + removeSpmInjection({appRoot, xcodeprojPath}); + expect(pbxprojOf(xcodeprojPath)).toBe(PLAIN); + }); }); // --------------------------------------------------------------------------- @@ -499,6 +930,21 @@ describe('deinit — scheme ownership', () => { expect(deinit().status).toBe('removed'); expect(fs.existsSync(schemePath)).toBe(false); }); + + it('deletes its own scheme written with raw line breaks in the script', () => { + const {sync, deinit, schemePath, readScheme} = setUp(); + sync(); + fs.writeFileSync( + schemePath, + readScheme().replace(/&#(9|10|13);/g, (_, c) => + String.fromCharCode(Number(c)), + ), + 'utf8', + ); + + expect(deinit().status).toBe('removed'); + expect(fs.existsSync(schemePath)).toBe(false); + }); }); // --------------------------------------------------------------------------- @@ -1310,10 +1756,9 @@ describe.each(Object.entries(PRE_EXISTING_HEADER_SEARCH_PATHS))( // findField's token for a BARE scalar ends AT the `;`, so it includes any // whitespace before it. Deinit must put those bytes back exactly, not a // tidied-up version of them. -describe.each([ - 'HEADER_SEARCH_PATHS = $(inherited) ; /* note */', - 'HEADER_SEARCH_PATHS = ;', -])('removeSpmInjection with the untrimmed scalar `%s`', field => { +describe('removeSpmInjection with an untrimmed scalar', () => { + const field = 'HEADER_SEARCH_PATHS = $(inherited) ; /* note */'; + it('restores it byte-for-byte', () => { const {appRoot, xcodeprojPath, rnRoot} = scaffoldApp(withSetting(field)); const before = pbxprojOf(xcodeprojPath); @@ -1330,12 +1775,10 @@ describe.each([ }); }); -describe('a scalar array setting injection has nothing to add to', () => { +describe("a list setting of the project's own that keeps $(inherited)", () => { const SCALAR = 'FRAMEWORK_SEARCH_PATHS = "$(inherited)";'; const EDITED = 'FRAMEWORK_SEARCH_PATHS = "$(inherited) $(SRCROOT)/Vendor";'; - // The fixture's flavored-frameworks manifest is empty, so - // FRAMEWORK_SEARCH_PATHS is injected with no values at all. it('is left untouched, unrecorded, and survives a later user edit', () => { const {appRoot, xcodeprojPath, rnRoot} = scaffoldApp(withSetting(SCALAR)); @@ -1348,11 +1791,7 @@ describe('a scalar array setting injection has nothing to add to', () => { const injected = pbxprojOf(xcodeprojPath); expect(injected).toContain(SCALAR); expect(injected).not.toMatch(/FRAMEWORK_SEARCH_PATHS = \(/); - for (const change of readMarker(xcodeprojPath).buildSettingChanges) { - expect(change.promotedArrayScalars ?? {}).not.toHaveProperty( - 'FRAMEWORK_SEARCH_PATHS', - ); - } + expect(markerTextOf(xcodeprojPath)).not.toContain('FRAMEWORK_SEARCH_PATHS'); fs.writeFileSync( path.join(xcodeprojPath, 'project.pbxproj'), @@ -1366,70 +1805,3 @@ describe('a scalar array setting injection has nothing to add to', () => { expect(after).not.toContain(SCALAR); }); }); - -describe('a promoted array setting the user deleted after add', () => { - const SCALAR = '"$(inherited) $(SRCROOT)/vendor/include"'; - - it('is not resurrected by deinit', () => { - const {appRoot, xcodeprojPath, rnRoot} = scaffoldApp( - withHeaderSearchPaths(SCALAR), - ); - const before = pbxprojOf(xcodeprojPath); - - injectSpmIntoExistingXcodeproj({ - appRoot, - reactNativeRoot: rnRoot, - xcodeprojPath, - }); - - const deleted = pbxprojOf(xcodeprojPath).replace( - /\n\t+HEADER_SEARCH_PATHS = \(\n[\s\S]*?\n\t+\);/g, - '', - ); - expect(deleted).not.toContain('HEADER_SEARCH_PATHS'); - fs.writeFileSync( - path.join(xcodeprojPath, 'project.pbxproj'), - deleted, - 'utf8', - ); - - removeSpmInjection({appRoot, xcodeprojPath}); - - // Everything else is back to its pre-injection bytes; only the setting the - // user deleted stays gone. - expect(pbxprojOf(xcodeprojPath)).toBe( - before.replaceAll(`\n\t\t\t\tHEADER_SEARCH_PATHS = ${SCALAR};`, ''), - ); - }); -}); - -// `deinit` removes appendedArrayValues before it restores promotedArrayScalars, -// so recording a key under both happens to come out right today: the scalar -// restore rewrites the whole value last. That makes the exclusivity below -// invisible to a round-trip test, which is why it is asserted on the marker -// directly — reversing those two loops would otherwise silently start removing -// array members from an already-restored scalar. -describe('a promoted scalar is recorded once, not twice', () => { - it('records promotedArrayScalars and not appendedArrayValues for the key', () => { - const {appRoot, xcodeprojPath, rnRoot} = scaffoldApp( - withHeaderSearchPaths('"$(inherited) $(SRCROOT)/vendor/include"'), - ); - - injectSpmIntoExistingXcodeproj({ - appRoot, - reactNativeRoot: rnRoot, - xcodeprojPath, - }); - - const changes = readMarker(xcodeprojPath).buildSettingChanges; - expect(changes.length).toBeGreaterThan(0); - for (const change of changes) { - expect(Object.keys(change.promotedArrayScalars ?? {})).toContain( - 'HEADER_SEARCH_PATHS', - ); - expect(Object.keys(change.appendedArrayValues ?? {})).not.toContain( - 'HEADER_SEARCH_PATHS', - ); - } - }); -}); diff --git a/packages/react-native/scripts/spm/__tests__/spm-pbxproj-test.js b/packages/react-native/scripts/spm/__tests__/spm-pbxproj-test.js index 755d808f7949..16db46f653ab 100644 --- a/packages/react-native/scripts/spm/__tests__/spm-pbxproj-test.js +++ b/packages/react-native/scripts/spm/__tests__/spm-pbxproj-test.js @@ -424,6 +424,43 @@ describe('ensureScalarField', () => { out, ); }); + + // Xcode writes an object's fields `isa` first, then alphabetically — landing + // anywhere else plants a diff the next time Xcode saves the project. + it('adds the field directly after `after` when that field exists', () => { + const config = findObjectByUuid(PLAIN_PBXPROJ, 'AA0000000000000000000901'); + const out = ensureScalarField( + PLAIN_PBXPROJ, + config, + 'baseConfigurationReference', + 'DD0000000000000000000101 /* App.xcconfig */', + {after: 'isa'}, + ); + expect(out).toContain( + 'AA0000000000000000000901 /* Debug */ = {\n' + + '\t\t\tisa = XCBuildConfiguration;\n' + + '\t\t\tbaseConfigurationReference = DD0000000000000000000101 /* App.xcconfig */;\n' + + '\t\t\tbuildSettings = {', + ); + expect( + removeField( + out, + findObjectByUuid(out, 'AA0000000000000000000901'), + 'baseConfigurationReference', + ), + ).toBe(PLAIN_PBXPROJ); + }); + + it('falls back to the top of the body when `after` is absent', () => { + const project = findProjectObject(PLAIN_PBXPROJ); + expect( + ensureScalarField(PLAIN_PBXPROJ, project, 'ORGANIZATIONNAME', 'Acme', { + after: 'NO_SUCH_FIELD', + }), + ).toBe( + ensureScalarField(PLAIN_PBXPROJ, project, 'ORGANIZATIONNAME', 'Acme'), + ); + }); }); describe('setScalarField', () => { diff --git a/packages/react-native/scripts/spm/__tests__/sync-spm-autolinking-test.js b/packages/react-native/scripts/spm/__tests__/sync-spm-autolinking-test.js index b2e81d23e5d0..1f12073df3ed 100644 --- a/packages/react-native/scripts/spm/__tests__/sync-spm-autolinking-test.js +++ b/packages/react-native/scripts/spm/__tests__/sync-spm-autolinking-test.js @@ -10,7 +10,10 @@ 'use strict'; -const {main} = require('../sync-spm-autolinking'); +const {MissingManifestError} = require('../generate-spm-autolinking'); +const {PluginFrameworkMismatchError} = require('../generate-spm-xcodeproj'); +const {RemoteVersionError} = require('../spm-utils'); +const {main, reportFailure} = require('../sync-spm-autolinking'); const fs = require('node:fs'); const os = require('node:os'); const path = require('node:path'); @@ -38,6 +41,7 @@ describe('sync-spm-autolinking main', () => { generateAutolinking: jest.fn(), installSpmCodegenTemplate: jest.fn(), buildPerAppHeaderTree: jest.fn(), + assertPluginFrameworksLinked: jest.fn(), findProjectRoot: jest.fn(() => appRoot), // Obsolete collaborators are deliberately supplied to prove sync does // not download artifacts or regenerate the runtime package graph. @@ -91,6 +95,7 @@ describe('sync-spm-autolinking main', () => { appRoot, expect.any(Object), ); + expect(deps.assertPluginFrameworksLinked).toHaveBeenCalledWith(appRoot); expect(deps.downloadArtifacts).not.toHaveBeenCalled(); expect(deps.generatePackage).not.toHaveBeenCalled(); expect(fs.existsSync(stampPath())).toBe(true); @@ -133,4 +138,93 @@ describe('sync-spm-autolinking main', () => { ).toBeLessThan(deps.generateAutolinking.mock.invocationCallOrder[0]); expect(fs.existsSync(stampPath())).toBe(false); }); + + function mismatchPath() { + return path.join( + appRoot, + 'build', + 'generated', + 'autolinking', + '.spm-plugin-mismatch', + ); + } + + it('records the errors and refreshes the stamp when plugin frameworks and the project disagree', async () => { + fs.mkdirSync(path.dirname(stampPath()), {recursive: true}); + fs.writeFileSync(stampPath(), 'earlier sync\n'); + const mismatch = new PluginFrameworkMismatchError([ + 'error: first problem', + 'error: second problem', + ]); + const deps = makeDeps({ + assertPluginFrameworksLinked: jest.fn(() => { + throw mismatch; + }), + }); + await expect(run(deps)).rejects.toBe(mismatch); + expect(deps.generateAutolinking.mock.invocationCallOrder[0]).toBeLessThan( + deps.assertPluginFrameworksLinked.mock.invocationCallOrder[0], + ); + expect(fs.readFileSync(mismatchPath(), 'utf8')).toBe( + 'error: first problem\nerror: second problem\n', + ); + expect(fs.readFileSync(stampPath(), 'utf8')).not.toBe('earlier sync\n'); + }); + + it('removes the recorded errors when the check passes', async () => { + fs.mkdirSync(path.dirname(mismatchPath()), {recursive: true}); + fs.writeFileSync(mismatchPath(), 'error: earlier problem\n'); + await run(makeDeps()); + expect(fs.existsSync(mismatchPath())).toBe(false); + }); + + it('leaves the stamp alone when the check fails for another reason', async () => { + const deps = makeDeps({ + assertPluginFrameworksLinked: jest.fn(() => { + throw new Error('unreadable marker'); + }), + }); + await expect(run(deps)).rejects.toThrow(/unreadable marker/); + expect(fs.existsSync(stampPath())).toBe(false); + expect(fs.existsSync(mismatchPath())).toBe(false); + }); +}); + +describe('sync-spm-autolinking reportFailure', () => { + let logSpy; + let errorSpy; + + beforeEach(() => { + logSpy = jest.spyOn(console, 'log').mockImplementation(() => {}); + errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + }); + + afterEach(() => { + jest.restoreAllMocks(); + process.exitCode = undefined; + }); + + it.each([ + ['a remote version error', new RemoteVersionError('bad remote version')], + ['a missing manifest', new MissingManifestError([])], + [ + 'a plugin framework mismatch', + new PluginFrameworkMismatchError(['error: a problem']), + ], + ])('exits 2 with only the message for %s', (_, error) => { + reportFailure(error); + expect(process.exitCode).toBe(2); + expect(errorSpy).not.toHaveBeenCalled(); + expect(logSpy.mock.calls.map(call => call.join(' '))).toEqual([ + expect.stringContaining(error.message), + ]); + expect(logSpy.mock.calls[0].join(' ')).not.toContain(' at '); + }); + + it('exits 1 with the stack trace for any other error', () => { + const error = new Error('unexpected'); + reportFailure(error); + expect(process.exitCode).toBe(1); + expect(errorSpy).toHaveBeenCalledWith(error); + }); }); diff --git a/packages/react-native/scripts/spm/autolinking-plugins.js b/packages/react-native/scripts/spm/autolinking-plugins.js index 1063267e0d5c..23dfc48d08c8 100644 --- a/packages/react-native/scripts/spm/autolinking-plugins.js +++ b/packages/react-native/scripts/spm/autolinking-plugins.js @@ -65,7 +65,8 @@ * // Absolute paths (dirs OR files) the Xcode auto-sync build phase * // should watch for staleness — e.g. the plugin dep's own * // `Package.swift`, `expo-module.config.json`, and per-module - * // manifests. Folded into `.spm-sync-watch-paths`; a file edit or a + * // manifests. Folded into `.spm-sync-watch-paths` (or + * // `.spm-sync-watch-absent` while missing); a file edit or a * // dir add/remove there re-triggers the sync. Relative/empty/non-string * // entries are dropped with a warning; a non-array is ignored. * watchPaths: ['/abs/path/Package.swift', '/abs/dir'], diff --git a/packages/react-native/scripts/spm/flavored-frameworks.js b/packages/react-native/scripts/spm/flavored-frameworks.js index 5e550a75dea6..5065cddb03a6 100644 --- a/packages/react-native/scripts/spm/flavored-frameworks.js +++ b/packages/react-native/scripts/spm/flavored-frameworks.js @@ -1002,6 +1002,7 @@ module.exports = { finalizeArtifactPublication, prepareFlavoredFrameworks, readFlavoredFrameworksManifest, + readPluginFrameworks, sdkConditionForSlice, transformReactModuleMap, validatePair, diff --git a/packages/react-native/scripts/spm/generate-spm-autolinking.js b/packages/react-native/scripts/spm/generate-spm-autolinking.js index 17ecbbe7bef9..24ebbca34921 100644 --- a/packages/react-native/scripts/spm/generate-spm-autolinking.js +++ b/packages/react-native/scripts/spm/generate-spm-autolinking.js @@ -1920,12 +1920,15 @@ function main(argv /*:: ?: Array */) /*: void */ { // stale until an unrelated install triggers a sync. The dep ROOT is // threaded through the entry (entry.root, from the autolinking model), // not derived by walking up from the (possibly nested) source dir. + // Both are listed even when absent: one that appears later (e.g. after + // `spm scaffold`) changes self-managed detection and needs a re-sync. // 3. Plugin-contributed paths (e.g. Expo's own Package.swift / per-module // manifests) — already validated absolute in invokePlugins. // The phase distinguishes dir vs file with `-d`/`-f` at build time (no - // markers). The existsSync filter here is safe: these paths come from a - // successful sync so they exist now; a path that later VANISHES is caught at - // phase time against this file and forces a re-sync. + // markers). A listed path that later VANISHES forces a re-sync. Paths that do + // not exist yet go to .spm-sync-watch-absent, where one that APPEARS forces a + // re-sync; they stay out of this file because phases from older versions + // treat a missing listed path as stale. const watchCandidates /*: Array */ = [...entryAbsDirs.values()]; for (const entry of entries) { const root = entry.root; @@ -1936,14 +1939,23 @@ function main(argv /*:: ?: Array */) /*: void */ { watchCandidates.push(path.join(root, '.react-native')); } watchCandidates.push(...pluginWatchPaths); - const watchPaths = Array.from(new Set(watchCandidates)) - .filter(p => p.length > 0 && fs.existsSync(p)) - .sort(); + const watchPaths /*: Array */ = []; + const absentWatchPaths /*: Array */ = []; + for (const p of Array.from(new Set(watchCandidates)).sort()) { + if (p.length > 0) { + (fs.existsSync(p) ? watchPaths : absentWatchPaths).push(p); + } + } fs.writeFileSync( path.join(outputDir, '.spm-sync-watch-paths'), watchPaths.join('\n') + (watchPaths.length > 0 ? '\n' : ''), 'utf8', ); + fs.writeFileSync( + path.join(outputDir, '.spm-sync-watch-absent'), + absentWatchPaths.join('\n') + (absentWatchPaths.length > 0 ? '\n' : ''), + 'utf8', + ); // AutolinkedAggregate is glue; needs at least one source file (Swift, so we // sidestep the Obj-C public-headers-dir requirement). diff --git a/packages/react-native/scripts/spm/generate-spm-xcodeproj.js b/packages/react-native/scripts/spm/generate-spm-xcodeproj.js index 349f366b2fa1..fe1d021d663b 100644 --- a/packages/react-native/scripts/spm/generate-spm-xcodeproj.js +++ b/packages/react-native/scripts/spm/generate-spm-xcodeproj.js @@ -14,18 +14,23 @@ * generate-spm-xcodeproj.js – Surgical, in-place Swift Package Manager * integration toolkit for an existing `.xcodeproj`. * - * `injectSpmIntoExistingXcodeproj` adds the SPM package references, React build - * settings, the "Sync SPM Autolinking" build phase, and a scheme pre-action to - * a user's existing project — purely additively, recording every edit in a - * `.spm-injected.json` marker. `removeSpmInjection` is the exact inverse (used - * by `spm deinit`). Consumed as a library by setup-apple-spm.js; not a CLI. + * `injectSpmIntoExistingXcodeproj` adds the SPM package references, a generated + * `.xcconfig` carrying the React build settings, the "Sync SPM Autolinking" build + * phase, and a scheme pre-action to a user's existing project — purely + * additively, recording every edit in a `.spm-injected.json` marker. + * `removeSpmInjection` is the exact inverse (used by `spm deinit`). Consumed as a + * library by setup-apple-spm.js; not a CLI. */ -const {readFlavoredFrameworksManifest} = require('./flavored-frameworks'); +const { + BUILTIN_FRAMEWORKS, + FLAVORED_FRAMEWORKS_MANIFEST, + readFlavoredFrameworksManifest, + readPluginFrameworks, +} = require('./flavored-frameworks'); const {parseConfigCommandJson} = require('./generate-spm-autolinking-config'); const { addArrayMembers, - addArrayStringValues, commentSafe, ensureScalarField, findApplicationTargets, @@ -56,6 +61,11 @@ const { makeLogger, remotePackageConfig, } = require('./spm-utils'); +const { + readXcconfig, + xcconfigIncludes, + xcconfigReferencePaths, +} = require('./xcconfig-files'); const fs = require('node:fs'); const path = require('node:path'); @@ -97,6 +107,23 @@ const SPM_SCRIPT_PHASES_MANIFEST = path.join( '.spm-plugin-script-phases.json', ); +// Everything that follows the installed React Native version and frameworks is +// written to these files on every `spm add`/`update`, so that a routine update +// leaves project.pbxproj alone — it only references them. Relative to the app +// root (== SRCROOT). Deliberately NOT under `build/`: that tree holds the SPM +// package roots (build/xcframeworks, build/generated/autolinking, +// build/generated/ios) and is gitignored + regenerated by the in-build "Sync +// SPM Autolinking" phase. These three files are read while Xcode resolves +// build settings and build-phase scripts — before any build phase has run — +// so unlike the package roots they cannot be lazily regenerated at build +// time. They must exist on a fresh clone, so they are committed like +// project.pbxproj itself. +const GENERATED_XCCONFIG_PATH = 'ReactNativeSPM/ReactNativeSPM.xcconfig'; +const SYNC_AUTOLINKING_SCRIPT_PATH = + 'ReactNativeSPM/Scripts/sync-autolinking.sh'; +const EMBED_FRAMEWORKS_SCRIPT_PATH = + 'ReactNativeSPM/Scripts/embed-flavored-frameworks.sh'; + // The single navigator group all injected generated sources are parented under // (created on first use). Its namespacedUUID id + display name. const SPM_GENERATED_SOURCES_GROUP_ID = 'SPMGeneratedSources'; @@ -136,7 +163,9 @@ const SPM_PRODUCT_PACKAGES /*: Array<{product: string, packagePath: string, pack /*:: type RemoteCfg = {url: string, version: string, identity: string}; // Precise record of the build-setting edits injection made to ONE build config, -// so deinit can reverse exactly those (and nothing the user already had). +// written by versions that put the React build settings into project.pbxproj +// directly. Now only read back, so the next sync or deinit can reverse exactly +// those edits (and nothing the user already had). type BuildSettingChange = { configUuid: string, createdArrayKeys: Array, @@ -155,6 +184,19 @@ type BuildSettingChange = { // An array field injection CREATED (rather than appended to a pre-existing // one), so deinit removes the whole field and lands byte-identical. type CreatedArrayField = {container: 'project' | 'target', key: string}; +// How the app configurations were based on the generated xcconfig. Deinit +// clears `baseConfigurationReference` only on these, and only while it still +// points at `fileRefUuid`. `includedByConfigUuids` keep a base of the user's +// own whose xcconfig #includes the generated one, so deinit keeps that file; +// markers written before it existed lack it. `removedPodsRootReactNativePath` +// is sticky and informational: deinit does not put that value back, because it +// resolves empty without CocoaPods. +type XcconfigRecord = { + fileRefUuid: string, + configUuids: Array, + includedByConfigUuids?: Array, + removedPodsRootReactNativePath: boolean, +}; // A plugin-contributed source, normalized for pbxproj emission. `path` is // SRCROOT-relative when under the app root, else absolute; `sourceTree` is the // matching pbxproj token ('SOURCE_ROOT' or '""'). @@ -354,6 +396,7 @@ function buildConfigurationName( return name.value.replace(/^"|"$/g, ''); } +/** The per-slice framework locations, as xcconfig keys and values. */ function frameworkConditionalSettings( frameworks /*: ReadonlyArray */, ) /*: Array<{key: string, value: string}> */ { @@ -379,17 +422,14 @@ function frameworkConditionalSettings( `${framework.artifactRelativePath}/${slice.libraryIdentifier}`; settings.push( { - key: quoteIfNeeded(`${prefix}_FRAMEWORK${condition}`), - value: quoteIfNeeded(`${root}/${slice.libraryPath}`), + key: `${prefix}_FRAMEWORK${condition}`, + value: `${root}/${slice.libraryPath}`, }, { - key: quoteIfNeeded(`${prefix}_BINARY${condition}`), - value: quoteIfNeeded(`${root}/${slice.binaryPath}`), - }, - { - key: quoteIfNeeded(`${prefix}_SEARCH_PATH${condition}`), - value: quoteIfNeeded(root), + key: `${prefix}_BINARY${condition}`, + value: `${root}/${slice.binaryPath}`, }, + {key: `${prefix}_SEARCH_PATH${condition}`, value: root}, ); }; if (slices.length === 1) { @@ -413,14 +453,16 @@ function frameworkConditionalSettings( return settings; } -function frameworkArrayBuildSettings( +// List-valued settings, in xcconfig syntax: quotes group a value whose expansion +// may contain spaces into one list element. +function frameworkListSettings( frameworks /*: ReadonlyArray */, ) /*: Array<{key: string, values: Array}> */ { return [ { key: 'OTHER_LDFLAGS', values: [ - '"-ObjC"', + '-ObjC', ...frameworks.map( framework => `"$(${frameworkSettingPrefix(framework.id)}_BINARY)"`, ), @@ -434,11 +476,33 @@ function frameworkArrayBuildSettings( }, { key: 'LD_RUNPATH_SEARCH_PATHS', - values: ['"@executable_path/Frameworks"'], + values: ['@executable_path/Frameworks'], }, ]; } +function generatedScript(body /*: string */) /*: string */ { + return `#!/bin/bash +# Generated by React Native on every \`spm add\` and \`spm update\`. +# Do not edit: changes are overwritten. + +${body}`; +} + +// What a build phase runs in place of its generated script, which lives in a +// file so that the phase itself never changes. The file is committed (see +// GENERATED_XCCONFIG_PATH's comment) so a fresh clone always has it; the +// guard below is for the rarer case of a checkout that predates this +// mechanism, or the file having been deleted by hand. +function generatedScriptWrapper(scriptPath /*: string */) /*: string */ { + return `SCRIPT="$SRCROOT/${scriptPath}" +if [ ! -f "$SCRIPT" ]; then + echo "error: $SCRIPT is missing. Run 'npx react-native spm update' to generate it." + exit 1 +fi +exec /bin/bash "$SCRIPT"`; +} + function pbxPathList(paths /*: ReadonlyArray */) /*: string */ { if (paths.length === 0) { return '(\n\t\t\t)'; @@ -687,6 +751,9 @@ fi`; // Runtime framework slots are never touched here; add/update owns them. function syncStaleCheckAndDispatch() /*: string */ { return `STAMP="$SRCROOT/build/generated/autolinking/.spm-sync-stamp" +PLUGIN_MISMATCH="$SRCROOT/build/generated/autolinking/.spm-plugin-mismatch" +PLUGIN_FRAMEWORKS="$SRCROOT/build/generated/autolinking/.spm-plugin-flavored-frameworks.json" +MARKER="\${PROJECT_FILE_PATH:-}/${SPM_INJECTED_MARKER}" STALE=0 # Find project root (where package.json lives — may be an ancestor of SRCROOT) @@ -698,10 +765,12 @@ if [ ! -f "$PROJECT_ROOT/package.json" ]; then PROJECT_ROOT="$SRCROOT" fi -# Check 1: dependency inputs (covers app projects after any package manager install) +# Check 1: dependency inputs (covers app projects after any package manager +# install), and the marker, which records the frameworks the project links. for INPUT in \\ "$PROJECT_ROOT/package.json" \\ - "$PROJECT_ROOT/react-native.config.js"; do + "$PROJECT_ROOT/react-native.config.js" \\ + "$MARKER"; do if [ -f "$INPUT" ] && [ "$INPUT" -nt "$STAMP" ]; then STALE=1 break @@ -785,6 +854,19 @@ if [ "$STALE" -eq 0 ] && [ -f "$WATCH_FILE" ]; then done < "$WATCH_FILE" fi +# Watched paths that did not exist at the last sync. One that APPEARS (e.g. a +# plugin's precompiled output folder) forces a re-sync. +ABSENT_FILE="$SRCROOT/build/generated/autolinking/.spm-sync-watch-absent" +if [ "$STALE" -eq 0 ] && [ -f "$ABSENT_FILE" ]; then + while IFS= read -r P; do + [ -z "$P" ] && continue + if [ -e "$P" ]; then + STALE=1 + break + fi + done < "$ABSENT_FILE" +fi + # Check 2: codegen spec files changed via git (covers monorepo after git pull) if [ "$STALE" -eq 0 ] && [ -f "$STAMP" ]; then STAMP_TIME=$(stat -f %m "$STAMP" 2>/dev/null || stat -c %Y "$STAMP" 2>/dev/null || echo 0) @@ -798,6 +880,20 @@ if [ ! -f "$STAMP" ]; then STALE=1 fi +# The last sync found a plugin framework mismatch and kept its errors. Until an +# input changes, report them again instead of re-running sync: the scheme +# pre-action and this build phase would otherwise both run it. spm add/update +# rewrite the plugin manifest without a sync, so that counts as a change too. +if [ -f "$PLUGIN_MISMATCH" ]; then + if [ "$STALE" -eq 0 ] && \\ + [ ! "$MARKER" -nt "$PLUGIN_MISMATCH" ] && \\ + [ ! "$PLUGIN_FRAMEWORKS" -nt "$PLUGIN_MISMATCH" ]; then + cat "$PLUGIN_MISMATCH" >&2 + exit 1 + fi + STALE=1 +fi + # Re-sync codegen + autolinking when a dependency input changed. Runtime # framework slots and Xcode linker settings are only changed by spm update. if [ "$STALE" -eq 1 ]; then @@ -817,7 +913,7 @@ if [ "$STALE" -eq 1 ]; then cd "$SRCROOT" # \`|| RC=$?\` so a non-zero exit is CAPTURED rather than aborting the phase # under \`set -e\` — the whole point is to branch on the code below (2 = fail - # the build with a scaffold hint; other non-zero = warn but don't break). + # the build; other non-zero = warn but don't break). RC=0 if [ -n "$NODE_BINARY" ] && [ -f "$RN_DIR/scripts/setup-apple-spm.js" ]; then # Direct, dependency-free dispatch (no \`npx react-native\`, which needs @@ -829,10 +925,10 @@ if [ "$STALE" -eq 1 ]; then echo "warning: node/npx not found — skipping SPM sync" fi if [ "$RC" -eq 2 ]; then - # Exit 2 = an autolinked community dependency has no Package.swift. The - # autolinker already printed an \`error:\` line per dep (so Xcode shows them - # and the fix). Fail the build — the developer must run - # \`npx react-native spm scaffold\` from a terminal to generate the manifest. + # Exit 2 = a problem only a terminal command fixes (e.g. a dependency + # without Package.swift, or plugin frameworks the project does not link). + # Sync already printed \`error:\` lines that name the command, so Xcode + # shows them. Fail the build. exit 1 elif [ "$RC" -ne 0 ]; then echo "warning: SPM sync failed — build may use stale codegen/autolinking" @@ -862,28 +958,21 @@ function buildSyncAutolinkingScript( ${syncStaleCheckAndDispatch()} `; } -// XML-attribute escape (the five named entities). The sync script uses `>` -// and `&` for redirection and bg/and chains, plus `<` for heredocs and -// comparisons — all of which break Xcode's scheme parser if left raw. +// XML-attribute escape. The sync script uses `>` and `&` for redirection and +// bg/and chains, plus `<` for heredocs and comparisons — all of which break +// Xcode's scheme parser if left raw. Raw line breaks and tabs are legal but an +// XML parser turns them into spaces (XML 1.0 §3.3.3), collapsing the script +// onto one line, so they are written as character references, as Xcode does. function escapeXmlAttribute(s /*: string */) /*: string */ { return s .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') - .replace(/'/g, '''); -} - -// The inverse of escapeXmlAttribute. `&` is expanded LAST so an entity that -// was itself escaped (`<` → `&lt;`) round-trips back to its own text -// rather than to `<`. -function unescapeXmlAttribute(s /*: string */) /*: string */ { - return s - .replace(/</g, '<') - .replace(/>/g, '>') - .replace(/"/g, '"') - .replace(/'/g, "'") - .replace(/&/g, '&'); + .replace(/'/g, ''') + .replace(/\r/g, ' ') + .replace(/\n/g, ' ') + .replace(/\t/g, ' '); } function generateXcscheme( @@ -1078,24 +1167,25 @@ function ensureStubPackages(appRoot /*: string */) /*: void */ { // In-place injection: add SPM packages to a user's EXISTING xcodeproj. // // This never creates a target or scans sources — it splices the SPM dependency -// graph, the React build settings, and the sync build phase / scheme pre-action -// into the project the user already owns, leaving everything else +// graph, a reference to the generated xcconfig carrying the React build +// settings, and the sync build phase / scheme pre-action into the project the +// user already owns, leaving everything else // byte-identical. The whole `spm add` / `spm update` xcodeproj strategy, so // hand-tuned signing / capabilities / extra targets survive. Fails loud (the // caller surfaces the error) when the project is CocoaPods-integrated or its // shape can't be safely anchored. // --------------------------------------------------------------------------- -// The React build settings the app target needs to compile against the SPM -// products. -const INJECTED_ARRAY_SETTINGS = [ +// The React list settings the app target needs to compile against the SPM +// products, in xcconfig syntax. +const REACT_LIST_SETTINGS = [ { key: 'HEADER_SEARCH_PATHS', values: ['"$(SRCROOT)/build/generated/autolinking/headers"'], }, ]; -// Array build settings injected only into debug-flavored configurations. +// List settings given only to debug-flavored configurations. // // Swift's `#if DEBUG` is gated by SWIFT_ACTIVE_COMPILATION_CONDITIONS, NOT by // GCC_PREPROCESSOR_DEFINITIONS (which only reaches C/ObjC/C++). The app @@ -1110,10 +1200,181 @@ const INJECTED_ARRAY_SETTINGS = [ // // Paired with RN_SPM_FLAVOR via flavorForBuildConfiguration, so a config that // links the debug xcframeworks also compiles its Swift with DEBUG. -const DEBUG_ARRAY_SETTINGS = [ +const DEBUG_LIST_SETTINGS = [ {key: 'SWIFT_ACTIVE_COMPILATION_CONDITIONS', values: ['DEBUG']}, ]; +/** + * The generated xcconfig every app configuration in `configNames` is based on: + * all of the React build settings, shared by every configuration, with the + * flavor-specific ones conditioned on each configuration's own name. List + * settings extend `$(inherited)`, the project-level value; a target's own + * value still outranks all of them (see listSettingsHidingXcconfig). + */ +function generateReactNativeXcconfig( + configNames /*: ReadonlyArray */, + reactNativePathFromSrcRoot /*: string */, + flavoredFrameworks /*: ReadonlyArray */, +) /*: string */ { + const listLine = (key /*: string */, values /*: ReadonlyArray */) => + `${key} = ${['$(inherited)', ...values].join(' ')}`; + const sections /*: Array> */ = [ + [ + '// Generated by React Native on every `spm add` and `spm update`.', + '// Do not edit: changes are overwritten.', + ], + [ + 'CLANG_CXX_LANGUAGE_STANDARD = c++20', + `REACT_NATIVE_PATH = ${['$(SRCROOT)', reactNativePathFromSrcRoot].filter(Boolean).join('/')}`, + ], + [...REACT_LIST_SETTINGS, ...frameworkListSettings(flavoredFrameworks)] + .filter(({values}) => values.length > 0) + .map(({key, values}) => listLine(key, values)), + [ + // For a configuration added since the last sync (duplicated in Xcode, + // which copies the base reference): flavorForBuildConfiguration's own + // fallback, rather than an unset flavor that empties every framework + // path. + 'RN_SPM_FLAVOR = release', + ...configNames.flatMap(name => { + const flavor = flavorForBuildConfiguration(name); + return [ + `RN_SPM_FLAVOR[config=${name}] = ${flavor}`, + ...(flavor === 'debug' ? DEBUG_LIST_SETTINGS : []).map( + ({key, values}) => listLine(`${key}[config=${name}]`, values), + ), + ]; + }), + ], + frameworkConditionalSettings(flavoredFrameworks).map( + ({key, value}) => `${key} = ${value}`, + ), + ]; + return ( + sections + .filter(lines => lines.length > 0) + .map(lines => lines.join('\n')) + .join('\n\n') + '\n' + ); +} + +function xcconfigFileRefUuid(rootUuid /*: string */) /*: string */ { + return namespacedUUID(rootUuid, 'PBXFileReference', GENERATED_XCCONFIG_PATH); +} + +/** A build configuration's `buildSettings` dict, as a body range. */ +function buildSettingsRange( + text /*: string */, + configUuid /*: string */, +) /*: ?{bodyOpen: number, bodyClose: number} */ { + const config = findObjectByUuid(text, configUuid); + const settings = + config != null ? findField(text, config, 'buildSettings') : null; + return settings != null + ? {bodyOpen: settings.valueStart, bodyClose: settings.tokenEnd - 1} + : null; +} + +/** + * The list settings the generated xcconfig sets that a configuration of the + * target sets itself without `$(inherited)` — setting → configuration names. A + * target's own value outranks the xcconfig it is based on, so each of these + * would silently drop the value React Native provides. + */ +function listSettingsHidingXcconfig( + text /*: string */, + configUuids /*: ReadonlyArray */, +) /*: Map> */ { + const hiding /*: Map> */ = new Map(); + for (const configUuid of configUuids) { + const name = buildConfigurationName(text, configUuid); + const settings = buildSettingsRange(text, configUuid); + if (settings == null) { + continue; + } + const keys = [ + ...REACT_LIST_SETTINGS, + ...frameworkListSettings([]), + ...(flavorForBuildConfiguration(name) === 'debug' + ? DEBUG_LIST_SETTINGS + : []), + ].map(({key}) => key); + for (const key of keys) { + const field = findField(text, settings, key); + if (field != null && !/\$[({]inherited[)}]/.test(field.value)) { + hiding.set(key, [...(hiding.get(key) ?? []), name]); + } + } + } + return hiding; +} + +// Xcode writes a PBXFileReference on one line, which findField can't read. +function fileReferenceField( + text /*: string */, + ref /*: {bodyOpen: number, bodyClose: number, ...} */, + key /*: string */, +) /*: ?string */ { + const value = new RegExp( + `[\\s{;]${key} = ("(?:[^"\\\\]|\\\\.)*"|[^;]*);`, + ).exec(text.slice(ref.bodyOpen, ref.bodyClose))?.[1]; + return value != null ? unquotePlist(value) : null; +} + +/** + * The app configurations based on an xcconfig other than the generated one — + * React Native never edits an xcconfig it does not own, so it cannot base + * those configurations on its own. `file` is the reference's path, and + * `absPath`/`content` the file on disk when it can be read. + */ +function foreignBaseConfigurations( + text /*: string */, + configUuids /*: ReadonlyArray */, + ownFileRefUuid /*: string */, + srcRoot /*: ?string */, +) /*: Array<{configUuid: string, config: string, file: string, onDisk: ?{absPath: string, content: string}}> */ { + const foreign = []; + for (const configUuid of configUuids) { + const config = findObjectByUuid(text, configUuid); + const base = + config != null + ? findField(text, config, 'baseConfigurationReference') + : null; + const refUuid = base?.value.match(/[0-9A-Fa-f]{24}/)?.[0]; + if (refUuid == null || refUuid === ownFileRefUuid) { + continue; + } + const ref = findObjectByUuid(text, refUuid); + const refPath = ref != null ? fileReferenceField(text, ref, 'path') : null; + let onDisk /*: ?{absPath: string, content: string} */ = null; + if (ref != null && refPath != null && srcRoot != null) { + const candidates = xcconfigReferencePaths( + text, + { + uuid: refUuid, + path: refPath, + sourceTree: fileReferenceField(text, ref, 'sourceTree') ?? '', + }, + srcRoot, + ); + for (const absPath of candidates) { + const content = readXcconfig(absPath); + if (content != null) { + onDisk = {absPath, content}; + break; + } + } + } + foreign.push({ + configUuid, + config: buildConfigurationName(text, configUuid), + file: refPath ?? refUuid, + onDisk, + }); + } + return foreign; +} + /** The XCBuildConfiguration UUIDs of a target (via its buildConfigurationList). */ function targetBuildConfigUuids( text /*: string */, @@ -1157,8 +1418,11 @@ function configUsesPods( * the chosen app target + its config/frameworks anchors, or a refusal reason * the caller surfaces (fail-loud). */ -function planInjection(text /*: string */, opts /*: {appName?: ?string} */) /*: - | {ok: true, rootUuid: string, target: {uuid: string, name: string, bodyOpen: number, bodyClose: number}, configUuids: Array, frameworksPhaseUuid: string, sourcesPhaseUuid: ?string} +function planInjection( + text /*: string */, + opts /*: {appName?: ?string, appRoot?: ?string, srcRoot?: ?string} */, +) /*: + | {ok: true, rootUuid: string, target: {uuid: string, name: string, bodyOpen: number, bodyClose: number}, configUuids: Array, includedByConfigUuids: Array, frameworksPhaseUuid: string, sourcesPhaseUuid: ?string} | {ok: false, reason: string} */ { const project = findProjectObject(text); if (project == null) { @@ -1201,6 +1465,55 @@ function planInjection(text /*: string */, opts /*: {appName?: ?string} */) /*: 'supports SPM-only targets', }; } + const generatedXcconfig = + opts.appRoot != null + ? path.resolve(opts.appRoot, GENERATED_XCCONFIG_PATH) + : null; + const includedByConfigUuids /*: Array */ = []; + for (const foreign of foreignBaseConfigurations( + text, + configUuids, + xcconfigFileRefUuid(project.uuid), + opts.srcRoot, + )) { + const {onDisk} = foreign; + if ( + onDisk != null && + generatedXcconfig != null && + xcconfigIncludes(onDisk.absPath, onDisk.content, generatedXcconfig) + ) { + includedByConfigUuids.push(foreign.configUuid); + continue; + } + const fix = + onDisk != null && generatedXcconfig != null + ? `add #include "${path + .relative(path.dirname(onDisk.absPath), generatedXcconfig) + .split(path.sep) + .join('/')}" to ${foreign.file} instead` + : `#include ${GENERATED_XCCONFIG_PATH} from ${foreign.file} instead, ` + + `with the path relative to the folder that holds ${foreign.file}`; + return { + ok: false, + reason: + `the ${foreign.config} configuration is already based on ` + + `${foreign.file}, and React Native does not edit xcconfig files it ` + + `does not own — ${fix}`, + }; + } + const hiding = listSettingsHidingXcconfig(text, configUuids); + if (hiding.size > 0) { + const listed = [...hiding] + .map(([key, configs]) => `${key} (${configs.join(', ')})`) + .join(', '); + return { + ok: false, + reason: + `the target sets ${listed} without $(inherited), which would hide ` + + `the values React Native provides in ${GENERATED_XCCONFIG_PATH} — ` + + 'add $(inherited) to each and re-run', + }; + } // The target's own Frameworks build phase (where product build files link). const buildPhases = findField(text, target, 'buildPhases'); const phaseUuids = @@ -1241,26 +1554,29 @@ function planInjection(text /*: string */, opts /*: {appName?: ?string} */) /*: rootUuid: project.uuid, target, configUuids, + includedByConfigUuids, frameworksPhaseUuid, sourcesPhaseUuid, }; } /** - * Splice the SPM dependency graph + React build settings + sync build phase - * into `text` and return the modified pbxproj. Pure string transform (no I/O), - * idempotent: objects already present (by UUID) and array members / settings - * already applied are skipped, so a second run is a no-op. + * Splice the SPM dependency graph, the generated xcconfig reference and the + * build phases into `text`, returning the modified pbxproj together with the + * files it references (`generatedFiles`, app-root-relative path → content) for + * the caller to write. Pure string transform (no I/O), idempotent: objects + * already present (by UUID) and array members / fields already applied are + * skipped, so a second run is a no-op. */ function injectSpmIntoPbxproj( input /*: string */, - plan /*: {rootUuid: string, targetUuid: string, configUuids: Array, frameworksPhaseUuid: string, sourcesPhaseUuid?: ?string} */, - reactNativePath /*: string */, + plan /*: {rootUuid: string, targetUuid: string, configUuids: Array, includedByConfigUuids?: ?ReadonlyArray, frameworksPhaseUuid: string, sourcesPhaseUuid?: ?string} */, + reactNativePaths /*: {fromAppRoot: string, fromSrcRoot: string} */, remote /*: ?RemoteCfg */, generatedSources /*: ReadonlyArray */ = [], flavoredFrameworks /*: ReadonlyArray */ = [], scriptPhases /*: ReadonlyArray */ = [], -) /*: {text: string, injectedUuids: Array, createdArrayFields: Array, buildSettingChanges: Array, generatedSourceUuids: {[string]: Array}, scriptPhaseUuids: {[string]: string}} */ { +) /*: {text: string, injectedUuids: Array, createdArrayFields: Array, xcconfig: XcconfigRecord, generatedFiles: {[string]: string}, generatedSourceUuids: {[string]: Array}, scriptPhaseUuids: {[string]: string}} */ { let text = input; const mkUuid = (section /*: string */, id /*: string */) => namespacedUUID(plan.rootUuid, section, id); @@ -1348,30 +1664,81 @@ function injectSpmIntoPbxproj( ); } - // 5. React build settings into every build config (Debug + Release). - const buildSettingChanges /*: Array */ = []; + // 5. Every app configuration is based on the generated xcconfig, which + // carries all React build settings — except one whose own xcconfig + // already #includes it. planInjection refused any other base + // configuration, so an existing one here is already ours. + const xcconfigName = path.basename(GENERATED_XCCONFIG_PATH); + const xcconfigRefUuid = xcconfigFileRefUuid(plan.rootUuid); + insertObjects('PBXFileReference', [ + { + uuid: xcconfigRefUuid, + comment: xcconfigName, + fields: { + isa: 'PBXFileReference', + lastKnownFileType: 'text.xcconfig', + name: xcconfigName, + path: GENERATED_XCCONFIG_PATH, + sourceTree: 'SOURCE_ROOT', + }, + }, + ]); + const xcconfigGroup = mainGroupObject(text); + if (xcconfigGroup != null) { + text = addArrayMembers(text, xcconfigGroup, 'children', [ + {uuid: xcconfigRefUuid, comment: xcconfigName}, + ]); + } + const includedBy = new Set(plan.includedByConfigUuids ?? []); + let removedPodsRootReactNativePath = false; for (const configUuid of plan.configUuids) { - const merged = mergeReactBuildSettings( - text, - configUuid, - buildConfigurationName(text, configUuid), - reactNativePath, - flavoredFrameworks, - ); - text = merged.text; - buildSettingChanges.push(merged.change); + const config = findObjectByUuid(text, configUuid); + if (config != null && !includedBy.has(configUuid)) { + text = ensureScalarField( + text, + config, + 'baseConfigurationReference', + `${xcconfigRefUuid}${uuidComment(xcconfigName)}`, + {after: 'isa'}, + ); + } + // The CocoaPods template's REACT_NATIVE_PATH is ${PODS_ROOT}-anchored, which + // resolves empty once CocoaPods is gone — and a target's own value outranks + // the xcconfig's, so it has to go for the working one to apply. + const settings = buildSettingsRange(text, configUuid); + if ( + settings != null && + findField(text, settings, 'REACT_NATIVE_PATH')?.value.includes( + 'PODS_ROOT', + ) === true + ) { + text = removeField(text, settings, 'REACT_NATIVE_PATH'); + removedPodsRootReactNativePath = true; + } } + const generatedFiles /*: {[string]: string} */ = { + [GENERATED_XCCONFIG_PATH]: generateReactNativeXcconfig( + plan.configUuids.map(uuid => buildConfigurationName(text, uuid)), + reactNativePaths.fromSrcRoot, + flavoredFrameworks, + ), + [SYNC_AUTOLINKING_SCRIPT_PATH]: generatedScript( + buildSyncAutolinkingScript(reactNativePaths.fromAppRoot), + ), + [EMBED_FRAMEWORKS_SCRIPT_PATH]: generatedScript( + buildEmbedFrameworksScript(flavoredFrameworks), + ), + }; // 6. The Sync SPM Autolinking build phase (safety net; the scheme pre-action // is what fires before SPM resolution). Prepended so it runs before // Sources. We do NOT add a JS-bundle phase — an existing app already // bundles JS via its own phase. - const syncScript = buildSyncAutolinkingScript(reactNativePath); const syncPhaseUuid = mkUuid('PBXShellScriptBuildPhase', 'SyncAutolinking'); const syncEntry = shellScriptPhase( syncPhaseUuid, 'Sync SPM Autolinking', - syncScript, + generatedScriptWrapper(SYNC_AUTOLINKING_SCRIPT_PATH), ); if (!text.includes(syncPhaseUuid)) { text = insertObjectsIntoSection( @@ -1381,11 +1748,11 @@ function injectSpmIntoPbxproj( ); } else { // Already injected on a prior run — the phase object owns these fields, - // so refresh them in place (same quoting used at creation) in case the - // generated script changed, or a project injected before shellPath moved - // to bash (see shellScriptPhase) still has the stale value. Byte-identical - // when neither did; field order and every other byte of the phase are - // untouched. + // so refresh them in place (same quoting used at creation) in case a + // project injected before the script moved to its own file still carries + // it inline, or one injected before shellPath moved to bash (see + // shellScriptPhase) still has the stale value. Byte-identical when neither + // did; field order and every other byte of the phase are untouched. for (const key of ['shellScript', 'shellPath']) { const current = findObjectByUuid(text, syncPhaseUuid); if (current != null) { @@ -1409,9 +1776,9 @@ function injectSpmIntoPbxproj( 'PBXShellScriptBuildPhase', 'EmbedFlavoredFrameworks', ); - const embedScript = buildEmbedFrameworksScript(flavoredFrameworks); const embedInputs = [ '$(SRCROOT)/build/xcframeworks/.artifact-stamp', + `$(SRCROOT)/${EMBED_FRAMEWORKS_SCRIPT_PATH}`, ...flavoredFrameworks.map( framework => `$(${frameworkSettingPrefix(framework.id)}_FRAMEWORK)`, ), @@ -1423,7 +1790,7 @@ function injectSpmIntoPbxproj( const embedEntry = shellScriptPhase( embedPhaseUuid, 'Embed React Native Flavored Frameworks', - embedScript, + generatedScriptWrapper(EMBED_FRAMEWORKS_SCRIPT_PATH), { inputPaths: pbxPathList(embedInputs), outputPaths: pbxPathList(embedOutputs), @@ -1556,17 +1923,7 @@ function injectSpmIntoPbxproj( // to a pre-existing children array, so no createdArrayField is recorded — // deinit removes the group's membership via removeArrayMembersByUuid and // the group object itself via removeObjectByUuid (groupUuid is injected). - const proj = findProjectObject(text); - const mainGroupField = - proj != null ? findField(text, proj, 'mainGroup') : null; - const mainGroupMatch = - mainGroupField != null - ? mainGroupField.value.match(/[0-9A-Fa-f]{24}/) - : null; - const mainGroupObj = - mainGroupMatch != null - ? findObjectByUuid(text, mainGroupMatch[0]) - : null; + const mainGroupObj = mainGroupObject(text); if (mainGroupObj != null) { text = addArrayMembers(text, mainGroupObj, 'children', [ {uuid: groupUuid, comment: SPM_GENERATED_SOURCES_GROUP_NAME}, @@ -1654,12 +2011,28 @@ function injectSpmIntoPbxproj( text, injectedUuids, createdArrayFields, - buildSettingChanges, + xcconfig: { + fileRefUuid: xcconfigRefUuid, + configUuids: plan.configUuids.filter(uuid => !includedBy.has(uuid)), + includedByConfigUuids: [...includedBy], + removedPodsRootReactNativePath, + }, + generatedFiles, generatedSourceUuids, scriptPhaseUuids, }; } +function mainGroupObject( + text /*: string */, +) /*: ?{uuid: string, bodyOpen: number, bodyClose: number} */ { + const project = findProjectObject(text); + const mainGroup = + project != null ? findField(text, project, 'mainGroup') : null; + const uuid = mainGroup?.value.match(/[0-9A-Fa-f]{24}/)?.[0]; + return uuid != null ? findObjectByUuid(text, uuid) : null; +} + /** Re-locate an application target by UUID against the current text. */ function findApplicationTargetByUuid( text /*: string */, @@ -1672,180 +2045,11 @@ function findApplicationTargetByUuid( return obj; } -/** Strip the surrounding plist quotes from a build-setting token, if any. */ +/** Strip the surrounding plist quotes from a token, if any. */ function unquotePlist(s /*: string */) /*: string */ { return s.replace(/^"/, '').replace(/"$/, ''); } -/** - * The individual values a build setting already carries, unquoted — for both - * shapes a pbxproj uses: the array form Xcode writes for a multi-value setting - * (`("$(inherited)", DEBUG)`) and the scalar form the app template and - * hand-edits use (`"$(inherited) DEBUG"`). Membership, not substring: the - * latter would read `MY_DEBUG_FLAG` as `DEBUG` already being set and silently - * skip the injection. - */ -function buildSettingValueTokens(value /*: string */) /*: Set */ { - return new Set( - value - .split(/[\s,()]+/) - .filter(Boolean) - .map(unquotePlist), - ); -} - -/** - * Merge the React build settings into one XCBuildConfiguration's dict. Returns - * the modified text plus a precise record of what was actually added — so - * `deinit` (removeSpmInjection) can reverse exactly these edits, never touching - * a value the user already had (key insight: ensureScalarField/ - * addArrayStringValues are no-ops / dedupe when a value is already present). - */ -function mergeReactBuildSettings( - input /*: string */, - configUuid /*: string */, - configurationName /*: string */, - reactNativePath /*: string */, - flavoredFrameworks /*: ReadonlyArray */ = [], -) /*: {text: string, change: BuildSettingChange} */ { - let text = input; - const scalars = [ - {key: 'CLANG_CXX_LANGUAGE_STANDARD', value: '"c++20"'}, - {key: 'REACT_NATIVE_PATH', value: quoteIfNeeded(reactNativePath)}, - ]; - // Re-locate the buildSettings dict before each edit (offsets shift). - const dict = () => { - const cfg = findObjectByUuid(text, configUuid); - if (cfg == null) { - return null; - } - const bs = findField(text, cfg, 'buildSettings'); - if (bs == null) { - return null; - } - return { - uuid: configUuid, - bodyOpen: bs.valueStart, - bodyClose: bs.tokenEnd - 1, - }; - }; - const createdArrayKeys /*: Array */ = []; - const appendedArrayValues /*: {[string]: Array} */ = {}; - const promotedArrayScalars /*: {[string]: string} */ = {}; - const createdScalars /*: Array */ = []; - const arraySettings = [ - ...INJECTED_ARRAY_SETTINGS, - ...(flavorForBuildConfiguration(configurationName) === 'debug' - ? DEBUG_ARRAY_SETTINGS - : []), - ...frameworkArrayBuildSettings(flavoredFrameworks), - ]; - for (const {key, values} of arraySettings) { - const d = dict(); - if (d == null) { - continue; - } - const existing = findField(text, d, key); - // Non-null only for a scalar addArrayStringValues would promote to an array - // (same array-vs-scalar test it uses). Kept RAW: findField's token for a - // bare scalar ends at the `;`, so it carries any whitespace before it, and - // deinit has to write those bytes back verbatim. - const priorScalar = - existing != null && !existing.value.trimStart().startsWith('(') - ? existing.value - : null; - if (existing == null) { - createdArrayKeys.push(key); - } else { - const present = buildSettingValueTokens(existing.value); - const fresh = values.filter(v => !present.has(unquotePlist(v))); - if (fresh.length === 0) { - // Nothing to add. Skip addArrayStringValues entirely: its dedupe is by - // EXACT array member, so a value the user carries in the scalar form - // (`SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited) DEBUG"`) would - // otherwise be promoted to an array and re-appended — an edit `deinit` - // has no record of and so could never reverse. - continue; - } - if (priorScalar == null) { - appendedArrayValues[key] = fresh; - } - } - const beforeAdd = text; - text = addArrayStringValues(text, d, key, values); - // Record only a promotion that actually happened: addArrayStringValues - // no-ops when `values` is empty or every value is already a member, and a - // recorded-but-untouched field would have deinit clobber whatever the user - // has there by then. Restoring the scalar subsumes removing the injected - // members, so the two records stay mutually exclusive per key. - if (priorScalar != null && text !== beforeAdd) { - promotedArrayScalars[key] = priorScalar; - } - } - const replacedScalars /*: {[string]: string} */ = {}; - for (const {key, value} of scalars) { - const d = dict(); - if (d == null) { - continue; - } - const existing = findField(text, d, key); - if (existing == null) { - createdScalars.push(key); - } else if ( - key === 'REACT_NATIVE_PATH' && - existing.value.includes('PODS_ROOT') - ) { - // A ${PODS_ROOT}-anchored REACT_NATIVE_PATH (the CocoaPods template - // default) dangles once CocoaPods is deintegrated: PODS_ROOT resolves - // empty at build time, so the Bundle React Native code and images - // phase looks for "/../…/scripts/xcode/with-environment.sh". Replace - // it with the SPM-computed path, recording the original for deinit. - replacedScalars[key] = existing.value; - text = removeField(text, d, key); - const d2 = dict(); - if (d2 == null) { - continue; - } - text = ensureScalarField(text, d2, key, value); - continue; - } - text = ensureScalarField(text, d, key, value); - } - const ownedScalars = [ - { - key: 'RN_SPM_FLAVOR', - value: flavorForBuildConfiguration(configurationName), - }, - ...frameworkConditionalSettings(flavoredFrameworks), - ]; - for (const {key, value} of ownedScalars) { - const d = dict(); - if (d == null) { - continue; - } - const existing = findField(text, d, key); - if (existing == null) { - createdScalars.push(key); - } else if (existing.value !== value) { - replacedScalars[key] = existing.value; - } - text = setScalarField(text, d, key, value); - } - return { - text, - change: { - configUuid, - createdArrayKeys, - appendedArrayValues, - createdScalars, - replacedScalars, - ...(Object.keys(promotedArrayScalars).length > 0 - ? {promotedArrayScalars} - : {}), - }, - }; -} - // Write only when content changed (avoids spurious Xcode reloads / git churn). function writeIfChanged( filePath /*: string */, @@ -2226,7 +2430,7 @@ function readScriptPhasesManifest( */ function readMarker( xcodeprojPath /*: string */, -) /*: ?{targetUuid?: ?string, generatedSources?: {[string]: Array}, scriptPhases?: {[string]: string}, artifactsVersionOverride?: ?string, configCommand?: ?Array, buildSettingChanges?: Array, createdArrayFields?: Array, scheme?: {file?: ?string, created?: ?boolean}, ...} */ { +) /*: ?{targetUuid?: ?string, generatedSources?: {[string]: Array}, scriptPhases?: {[string]: string}, linkedFrameworks?: Array<{id: string, frameworkName: string}>, artifactsVersionOverride?: ?string, configCommand?: ?Array, buildSettingChanges?: Array, xcconfig?: XcconfigRecord, createdArrayFields?: Array, scheme?: {file?: ?string, created?: ?boolean}, ...} */ { const markerPath = path.join(xcodeprojPath, SPM_INJECTED_MARKER); try { // $FlowFixMe[incompatible-return] JSON.parse returns any @@ -2262,6 +2466,176 @@ function findInjectedXcodeproj(appRoot /*: string */) /*: string | null */ { return null; } +class PluginFrameworkMismatchError extends Error { + /*:: problems: Array; */ + constructor(problems /*: Array */) { + super( + 'The precompiled frameworks that autolinking plugins provide differ ' + + 'from the frameworks that the Xcode project links.', + ); + this.name = 'PluginFrameworkMismatchError'; + this.problems = problems; + } +} + +/** + * The frameworks the project links: the setting prefix of every one, and the + * names of the plugin ones. A marker that records them also pairs each plugin + * framework's id with its name. For an older marker they come from the embed + * phase, which cannot pair them: null without one, and null names when the + * frameworks manifest that tells plugin names from built-in ones is missing. + */ +function readLinkedFrameworks( + appRoot /*: string */, + xcodeprojPath /*: string */, +) /*: ?{ + settingPrefixes: Set, + pluginNames: ?Set, + pluginFrameworks?: Array<{id: string, frameworkName: string}>, +} */ { + const builtinIds = new Set(BUILTIN_FRAMEWORKS.map(({id}) => id)); + const recorded = readMarker(xcodeprojPath)?.linkedFrameworks; + if (recorded != null) { + const pluginFrameworks = recorded.filter( + framework => !builtinIds.has(framework.id), + ); + return { + settingPrefixes: new Set( + recorded.map(framework => frameworkSettingPrefix(framework.id)), + ), + pluginNames: new Set( + pluginFrameworks.map(framework => framework.frameworkName), + ), + pluginFrameworks, + }; + } + + // Markers written before `linkedFrameworks` existed: read the embed phase. + const text = fs.readFileSync( + path.join(xcodeprojPath, 'project.pbxproj'), + 'utf8', + ); + const rootUuid = findProjectObject(text)?.uuid; + if (rootUuid == null) { + return null; + } + const embedPhase = findObjectByUuid( + text, + namespacedUUID( + rootUuid, + 'PBXShellScriptBuildPhase', + 'EmbedFlavoredFrameworks', + ), + ); + if (embedPhase == null) { + return null; + } + + const inputs = findField(text, embedPhase, 'inputPaths')?.value ?? ''; + const outputs = findField(text, embedPhase, 'outputPaths')?.value ?? ''; + const settingPrefixes = new Set( + Array.from(inputs.matchAll(/\$\((RN_SPM_\w+)_FRAMEWORK\)/g), m => m[1]), + ); + // Built-in framework names come from the staged manifest. Without it, only + // setting prefixes are compared: a missing manifest is not a plugin mismatch. + if ( + !fs.existsSync( + path.join(appRoot, 'build', 'xcframeworks', FLAVORED_FRAMEWORKS_MANIFEST), + ) + ) { + return {settingPrefixes, pluginNames: null}; + } + const builtinNames = new Set( + readFlavoredFrameworksManifest(appRoot) + .frameworks.filter(framework => builtinIds.has(framework.id)) + .map(framework => framework.frameworkName), + ); + const pluginNames = new Set( + Array.from(outputs.matchAll(/([^/"\n]+)\.framework\b/g), m => m[1]).filter( + name => !builtinNames.has(name), + ), + ); + return {settingPrefixes, pluginNames}; +} + +/** + * Only `spm add` / `spm update` change what the project links, so the + * build-time sync reports a plugin framework mismatch instead of fixing it. + * The committed project is the team's contract: every machine precompiles the + * frameworks it links. + */ +function assertPluginFrameworksLinked( + appRoot /*: string */, + env /*: {readonly [string]: ?string} */ = process.env, +) /*: void */ { + const builtProjectPath = env.PROJECT_FILE_PATH; + const xcodeprojPath = + builtProjectPath != null && + builtProjectPath.endsWith('.xcodeproj') && + fs.existsSync(path.join(builtProjectPath, SPM_INJECTED_MARKER)) + ? builtProjectPath + : findInjectedXcodeproj(appRoot); + if (xcodeprojPath == null) { + return; + } + const linked = readLinkedFrameworks(appRoot, xcodeprojPath); + if (linked == null) { + return; + } + + const pluginFrameworks = readPluginFrameworks(appRoot); + const pairedNames = new Set( + pluginFrameworks.map(framework => framework.frameworkName), + ); + const pairKey = ( + {id, frameworkName} /*: {id: string, frameworkName: string, ...} */, + ) => JSON.stringify([id, frameworkName]); + const pairedPairs = new Set(pluginFrameworks.map(pairKey)); + const problems /*: Array */ = []; + for (const {id, frameworkName} of pluginFrameworks) { + // A name linked under another id is reported once, from the linked side. + const notLinked = + linked.pluginFrameworks != null + ? linked.pluginNames?.has(frameworkName) === false + : !linked.settingPrefixes.has(frameworkSettingPrefix(id)) || + linked.pluginNames?.has(frameworkName) === false; + if (notLinked) { + problems.push( + `error: ${frameworkName} (${id}) is a precompiled framework from an autolinking plugin, but the Xcode project does not link it. Run \`npx react-native spm update\` to add ${frameworkName} to the committed project for the whole team.`, + ); + } + } + if (linked.pluginNames == null) { + console.warn( + 'warning: Skipped the check for frameworks that the Xcode project links but no plugin provides. The project does not record them in .spm-injected.json, and build/xcframeworks/flavored-frameworks.json is missing. Run `npx react-native spm update` to record them.', + ); + } + for (const frameworkName of linked.pluginNames ?? []) { + if (!pairedNames.has(frameworkName)) { + // Precompiling changes no input the build checks, so the recorded + // error stays until sync runs again. + problems.push( + `error: The Xcode project links ${frameworkName}.framework, but no autolinking plugin provides it on this machine. Running \`npx react-native spm update\` removes ${frameworkName} from the committed project for the whole team. Instead, precompile ${frameworkName}, then run \`npx react-native spm sync\`.`, + ); + } + } + for (const framework of linked.pluginFrameworks ?? []) { + const {id, frameworkName} = framework; + if ( + pairedNames.has(frameworkName) && + !pairedPairs.has(pairKey(framework)) + ) { + problems.push( + `error: The Xcode project links ${frameworkName}.framework (${id}), but the autolinking plugin that provides ${frameworkName} gives it a different id. Run \`npx react-native spm update\` to relink ${frameworkName} in the committed project for the whole team.`, + ); + } + } + if (problems.length > 0) { + problems.forEach(problem => console.error(problem)); + throw new PluginFrameworkMismatchError(problems); + } +} + /** * Read the `artifactsVersionOverride` a previous `spm add --version` / `spm * update --version` pinned into the injected xcodeproj's `.spm-injected.json` @@ -2341,6 +2715,32 @@ function mergeCreatedArrayFields( return merged; } +// The react-native dir the app reaches through `node_modules/react-native`, in +// the nearest ancestor of `appRoot` where that link resolves to +// `reactNativeRoot`. Config commands report the realpath, which under pnpm is a +// version- and hash-named store dir that must not end up in committed files. +function linkedReactNativeRoot( + appRoot /*: string */, + reactNativeRoot /*: string */, +) /*: string */ { + try { + const target = fs.realpathSync(reactNativeRoot); + let dir = path.resolve(appRoot); + while (true) { + const candidate = path.join(dir, 'node_modules', 'react-native'); + if (fs.existsSync(candidate) && fs.realpathSync(candidate) === target) { + return candidate; + } + const parent = path.dirname(dir); + if (parent === dir) { + break; + } + dir = parent; + } + } catch {} + return reactNativeRoot; +} + /** * Add SPM packages to a user's EXISTING xcodeproj in place. Returns * {status: 'injected', target} on success, or {status: 'refused', reason} @@ -2358,18 +2758,33 @@ function injectSpmIntoExistingXcodeproj( }; } const original = fs.readFileSync(pbxprojPath, 'utf8'); - const plan = planInjection(original, {appName: opts.appName}); + const prevMarker = readMarker(xcodeprojPath); + // A project injected before the React build settings moved to the generated + // xcconfig carries them in project.pbxproj. Reversing that record first moves + // it over, and has to precede planning: a setting the user had without + // $(inherited) only shows again once the promotion that added it is undone. + const withoutRecordedSettings = removeRecordedBuildSettings( + original, + prevMarker?.buildSettingChanges ?? [], + ); + const plan = planInjection(withoutRecordedSettings, { + appName: opts.appName, + appRoot, + srcRoot: path.dirname(xcodeprojPath), + }); if (!plan.ok) { return {status: 'refused', reason: plan.reason}; } - const reactNativePath = path.relative(appRoot, reactNativeRoot); + const reactNativeDir = linkedReactNativeRoot(appRoot, reactNativeRoot); + const reactNativePaths = { + fromAppRoot: path.relative(appRoot, reactNativeDir), + fromSrcRoot: path.relative(path.dirname(xcodeprojPath), reactNativeDir), + }; const remote = remotePackageConfig(appRoot); const generatedSources = readGeneratedSourcesManifest(appRoot); const scriptPhases = readScriptPhasesManifest(appRoot); const flavoredFrameworks = readFlavoredFrameworksManifest(appRoot).frameworks; - const prevMarker = readMarker(xcodeprojPath); - // Reconcile generated sources injected on a PRIOR run that are no longer in // the manifest (a plugin's entry was dropped, or the plugin was removed). // Diff the marker's `generatedSources` map against the current manifest and @@ -2404,13 +2819,7 @@ function injectSpmIntoExistingXcodeproj( staleUuids.push(prevScriptPhases[id]); } } - // Re-apply generated settings from a clean recorded baseline. This removes - // linker entries for plugin frameworks that disappeared and keeps the new - // marker a complete inverse after an idempotent update. - let base = removeRecordedBuildSettings( - original, - prevMarker?.buildSettingChanges ?? [], - ); + let base = withoutRecordedSettings; if (staleUuids.length > 0) { base = removeArrayMembersByUuid(base, staleUuids); for (const u of staleUuids) { @@ -2422,7 +2831,8 @@ function injectSpmIntoExistingXcodeproj( text, injectedUuids, createdArrayFields, - buildSettingChanges, + xcconfig, + generatedFiles, generatedSourceUuids, scriptPhaseUuids, } = injectSpmIntoPbxproj( @@ -2431,16 +2841,21 @@ function injectSpmIntoExistingXcodeproj( rootUuid: plan.rootUuid, targetUuid: plan.target.uuid, configUuids: plan.configUuids, + includedByConfigUuids: plan.includedByConfigUuids, frameworksPhaseUuid: plan.frameworksPhaseUuid, sourcesPhaseUuid: plan.sourcesPhaseUuid, }, - reactNativePath, + reactNativePaths, remote, generatedSources, flavoredFrameworks, scriptPhases, ); + // Before the project, so an open Xcode that reloads it finds them in place. + for (const [relPath, content] of Object.entries(generatedFiles)) { + writeIfChanged(path.join(appRoot, relPath), content); + } const changed = writeIfChanged(pbxprojPath, text); log( changed @@ -2455,7 +2870,7 @@ function injectSpmIntoExistingXcodeproj( projName, // The scheme pre-action is SYNC-ONLY (no flavor swap). A pre-action swap // could win its race and mask a mismatch from the in-target detector. - syncScript: buildSchemePreActionScript(reactNativePath), + syncScript: buildSchemePreActionScript(reactNativePaths.fromAppRoot), }); log(`Scheme sync pre-action: ${schemeResult.status}`); @@ -2498,13 +2913,25 @@ function injectSpmIntoExistingXcodeproj( prevMarker?.createdArrayFields ?? [], createdArrayFields, ), - buildSettingChanges, + xcconfig: { + ...xcconfig, + // Sticky: the next sync no longer sees the value this one removed. + removedPodsRootReactNativePath: + xcconfig.removedPodsRootReactNativePath || + prevMarker?.xcconfig?.removedPodsRootReactNativePath === true, + }, // Normalized path → [fileRefUuid, buildFileUuid]. Read back on the next // `update` to reconcile away entries that left the manifest. generatedSources: generatedSourceUuids, // Plugin phase id → its PBXShellScriptBuildPhase UUID, reconciled the // same way. scriptPhases: scriptPhaseUuids, + // What the embed phase links. The build-time sync compares it with + // the frameworks plugins provide, and re-runs when it changes. + linkedFrameworks: flavoredFrameworks.map(({id, frameworkName}) => ({ + id, + frameworkName, + })), artifactsVersionOverride, configCommand, scheme: { @@ -2525,8 +2952,9 @@ function injectSpmIntoExistingXcodeproj( return {status: 'injected', target: plan.target.name}; } -/** The sync pre-action's script, unescaped, or null when the scheme has none. */ -function schemePreActionScript(xml /*: string */) /*: ?string */ { +// The scheme with the sync pre-action's scriptText emptied, so the compare +// ignores how the script was encoded; null when there is no pre-action. +function withoutPreActionScript(xml /*: string */) /*: ?string */ { const titleIdx = xml.indexOf('title = "Sync SPM Autolinking"'); if (titleIdx < 0) { return null; @@ -2540,9 +2968,7 @@ function schemePreActionScript(xml /*: string */) /*: ?string */ { // escapeXmlAttribute maps a literal `"` to `"`, so the next `"` is always // the closing delimiter. const valueEnd = xml.indexOf('"', valueStart); - return valueEnd < 0 - ? null - : unescapeXmlAttribute(xml.slice(valueStart, valueEnd)); + return valueEnd < 0 ? null : xml.slice(0, valueStart) + xml.slice(valueEnd); } /** @@ -2564,10 +2990,9 @@ function isGeneratedScheme( targetUuid /*: string */, projName /*: string */, ) /*: boolean */ { - const script = schemePreActionScript(xml); return ( - script != null && - xml === generateXcscheme(appName, targetUuid, projName, script) + withoutPreActionScript(xml) === + generateXcscheme(appName, targetUuid, projName, '') ); } @@ -2666,6 +3091,36 @@ function removeRecordedBuildSettings( return text; } +/** + * The configurations, by name, that may #include the generated xcconfig once + * deinit has un-based its own: those based on any other xcconfig, and those + * recorded as including it. + */ +function configsThatMayInclude( + text /*: string */, + targetUuid /*: string */, + xcconfig /*: XcconfigRecord */, +) /*: Array */ { + const target = findObjectByUuid(text, targetUuid); + const rebased = ( + target != null ? targetBuildConfigUuids(text, target) : [] + ).filter(uuid => { + const config = findObjectByUuid(text, uuid); + const base = + config != null + ? findField(text, config, 'baseConfigurationReference') + : null; + return base != null && !base.value.includes(xcconfig.fileRefUuid); + }); + return [ + ...new Set([...rebased, ...(xcconfig.includedByConfigUuids ?? [])]), + ].map(uuid => + findObjectByUuid(text, uuid) != null + ? buildConfigurationName(text, uuid) + : uuid, + ); +} + /** * The exact inverse of `add` (injectSpmIntoExistingXcodeproj): using the * `.spm-injected.json` marker's precise record of every edit, remove only what @@ -2719,10 +3174,52 @@ function removeSpmInjection( '', ); - // 2. Reverse the per-config build-setting edits (only what we added). + // 2. Un-base the configurations still based on the generated xcconfig, and + // delete it only then, and only while no configuration is based on an + // xcconfig of the user's own — that file may #include it, as the ones + // recorded in `includedByConfigUuids` do. A ${PODS_ROOT}-anchored + // REACT_NATIVE_PATH injection removed is deliberately not put back: it + // resolves empty without CocoaPods. + const xcconfig /*: ?XcconfigRecord */ = marker.xcconfig; + let ownsXcconfig = false; + if (xcconfig != null) { + for (const configUuid of xcconfig.configUuids) { + const config = findObjectByUuid(text, configUuid); + if ( + config != null && + findField(text, config, 'baseConfigurationReference')?.value.includes( + xcconfig.fileRefUuid, + ) === true + ) { + text = removeField(text, config, 'baseConfigurationReference'); + ownsXcconfig = true; + } + } + } + const includingConfigs = + xcconfig != null + ? configsThatMayInclude(text, marker.targetUuid, xcconfig) + : []; + if (includingConfigs.length > 0) { + log( + `Kept ${GENERATED_XCCONFIG_PATH}: an xcconfig of your own may still ` + + `#include it (configurations: ${includingConfigs.join(', ')})`, + ); + } + // Settings a project injected before the generated xcconfig still carries + // in project.pbxproj (only what we added). text = removeRecordedBuildSettings(text, marker.buildSettingChanges ?? []); writeIfChanged(pbxprojPath, text); log(`Removed SPM injection from ${path.relative(appRoot, pbxprojPath)}`); + for (const relPath of [ + ...(ownsXcconfig && includingConfigs.length === 0 + ? [GENERATED_XCCONFIG_PATH] + : []), + SYNC_AUTOLINKING_SCRIPT_PATH, + EMBED_FRAMEWORKS_SCRIPT_PATH, + ]) { + fs.rmSync(path.join(appRoot, relPath), {force: true}); + } // 3. Scheme: delete it if injection created it AND still owns its contents, // else strip the pre-action and leave the file (see isGeneratedScheme). @@ -2765,6 +3262,7 @@ module.exports = { flavorForBuildConfiguration, targetBuildConfigUuids, frameworkConditionalSettings, + generateReactNativeXcconfig, ensureStubPackages, buildSpmDependencyGraph, spmGraphToEntries, @@ -2782,5 +3280,7 @@ module.exports = { readArtifactsVersionOverride, readPinnedConfigCommand, readScriptPhasesManifest, + assertPluginFrameworksLinked, + PluginFrameworkMismatchError, SPM_INJECTED_MARKER, }; diff --git a/packages/react-native/scripts/spm/ios-deployment-target.js b/packages/react-native/scripts/spm/ios-deployment-target.js index b6239b01925e..5d76ac522201 100644 --- a/packages/react-native/scripts/spm/ios-deployment-target.js +++ b/packages/react-native/scripts/spm/ios-deployment-target.js @@ -23,9 +23,12 @@ const { findField, findObjectByUuid, findProjectObject, - forEachObjectInSection, - uuidsInArray, } = require('./spm-pbxproj'); +const { + readXcconfig, + xcconfigIncludePath, + xcconfigReferencePaths, +} = require('./xcconfig-files'); const fs = require('node:fs'); const path = require('node:path'); @@ -87,14 +90,6 @@ function sanitizeIosDeploymentTarget(raw /*: ?string */) /*: string */ { : MIN_IOS_VERSION_SUPPORTED; } -function defaultReadFile(absPath /*: string */) /*: ?string */ { - try { - return fs.readFileSync(absPath, 'utf8'); - } catch { - return null; - } -} - /** * `IPHONEOS_DEPLOYMENT_TARGET` as an xcconfig defines it, following `#include` * lines in place so a later assignment in the includer wins. Null when the file @@ -116,10 +111,10 @@ function parseXcconfigSetting( let value /*: ?string */ = null; for (const rawLine of content.split('\n')) { const line = rawLine.replace(/\/\/.*$/, '').trim(); - const include = line.match(/^#include\??\s+"([^"]+)"/); - if (include != null) { + const includePath = xcconfigIncludePath(absPath, line); + if (includePath != null) { const included = readXcconfigSetting( - path.resolve(path.dirname(absPath), include[1]), + includePath, readFile, visited, depth + 1, @@ -154,41 +149,6 @@ function readXcconfigSetting( : null; } -/** - * Directory components of the PBXGroup chain holding `uuid`, outermost first — - * how a `""` file reference's path is anchored to the project dir. - */ -function groupPathPrefix( - text /*: string */, - uuid /*: string */, -) /*: Array */ { - const groups /*: Array<{uuid: string, path: ?string, children: Set}> */ = - []; - forEachObjectInSection(text, 'PBXGroup', ({uuid: groupUuid, ...body}) => { - const children = findField(text, body, 'children'); - const groupPath = findField(text, body, 'path'); - groups.push({ - uuid: groupUuid, - path: groupPath != null ? unquote(groupPath.value) : null, - children: children != null ? uuidsInArray(children.value) : new Set(), - }); - }); - - const parts /*: Array */ = []; - let current = uuid; - for (let i = 0; i < groups.length; i++) { - const parent = groups.find(group => group.children.has(current)); - if (parent == null) { - break; - } - if (parent.path != null) { - parts.unshift(parent.path); - } - current = parent.uuid; - } - return parts; -} - /** * Absolute paths to try for the xcconfig a configuration is based on, in order: * the reference's own anchoring first, then plain `/`. @@ -211,23 +171,16 @@ function xcconfigCandidates( if (pathField == null) { return []; } - const refPath = unquote(pathField.value); - if (path.isAbsolute(refPath)) { - return [refPath]; - } const sourceTreeField = findField(text, ref, 'sourceTree'); - const sourceTree = - sourceTreeField != null ? unquote(sourceTreeField.value) : ''; - const fallback = path.join(srcRoot, refPath); - if (sourceTree !== '') { - return [fallback]; - } - const anchored = path.join( + return xcconfigReferencePaths( + text, + { + uuid: refUuid, + path: unquote(pathField.value), + sourceTree: sourceTreeField != null ? unquote(sourceTreeField.value) : '', + }, srcRoot, - ...groupPathPrefix(text, refUuid), - refPath, ); - return anchored === fallback ? [fallback] : [anchored, fallback]; } function configName( @@ -300,7 +253,7 @@ function readIosDeploymentTargetFromPbxproj( ) /*: ?string */ { const ctx /*: XcconfigContext */ = { srcRoot: opts?.srcRoot, - readFile: opts?.readFile ?? defaultReadFile, + readFile: opts?.readFile ?? readXcconfig, }; const targetUuid = opts?.targetUuid; const marked = targetUuid != null ? findObjectByUuid(text, targetUuid) : null; diff --git a/packages/react-native/scripts/spm/spm-pbxproj.js b/packages/react-native/scripts/spm/spm-pbxproj.js index 9cd40df867a1..066f50b0792a 100644 --- a/packages/react-native/scripts/spm/spm-pbxproj.js +++ b/packages/react-native/scripts/spm/spm-pbxproj.js @@ -530,21 +530,26 @@ function addArrayStringValues( } /** - * Add a scalar field after the object's `{` only when ABSENT (never clobbers a - * value the user already set). Returns text unchanged if the key exists. + * Add a scalar field only when ABSENT (never clobbers a value the user already + * set). Returns text unchanged if the key exists. Lands right after the field + * named `after` when the object has one, else after the object's `{`. */ function ensureScalarField( text /*: string */, obj /*: BodyRange */, key /*: string */, value /*: string */, + options /*: {after?: string} */ = {}, ) /*: string */ { if (findField(text, obj, key) != null) { return text; } const fieldIndent = detectFieldIndent(text, obj); const block = `\n${fieldIndent}${key} = ${value};`; - return text.slice(0, obj.bodyOpen + 1) + block + text.slice(obj.bodyOpen + 1); + const anchor = + options.after != null ? findField(text, obj, options.after) : null; + const at = anchor != null ? anchor.tokenEnd + 1 : obj.bodyOpen + 1; + return text.slice(0, at) + block + text.slice(at); } /** diff --git a/packages/react-native/scripts/spm/spm-types.js b/packages/react-native/scripts/spm/spm-types.js index a89142f6ada0..c868c0043d36 100644 --- a/packages/react-native/scripts/spm/spm-types.js +++ b/packages/react-native/scripts/spm/spm-types.js @@ -324,7 +324,8 @@ export type PluginResult = { flavoredFrameworks: Array, // Absolute paths (dirs or files) the Xcode auto-sync build phase should watch // for staleness, e.g. the plugin dep's own `Package.swift` and per-module - // manifests. Folded into `.spm-sync-watch-paths` by main(). + // manifests. Folded into `.spm-sync-watch-paths` (or `.spm-sync-watch-absent` + // while missing) by main(). watchPaths: Array, // Build-time shell phases for the app target, recorded to // `.spm-plugin-script-phases.json` by main() and injected by `spm add`/ diff --git a/packages/react-native/scripts/spm/sync-spm-autolinking.js b/packages/react-native/scripts/spm/sync-spm-autolinking.js index 78e1fcbd43a0..458c77ec0b88 100644 --- a/packages/react-native/scripts/spm/sync-spm-autolinking.js +++ b/packages/react-native/scripts/spm/sync-spm-autolinking.js @@ -31,14 +31,24 @@ * call site) * 2. Calls generate-spm-autolinking.js → build/generated/autolinking/Package.swift * 3. Rebuilds the generated-headers farm - * 4. Writes build/generated/autolinking/.spm-sync-stamp + * 4. Fails when the Xcode project does not link exactly the precompiled + * frameworks autolinking plugins provide, and records the errors in + * build/generated/autolinking/.spm-plugin-mismatch for later builds + * 5. Writes build/generated/autolinking/.spm-sync-stamp * * Runtime frameworks are deliberately not downloaded or regenerated from an * Xcode build. `spm add` / `spm update` own the immutable dual-flavor slots and * the app target's linker/embed configuration. */ -const {main: generateAutolinking} = require('./generate-spm-autolinking'); +const { + MissingManifestError, + main: generateAutolinking, +} = require('./generate-spm-autolinking'); +const { + PluginFrameworkMismatchError, + assertPluginFrameworksLinked, +} = require('./generate-spm-xcodeproj'); const { RemoteVersionError, buildPerAppHeaderTree, @@ -62,6 +72,7 @@ const defaultDeps = { generateAutolinking, installSpmCodegenTemplate, buildPerAppHeaderTree, + assertPluginFrameworksLinked, findProjectRoot, }; @@ -130,6 +141,27 @@ async function main( autolinkingArgv.push('--ios-deployment-target', iosDeploymentTarget); } deps.generateAutolinking(autolinkingArgv); + const autolinkingDir = path.join( + appRoot, + 'build', + 'generated', + 'autolinking', + ); + const stampPath = path.join(autolinkingDir, '.spm-sync-stamp'); + const mismatchPath = path.join(autolinkingDir, '.spm-plugin-mismatch'); + try { + deps.assertPluginFrameworksLinked(appRoot); + } catch (e) { + if (e instanceof PluginFrameworkMismatchError) { + // The build phase prints the recorded errors and fails until an input + // changes, so these inputs count as synced. + fs.mkdirSync(autolinkingDir, {recursive: true}); + fs.writeFileSync(mismatchPath, e.problems.join('\n') + '\n', 'utf8'); + writeStamp(stampPath); + } + throw e; + } + fs.rmSync(mismatchPath, {force: true}); // Rebuild the per-app generated-headers farm (vended as the ReactAppHeaders // SPM target inside the codegen package). React core headers need no trees @@ -139,31 +171,33 @@ async function main( // so no path-locator JSON is written. deps.buildPerAppHeaderTree(appRoot, {log}); - const stampPath = path.join( - appRoot, - 'build', - 'generated', - 'autolinking', - '.spm-sync-stamp', - ); + writeStamp(stampPath); + log('SPM autolinking sync complete.'); +} + +function writeStamp(stampPath /*: string */) /*: void */ { fs.mkdirSync(path.dirname(stampPath), {recursive: true}); fs.writeFileSync(stampPath, new Date().toISOString() + '\n', 'utf8'); - log('SPM autolinking sync complete.'); +} + +function reportFailure(e /*: unknown */) /*: void */ { + if ( + e instanceof RemoteVersionError || + e instanceof MissingManifestError || + e instanceof PluginFrameworkMismatchError + ) { + // Clean message + exit 2 (the build phase hard-fails on it) instead of a + // stack trace, matching how setup-apple-spm.js surfaces these errors. + log(e.message); + process.exitCode = 2; + return; + } + console.error(e); + process.exitCode = 1; } if (require.main === module) { - main().catch(e => { - if (e instanceof RemoteVersionError) { - // Clean message + exit 2 (the build phase hard-fails on it) instead of a - // stack trace, matching how setup-apple-spm.js surfaces remote-mode - // version errors. - log(e.message); - process.exitCode = 2; - return; - } - console.error(e); - process.exitCode = 1; - }); + main().catch(reportFailure); } -module.exports = {main}; +module.exports = {main, reportFailure}; diff --git a/packages/react-native/scripts/spm/xcconfig-files.js b/packages/react-native/scripts/spm/xcconfig-files.js new file mode 100644 index 000000000000..42a6021901a7 --- /dev/null +++ b/packages/react-native/scripts/spm/xcconfig-files.js @@ -0,0 +1,149 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict-local + * @format + */ + +'use strict'; + +/** + * Where the xcconfig file a project references lives on disk, and the files it + * pulls in through `#include` — resolved the way Xcode resolves them. + */ + +const { + findField, + forEachObjectInSection, + uuidsInArray, +} = require('./spm-pbxproj'); +const fs = require('node:fs'); +const path = require('node:path'); + +function unquote(value /*: string */) /*: string */ { + return value.replace(/^"|"$/g, ''); +} + +function readXcconfig(absPath /*: string */) /*: ?string */ { + try { + return fs.readFileSync(absPath, 'utf8'); + } catch { + return null; + } +} + +/** + * Directory components of the PBXGroup chain holding `uuid`, outermost first — + * how a `""` file reference's path is anchored to the project dir. + */ +function groupPathPrefix( + text /*: string */, + uuid /*: string */, +) /*: Array */ { + const groups /*: Array<{uuid: string, path: ?string, children: Set}> */ = + []; + forEachObjectInSection(text, 'PBXGroup', ({uuid: groupUuid, ...body}) => { + const children = findField(text, body, 'children'); + const groupPath = findField(text, body, 'path'); + groups.push({ + uuid: groupUuid, + path: groupPath != null ? unquote(groupPath.value) : null, + children: children != null ? uuidsInArray(children.value) : new Set(), + }); + }); + + const parts /*: Array */ = []; + let current = uuid; + for (let i = 0; i < groups.length; i++) { + const parent = groups.find(group => group.children.has(current)); + if (parent == null) { + break; + } + if (parent.path != null) { + parts.unshift(parent.path); + } + current = parent.uuid; + } + return parts; +} + +/** + * Absolute paths to try for the file a PBXFileReference names, in order: the + * reference's own anchoring first, then plain `/`. `ref.path` + * and `ref.sourceTree` are unquoted. + */ +function xcconfigReferencePaths( + text /*: string */, + ref /*: {uuid: string, path: string, sourceTree: string} */, + srcRoot /*: string */, +) /*: Array */ { + if (path.isAbsolute(ref.path)) { + return [ref.path]; + } + const fallback = path.join(srcRoot, ref.path); + if (ref.sourceTree !== '') { + return [fallback]; + } + const anchored = path.join( + srcRoot, + ...groupPathPrefix(text, ref.uuid), + ref.path, + ); + return anchored === fallback ? [fallback] : [anchored, fallback]; +} + +/** + * The absolute path an `#include "…"` or `#include? "…"` line of the xcconfig + * at `absPath` names, or null for any other line. Xcode resolves it from the + * folder of the file holding the line. + */ +function xcconfigIncludePath( + absPath /*: string */, + line /*: string */, +) /*: ?string */ { + const include = line + .replace(/\/\/.*$/, '') + .trim() + .match(/^#include\??\s+"([^"]+)"/); + return include != null + ? path.resolve(path.dirname(absPath), include[1]) + : null; +} + +/** + * Whether the xcconfig at `absPath` (holding `content`) pulls in `targetPath` + * through its `#include` chain. An included file that cannot be read ends its + * branch of the chain. + */ +function xcconfigIncludes( + absPath /*: string */, + content /*: string */, + targetPath /*: string */, + visited /*: Set */ = new Set(), +) /*: boolean */ { + visited.add(absPath); + for (const line of content.split('\n')) { + const included = xcconfigIncludePath(absPath, line); + if (included == null || visited.has(included)) { + continue; + } + if (included === path.resolve(targetPath)) { + return true; + } + const next = readXcconfig(included); + if (next != null && xcconfigIncludes(included, next, targetPath, visited)) { + return true; + } + } + return false; +} + +module.exports = { + readXcconfig, + xcconfigIncludePath, + xcconfigIncludes, + xcconfigReferencePaths, +};