Skip to content

Commit 79ee3bc

Browse files
committed
fix(spm): hermes default, partial-cache detection, codegen template freshness, multi-line logging
A cluster of issues that all surfaced together while iterating against react-native@nightly. None of them showed up cleanly — they cascaded into "Missing package product 'hermes-engine'" at Xcode build time after the spm pipeline reported success. 1. Hermes was tied to the RN nightly version. The default `process.env.HERMES_VERSION ?? rawVersion ?? rnVersion` made hermes download attempt `hermes-ios/0.87.0-nightly-20260519-.../`, which has never existed — hermes binaries publish under their own version space via the `hermes-compiler` npm package. Default flipped to `latest-v1` to match RN's CocoaPods prebuild (scripts/ios-prebuild/hermes.js:62). HERMES_VERSION env var still overrides (literal version / `latest-v1` / `nightly` all work). New test file covering both the new default and the existing escape hatches. TDD: 2 RED tests confirmed the bug, then green after the default flipped. 2. `artifacts.json` was being written on partial download. If hermes 404'd (the case above) but React + ReactNativeDependencies succeeded, the downloader logged the failure to stderr and exited 0 — the orchestrator then saw `artifacts.json` exists and skipped re-download on the next run, locking in the partial state. Now: downloader die()s on any failure and only writes artifacts.json on full success. Added `validateArtifactsCache(dir)` that verifies every REQUIRED_ARTIFACT has a present xcframework on disk; ensureArtifacts uses it instead of a bare existsSync. 3. Belt-and-suspenders in generate-spm-package.js — refuses to proceed if `--artifacts-dir` points at an incomplete artifacts.json (covers anyone bypassing the orchestrator). 4. Codegen Package.swift template was installed BEFORE the xcframework symlinks existed. `renderCodegenTemplate`'s `realpathSync` therefore threw and fell back to a runtime URL expression — content-stable across slot changes, so SPM's manifest hash never bumped on nightly transitions and Xcode kept using a cached evaluation with the previous-slot path. Re-installs the template in both setup-apple-spm.js main() (after generateXcframeworksPackage) and sync-spm-autolinking.js (after generatePackage) so the absolute slot path gets baked in. 5. `--forceXcodeproj` was registered in the underlying script but missing from the community CLI plugin (react-native.config.js), so `npx react-native spm --forceXcodeproj` was rejected by commander before reaching the script. 6. Logger output: multi-line messages had the `[name]` prefix only on the first line. Now each line gets the prefix — terminal log scrapers and visual scanning both work. 7. Download progress display: long lines wrapped past the terminal width; `\x1b[2K` clears only the current row, so wrap residue stayed on screen after the next update. Now truncates to width-1 with `…`, counting visible chars (skips ANSI escapes), and prefixes each line with the `[download-spm-artifacts]` tag for consistency with the rest of the log.
1 parent 233fb49 commit 79ee3bc

7 files changed

Lines changed: 361 additions & 66 deletions

File tree

