Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 51 additions & 3 deletions packages/react-native/scripts/setup-apple-spm.js
Original file line number Diff line number Diff line change
Expand Up @@ -85,14 +85,18 @@ const {
const {
generateAutolinkingConfig,
parseConfigCommandJson,
readEnvConfigCommand,
resolveEnvConfigCommand,
} = require('./spm/generate-spm-autolinking-config');
const {main: generatePackage} = require('./spm/generate-spm-package');
const {findSourcePath} = require('./spm/generate-spm-package');
const {
SPM_INJECTED_MARKER,
cleanupDanglingJavaScriptCoreRef,
cleanupLeftoverPodsGroup,
findInjectedXcodeproj,
injectSpmIntoExistingXcodeproj,
readPinnedConfigCommand,
removeSpmInjection,
} = require('./spm/generate-spm-xcodeproj');
const {scaffoldAll} = require('./spm/scaffold-package-swift');
Expand Down Expand Up @@ -192,7 +196,7 @@ function parseArgs(argv /*: Array<string> */) /*: SetupArgs */ {
.option('config-command', {
type: 'string',
describe:
'[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"]\'',
'[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. Either way `add`/`update` remembers the value in .spm-injected.json, so later runs and Xcode builds reuse it. Example: \'["npx","expo-modules-autolinking","react-native-config","--json","--platform","ios"]\'',
})
.usage(
'Usage: $0 [action] [options]\n\nSets up Swift Package Manager support in a React Native app.',
Expand Down Expand Up @@ -844,6 +848,7 @@ async function setupXcodeproj(
// (injectSpmIntoExistingXcodeproj preserves it — see
// generate-spm-xcodeproj.js).
artifactsVersionOverride: args.version ?? null,
configCommand: resolveConfigCommandToPin(args),
});
if (result.status !== 'injected') {
logError(`SPM injection failed: ${result.reason}`);
Expand Down Expand Up @@ -903,6 +908,45 @@ function logNextSteps(
log('To remove SPM later: `npx react-native spm deinit`');
}

// The autolinking config command for this run: an explicit `--config-command`
// first, then the value a previous `add`/`update` pinned into the injection
// marker. undefined means "no explicit command", which is what makes
// generateAutolinkingConfig fall back to RCT_SPM_AUTOLINKING_CONFIG_COMMAND and
// then to the built-in default — so the pin has to be WITHHELD while the env
// var is set, or a stale pin would outrank a developer's env override.
function resolveExplicitConfigCommand(
args /*: SetupArgs */,
appRoot /*: string */,
) /*: Array<string> | void */ {
if (args.configCommand != null) {
return args.configCommand;
}
if (readEnvConfigCommand() != null) {
return undefined;
}
const pinned = readPinnedConfigCommand(appRoot);
if (pinned == null) {
return undefined;
}
log(
`Autolinking config command (pinned in ${SPM_INJECTED_MARKER}): ` +
pinned.join(' '),
);
return pinned;
}

// The command to record in the injection marker. The env var is resolved here
// too, because the Xcode build phase inherits neither the flag nor the shell
// that set it — an env-only override that went unpinned would leave the build
// re-deriving autolinking.json with the default command. null pins nothing and
// preserves any earlier pin. An invalid env value throws, as the flag does,
// though `add` has already failed closed on it by this point.
function resolveConfigCommandToPin(
args /*: SetupArgs */,
) /*: ?Array<string> */ {
return args.configCommand ?? resolveEnvConfigCommand();
}

// Generate autolinking.json, failing closed on a config-command error.
//
// generateAutolinkingConfig throws ONLY when the config command itself fails —
Expand Down Expand Up @@ -937,7 +981,9 @@ function generateAutolinkingConfigOrFailClosed(
'RCT_SPM_AUTOLINKING_CONFIG_COMMAND (or pass --config-command) to a ' +
'JSON argv array whose command prints the React Native CLI config, ' +
'e.g. \'["npx","expo-modules-autolinking","react-native-config",' +
'"--json","--platform","ios"]\'.',
'"--json","--platform","ios"]\'. An earlier `add`/`update` may also ' +
`have pinned a command in ${SPM_INJECTED_MARKER}; re-run with ` +
'--config-command to replace a stale one.',
);
process.exitCode = 2;
return null;
Expand Down Expand Up @@ -1035,7 +1081,7 @@ async function main(argv /*:: ?: Array<string> */) /*: Promise<void> */ {
log('Generating autolinking.json (CLI config)...');
autolinkingConfigResult = generateAutolinkingConfigOrFailClosed({
projectRoot,
configCommand: args.configCommand ?? undefined,
configCommand: resolveExplicitConfigCommand(args, appRoot),
});
if (autolinkingConfigResult == null) {
// Fail closed: the config command errored and the helper already set
Expand Down Expand Up @@ -1214,6 +1260,8 @@ module.exports = {
generateAutolinkingConfigOrFailClosed,
parseArgs,
resolveAction,
resolveConfigCommandToPin,
resolveExplicitConfigCommand,
shouldAutoDeintegrate,
ensureBothArtifactFlavors,
};
37 changes: 36 additions & 1 deletion packages/react-native/scripts/spm/__doc__/spm-scripts.md
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,40 @@ accepts kebab-case equivalents (e.g. `--skip-codegen`).
| `--artifacts <path>` | [advanced] Local artifact root containing complete `debug/` and `release/` cache slots |
| `--download <auto\|skip\|force>` | [advanced] Artifact download policy (default: auto) |
| `--skipCodegen` | [advanced] Skip the codegen step |
| `--configCommand <json>` | [advanced] JSON array of the argv used to generate `autolinking.json`, overriding the default `@react-native-community/cli config` command. Also settable via the `RCT_SPM_AUTOLINKING_CONFIG_COMMAND` env var. Either way the value is remembered, so you pass it once. Example: `'["npx","expo-modules-autolinking","react-native-config","--json","--platform","ios"]'` |

### The autolinking config command is remembered

An app that replaces `@react-native-community/cli` autolinking (an Expo app,
for example) has to tell `spm` how to produce `autolinking.json`. Pass the
command once, on `add` or `update`:

```bash
npx react-native spm add --configCommand '["npx","expo-modules-autolinking","react-native-config","--json","--platform","ios"]'
```

Every action that needs `autolinking.json` — `add`, `update`, `scaffold`, and
the build-time `sync` — resolves the command in this order:

1. `--configCommand`
2. `RCT_SPM_AUTOLINKING_CONFIG_COMMAND`
3. the `configCommand` pinned in `MyApp.xcodeproj/.spm-injected.json` by an
earlier `add`/`update`
4. the default `@react-native-community/cli config`

`add`/`update` pin whichever of the first two routes supplied the command,
validated as an argv array; a later run that passes neither keeps the existing
pin, and passing `--configCommand` again replaces it. The pin exists because
the **Sync SPM Autolinking** build phase inherits neither your flag nor the
shell that exported the env var — without it, a successful `add` is followed by
failing builds, because the phase re-derives `autolinking.json` with the
default command. A pin never shadows the env var, so an override in your shell
still takes effect, and a pin that no longer parses is ignored in favor of the
default.

`deinit` deletes `.spm-injected.json`, and the pin with it. A later `add`
therefore falls back to the default command unless you pass `--configCommand`
(or export the env var) again.

### Debug/Release flavor is automatic

Expand All @@ -161,7 +195,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 pins settings later runs and Xcode builds must reuse, such as the [autolinking config command](#the-autolinking-config-command-is-remembered). |
| `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 |
Expand Down Expand Up @@ -335,6 +369,7 @@ across apps; refresh it with `react-native spm update --download force`.
| "not contained in target" | Re-run setup (regenerates file-level symlinks) |
| Codegen fails | Use `--skipCodegen` to iterate on other parts |
| "SPM sync failed" warning | Check Xcode build log for details; node may not be in PATH — ensure `with-environment.sh` is present |
| "Sync SPM Autolinking" build phase fails: `'npx --no-install @react-native-community/cli config' exited with status 1` | This app replaces `@react-native-community/cli` autolinking (e.g. an Expo app). Re-run `spm add`/`update` with `--configCommand` (or with `RCT_SPM_AUTOLINKING_CONFIG_COMMAND` exported) so the working command is pinned for the build phase to reuse — see [The autolinking config command is remembered](#the-autolinking-config-command-is-remembered). |
| Autolinking not updating on build | Touch `package.json` to force a sync, or delete `build/generated/autolinking/.spm-sync-stamp` |
| Stale SwiftPM state or corrupted build | `rm -rf build/ .build/`, then `react-native spm update`, then reopen Xcode |
| Want to revert to CocoaPods | `react-native spm deinit`, then `pod install` |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ const {
SPM_INJECTED_MARKER,
injectSpmIntoExistingXcodeproj,
readArtifactsVersionOverride,
readPinnedConfigCommand,
removeSpmInjection,
} = require('../generate-spm-xcodeproj');
const fs = require('node:fs');
Expand Down Expand Up @@ -393,3 +394,148 @@ describe('readArtifactsVersionOverride', () => {
expect(readArtifactsVersionOverride(appRoot)).toBeNull();
});
});

// ---------------------------------------------------------------------------
// configCommand — the marker field persisting an explicit `spm add/update
// --config-command '<json argv>'`. Without the pin, the build-time `sync`
// re-derived autolinking.json with the default @react-native-community/cli
// command and failed the "Sync SPM Autolinking" build phase in apps (e.g. Expo
// apps) that replace it.
// ---------------------------------------------------------------------------
describe('configCommand marker field', () => {
const EXPO_COMMAND = [
'npx',
'expo-modules-autolinking',
'react-native-config',
'--json',
'--platform',
'ios',
];

it('records an explicit config command into the marker', () => {
const {appRoot, xcodeprojPath, rnRoot} = scaffoldApp();
injectSpmIntoExistingXcodeproj({
appRoot,
reactNativeRoot: rnRoot,
xcodeprojPath,
configCommand: EXPO_COMMAND,
});
expect(readMarker(xcodeprojPath).configCommand).toEqual(EXPO_COMMAND);
expect(readPinnedConfigCommand(appRoot)).toEqual(EXPO_COMMAND);
});

it('defaults to null when --config-command has never been given', () => {
const {appRoot, xcodeprojPath, rnRoot} = scaffoldApp();
injectSpmIntoExistingXcodeproj({
appRoot,
reactNativeRoot: rnRoot,
xcodeprojPath,
});
expect(readMarker(xcodeprojPath).configCommand).toBeNull();
expect(readPinnedConfigCommand(appRoot)).toBeNull();
});

it('preserves the pin on a later run without --config-command', () => {
const {appRoot, xcodeprojPath, rnRoot} = scaffoldApp();
injectSpmIntoExistingXcodeproj({
appRoot,
reactNativeRoot: rnRoot,
xcodeprojPath,
configCommand: EXPO_COMMAND,
});
injectSpmIntoExistingXcodeproj({
appRoot,
reactNativeRoot: rnRoot,
xcodeprojPath,
});
expect(readMarker(xcodeprojPath).configCommand).toEqual(EXPO_COMMAND);
expect(readPinnedConfigCommand(appRoot)).toEqual(EXPO_COMMAND);
});

it('a later explicit --config-command overwrites the previous pin', () => {
const {appRoot, xcodeprojPath, rnRoot} = scaffoldApp();
injectSpmIntoExistingXcodeproj({
appRoot,
reactNativeRoot: rnRoot,
xcodeprojPath,
configCommand: EXPO_COMMAND,
});
injectSpmIntoExistingXcodeproj({
appRoot,
reactNativeRoot: rnRoot,
xcodeprojPath,
configCommand: ['my-cli', 'config'],
});
expect(readMarker(xcodeprojPath).configCommand).toEqual([
'my-cli',
'config',
]);
expect(readPinnedConfigCommand(appRoot)).toEqual(['my-cli', 'config']);
});

it('deinit drops the pin along with the whole marker', () => {
const {appRoot, xcodeprojPath, rnRoot} = scaffoldApp();
injectSpmIntoExistingXcodeproj({
appRoot,
reactNativeRoot: rnRoot,
xcodeprojPath,
configCommand: EXPO_COMMAND,
});
removeSpmInjection({appRoot, xcodeprojPath});
expect(fs.existsSync(path.join(xcodeprojPath, SPM_INJECTED_MARKER))).toBe(
false,
);
expect(readPinnedConfigCommand(appRoot)).toBeNull();
});
});

// ---------------------------------------------------------------------------
// readPinnedConfigCommand — pure fs read, used by setup-apple-spm.js (including
// the build-time `sync`) to reuse the config command an earlier `add`/`update`
// pinned. A hand-edited or corrupt marker must degrade to the env/default path
// instead of injecting a bogus argv or throwing mid-build.
// ---------------------------------------------------------------------------
describe('readPinnedConfigCommand', () => {
it('returns null when no xcodeproj has been injected yet', () => {
const {appRoot} = scaffoldApp();
expect(readPinnedConfigCommand(appRoot)).toBeNull();
});

it('returns null (never throws) on a malformed marker', () => {
const {appRoot, xcodeprojPath, rnRoot} = scaffoldApp();
injectSpmIntoExistingXcodeproj({
appRoot,
reactNativeRoot: rnRoot,
xcodeprojPath,
configCommand: ['my-cli', 'config'],
});
fs.writeFileSync(
path.join(xcodeprojPath, SPM_INJECTED_MARKER),
'{ not valid json',
'utf8',
);
expect(() => readPinnedConfigCommand(appRoot)).not.toThrow();
expect(readPinnedConfigCommand(appRoot)).toBeNull();
});

it.each([
['a bare string', '"npx expo-modules-autolinking"'],
['an empty array', '[]'],
['a non-string member', '["npx", 7]'],
['an empty-string member', '["npx", ""]'],
['an object', '{"command": "npx"}'],
])('returns null for a pinned value that is %s', (_label, pinned) => {
const {appRoot, xcodeprojPath, rnRoot} = scaffoldApp();
injectSpmIntoExistingXcodeproj({
appRoot,
reactNativeRoot: rnRoot,
xcodeprojPath,
configCommand: ['my-cli', 'config'],
});
const markerPath = path.join(xcodeprojPath, SPM_INJECTED_MARKER);
const marker = JSON.parse(fs.readFileSync(markerPath, 'utf8'));
marker.configCommand = JSON.parse(pinned);
fs.writeFileSync(markerPath, JSON.stringify(marker), 'utf8');
expect(readPinnedConfigCommand(appRoot)).toBeNull();
});
});
Loading
Loading