Skip to content

Commit d8b52df

Browse files
chrfalchfabriziocucci
authored andcommitted
SPM: allow overriding the autolinking config command (#57662)
Summary: The SwiftPM autolinking flow hardcodes `react-native-community/cli config` to generate `autolinking.json` (`generate-spm-autolinking-config.js`). Apps that replace community autolinking — most notably **Expo**, which ships `expo-modules-autolinking` instead of `react-native-community/cli` — had no way to override that command, and any failure was swallowed. The result: the config command fails, `autolinking.json` is never written, the `Autolinked` SwiftPM package comes out empty, and `import Expo` (and every Expo module) fails to resolve — surfacing much later as an inscrutable `unable to resolve module dependency: 'Expo'`. CocoaPods already solves the injection half: `use_native_modules!(config_command = $default_command)` accepts the command as a parameter, so an Expo `Podfile` passes `expo-modules-autolinking react-native-config` in place of the `rncli` default. This PR adds the equivalent hook to the SwiftPM path **and** closes the silent-failure trap. ### 1. Allow overriding the config command `generateAutolinkingConfig` already accepted a `configCommand` option internally; it was just never reachable. Two ways to supply it, mirroring the CocoaPods hook: - **`--config-command '<json>'`** — CLI flag taking a JSON array of the argv. - **`RCT_SPM_AUTOLINKING_CONFIG_COMMAND`** — env var in the same JSON-array format. This is the vehicle for the injected Xcode build phase, which usually can't rewrite the script's argv but can read env. Both go through one `parseConfigCommandJson` validator (rejects non-JSON, non-arrays, empty arrays, and non-string / empty-string elements, with a `source`-named error). Precedence: **`--config-command` > `RCT_SPM_AUTOLINKING_CONFIG_COMMAND` > default** (local `rncli` → `npx --no-install` fallback, unchanged). JSON (rather than whitespace-splitting) because real commands contain dashed flags and quoted script strings. The value is the **command to execute** — its stdout is captured as the config JSON and written verbatim — exactly matching CocoaPods, not a precomputed result. An Expo app feeds the same argv it already builds for `use_native_modules!`: ```jsonc RCT_SPM_AUTOLINKING_CONFIG_COMMAND='["node","--no-warnings","--eval","require('expo/bin/autolinking')","expo-modules-autolinking","react-native-config","--json","--platform","ios","--source-dir","/abs/path"]' ``` (Use `--platform ios`: the generator requires `project.ios.sourceDir` and everything downstream is iOS-only.) ### 2. Fail closed when the config command errors Previously `main()` swallowed a config-command failure as a warning and continued, which is what let the empty package be produced silently. That policy is now extracted into `generateAutolinkingConfigOrFailClosed`: on a config-command error (non-zero exit, unparseable output, or a config missing `project.ios.sourceDir`) it logs an actionable message naming `RCT_SPM_AUTOLINKING_CONFIG_COMMAND` / `--config-command`, sets `process.exitCode = 2` (a hard Xcode build-phase error, matching the existing `RemoteVersionError` path), and stops. The guard is deliberately narrow: a **genuinely native-module-free app never reaches the error path** — its command exits 0 with valid, empty-dependency JSON, so the generator returns normally and the legitimate empty-package path stays valid. Only an *erroring* command fails the build. ## Changelog: [IOS] [ADDED] - Allow overriding the SwiftPM autolinking config command via `--config-command` / `RCT_SPM_AUTOLINKING_CONFIG_COMMAND` [IOS] [CHANGED] - Fail closed with an actionable error when the SwiftPM autolinking config command fails, instead of silently emitting an empty Autolinked package Pull Request resolved: #57662 Test Plan: New unit tests, developed red → green: - `generate-spm-autolinking-config-test.js` — env var honored; explicit `configCommand` beats env; invalid-JSON and invalid-shape (`[]`, `[1,2]`) throw with the source name; env unset falls back to the default command; env state saved/restored per test. - `setup-apple-spm-test.js` — `parseArgs` parses `--config-command` into an argv array, defaults to `null` when omitted, and throws on an invalid value; `generateAutolinkingConfigOrFailClosed` returns the result on success (exit code untouched), passes `projectRoot`/`configCommand` through, and on a config-command error returns `null`, sets exit 2, and logs an actionable error that names the env var and preserves the underlying cause. ``` $ yarn jest --no-cache -i \ packages/react-native/scripts/spm/__tests__/generate-spm-autolinking-config-test.js \ packages/react-native/scripts/spm/__tests__/setup-apple-spm-test.js Test Suites: 2 passed, 2 total Tests: 39 passed, 39 total ``` `prettier` and `eslint` clean on all changed files. Reviewed By: zeyap Differential Revision: D113554857 Pulled By: cipolleschi fbshipit-source-id: d0baeeefc91aed144cf9e405e198531ff88088a7 (cherry picked from commit a7ba4ce)
1 parent 0cacb90 commit d8b52df

5 files changed

Lines changed: 317 additions & 11 deletions

File tree

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

Lines changed: 63 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@
5555
* must contain debug/ and release/ cache slots.
5656
* [advanced] --download <auto|skip|force> Artifact policy (default: auto).
5757
* [advanced] --skip-codegen Skip the react-native codegen step.
58+
* [advanced] --config-command <json> Override the autolinking config command.
5859
*
5960
* Steps performed (add/update):
6061
* 1. react-native codegen → build/generated/ios/ + install SPM codegen template
@@ -83,6 +84,7 @@ const {
8384
} = require('./spm/generate-spm-autolinking');
8485
const {
8586
generateAutolinkingConfig,
87+
parseConfigCommandJson,
8688
} = require('./spm/generate-spm-autolinking-config');
8789
const {main: generatePackage} = require('./spm/generate-spm-package');
8890
const {findSourcePath} = require('./spm/generate-spm-package');
@@ -187,6 +189,11 @@ function parseArgs(argv /*: Array<string> */) /*: SetupArgs */ {
187189
default: false,
188190
describe: '[advanced] Skip the react-native codegen step',
189191
})
192+
.option('config-command', {
193+
type: 'string',
194+
describe:
195+
'[advanced] JSON array of the argv used to generate autolinking.json, overriding the default @react-native-community/cli config command. Also settable via RCT_SPM_AUTOLINKING_CONFIG_COMMAND. Example: \'["npx","expo-modules-autolinking","react-native-config","--json","--platform","ios"]\'',
196+
})
190197
.usage(
191198
'Usage: $0 [action] [options]\n\nSets up Swift Package Manager support in a React Native app.',
192199
)
@@ -214,6 +221,10 @@ function parseArgs(argv /*: Array<string> */) /*: SetupArgs */ {
214221
version: parsed.version ?? null,
215222
artifacts: parsed.artifacts ?? null,
216223
skipCodegen: parsed['skip-codegen'],
224+
configCommand:
225+
parsed['config-command'] != null
226+
? parseConfigCommandJson(parsed['config-command'], '--config-command')
227+
: null,
217228
downloadPolicy: parsed.download,
218229
productName: parsed['product-name'] ?? null,
219230
xcodeprojPath: parsed.xcodeproj ?? null,
@@ -892,6 +903,47 @@ function logNextSteps(
892903
log('To remove SPM later: `npx react-native spm deinit`');
893904
}
894905

906+
// Generate autolinking.json, failing closed on a config-command error.
907+
//
908+
// generateAutolinkingConfig throws ONLY when the config command itself fails —
909+
// a non-zero exit, unparseable output, or a config missing
910+
// project.ios.sourceDir. Swallowing that (the old behavior) let the run proceed
911+
// and emit an empty Autolinked package, which only surfaced much later as an
912+
// inscrutable `unable to resolve module dependency` at build time. Instead we
913+
// set process.exitCode = 2 (a hard Xcode build-phase error, matching the
914+
// RemoteVersionError path) and return null so the caller stops.
915+
//
916+
// A genuinely native-module-free app does NOT reach the error path: its command
917+
// exits 0 with valid, empty-dependency JSON, so generateAutolinkingConfig
918+
// returns normally and the empty-package path downstream stays valid.
919+
function generateAutolinkingConfigOrFailClosed(
920+
opts /*: {
921+
projectRoot: string,
922+
configCommand?: Array<string>,
923+
generate?: typeof generateAutolinkingConfig,
924+
} */,
925+
) /*: ?AutolinkingConfigResult */ {
926+
const generate = opts.generate ?? generateAutolinkingConfig;
927+
try {
928+
return generate({
929+
projectRoot: opts.projectRoot,
930+
configCommand: opts.configCommand,
931+
});
932+
} catch (e) {
933+
logError(
934+
`Failed to generate autolinking.json: ${e.message}\n` +
935+
'The autolinking config command failed. If this app replaces ' +
936+
'@react-native-community/cli autolinking (e.g. an Expo app), set ' +
937+
'RCT_SPM_AUTOLINKING_CONFIG_COMMAND (or pass --config-command) to a ' +
938+
'JSON argv array whose command prints the React Native CLI config, ' +
939+
'e.g. \'["npx","expo-modules-autolinking","react-native-config",' +
940+
'"--json","--platform","ios"]\'.',
941+
);
942+
process.exitCode = 2;
943+
return null;
944+
}
945+
}
946+
895947
async function main(argv /*:: ?: Array<string> */) /*: Promise<void> */ {
896948
let appRoot = process.cwd();
897949
const projectRoot = findProjectRoot(appRoot);
@@ -981,16 +1033,16 @@ async function main(argv /*:: ?: Array<string> */) /*: Promise<void> */ {
9811033
let autolinkingConfigResult /*: ?AutolinkingConfigResult */ = null;
9821034
if (needsCliConfig) {
9831035
log('Generating autolinking.json (CLI config)...');
984-
try {
985-
autolinkingConfigResult = generateAutolinkingConfig({projectRoot});
986-
log(
987-
`Wrote ${path.relative(appRoot, autolinkingConfigResult.outputPath)}`,
988-
);
989-
} catch (e) {
990-
logError(
991-
`generate-spm-autolinking-config failed: ${e.message}. External native modules may not be discovered.`,
992-
);
1036+
autolinkingConfigResult = generateAutolinkingConfigOrFailClosed({
1037+
projectRoot,
1038+
configCommand: args.configCommand ?? undefined,
1039+
});
1040+
if (autolinkingConfigResult == null) {
1041+
// Fail closed: the config command errored and the helper already set
1042+
// process.exitCode = 2. Stop rather than emit an empty Autolinked package.
1043+
return;
9931044
}
1045+
log(`Wrote ${path.relative(appRoot, autolinkingConfigResult.outputPath)}`);
9941046
}
9951047
const reactNativeRoot = resolveReactNativeRoot(
9961048
autolinkingConfigResult,
@@ -1159,6 +1211,8 @@ module.exports = {
11591211
main,
11601212
detectStandardRnLayoutRedirect,
11611213
findInjectedXcodeproj,
1214+
generateAutolinkingConfigOrFailClosed,
1215+
parseArgs,
11621216
resolveAction,
11631217
shouldAutoDeintegrate,
11641218
ensureBothArtifactFlavors,

packages/react-native/scripts/spm/__tests__/generate-spm-autolinking-config-test.js

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,20 @@ const os = require('os');
3636
const path = require('path');
3737

3838
let tmpProjects = [];
39+
let originalConfigCommandEnv;
40+
41+
beforeEach(() => {
42+
originalConfigCommandEnv = process.env.RCT_SPM_AUTOLINKING_CONFIG_COMMAND;
43+
delete process.env.RCT_SPM_AUTOLINKING_CONFIG_COMMAND;
44+
});
45+
46+
afterEach(() => {
47+
if (originalConfigCommandEnv == null) {
48+
delete process.env.RCT_SPM_AUTOLINKING_CONFIG_COMMAND;
49+
} else {
50+
process.env.RCT_SPM_AUTOLINKING_CONFIG_COMMAND = originalConfigCommandEnv;
51+
}
52+
});
3953

4054
function makeTmpProject() {
4155
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'spm-autolink-config-'));
@@ -256,4 +270,95 @@ describe('generateAutolinkingConfig', () => {
256270
rawJson: raw,
257271
});
258272
});
273+
274+
describe('config command override', () => {
275+
it('uses the config command from RCT_SPM_AUTOLINKING_CONFIG_COMMAND', () => {
276+
const {projectRoot, iosDir} = makeTmpProject();
277+
const raw = JSON.stringify(fakeCliConfig(iosDir));
278+
let receivedCommand = null;
279+
process.env.RCT_SPM_AUTOLINKING_CONFIG_COMMAND = JSON.stringify([
280+
'my-cli',
281+
'config',
282+
]);
283+
284+
generateAutolinkingConfig({
285+
projectRoot,
286+
cliRunner: command => {
287+
receivedCommand = command;
288+
return {stdout: raw, stderr: '', exitCode: 0};
289+
},
290+
});
291+
292+
expect(receivedCommand).toEqual(['my-cli', 'config']);
293+
});
294+
295+
it('prefers an explicit configCommand over the environment variable', () => {
296+
const {projectRoot, iosDir} = makeTmpProject();
297+
const raw = JSON.stringify(fakeCliConfig(iosDir));
298+
let receivedCommand = null;
299+
process.env.RCT_SPM_AUTOLINKING_CONFIG_COMMAND = JSON.stringify([
300+
'environment',
301+
'config',
302+
]);
303+
304+
generateAutolinkingConfig({
305+
projectRoot,
306+
configCommand: ['explicit', 'config'],
307+
cliRunner: command => {
308+
receivedCommand = command;
309+
return {stdout: raw, stderr: '', exitCode: 0};
310+
},
311+
});
312+
313+
expect(receivedCommand).toEqual(['explicit', 'config']);
314+
});
315+
316+
it('throws when the environment variable is not JSON', () => {
317+
const {projectRoot} = makeTmpProject();
318+
process.env.RCT_SPM_AUTOLINKING_CONFIG_COMMAND = 'not json';
319+
320+
expect(() =>
321+
generateAutolinkingConfig({
322+
projectRoot,
323+
cliRunner: () => ({stdout: '{}', stderr: '', exitCode: 0}),
324+
}),
325+
).toThrow(/RCT_SPM_AUTOLINKING_CONFIG_COMMAND/);
326+
});
327+
328+
it.each(['[]', '[1,2]'])(
329+
'throws when the environment variable is not a non-empty string array: %s',
330+
rawConfigCommand => {
331+
const {projectRoot} = makeTmpProject();
332+
process.env.RCT_SPM_AUTOLINKING_CONFIG_COMMAND = rawConfigCommand;
333+
334+
expect(() =>
335+
generateAutolinkingConfig({
336+
projectRoot,
337+
cliRunner: () => ({stdout: '{}', stderr: '', exitCode: 0}),
338+
}),
339+
).toThrow(/RCT_SPM_AUTOLINKING_CONFIG_COMMAND/);
340+
},
341+
);
342+
343+
it('falls back to the default command when the environment variable is unset', () => {
344+
const {projectRoot, iosDir} = makeTmpProject();
345+
const raw = JSON.stringify(fakeCliConfig(iosDir));
346+
let receivedCommand = null;
347+
348+
generateAutolinkingConfig({
349+
projectRoot,
350+
cliRunner: command => {
351+
receivedCommand = command;
352+
return {stdout: raw, stderr: '', exitCode: 0};
353+
},
354+
});
355+
356+
expect(receivedCommand).toEqual([
357+
'npx',
358+
'--no-install',
359+
'@react-native-community/cli',
360+
'config',
361+
]);
362+
});
363+
});
259364
});

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

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ const {
1414
detectStandardRnLayoutRedirect,
1515
ensureBothArtifactFlavors,
1616
findInjectedXcodeproj,
17+
generateAutolinkingConfigOrFailClosed,
18+
parseArgs,
1719
resolveAction,
1820
shouldAutoDeintegrate,
1921
} = require('../../setup-apple-spm');
@@ -59,6 +61,102 @@ function gitInitAndCommit(dir) {
5961
execFileSync('git', ['commit', '-m', 'init'], opts);
6062
}
6163

64+
describe('parseArgs', () => {
65+
it('parses --config-command as a JSON argv array', () => {
66+
const args = parseArgs([
67+
'update',
68+
'--config-command',
69+
'["a","b","config"]',
70+
]);
71+
72+
expect(args.action).toBe('update');
73+
expect(args.configCommand).toEqual(['a', 'b', 'config']);
74+
});
75+
76+
it('sets configCommand to null when --config-command is omitted', () => {
77+
expect(parseArgs(['update']).configCommand).toBeNull();
78+
});
79+
80+
it('throws for an invalid --config-command value', () => {
81+
expect(() => parseArgs(['update', '--config-command', 'not json'])).toThrow(
82+
/--config-command/,
83+
);
84+
});
85+
});
86+
87+
// ---------------------------------------------------------------------------
88+
// generateAutolinkingConfigOrFailClosed — the fail-closed policy main() applies
89+
// to the autolinking config step. Swallowing a config-command error (the old
90+
// behavior) let the build proceed with a silently-empty Autolinked package that
91+
// only surfaced later as `unable to resolve module dependency`. A native-
92+
// module-free app does NOT hit the error path: its command exits 0 with valid
93+
// empty-dependency JSON and the generator returns normally.
94+
// ---------------------------------------------------------------------------
95+
96+
describe('generateAutolinkingConfigOrFailClosed', () => {
97+
let prevExitCode;
98+
let warnSpy;
99+
100+
beforeEach(() => {
101+
prevExitCode = process.exitCode;
102+
warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
103+
});
104+
105+
afterEach(() => {
106+
process.exitCode = prevExitCode;
107+
jest.restoreAllMocks();
108+
});
109+
110+
it('returns the config result and leaves the exit code untouched on success', () => {
111+
const result = {
112+
config: {},
113+
outputPath: '/app/ios/autolinking.json',
114+
rawJson: '{}',
115+
};
116+
const out = generateAutolinkingConfigOrFailClosed({
117+
projectRoot: '/app',
118+
generate: () => result,
119+
});
120+
121+
expect(out).toBe(result);
122+
expect(process.exitCode).not.toBe(2);
123+
});
124+
125+
it('passes projectRoot and configCommand through to the generator', () => {
126+
let received;
127+
generateAutolinkingConfigOrFailClosed({
128+
projectRoot: '/proj',
129+
configCommand: ['my-cli', 'config'],
130+
generate: opts => {
131+
received = opts;
132+
return {config: {}, outputPath: '', rawJson: ''};
133+
},
134+
});
135+
136+
expect(received).toEqual({
137+
projectRoot: '/proj',
138+
configCommand: ['my-cli', 'config'],
139+
});
140+
});
141+
142+
it('fails closed (null, exit 2, actionable error) when the config command errors', () => {
143+
const out = generateAutolinkingConfigOrFailClosed({
144+
projectRoot: '/app',
145+
generate: () => {
146+
throw new Error("'my-cli config' exited with status 1");
147+
},
148+
});
149+
150+
expect(out).toBeNull();
151+
expect(process.exitCode).toBe(2);
152+
const warnings = warnSpy.mock.calls.map(c => c.join(' ')).join('\n');
153+
// Names the override so the next person can fix it...
154+
expect(warnings).toMatch(/RCT_SPM_AUTOLINKING_CONFIG_COMMAND/);
155+
// ...and preserves the underlying cause.
156+
expect(warnings).toMatch(/exited with status 1/);
157+
});
158+
});
159+
62160
// ---------------------------------------------------------------------------
63161
// resolveAction — zero-arg default. Explicit action wins; otherwise `update`
64162
// when an injection marker exists, else `add` (first run).

0 commit comments

Comments
 (0)