packages/react-native/react-native.config.js

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,12 @@ const spmCommand /*: Command */ = {
152152
name: '--skipXcodeproj',
153153
description: 'Skip .xcodeproj generation.',
154154
},
155+
{
156+
name: '--forceXcodeproj',
157+
description:
158+
'Regenerate <App>.xcodeproj even when one already exists. ' +
159+
'Clobbers Xcode-side edits (signing, capabilities, Build Phases).',
160+
},
155161
{
156162
name: '--bundleIdentifier <string>',
157163
description: 'Override CFBundleIdentifier in the generated Info.plist.',
@@ -222,6 +228,7 @@ const spmCommand /*: Command */ = {
222228
['skipDownload', '--skip-download'],
223229
['forceDownload', '--force-download'],
224230
['skipXcodeproj', '--skip-xcodeproj'],
231+
['forceXcodeproj', '--force-xcodeproj'],
225232
['project', '--project'],
226233
['derivedData', '--derived-data'],
227234
['cache', '--cache'],

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

Lines changed: 37 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,7 @@
8080
const {
8181
main: downloadArtifacts,
8282
resolveCacheSlotVersion,
83+
validateArtifactsCache,
8384
} = require('./spm/download-spm-artifacts');
8485
const {main: generateAutolinking} = require('./spm/generate-spm-autolinking');
8586
const {
@@ -1114,34 +1115,36 @@ async function ensureArtifacts(
11141115
fs.rmSync(resolvedArtifactsDir, {recursive: true, force: true});
11151116
}
11161117

1117-
const artifactsJsonPath =
1118-
resolvedArtifactsDir != null
1119-
? path.join(resolvedArtifactsDir, 'artifacts.json')
1120-
: null;
1121-
const needsDownload =
1122-
resolvedArtifactsDir != null &&
1123-
!args.skipDownload &&
1124-
artifactsJsonPath != null &&
1125-
!fs.existsSync(artifactsJsonPath);
1126-
1127-
if (needsDownload === true && resolvedArtifactsDir != null) {
1128-
log(`Downloading xcframework artifacts (slot: ${slotVersion})...`);
1129-
await downloadArtifacts([
1130-
'--version',
1131-
rawVersion,
1132-
'--flavor',
1133-
args.flavor,
1134-
'--output',
1135-
resolvedArtifactsDir,
1136-
]);
1137-
} else if (resolvedArtifactsDir != null && args.skipDownload) {
1138-
log('Skipping artifact download (--skip-download)');
1139-
} else if (resolvedArtifactsDir != null) {
1140-
log(`Artifacts already present in ${displayPath(resolvedArtifactsDir)}`);
1141-
} else {
1118+
if (resolvedArtifactsDir == null) {
11421119
log('No --artifacts-dir set, skipping download step');
1120+
return resolvedArtifactsDir;
1121+
}
1122+
if (args.skipDownload) {
1123+
log('Skipping artifact download (--skip-download)');
1124+
return resolvedArtifactsDir;
11431125
}
11441126

1127+
// Validate the cache before trusting it. A bare existsSync(artifacts.json)
1128+
// check would accept a partial write from a prior failed download (e.g.
1129+
// hermes-engine 404 on a not-yet-published nightly) and silently propagate
1130+
// the gap into the xcodeproj, surfacing only as "Missing package product"
1131+
// in Xcode. validateArtifactsCache reads the JSON and confirms every
1132+
// REQUIRED_ARTIFACT has a present xcframework on disk.
1133+
const cacheError = validateArtifactsCache(resolvedArtifactsDir);
1134+
if (cacheError == null) {
1135+
log(`Artifacts already present in ${displayPath(resolvedArtifactsDir)}`);
1136+
return resolvedArtifactsDir;
1137+
}
1138+
log(`Cache incomplete (${cacheError}); re-downloading...`);
1139+
log(`Downloading xcframework artifacts (slot: ${slotVersion})...`);
1140+
await downloadArtifacts([
1141+
'--version',
1142+
rawVersion,
1143+
'--flavor',
1144+
args.flavor,
1145+
'--output',
1146+
resolvedArtifactsDir,
1147+
]);
11451148
return resolvedArtifactsDir;
11461149
}
11471150

@@ -1507,6 +1510,15 @@ async function main(argv /*:: ?: Array<string> */) /*: Promise<void> */ {
15071510
return;
15081511
}
15091512

1513+
// Re-install the codegen Package.swift template AFTER the xcframework
1514+
// symlinks have been created. The earlier install (during runCodegenStep)
1515+
// hits resolveHeadersAbsolute's catch block because the symlinks don't
1516+
// exist yet — the template falls back to a runtime URL expression. SPM
1517+
// caches that manifest evaluation and never re-resolves on slot changes,
1518+
// so a stale slot path leaks into compile args. Re-installing now bakes
1519+
// the absolute path in, changing the manifest content and the SPM hash.
1520+
installSpmCodegenTemplate(appRoot, reactNativeRoot, {log});
1521+
15101522
resolveAndWriteVFSOverlay(appRoot, reactNativeRoot, {log});
15111523

15121524
let migrationRename /*: {from: string, to: string} | null */ = null;
Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
/**
2+
* Copyright (c) Meta Platforms, Inc. and affiliates.
3+
*
4+
* This source code is licensed under the MIT license found in the
5+
* LICENSE file in the root directory of this source tree.
6+
*
7+
* @format
8+
* @noflow
9+
*/
10+
11+
'use strict';
12+
13+
const {resolveHermesArtifact} = require('../download-spm-artifacts');
14+
15+
// ---------------------------------------------------------------------------
16+
// resolveHermesArtifact — hermes uses its own version space, decoupled from
17+
// React Native's nightly cadence. The default behavior mirrors RN's
18+
// CocoaPods prebuild (HERMES_VERSION='latest-v1'): resolve via the
19+
// hermes-compiler npm dist-tag instead of trying to download a hermes-ios
20+
// artifact at the RN nightly version (which won't exist on Maven).
21+
// ---------------------------------------------------------------------------
22+
23+
describe('resolveHermesArtifact', () => {
24+
let origFetch;
25+
let origHermesEnv;
26+
27+
beforeEach(() => {
28+
origFetch = globalThis.fetch;
29+
origHermesEnv = process.env.HERMES_VERSION;
30+
delete process.env.HERMES_VERSION;
31+
});
32+
33+
afterEach(() => {
34+
globalThis.fetch = origFetch;
35+
if (origHermesEnv !== undefined) {
36+
process.env.HERMES_VERSION = origHermesEnv;
37+
} else {
38+
delete process.env.HERMES_VERSION;
39+
}
40+
});
41+
42+
// Mock fetch with a router: each entry's key is a URL substring; the value
43+
// describes the response. Anything not matched returns 404 (mimicking the
44+
// "release not found, try snapshot" path).
45+
function mockFetch(routes /*: {[string]: any} */) {
46+
globalThis.fetch = jest.fn(async url => {
47+
for (const [key, resp] of Object.entries(routes)) {
48+
if (String(url).includes(key)) {
49+
return {
50+
ok: resp.ok ?? true,
51+
status: resp.status ?? 200,
52+
json: async () => resp.json,
53+
text: async () => resp.text ?? '',
54+
};
55+
}
56+
}
57+
return {
58+
ok: false,
59+
status: 404,
60+
json: async () => ({}),
61+
text: async () => '',
62+
};
63+
});
64+
}
65+
66+
describe('default behavior (no HERMES_VERSION set)', () => {
67+
it('resolves to the latest-v1 hermes-compiler dist-tag, NOT the RN version', async () => {
68+
mockFetch({
69+
'hermes-compiler/latest-v1': {json: {version: '0.13.0'}},
70+
// Pretend the release URL exists once we ask for 0.13.0.
71+
'hermes-ios/0.13.0/hermes-ios-0.13.0': {ok: true},
72+
});
73+
const result = await resolveHermesArtifact(
74+
'0.87.0-nightly-20260519-58cd1bf58',
75+
'debug',
76+
null,
77+
);
78+
expect(result.version).toBe('0.13.0');
79+
expect(result.url).toContain('/0.13.0/');
80+
// The RN nightly hash MUST NOT leak into the hermes URL.
81+
expect(result.url).not.toContain('20260519');
82+
});
83+
84+
it('ignores rawVersion (the RN --version arg) when HERMES_VERSION is unset', async () => {
85+
mockFetch({
86+
'hermes-compiler/latest-v1': {json: {version: '0.13.0'}},
87+
'hermes-ios/0.13.0/hermes-ios-0.13.0': {ok: true},
88+
});
89+
// Caller passes the original RN --version verbatim; hermes should
90+
// still default to latest-v1 instead of using this.
91+
const result = await resolveHermesArtifact(
92+
'0.87.0-nightly-20260519-58cd1bf58',
93+
'debug',
94+
'0.87.0-nightly-20260519-58cd1bf58',
95+
);
96+
expect(result.version).toBe('0.13.0');
97+
expect(result.url).not.toContain('20260519');
98+
});
99+
});
100+
101+
describe('HERMES_VERSION escape hatches', () => {
102+
it('HERMES_VERSION=<literal-version> uses it verbatim', async () => {
103+
process.env.HERMES_VERSION = '0.13.5';
104+
mockFetch({
105+
'hermes-ios/0.13.5/hermes-ios-0.13.5': {ok: true},
106+
});
107+
const result = await resolveHermesArtifact(
108+
'0.87.0-nightly-anything',
109+
'debug',
110+
null,
111+
);
112+
expect(result.version).toBe('0.13.5');
113+
expect(result.url).toContain('/0.13.5/');
114+
});
115+
116+
it('HERMES_VERSION=latest-v1 resolves via npm dist-tag', async () => {
117+
process.env.HERMES_VERSION = 'latest-v1';
118+
mockFetch({
119+
'hermes-compiler/latest-v1': {json: {version: '0.13.0'}},
120+
'hermes-ios/0.13.0/hermes-ios-0.13.0': {ok: true},
121+
});
122+
const result = await resolveHermesArtifact(
123+
'0.87.0-nightly-anything',
124+
'debug',
125+
null,
126+
);
127+
expect(result.version).toBe('0.13.0');
128+
});
129+
130+
it('HERMES_VERSION=nightly resolves hermes-compiler@nightly from npm', async () => {
131+
process.env.HERMES_VERSION = 'nightly';
132+
mockFetch({
133+
'hermes-compiler/nightly': {json: {version: '0.14.0-nightly-abc'}},
134+
'hermes-ios/0.14.0-nightly-abc/hermes-ios-0.14.0-nightly-abc': {
135+
ok: true,
136+
},
137+
});
138+
const result = await resolveHermesArtifact(
139+
'0.87.0-nightly-anything',
140+
'debug',
141+
null,
142+
);
143+
expect(result.version).toBe('0.14.0-nightly-abc');
144+
});
145+
});
146+
});

0 commit comments

Comments
 (0)