From a1e29717ef89fab0a5bc260d25e98bdb35b9f843 Mon Sep 17 00:00:00 2001 From: Christian Falch Date: Thu, 30 Jul 2026 10:55:50 +0200 Subject: [PATCH 1/2] SPM: actually read the artifacts version pin back MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `spm add --version ` / `spm update --version ` already wrote an `artifactsVersionOverride` into the `.spm-injected.json` marker, and `readArtifactsVersionOverride` already existed to read it back — but nothing ever called it. Only the write half was wired; every reference to the reader was a test. The sole resolver, determineVersion, went straight from the `--version` flag to node_modules/react-native/package.json. So after `spm add --version X`, a later flagless `spm update` silently re-pointed the project at package.json's version instead, while the marker went on claiming X. `--version` was effectively single-use. In this monorepo package.json is 1000.0.0, which has no published artifacts, so a flagless run after a pinned add fails outright — hence the standing advice to pass `--version` on every invocation. Insert the pin between the two existing sources: --version -> pinned override -> react-native/package.json `spm download` and the scaffold path pick it up for free, since both use the same resolved value. Since this is persistent state, log one line when the pin is the source, so a stale pin is diagnosable rather than silent; there is still no way to clear it short of `deinit`. Three comments claimed the build-time sync read this override via readArtifactsVersionOverride. It does not, and never did — the sync action returns before artifacts are resolved at all. They now name the real consumer. Co-Authored-By: Claude Opus 5 --- .../react-native/scripts/setup-apple-spm.js | 40 ++++++--- .../__tests__/remove-spm-injection-test.js | 10 +-- .../spm/__tests__/setup-apple-spm-test.js | 89 +++++++++++++++++++ .../scripts/spm/generate-spm-xcodeproj.js | 18 ++-- 4 files changed, 130 insertions(+), 27 deletions(-) diff --git a/packages/react-native/scripts/setup-apple-spm.js b/packages/react-native/scripts/setup-apple-spm.js index b6ce83ca5b3..cfdb7f10512 100644 --- a/packages/react-native/scripts/setup-apple-spm.js +++ b/packages/react-native/scripts/setup-apple-spm.js @@ -44,8 +44,10 @@ * directing you to `--deintegrate`). * * Options: - * --version React Native version (default: the resolved - * node_modules/react-native version). + * --version React Native version. Pinned into + * .spm-injected.json and reused by later runs + * until a new one is passed (default: the + * resolved node_modules/react-native version). * --yes Skip the dirty-pbxproj confirmation prompt. * [add] --xcodeproj Which .xcodeproj to inject into (when several). * [add] --product-name Which app target to inject into (when several). @@ -96,6 +98,7 @@ const { cleanupLeftoverPodsGroup, findInjectedXcodeproj, injectSpmIntoExistingXcodeproj, + readArtifactsVersionOverride, readPinnedConfigCommand, removeSpmInjection, } = require('./spm/generate-spm-xcodeproj'); @@ -153,7 +156,7 @@ function parseArgs(argv /*: Array */) /*: SetupArgs */ { .option('version', { type: 'string', describe: - 'React Native version (e.g. 0.80.0). Defaults to the version in node_modules/react-native/package.json', + 'React Native version (e.g. 0.80.0). Sticks: later runs reuse it until you pass a new one. Defaults to the version in node_modules/react-native/package.json', }) .option('yes', { type: 'boolean', @@ -377,19 +380,31 @@ function resolveReactNativeRoot( return reactNativeRoot; } +// Explicit `--version` → the version an earlier `--version` pinned into the +// injection marker → node_modules/react-native/package.json. The pin makes +// `--version` stick for later flagless runs, which would otherwise re-point the +// project at a different artifact slot than the one it was wired to. function determineVersion( args /*: SetupArgs */, reactNativeRoot /*: string */, + appRoot /*: string */, ) /*: string */ { - let version = args.version; - if (version == null) { - // $FlowFixMe[incompatible-type] JSON.parse returns any - const pkgJson /*: {version: string} */ = JSON.parse( - fs.readFileSync(path.join(reactNativeRoot, 'package.json'), 'utf8'), + if (args.version != null) { + return args.version; + } + const pinned = readArtifactsVersionOverride(appRoot); + if (pinned != null) { + log( + `Using version ${pinned} pinned in ${SPM_INJECTED_MARKER} by an earlier ` + + '--version. Pass --version to change it.', ); - version = pkgJson.version; + return pinned; } - return version; + // $FlowFixMe[incompatible-type] JSON.parse returns any + const pkgJson /*: {version: string} */ = JSON.parse( + fs.readFileSync(path.join(reactNativeRoot, 'package.json'), 'utf8'), + ); + return pkgJson.version; } function runCodegenStep( @@ -442,7 +457,7 @@ async function runScaffold( // a comment — that's how SPM's manifest hash bumps on slot transitions. let cacheSlotLabel /*: ?string */ = null; try { - const rawVersion = args.version ?? determineVersion(args, reactNativeRoot); + const rawVersion = determineVersion(args, reactNativeRoot, appRoot); const slotVersion = await resolveCacheSlotVersion(rawVersion); cacheSlotLabel = `${slotVersion}/dual-flavor`; } catch { @@ -1094,7 +1109,7 @@ async function main(argv /*:: ?: Array */) /*: Promise */ { autolinkingConfigResult, projectRoot, ); - const version = determineVersion(args, reactNativeRoot); + const version = determineVersion(args, reactNativeRoot, appRoot); log(`React Native version: ${version}`); // Resolve remote SPM mode ONCE up front. remotePackageConfig throws @@ -1256,6 +1271,7 @@ if (require.main === module) { module.exports = { main, detectStandardRnLayoutRedirect, + determineVersion, findInjectedXcodeproj, generateAutolinkingConfigOrFailClosed, parseArgs, 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 1da3f62301f..fb01368972b 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 @@ -285,8 +285,8 @@ describe('generated-sources reconciliation on update', () => { // --------------------------------------------------------------------------- // artifactsVersionOverride — the marker field persisting an explicit -// `spm add/update --version ` pin (see setup-apple-spm.js / -// sync-spm-autolinking.js). SETS on an explicit override; PRESERVES a +// `spm add/update --version ` pin (see setup-apple-spm.js's +// determineVersion). SETS on an explicit override; PRESERVES a // previously-recorded value when the caller omits one; deinit drops it along // with the rest of the marker. // --------------------------------------------------------------------------- @@ -367,9 +367,9 @@ describe('artifactsVersionOverride marker field', () => { }); // --------------------------------------------------------------------------- -// readArtifactsVersionOverride — pure fs read, used by the build-time sync -// (sync-spm-autolinking.js) to prefer a pinned version over the one derived -// from node_modules/react-native/package.json. +// readArtifactsVersionOverride — pure fs read, used by setup-apple-spm.js's +// determineVersion to prefer a pinned version over the one derived from +// node_modules/react-native/package.json. // --------------------------------------------------------------------------- describe('readArtifactsVersionOverride', () => { it('returns null when no xcodeproj has been injected yet', () => { diff --git a/packages/react-native/scripts/spm/__tests__/setup-apple-spm-test.js b/packages/react-native/scripts/spm/__tests__/setup-apple-spm-test.js index 8e506bb5615..362538af284 100644 --- a/packages/react-native/scripts/spm/__tests__/setup-apple-spm-test.js +++ b/packages/react-native/scripts/spm/__tests__/setup-apple-spm-test.js @@ -12,6 +12,7 @@ const { detectStandardRnLayoutRedirect, + determineVersion, ensureBothArtifactFlavors, findInjectedXcodeproj, generateAutolinkingConfigOrFailClosed, @@ -561,3 +562,91 @@ describe('shouldAutoDeintegrate', () => { expect(shouldAutoDeintegrate(tempDir, xcodeproj)).toBe(true); }); }); + +// --------------------------------------------------------------------------- +// determineVersion — which RN version the artifact slots are wired to: +// explicit --version → the `artifactsVersionOverride` pinned in the injection +// marker by a previous `--version` → node_modules/react-native/package.json. +// --------------------------------------------------------------------------- + +describe('determineVersion', () => { + let appRoot; + let reactNativeRoot; + let logSpy; + + beforeEach(() => { + appRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'spm-version-app-')); + reactNativeRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'spm-version-rn-')); + fs.writeFileSync( + path.join(reactNativeRoot, 'package.json'), + JSON.stringify({name: 'react-native', version: '1000.0.0'}), + ); + logSpy = jest.spyOn(console, 'log').mockImplementation(() => {}); + }); + + afterEach(() => { + jest.restoreAllMocks(); + fs.rmSync(appRoot, {recursive: true, force: true}); + fs.rmSync(reactNativeRoot, {recursive: true, force: true}); + }); + + const logged = () => logSpy.mock.calls.map(c => c.join(' ')).join('\n'); + + it('prefers an explicit --version over a pinned override', () => { + mkInjectedXcodeproj(appRoot, 'MyApp.xcodeproj', { + artifactsVersionOverride: '0.80.0', + }); + + expect( + determineVersion({version: '0.81.0'}, reactNativeRoot, appRoot), + ).toBe('0.81.0'); + expect(logged()).not.toMatch(/spm-injected\.json/); + }); + + it('uses the pinned override when --version is omitted', () => { + mkInjectedXcodeproj(appRoot, 'MyApp.xcodeproj', { + artifactsVersionOverride: '0.80.0', + }); + + expect(determineVersion({version: null}, reactNativeRoot, appRoot)).toBe( + '0.80.0', + ); + }); + + it('names the marker in the log when the pin is the source', () => { + mkInjectedXcodeproj(appRoot, 'MyApp.xcodeproj', { + artifactsVersionOverride: '0.80.0', + }); + determineVersion({version: null}, reactNativeRoot, appRoot); + + expect(logged()).toMatch(/0\.80\.0/); + expect(logged()).toMatch(/spm-injected\.json/); + }); + + it("falls back to react-native's package.json with no pin recorded", () => { + mkInjectedXcodeproj(appRoot, 'MyApp.xcodeproj'); + + expect(determineVersion({version: null}, reactNativeRoot, appRoot)).toBe( + '1000.0.0', + ); + expect(logged()).not.toMatch(/spm-injected\.json/); + }); + + it("falls back to react-native's package.json when no project is injected", () => { + mkXcodeproj(appRoot, 'MyApp.xcodeproj'); + + expect(determineVersion({version: null}, reactNativeRoot, appRoot)).toBe( + '1000.0.0', + ); + }); + + it('falls back without throwing when the marker is corrupt', () => { + const xcodeproj = path.join(appRoot, 'MyApp.xcodeproj'); + fs.mkdirSync(xcodeproj, {recursive: true}); + fs.writeFileSync(path.join(xcodeproj, SPM_INJECTED_MARKER), '{not json'); + + expect(determineVersion({version: null}, reactNativeRoot, appRoot)).toBe( + '1000.0.0', + ); + }); +}); diff --git a/packages/react-native/scripts/spm/generate-spm-xcodeproj.js b/packages/react-native/scripts/spm/generate-spm-xcodeproj.js index 6ca2a682f1c..a5d5cf938c6 100644 --- a/packages/react-native/scripts/spm/generate-spm-xcodeproj.js +++ b/packages/react-native/scripts/spm/generate-spm-xcodeproj.js @@ -1886,13 +1886,11 @@ function findInjectedXcodeproj(appRoot /*: string */) /*: string | null */ { * update --version` pinned into the injected xcodeproj's `.spm-injected.json` * marker (see the field's doc comment in injectSpmIntoExistingXcodeproj * below), or null when no project is injected yet, no override is pinned, or - * the marker can't be read (never throws). Pure fs reads. - * - * Nothing in production calls this yet: the pin is written but never read - * back, so the build-time sync (sync-spm-autolinking.js) still derives the - * version from node_modules/react-native/package.json and can heal against a - * different artifact slot than the explicit `--version` selected. Only the - * tests cover it. + * the marker can't be read (never throws). Pure fs reads — setup-apple-spm.js's + * determineVersion prefers the pinned version over the one derived from + * node_modules/react-native/package.json, so a later flagless `add`/`update` + * (and `download`) stays on the SAME artifact slot the explicit `--version` + * selected. */ function readArtifactsVersionOverride(appRoot /*: string */) /*: ?string */ { const xcodeprojPath = findInjectedXcodeproj(appRoot); @@ -2044,9 +2042,9 @@ function injectSpmIntoExistingXcodeproj( // intentional pin, not something to silently re-derive from // node_modules/react-native/package.json. There is no "clear" verb yet; // `deinit` (removeSpmInjection) drops the whole marker, including this - // field. Read back by readArtifactsVersionOverride (above) so the - // build-time sync (sync-spm-autolinking.js) heals against the SAME slot - // `add`/`update` selected, even on a version-mismatched setup. + // field. Read back by readArtifactsVersionOverride (above) so a later + // flagless `add`/`update`/`download` resolves to the SAME slot, even on a + // version-mismatched setup. const artifactsVersionOverride = opts.artifactsVersionOverride ?? prevMarker?.artifactsVersionOverride ?? From a56f200068a31183b8b1f471589ed988a9080545 Mon Sep 17 00:00:00 2001 From: Christian Falch Date: Thu, 30 Jul 2026 16:20:40 +0200 Subject: [PATCH 2/2] Docs: document the --version pin in the SwiftPM scripts doc MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The SwiftPM docs still described `--version` as "RN version (default: from package.json)", which stopped being true once determineVersion started reading the `artifactsVersionOverride` pin back out of `.spm-injected.json`. Document the real resolution order and why it matters: - The `--version` row in "CLI Options" now names all three sources in order — the flag, the pin a previous `--version` wrote into the marker, then node_modules/react-native/package.json — and says you pass it once. - A new "Pinning the React Native version" subsection explains the failure the pin prevents, which is not self-evident: the resolved version selects which artifact slots the project is wired to, so a flagless run falling back to package.json re-points the project at different slots while the marker still advertises the pinned one. It also states that `deinit` drops the marker and with it the pin, so a later `add` resolves package.json again unless you pass `--version`. - The `.spm-injected.json` row in "What to commit" described the marker only as a record of the edits `add` made. One appended sentence makes its second role visible; the row is otherwise untouched, since sibling PRs edit it too. No advice to repeat `--version` on every invocation existed in the docs to correct — that workaround was only ever passed on verbally. The file is not Prettier-formatted on this branch, so it was deliberately not reformatted: prose stays wrapped near 80 columns and table rows stay on one line, matching the surrounding style. [Internal] - Document the `spm --version` pin and its resolution order Docs-only change; no code or tests touched. Verified against the branch's own diff that the marker key is `artifactsVersionOverride` (read by `readArtifactsVersionOverride` in scripts/spm/generate-spm-xcodeproj.js) and that `determineVersion` in scripts/setup-apple-spm.js resolves flag → pin → node_modules/react-native/package.json. Co-Authored-By: Claude Opus 5 --- .../scripts/spm/__doc__/spm-scripts.md | 23 +++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/packages/react-native/scripts/spm/__doc__/spm-scripts.md b/packages/react-native/scripts/spm/__doc__/spm-scripts.md index abe6df11fc7..d2db90c4f59 100644 --- a/packages/react-native/scripts/spm/__doc__/spm-scripts.md +++ b/packages/react-native/scripts/spm/__doc__/spm-scripts.md @@ -129,7 +129,7 @@ accepts kebab-case equivalents (e.g. `--skip-codegen`). | Option | Description | |---|---| -| `--version ` | RN version (default: from package.json) | +| `--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) | @@ -172,6 +172,25 @@ default. therefore falls back to the default command unless you pass `--configCommand` (or export the env var) again. +### Pinning the React Native version + +The resolved version selects **which artifact slots the project is wired to**, so +it has to stay the same from one run to the next. `--version` is therefore +recorded in the `.spm-injected.json` marker (as `artifactsVersionOverride`) and +read back by later runs, which resolve the version in this order: + +1. an explicit `--version `, +2. the version a previous `--version` pinned into the marker, +3. `node_modules/react-native/package.json`. + +So you pass the flag once, and a later flagless `add`/`update` stays on the slots +it selected. Without the pin, that flagless run falls back to `package.json` and +re-points the project at different artifact slots while the marker still +advertises the pinned version. + +`deinit` deletes the marker, and with it the pin — a later `add` resolves +`node_modules/react-native/package.json` again unless you pass `--version`. + ### Debug/Release flavor is automatic React Native ships **flavored** prebuilt binaries: the *debug* `React.framework` @@ -195,7 +214,7 @@ package graph, or require a second build. | Path | Commit? | Why | |------|---------|-----| | `MyApp.xcodeproj/` | Yes | Your project, with SwiftPM injected in place. Holds your signing, capabilities, Build Phases — `add` only adds SwiftPM refs/settings, additively. | -| `MyApp.xcodeproj/.spm-injected.json` | Yes | Marker recording every edit `add` made, so `deinit` can surgically reverse it and re-runs stay idempotent. Also pins settings later runs and Xcode builds must reuse, such as the [autolinking config command](#the-autolinking-config-command-is-remembered). | +| `MyApp.xcodeproj/.spm-injected.json` | Yes | Marker recording every edit `add` made, so `deinit` can surgically reverse it and re-runs stay idempotent. Also pins settings later runs and Xcode builds must reuse: the `--version` pin (`artifactsVersionOverride`) that keeps later runs on the same artifact slots, and the [autolinking config command](#the-autolinking-config-command-is-remembered). | | `build/generated/` | No | Codegen/autolinking output; regenerated | | `build/xcframeworks/` | No | Symlinks to the machine-local artifact cache | | `Package.resolved` | No | SwiftPM resolution file; machine-specific |