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/__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 | 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 ??