Skip to content

Commit a1e2971

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 c7d62a1 commit a1e2971

4 files changed

Lines changed: 130 additions & 27 deletions

File tree

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

Lines changed: 28 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).
@@ -96,6 +98,7 @@ const {
9698
cleanupLeftoverPodsGroup,
9799
findInjectedXcodeproj,
98100
injectSpmIntoExistingXcodeproj,
101+
readArtifactsVersionOverride,
99102
readPinnedConfigCommand,
100103
removeSpmInjection,
101104
} = require('./spm/generate-spm-xcodeproj');
@@ -153,7 +156,7 @@ function parseArgs(argv /*: Array<string> */) /*: SetupArgs */ {
153156
.option('version', {
154157
type: 'string',
155158
describe:
156-
'React Native version (e.g. 0.80.0). Defaults to the version in node_modules/react-native/package.json',
159+
'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',
157160
})
158161
.option('yes', {
159162
type: 'boolean',
@@ -377,19 +380,31 @@ function resolveReactNativeRoot(
377380
return reactNativeRoot;
378381
}
379382

383+
// Explicit `--version` → the version an earlier `--version` pinned into the
384+
// injection marker → node_modules/react-native/package.json. The pin makes
385+
// `--version` stick for later flagless runs, which would otherwise re-point the
386+
// project at a different artifact slot than the one it was wired to.
380387
function determineVersion(
381388
args /*: SetupArgs */,
382389
reactNativeRoot /*: string */,
390+
appRoot /*: string */,
383391
) /*: string */ {
384-
let version = args.version;
385-
if (version == null) {
386-
// $FlowFixMe[incompatible-type] JSON.parse returns any
387-
const pkgJson /*: {version: string} */ = JSON.parse(
388-
fs.readFileSync(path.join(reactNativeRoot, 'package.json'), 'utf8'),
392+
if (args.version != null) {
393+
return args.version;
394+
}
395+
const pinned = readArtifactsVersionOverride(appRoot);
396+
if (pinned != null) {
397+
log(
398+
`Using version ${pinned} pinned in ${SPM_INJECTED_MARKER} by an earlier ` +
399+
'--version. Pass --version to change it.',
389400
);
390-
version = pkgJson.version;
401+
return pinned;
391402
}
392-
return version;
403+
// $FlowFixMe[incompatible-type] JSON.parse returns any
404+
const pkgJson /*: {version: string} */ = JSON.parse(
405+
fs.readFileSync(path.join(reactNativeRoot, 'package.json'), 'utf8'),
406+
);
407+
return pkgJson.version;
393408
}
394409

395410
function runCodegenStep(
@@ -442,7 +457,7 @@ async function runScaffold(
442457
// a comment — that's how SPM's manifest hash bumps on slot transitions.
443458
let cacheSlotLabel /*: ?string */ = null;
444459
try {
445-
const rawVersion = args.version ?? determineVersion(args, reactNativeRoot);
460+
const rawVersion = determineVersion(args, reactNativeRoot, appRoot);
446461
const slotVersion = await resolveCacheSlotVersion(rawVersion);
447462
cacheSlotLabel = `${slotVersion}/dual-flavor`;
448463
} catch {
@@ -1094,7 +1109,7 @@ async function main(argv /*:: ?: Array<string> */) /*: Promise<void> */ {
10941109
autolinkingConfigResult,
10951110
projectRoot,
10961111
);
1097-
const version = determineVersion(args, reactNativeRoot);
1112+
const version = determineVersion(args, reactNativeRoot, appRoot);
10981113
log(`React Native version: ${version}`);
10991114

11001115
// Resolve remote SPM mode ONCE up front. remotePackageConfig throws
@@ -1256,6 +1271,7 @@ if (require.main === module) {
12561271
module.exports = {
12571272
main,
12581273
detectStandardRnLayoutRedirect,
1274+
determineVersion,
12591275
findInjectedXcodeproj,
12601276
generateAutolinkingConfigOrFailClosed,
12611277
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
@@ -285,8 +285,8 @@ describe('generated-sources reconciliation on update', () => {
285285

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

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

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

Lines changed: 89 additions & 0 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,
@@ -561,3 +562,91 @@ describe('shouldAutoDeintegrate', () => {
561562
expect(shouldAutoDeintegrate(tempDir, xcodeproj)).toBe(true);
562563
});
563564
});
565+
566+
// ---------------------------------------------------------------------------
567+
// determineVersion — which RN version the artifact slots are wired to:
568+
// explicit --version → the `artifactsVersionOverride` pinned in the injection
569+
// marker by a previous `--version` → node_modules/react-native/package.json.
570+
// ---------------------------------------------------------------------------
571+
572+
describe('determineVersion', () => {
573+
let appRoot;
574+
let reactNativeRoot;
575+
let logSpy;
576+
577+
beforeEach(() => {
578+
appRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'spm-version-app-'));
579+
reactNativeRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'spm-version-rn-'));
580+
fs.writeFileSync(
581+
path.join(reactNativeRoot, 'package.json'),
582+
JSON.stringify({name: 'react-native', version: '1000.0.0'}),
583+
);
584+
logSpy = jest.spyOn(console, 'log').mockImplementation(() => {});
585+
});
586+
587+
afterEach(() => {
588+
jest.restoreAllMocks();
589+
fs.rmSync(appRoot, {recursive: true, force: true});
590+
fs.rmSync(reactNativeRoot, {recursive: true, force: true});
591+
});
592+
593+
const logged = () => logSpy.mock.calls.map(c => c.join(' ')).join('\n');
594+
595+
it('prefers an explicit --version over a pinned override', () => {
596+
mkInjectedXcodeproj(appRoot, 'MyApp.xcodeproj', {
597+
artifactsVersionOverride: '0.80.0',
598+
});
599+
600+
expect(
601+
determineVersion({version: '0.81.0'}, reactNativeRoot, appRoot),
602+
).toBe('0.81.0');
603+
expect(logged()).not.toMatch(/spm-injected\.json/);
604+
});
605+
606+
it('uses the pinned override when --version is omitted', () => {
607+
mkInjectedXcodeproj(appRoot, 'MyApp.xcodeproj', {
608+
artifactsVersionOverride: '0.80.0',
609+
});
610+
611+
expect(determineVersion({version: null}, reactNativeRoot, appRoot)).toBe(
612+
'0.80.0',
613+
);
614+
});
615+
616+
it('names the marker in the log when the pin is the source', () => {
617+
mkInjectedXcodeproj(appRoot, 'MyApp.xcodeproj', {
618+
artifactsVersionOverride: '0.80.0',
619+
});
620+
determineVersion({version: null}, reactNativeRoot, appRoot);
621+
622+
expect(logged()).toMatch(/0\.80\.0/);
623+
expect(logged()).toMatch(/spm-injected\.json/);
624+
});
625+
626+
it("falls back to react-native's package.json with no pin recorded", () => {
627+
mkInjectedXcodeproj(appRoot, 'MyApp.xcodeproj');
628+
629+
expect(determineVersion({version: null}, reactNativeRoot, appRoot)).toBe(
630+
'1000.0.0',
631+
);
632+
expect(logged()).not.toMatch(/spm-injected\.json/);
633+
});
634+
635+
it("falls back to react-native's package.json when no project is injected", () => {
636+
mkXcodeproj(appRoot, 'MyApp.xcodeproj');
637+
638+
expect(determineVersion({version: null}, reactNativeRoot, appRoot)).toBe(
639+
'1000.0.0',
640+
);
641+
});
642+
643+
it('falls back without throwing when the marker is corrupt', () => {
644+
const xcodeproj = path.join(appRoot, 'MyApp.xcodeproj');
645+
fs.mkdirSync(xcodeproj, {recursive: true});
646+
fs.writeFileSync(path.join(xcodeproj, SPM_INJECTED_MARKER), '{not json');
647+
648+
expect(determineVersion({version: null}, reactNativeRoot, appRoot)).toBe(
649+
'1000.0.0',
650+
);
651+
});
652+
});

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

Lines changed: 8 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1886,13 +1886,11 @@ function findInjectedXcodeproj(appRoot /*: string */) /*: string | null */ {
18861886
* update --version` pinned into the injected xcodeproj's `.spm-injected.json`
18871887
* marker (see the field's doc comment in injectSpmIntoExistingXcodeproj
18881888
* below), or null when no project is injected yet, no override is pinned, or
1889-
* the marker can't be read (never throws). Pure fs reads.
1890-
*
1891-
* Nothing in production calls this yet: the pin is written but never read
1892-
* back, so the build-time sync (sync-spm-autolinking.js) still derives the
1893-
* version from node_modules/react-native/package.json and can heal against a
1894-
* different artifact slot than the explicit `--version` selected. Only the
1895-
* tests cover it.
1889+
* the marker can't be read (never throws). Pure fs reads — setup-apple-spm.js's
1890+
* determineVersion prefers the pinned version over the one derived from
1891+
* node_modules/react-native/package.json, so a later flagless `add`/`update`
1892+
* (and `download`) stays on the SAME artifact slot the explicit `--version`
1893+
* selected.
18961894
*/
18971895
function readArtifactsVersionOverride(appRoot /*: string */) /*: ?string */ {
18981896
const xcodeprojPath = findInjectedXcodeproj(appRoot);
@@ -2044,9 +2042,9 @@ function injectSpmIntoExistingXcodeproj(
20442042
// intentional pin, not something to silently re-derive from
20452043
// node_modules/react-native/package.json. There is no "clear" verb yet;
20462044
// `deinit` (removeSpmInjection) drops the whole marker, including this
2047-
// field. Read back by readArtifactsVersionOverride (above) so the
2048-
// build-time sync (sync-spm-autolinking.js) heals against the SAME slot
2049-
// `add`/`update` selected, even on a version-mismatched setup.
2045+
// field. Read back by readArtifactsVersionOverride (above) so a later
2046+
// flagless `add`/`update`/`download` resolves to the SAME slot, even on a
2047+
// version-mismatched setup.
20502048
const artifactsVersionOverride =
20512049
opts.artifactsVersionOverride ??
20522050
prevMarker?.artifactsVersionOverride ??

0 commit comments

Comments
 (0)