From 090959f43e8ee390dc5d85af007f314689902c22 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 | 41 +++++--- .../__tests__/remove-spm-injection-test.js | 10 +- .../spm/__tests__/setup-apple-spm-test.js | 98 ++++++++++++++++++- .../scripts/spm/generate-spm-xcodeproj.js | 22 ++--- 4 files changed, 141 insertions(+), 30 deletions(-) diff --git a/packages/react-native/scripts/setup-apple-spm.js b/packages/react-native/scripts/setup-apple-spm.js index 61104df17235..8f4ed8e1eb6a 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). @@ -89,10 +91,12 @@ const { const {main: generatePackage} = require('./spm/generate-spm-package'); const {findSourcePath} = require('./spm/generate-spm-package'); const { + SPM_INJECTED_MARKER, cleanupDanglingJavaScriptCoreRef, cleanupLeftoverPodsGroup, findInjectedXcodeproj, injectSpmIntoExistingXcodeproj, + readArtifactsVersionOverride, removeSpmInjection, } = require('./spm/generate-spm-xcodeproj'); const {scaffoldAll} = require('./spm/scaffold-package-swift'); @@ -149,7 +153,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', @@ -373,19 +377,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( @@ -438,7 +454,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 { @@ -1048,7 +1064,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 @@ -1210,6 +1226,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 4fe3d342fdc7..c3b8eae9c6fd 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 @@ -284,8 +284,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. // --------------------------------------------------------------------------- @@ -366,9 +366,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 4fe297cfc5e1..73467a9640ee 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, @@ -28,12 +29,17 @@ const path = require('node:path'); // Create an in-place-injected xcodeproj fixture: a directory carrying the // `.spm-injected.json` marker (what injectSpmIntoExistingXcodeproj writes). -function mkInjectedXcodeproj(appRoot, name) { +function mkInjectedXcodeproj(appRoot, name, extraMarkerFields = {}) { const dir = path.join(appRoot, name); fs.mkdirSync(dir, {recursive: true}); fs.writeFileSync( path.join(dir, SPM_INJECTED_MARKER), - JSON.stringify({rootUuid: 'X', target: 'MyApp', injectedUuids: []}), + JSON.stringify({ + rootUuid: 'X', + target: 'MyApp', + injectedUuids: [], + ...extraMarkerFields, + }), ); return dir; } @@ -388,3 +394,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 e75af429ce29..01cf3f69766d 100644 --- a/packages/react-native/scripts/spm/generate-spm-xcodeproj.js +++ b/packages/react-native/scripts/spm/generate-spm-xcodeproj.js @@ -1856,9 +1856,9 @@ function readMarker( // Returns the `*.xcodeproj` under `appRoot` carrying a `.spm-injected.json` // marker (the user-owned project SPM packages were injected into in place), -// or null when none has been injected yet. Pure fs reads — safe for the -// build-time sync (sync-spm-autolinking.js, via readArtifactsVersionOverride -// below) to call without pulling in any pbxproj-editing machinery at runtime. +// or null when none has been injected yet. Pure fs reads — no pbxproj-editing +// machinery, so cheap callers (action resolution, readArtifactsVersionOverride +// below) can use it freely. function findInjectedXcodeproj(appRoot /*: string */) /*: string | null */ { let entries /*: Array<{name: string, isDirectory(): boolean}> */ = []; try { @@ -1884,11 +1884,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 — the build-time - * sync (sync-spm-autolinking.js) calls this to prefer the pinned version over - * the one derived from node_modules/react-native/package.json, so a - * version-mismatched setup keeps healing against the SAME artifact slot the - * explicit `--version` selected. + * 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); @@ -2014,9 +2014,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 dd7f1f3034cbea5e5f418838f14a1e2d71cdca43 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 ## Summary: 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. ## Changelog: [Internal] - Document the `spm --version` pin and its resolution order ## Test Plan: 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 d0d0f9cbf7a0..e49e981ffd6f 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) | @@ -138,6 +138,25 @@ accepts kebab-case equivalents (e.g. `--skip-codegen`). | `--download ` | [advanced] Artifact download policy (default: auto) | | `--skipCodegen` | [advanced] Skip the codegen step | +### 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` @@ -161,7 +180,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. | +| `MyApp.xcodeproj/.spm-injected.json` | Yes | Marker recording every edit `add` made, so `deinit` can surgically reverse it and re-runs stay idempotent. Also holds the `--version` pin (`artifactsVersionOverride`) that keeps later runs on the same artifact slots. | | `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 |