Skip to content

Commit 090959f

Browse files
chrfalchclaude
andcommitted
SPM: actually read the artifacts version pin back
`spm add --version <ver>` / `spm update --version <ver>` 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 <noreply@anthropic.com>
1 parent 8e74464 commit 090959f

4 files changed

Lines changed: 141 additions & 30 deletions

File tree

packages/react-native/scripts/setup-apple-spm.js

Lines changed: 29 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -44,8 +44,10 @@
4444
* directing you to `--deintegrate`).
4545
*
4646
* Options:
47-
* --version <ver> React Native version (default: the resolved
48-
* node_modules/react-native version).
47+
* --version <ver> React Native version. Pinned into
48+
* .spm-injected.json and reused by later runs
49+
* until a new one is passed (default: the
50+
* resolved node_modules/react-native version).
4951
* --yes Skip the dirty-pbxproj confirmation prompt.
5052
* [add] --xcodeproj <path> Which .xcodeproj to inject into (when several).
5153
* [add] --product-name <name> Which app target to inject into (when several).
@@ -89,10 +91,12 @@ const {
8991
const {main: generatePackage} = require('./spm/generate-spm-package');
9092
const {findSourcePath} = require('./spm/generate-spm-package');
9193
const {
94+
SPM_INJECTED_MARKER,
9295
cleanupDanglingJavaScriptCoreRef,
9396
cleanupLeftoverPodsGroup,
9497
findInjectedXcodeproj,
9598
injectSpmIntoExistingXcodeproj,
99+
readArtifactsVersionOverride,
96100
removeSpmInjection,
97101
} = require('./spm/generate-spm-xcodeproj');
98102
const {scaffoldAll} = require('./spm/scaffold-package-swift');
@@ -149,7 +153,7 @@ function parseArgs(argv /*: Array<string> */) /*: SetupArgs */ {
149153
.option('version', {
150154
type: 'string',
151155
describe:
152-
'React Native version (e.g. 0.80.0). Defaults to the version in node_modules/react-native/package.json',
156+
'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',
153157
})
154158
.option('yes', {
155159
type: 'boolean',
@@ -373,19 +377,31 @@ function resolveReactNativeRoot(
373377
return reactNativeRoot;
374378
}
375379

380+
// Explicit `--version` → the version an earlier `--version` pinned into the
381+
// injection marker → node_modules/react-native/package.json. The pin makes
382+
// `--version` stick for later flagless runs, which would otherwise re-point the
383+
// project at a different artifact slot than the one it was wired to.
376384
function determineVersion(
377385
args /*: SetupArgs */,
378386
reactNativeRoot /*: string */,
387+
appRoot /*: string */,
379388
) /*: string */ {
380-
let version = args.version;
381-
if (version == null) {
382-
// $FlowFixMe[incompatible-type] JSON.parse returns any
383-
const pkgJson /*: {version: string} */ = JSON.parse(
384-
fs.readFileSync(path.join(reactNativeRoot, 'package.json'), 'utf8'),
389+
if (args.version != null) {
390+
return args.version;
391+
}
392+
const pinned = readArtifactsVersionOverride(appRoot);
393+
if (pinned != null) {
394+
log(
395+
`Using version ${pinned} pinned in ${SPM_INJECTED_MARKER} by an earlier ` +
396+
'--version. Pass --version to change it.',
385397
);
386-
version = pkgJson.version;
398+
return pinned;
387399
}
388-
return version;
400+
// $FlowFixMe[incompatible-type] JSON.parse returns any
401+
const pkgJson /*: {version: string} */ = JSON.parse(
402+
fs.readFileSync(path.join(reactNativeRoot, 'package.json'), 'utf8'),
403+
);
404+
return pkgJson.version;
389405
}
390406

391407
function runCodegenStep(
@@ -438,7 +454,7 @@ async function runScaffold(
438454
// a comment — that's how SPM's manifest hash bumps on slot transitions.
439455
let cacheSlotLabel /*: ?string */ = null;
440456
try {
441-
const rawVersion = args.version ?? determineVersion(args, reactNativeRoot);
457+
const rawVersion = determineVersion(args, reactNativeRoot, appRoot);
442458
const slotVersion = await resolveCacheSlotVersion(rawVersion);
443459
cacheSlotLabel = `${slotVersion}/dual-flavor`;
444460
} catch {
@@ -1048,7 +1064,7 @@ async function main(argv /*:: ?: Array<string> */) /*: Promise<void> */ {
10481064
autolinkingConfigResult,
10491065
projectRoot,
10501066
);
1051-
const version = determineVersion(args, reactNativeRoot);
1067+
const version = determineVersion(args, reactNativeRoot, appRoot);
10521068
log(`React Native version: ${version}`);
10531069

10541070
// Resolve remote SPM mode ONCE up front. remotePackageConfig throws
@@ -1210,6 +1226,7 @@ if (require.main === module) {
12101226
module.exports = {
12111227
main,
12121228
detectStandardRnLayoutRedirect,
1229+
determineVersion,
12131230
findInjectedXcodeproj,
12141231
generateAutolinkingConfigOrFailClosed,
12151232
parseArgs,

packages/react-native/scripts/spm/__tests__/remove-spm-injection-test.js

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -284,8 +284,8 @@ describe('generated-sources reconciliation on update', () => {
284284

285285
// ---------------------------------------------------------------------------
286286
// artifactsVersionOverride — the marker field persisting an explicit
287-
// `spm add/update --version <ver>` pin (see setup-apple-spm.js /
288-
// sync-spm-autolinking.js). SETS on an explicit override; PRESERVES a
287+
// `spm add/update --version <ver>` pin (see setup-apple-spm.js's
288+
// determineVersion). SETS on an explicit override; PRESERVES a
289289
// previously-recorded value when the caller omits one; deinit drops it along
290290
// with the rest of the marker.
291291
// ---------------------------------------------------------------------------
@@ -366,9 +366,9 @@ describe('artifactsVersionOverride marker field', () => {
366366
});
367367

368368
// ---------------------------------------------------------------------------
369-
// readArtifactsVersionOverride — pure fs read, used by the build-time sync
370-
// (sync-spm-autolinking.js) to prefer a pinned version over the one derived
371-
// from node_modules/react-native/package.json.
369+
// readArtifactsVersionOverride — pure fs read, used by setup-apple-spm.js's
370+
// determineVersion to prefer a pinned version over the one derived from
371+
// node_modules/react-native/package.json.
372372
// ---------------------------------------------------------------------------
373373
describe('readArtifactsVersionOverride', () => {
374374
it('returns null when no xcodeproj has been injected yet', () => {

packages/react-native/scripts/spm/__tests__/setup-apple-spm-test.js

Lines changed: 96 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212

1313
const {
1414
detectStandardRnLayoutRedirect,
15+
determineVersion,
1516
ensureBothArtifactFlavors,
1617
findInjectedXcodeproj,
1718
generateAutolinkingConfigOrFailClosed,
@@ -28,12 +29,17 @@ const path = require('node:path');
2829

2930
// Create an in-place-injected xcodeproj fixture: a directory carrying the
3031
// `.spm-injected.json` marker (what injectSpmIntoExistingXcodeproj writes).
31-
function mkInjectedXcodeproj(appRoot, name) {
32+
function mkInjectedXcodeproj(appRoot, name, extraMarkerFields = {}) {
3233
const dir = path.join(appRoot, name);
3334
fs.mkdirSync(dir, {recursive: true});
3435
fs.writeFileSync(
3536
path.join(dir, SPM_INJECTED_MARKER),
36-
JSON.stringify({rootUuid: 'X', target: 'MyApp', injectedUuids: []}),
37+
JSON.stringify({
38+
rootUuid: 'X',
39+
target: 'MyApp',
40+
injectedUuids: [],
41+
...extraMarkerFields,
42+
}),
3743
);
3844
return dir;
3945
}
@@ -388,3 +394,91 @@ describe('shouldAutoDeintegrate', () => {
388394
expect(shouldAutoDeintegrate(tempDir, xcodeproj)).toBe(true);
389395
});
390396
});
397+
398+
// ---------------------------------------------------------------------------
399+
// determineVersion — which RN version the artifact slots are wired to:
400+
// explicit --version → the `artifactsVersionOverride` pinned in the injection
401+
// marker by a previous `--version` → node_modules/react-native/package.json.
402+
// ---------------------------------------------------------------------------
403+
404+
describe('determineVersion', () => {
405+
let appRoot;
406+
let reactNativeRoot;
407+
let logSpy;
408+
409+
beforeEach(() => {
410+
appRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'spm-version-app-'));
411+
reactNativeRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'spm-version-rn-'));
412+
fs.writeFileSync(
413+
path.join(reactNativeRoot, 'package.json'),
414+
JSON.stringify({name: 'react-native', version: '1000.0.0'}),
415+
);
416+
logSpy = jest.spyOn(console, 'log').mockImplementation(() => {});
417+
});
418+
419+
afterEach(() => {
420+
jest.restoreAllMocks();
421+
fs.rmSync(appRoot, {recursive: true, force: true});
422+
fs.rmSync(reactNativeRoot, {recursive: true, force: true});
423+
});
424+
425+
const logged = () => logSpy.mock.calls.map(c => c.join(' ')).join('\n');
426+
427+
it('prefers an explicit --version over a pinned override', () => {
428+
mkInjectedXcodeproj(appRoot, 'MyApp.xcodeproj', {
429+
artifactsVersionOverride: '0.80.0',
430+
});
431+
432+
expect(
433+
determineVersion({version: '0.81.0'}, reactNativeRoot, appRoot),
434+
).toBe('0.81.0');
435+
expect(logged()).not.toMatch(/spm-injected\.json/);
436+
});
437+
438+
it('uses the pinned override when --version is omitted', () => {
439+
mkInjectedXcodeproj(appRoot, 'MyApp.xcodeproj', {
440+
artifactsVersionOverride: '0.80.0',
441+
});
442+
443+
expect(determineVersion({version: null}, reactNativeRoot, appRoot)).toBe(
444+
'0.80.0',
445+
);
446+
});
447+
448+
it('names the marker in the log when the pin is the source', () => {
449+
mkInjectedXcodeproj(appRoot, 'MyApp.xcodeproj', {
450+
artifactsVersionOverride: '0.80.0',
451+
});
452+
determineVersion({version: null}, reactNativeRoot, appRoot);
453+
454+
expect(logged()).toMatch(/0\.80\.0/);
455+
expect(logged()).toMatch(/spm-injected\.json/);
456+
});
457+
458+
it("falls back to react-native's package.json with no pin recorded", () => {
459+
mkInjectedXcodeproj(appRoot, 'MyApp.xcodeproj');
460+
461+
expect(determineVersion({version: null}, reactNativeRoot, appRoot)).toBe(
462+
'1000.0.0',
463+
);
464+
expect(logged()).not.toMatch(/spm-injected\.json/);
465+
});
466+
467+
it("falls back to react-native's package.json when no project is injected", () => {
468+
mkXcodeproj(appRoot, 'MyApp.xcodeproj');
469+
470+
expect(determineVersion({version: null}, reactNativeRoot, appRoot)).toBe(
471+
'1000.0.0',
472+
);
473+
});
474+
475+
it('falls back without throwing when the marker is corrupt', () => {
476+
const xcodeproj = path.join(appRoot, 'MyApp.xcodeproj');
477+
fs.mkdirSync(xcodeproj, {recursive: true});
478+
fs.writeFileSync(path.join(xcodeproj, SPM_INJECTED_MARKER), '{not json');
479+
480+
expect(determineVersion({version: null}, reactNativeRoot, appRoot)).toBe(
481+
'1000.0.0',
482+
);
483+
});
484+
});

packages/react-native/scripts/spm/generate-spm-xcodeproj.js

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1856,9 +1856,9 @@ function readMarker(
18561856

18571857
// Returns the `*.xcodeproj` under `appRoot` carrying a `.spm-injected.json`
18581858
// marker (the user-owned project SPM packages were injected into in place),
1859-
// or null when none has been injected yet. Pure fs reads — safe for the
1860-
// build-time sync (sync-spm-autolinking.js, via readArtifactsVersionOverride
1861-
// below) to call without pulling in any pbxproj-editing machinery at runtime.
1859+
// or null when none has been injected yet. Pure fs reads — no pbxproj-editing
1860+
// machinery, so cheap callers (action resolution, readArtifactsVersionOverride
1861+
// below) can use it freely.
18621862
function findInjectedXcodeproj(appRoot /*: string */) /*: string | null */ {
18631863
let entries /*: Array<{name: string, isDirectory(): boolean}> */ = [];
18641864
try {
@@ -1884,11 +1884,11 @@ function findInjectedXcodeproj(appRoot /*: string */) /*: string | null */ {
18841884
* update --version` pinned into the injected xcodeproj's `.spm-injected.json`
18851885
* marker (see the field's doc comment in injectSpmIntoExistingXcodeproj
18861886
* below), or null when no project is injected yet, no override is pinned, or
1887-
* the marker can't be read (never throws). Pure fs reads — the build-time
1888-
* sync (sync-spm-autolinking.js) calls this to prefer the pinned version over
1889-
* the one derived from node_modules/react-native/package.json, so a
1890-
* version-mismatched setup keeps healing against the SAME artifact slot the
1891-
* explicit `--version` selected.
1887+
* the marker can't be read (never throws). Pure fs reads — setup-apple-spm.js's
1888+
* determineVersion prefers the pinned version over the one derived from
1889+
* node_modules/react-native/package.json, so a later flagless `add`/`update`
1890+
* (and `download`) stays on the SAME artifact slot the explicit `--version`
1891+
* selected.
18921892
*/
18931893
function readArtifactsVersionOverride(appRoot /*: string */) /*: ?string */ {
18941894
const xcodeprojPath = findInjectedXcodeproj(appRoot);
@@ -2014,9 +2014,9 @@ function injectSpmIntoExistingXcodeproj(
20142014
// intentional pin, not something to silently re-derive from
20152015
// node_modules/react-native/package.json. There is no "clear" verb yet;
20162016
// `deinit` (removeSpmInjection) drops the whole marker, including this
2017-
// field. Read back by readArtifactsVersionOverride (above) so the
2018-
// build-time sync (sync-spm-autolinking.js) heals against the SAME slot
2019-
// `add`/`update` selected, even on a version-mismatched setup.
2017+
// field. Read back by readArtifactsVersionOverride (above) so a later
2018+
// flagless `add`/`update`/`download` resolves to the SAME slot, even on a
2019+
// version-mismatched setup.
20202020
const artifactsVersionOverride =
20212021
opts.artifactsVersionOverride ??
20222022
prevMarker?.artifactsVersionOverride ??

0 commit comments

Comments
 (0)