react-native 0.87.0-rc.3 → 0.87.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -29,7 +29,7 @@ export default class ReactNativeVersion {
29
29
  static major: number = 0;
30
30
  static minor: number = 87;
31
31
  static patch: number = 0;
32
- static prerelease: string | null = 'rc.3';
32
+ static prerelease: string | null = null;
33
33
 
34
34
  static getVersionString(): string {
35
35
  return `${this.major}.${this.minor}.${this.patch}${this.prerelease != null ? `-${this.prerelease}` : ''}`;
@@ -24,7 +24,7 @@ NSDictionary* RCTGetReactNativeVersion(void)
24
24
  RCTVersionMajor: @(0),
25
25
  RCTVersionMinor: @(87),
26
26
  RCTVersionPatch: @(0),
27
- RCTVersionPrerelease: @"rc.3",
27
+ RCTVersionPrerelease: [NSNull null],
28
28
  };
29
29
  });
30
30
  return __rnVersion;
@@ -1,4 +1,4 @@
1
- VERSION_NAME=0.87.0-rc.3
1
+ VERSION_NAME=0.87.0
2
2
  react.internal.publishingGroup=com.facebook.react
3
3
  react.internal.hermesPublishingGroup=com.facebook.hermes
4
4
 
@@ -15,6 +15,6 @@ public object ReactNativeVersion {
15
15
  "major" to 0,
16
16
  "minor" to 87,
17
17
  "patch" to 0,
18
- "prerelease" to "rc.3"
18
+ "prerelease" to null
19
19
  )
20
20
  }
@@ -22,7 +22,7 @@ struct ReactNativeVersionType {
22
22
  int32_t Major = 0;
23
23
  int32_t Minor = 87;
24
24
  int32_t Patch = 0;
25
- std::string_view Prerelease = "rc.3";
25
+ std::string_view Prerelease = "";
26
26
  };
27
27
 
28
28
  constexpr ReactNativeVersionType ReactNativeVersion;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-native",
3
- "version": "0.87.0-rc.3",
3
+ "version": "0.87.0",
4
4
  "description": "A framework for building native apps using React",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -42,7 +42,9 @@
42
42
  "types": null,
43
43
  "default": "./Libraries/*.js"
44
44
  },
45
- "./scripts/*": "./scripts/*",
45
+ "./scripts/*": "./scripts/*.js",
46
+ "./scripts/*.sh": "./scripts/*.sh",
47
+ "./scripts/*.rb": "./scripts/*.rb",
46
48
  "./asset-registry": {
47
49
  "types": null,
48
50
  "default": "./src/asset-registry.js"
@@ -148,12 +150,12 @@
148
150
  }
149
151
  },
150
152
  "dependencies": {
151
- "@react-native/asset-utils": "0.87.0-rc.3",
152
- "@react-native/codegen": "0.87.0-rc.3",
153
- "@react-native/community-cli-plugin": "0.87.0-rc.3",
154
- "@react-native/gradle-plugin": "0.87.0-rc.3",
155
- "@react-native/normalize-colors": "0.87.0-rc.3",
156
- "@react-native/virtualized-lists": "0.87.0-rc.3",
153
+ "@react-native/asset-utils": "0.87.0",
154
+ "@react-native/codegen": "0.87.0",
155
+ "@react-native/community-cli-plugin": "0.87.0",
156
+ "@react-native/gradle-plugin": "0.87.0",
157
+ "@react-native/normalize-colors": "0.87.0",
158
+ "@react-native/virtualized-lists": "0.87.0",
157
159
  "anser": "^1.4.9",
158
160
  "ansi-regex": "^5.0.0",
159
161
  "babel-plugin-syntax-hermes-parser": "0.36.1",
@@ -568,6 +568,7 @@ class ReactNativeCoreUtils
568
568
  # Quoted so a $(PODS_ROOT) containing spaces stays a single clang argument.
569
569
  module_map_flag = " \"-fmodule-map-file=$(PODS_ROOT)/React-Core-prebuilt/Headers/module.modulemap\""
570
570
  ReactNativePodsUtils.add_flag_to_map_with_inheritance(attributes, "OTHER_CFLAGS", module_map_flag)
571
+ ReactNativePodsUtils.add_flag_to_map_with_inheritance(attributes, "OTHER_CPLUSPLUSFLAGS", module_map_flag)
571
572
  ReactNativePodsUtils.add_flag_to_map_with_inheritance(attributes, "OTHER_SWIFT_FLAGS", " -Xcc" + module_map_flag)
572
573
  end
573
574
  end
@@ -59,7 +59,7 @@ function replaceRNCoreConfiguration(
59
59
  configuration /*: string */,
60
60
  version /*: string */,
61
61
  podsRoot /*: string */,
62
- ) {
62
+ ) /*: void */ {
63
63
  // Filename comes from rncore.rb
64
64
  const tarballURLPath = `${podsRoot}/ReactNativeCore-artifacts/reactnative-core-${version.toLowerCase()}-${configuration.toLowerCase()}.tar.gz`;
65
65
 
@@ -73,18 +73,6 @@ function replaceRNCoreConfiguration(
73
73
  const tmpExtractDir = path.join(tmpDir, 'React-Core-prebuilt');
74
74
  fs.mkdirSync(tmpExtractDir, {recursive: true});
75
75
 
76
- // Preserve Expo-generated modulemap before replacing directories
77
- const useFrameworksModulemapName = 'React-use-frameworks.modulemap';
78
- const useFrameworksModulemapPath = path.join(
79
- finalLocation,
80
- useFrameworksModulemapName,
81
- );
82
- let savedModulemap = null;
83
- if (fs.existsSync(useFrameworksModulemapPath)) {
84
- console.log('Preserving', useFrameworksModulemapName);
85
- savedModulemap = fs.readFileSync(useFrameworksModulemapPath);
86
- }
87
-
88
76
  try {
89
77
  console.log('Extracting the tarball to temp dir', tarballURLPath);
90
78
  const result = spawnSync(
@@ -110,98 +98,30 @@ function replaceRNCoreConfiguration(
110
98
  );
111
99
  }
112
100
 
113
- // Delete only directories in finalLocation (e.g. the React.xcframework) -
114
- // not files, so any sibling files written during pod install are preserved.
115
- const dirs = fs
116
- .readdirSync(finalLocation, {withFileTypes: true})
117
- .filter(dirent => dirent.isDirectory());
118
- for (const dirent of dirs) {
119
- const direntName =
120
- typeof dirent.name === 'string' ? dirent.name : dirent.name.toString();
121
- const dirPath = `${finalLocation}/${direntName}`;
122
- console.log('Removing directory', dirPath);
123
- fs.rmSync(dirPath, {force: true, recursive: true});
124
- }
125
-
126
- // Move extracted directories from temp to final location
127
- const extractedEntries = fs
128
- .readdirSync(tmpExtractDir, {withFileTypes: true})
129
- .filter(dirent => dirent.isDirectory());
130
- for (const dirent of extractedEntries) {
131
- const direntName =
132
- typeof dirent.name === 'string' ? dirent.name : dirent.name.toString();
133
- const src = path.join(tmpExtractDir, direntName);
134
- const dst = path.join(finalLocation, direntName);
135
- const mvResult = spawnSync('mv', [src, dst], {stdio: 'inherit'});
136
- if (mvResult.status !== 0) {
137
- // Fallback: copy recursively then remove source
138
- console.log(`mv failed for ${direntName}, falling back to cp -R`);
139
- const cpResult = spawnSync('cp', ['-R', src, dst], {
140
- stdio: 'inherit',
141
- });
142
- if (cpResult.status !== 0) {
143
- throw new Error(
144
- `cp fallback failed with exit code ${cpResult.status}`,
145
- );
146
- }
101
+ // Replace only the compiled framework. Headers/ is flattened from
102
+ // ReactNativeHeaders by the podspec prepare_command, and the prebuild
103
+ // compose job emits one set of those headers for both configurations, so a
104
+ // config switch leaves them identical. Leaving them alone keeps
105
+ // Headers/module.modulemap which consumers activate through
106
+ // -fmodule-map-file — in place for the whole build; deleting and recreating
107
+ // it mid-build lets a concurrent dependency scan miss it, and the React
108
+ // module then precompiles without it (#57803).
109
+ const dest = path.join(finalLocation, 'React.xcframework');
110
+ console.log('Replacing', dest);
111
+ fs.rmSync(dest, {force: true, recursive: true});
112
+ const mvResult = spawnSync('mv', [xcfwPath, dest], {stdio: 'inherit'});
113
+ if (mvResult.status !== 0) {
114
+ // Fallback: copy recursively then remove source
115
+ console.log('mv failed for React.xcframework, falling back to cp -R');
116
+ const cpResult = spawnSync('cp', ['-R', xcfwPath, dest], {
117
+ stdio: 'inherit',
118
+ });
119
+ if (cpResult.status !== 0) {
120
+ throw new Error(`cp fallback failed with exit code ${cpResult.status}`);
147
121
  }
148
122
  }
149
-
150
- // The podspec prepare_command flattens ReactNativeHeaders' headers into a
151
- // top-level Headers/ dir, but it does not re-run on a config swap. Mirror
152
- // it here: re-flatten the headers (identical across slices) and drop the
153
- // now-redundant xcframework so $(PODS_ROOT)/React-Core-prebuilt/Headers
154
- // keeps resolving <react/...>, <yoga/...>, etc.
155
- //
156
- // Fail closed when the swapped-in tarball lacks ReactNativeHeaders: the
157
- // directory purge above already deleted the previous Headers/, so
158
- // continuing silently would leave the injected -fmodule-map-file flag
159
- // dangling and break every <react/...> include only on a config switch —
160
- // with no pointer to the version-skewed artifact that caused it.
161
- const rnhXcfw = path.join(finalLocation, 'ReactNativeHeaders.xcframework');
162
- if (!fs.existsSync(rnhXcfw)) {
163
- throw new Error(
164
- `ReactNativeHeaders.xcframework not found in the extracted tarball at ${finalLocation}. ` +
165
- 'The downloaded artifact predates the headers-spec layout (or is incomplete); ' +
166
- 'use a prebuilt tarball matching this react-native version.',
167
- );
168
- }
169
- const slice = fs
170
- .readdirSync(rnhXcfw, {withFileTypes: true})
171
- .find(
172
- dirent =>
173
- dirent.isDirectory() &&
174
- fs.existsSync(path.join(rnhXcfw, dirent.name.toString(), 'Headers')),
175
- );
176
- if (!slice) {
177
- throw new Error(
178
- `No slice with a Headers directory found inside ${rnhXcfw}.`,
179
- );
180
- }
181
- const headersDest = path.join(finalLocation, 'Headers');
182
- fs.rmSync(headersDest, {force: true, recursive: true});
183
- const cpHeaders = spawnSync(
184
- 'cp',
185
- ['-R', path.join(rnhXcfw, slice.name.toString(), 'Headers'), headersDest],
186
- {stdio: 'inherit'},
187
- );
188
- if (cpHeaders.status !== 0) {
189
- throw new Error(
190
- `Flattening ReactNativeHeaders failed with exit code ${cpHeaders.status}`,
191
- );
192
- }
193
- fs.rmSync(rnhXcfw, {force: true, recursive: true});
194
123
  } finally {
195
- // Clean up temp directory
196
124
  fs.rmSync(tmpDir, {force: true, recursive: true});
197
-
198
- // Restore Expo-generated modulemap after directory replacement.
199
- // Runs in finally so it is not skipped if mv/cp partially fails.
200
- if (savedModulemap != null) {
201
- const restoredPath = path.join(finalLocation, useFrameworksModulemapName);
202
- fs.writeFileSync(restoredPath, savedModulemap);
203
- console.log('Restored', useFrameworksModulemapName);
204
- }
205
125
  }
206
126
  }
207
127
 
@@ -227,29 +147,33 @@ function main(
227
147
  console.log('Done replacing React Native prebuilt');
228
148
  }
229
149
 
230
- // This script is executed in the Pods folder, which is usually not synched to Github, so it should be ok
231
- const argv = yargs
232
- .option('c', {
233
- alias: 'configuration',
234
- description:
235
- 'Configuration to use to download the right React-Core prebuilt version. Allowed values are "Debug" and "Release".',
236
- })
237
- .option('r', {
238
- alias: 'reactNativeVersion',
239
- description:
240
- 'The Version of React Native associated with the React-Core prebuilt tarball.',
241
- })
242
- .option('p', {
243
- alias: 'podsRoot',
244
- description: 'The path to the Pods root folder',
245
- })
246
- .usage('Usage: $0 -c Debug -r <version> -p <path/to/react-native>').argv;
247
-
248
- // $FlowFixMe[prop-missing]
249
- const configuration = argv.configuration;
250
- // $FlowFixMe[prop-missing]
251
- const version = argv.reactNativeVersion;
252
- // $FlowFixMe[prop-missing]
253
- const podsRoot = argv.podsRoot;
254
-
255
- main(configuration, version, podsRoot);
150
+ if (require.main === module) {
151
+ // This script is executed in the Pods folder, which is usually not synched to Github, so it should be ok
152
+ const argv = yargs
153
+ .option('c', {
154
+ alias: 'configuration',
155
+ description:
156
+ 'Configuration to use to download the right React-Core prebuilt version. Allowed values are "Debug" and "Release".',
157
+ })
158
+ .option('r', {
159
+ alias: 'reactNativeVersion',
160
+ description:
161
+ 'The Version of React Native associated with the React-Core prebuilt tarball.',
162
+ })
163
+ .option('p', {
164
+ alias: 'podsRoot',
165
+ description: 'The path to the Pods root folder',
166
+ })
167
+ .usage('Usage: $0 -c Debug -r <version> -p <path/to/react-native>').argv;
168
+
169
+ // $FlowFixMe[prop-missing]
170
+ const configuration = argv.configuration;
171
+ // $FlowFixMe[prop-missing]
172
+ const version = argv.reactNativeVersion;
173
+ // $FlowFixMe[prop-missing]
174
+ const podsRoot = argv.podsRoot;
175
+
176
+ main(configuration, version, podsRoot);
177
+ }
178
+
179
+ module.exports = {replaceRNCoreConfiguration};
@@ -55,6 +55,7 @@
55
55
  * must contain debug/ and release/ cache slots.
56
56
  * [advanced] --download <auto|skip|force> Artifact policy (default: auto).
57
57
  * [advanced] --skip-codegen Skip the react-native codegen step.
58
+ * [advanced] --config-command <json> Override the autolinking config command.
58
59
  *
59
60
  * Steps performed (add/update):
60
61
  * 1. react-native codegen → build/generated/ios/ + install SPM codegen template
@@ -83,14 +84,19 @@ const {
83
84
  } = require('./spm/generate-spm-autolinking');
84
85
  const {
85
86
  generateAutolinkingConfig,
87
+ parseConfigCommandJson,
88
+ readEnvConfigCommand,
89
+ resolveEnvConfigCommand,
86
90
  } = require('./spm/generate-spm-autolinking-config');
87
91
  const {main: generatePackage} = require('./spm/generate-spm-package');
88
92
  const {findSourcePath} = require('./spm/generate-spm-package');
89
93
  const {
94
+ SPM_INJECTED_MARKER,
90
95
  cleanupDanglingJavaScriptCoreRef,
91
96
  cleanupLeftoverPodsGroup,
92
97
  findInjectedXcodeproj,
93
98
  injectSpmIntoExistingXcodeproj,
99
+ readPinnedConfigCommand,
94
100
  removeSpmInjection,
95
101
  } = require('./spm/generate-spm-xcodeproj');
96
102
  const {scaffoldAll} = require('./spm/scaffold-package-swift');
@@ -187,6 +193,11 @@ function parseArgs(argv /*: Array<string> */) /*: SetupArgs */ {
187
193
  default: false,
188
194
  describe: '[advanced] Skip the react-native codegen step',
189
195
  })
196
+ .option('config-command', {
197
+ type: 'string',
198
+ describe:
199
+ '[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"]\'',
200
+ })
190
201
  .usage(
191
202
  'Usage: $0 [action] [options]\n\nSets up Swift Package Manager support in a React Native app.',
192
203
  )
@@ -214,6 +225,10 @@ function parseArgs(argv /*: Array<string> */) /*: SetupArgs */ {
214
225
  version: parsed.version ?? null,
215
226
  artifacts: parsed.artifacts ?? null,
216
227
  skipCodegen: parsed['skip-codegen'],
228
+ configCommand:
229
+ parsed['config-command'] != null
230
+ ? parseConfigCommandJson(parsed['config-command'], '--config-command')
231
+ : null,
217
232
  downloadPolicy: parsed.download,
218
233
  productName: parsed['product-name'] ?? null,
219
234
  xcodeprojPath: parsed.xcodeproj ?? null,
@@ -833,6 +848,7 @@ async function setupXcodeproj(
833
848
  // (injectSpmIntoExistingXcodeproj preserves it — see
834
849
  // generate-spm-xcodeproj.js).
835
850
  artifactsVersionOverride: args.version ?? null,
851
+ configCommand: resolveConfigCommandToPin(args),
836
852
  });
837
853
  if (result.status !== 'injected') {
838
854
  logError(`SPM injection failed: ${result.reason}`);
@@ -892,6 +908,88 @@ function logNextSteps(
892
908
  log('To remove SPM later: `npx react-native spm deinit`');
893
909
  }
894
910
 
911
+ // The autolinking config command for this run: an explicit `--config-command`
912
+ // first, then the value a previous `add`/`update` pinned into the injection
913
+ // marker. undefined means "no explicit command", which is what makes
914
+ // generateAutolinkingConfig fall back to RCT_SPM_AUTOLINKING_CONFIG_COMMAND and
915
+ // then to the built-in default — so the pin has to be WITHHELD while the env
916
+ // var is set, or a stale pin would outrank a developer's env override.
917
+ function resolveExplicitConfigCommand(
918
+ args /*: SetupArgs */,
919
+ appRoot /*: string */,
920
+ ) /*: Array<string> | void */ {
921
+ if (args.configCommand != null) {
922
+ return args.configCommand;
923
+ }
924
+ if (readEnvConfigCommand() != null) {
925
+ return undefined;
926
+ }
927
+ const pinned = readPinnedConfigCommand(appRoot);
928
+ if (pinned == null) {
929
+ return undefined;
930
+ }
931
+ log(
932
+ `Autolinking config command (pinned in ${SPM_INJECTED_MARKER}): ` +
933
+ pinned.join(' '),
934
+ );
935
+ return pinned;
936
+ }
937
+
938
+ // The command to record in the injection marker. The env var is resolved here
939
+ // too, because the Xcode build phase inherits neither the flag nor the shell
940
+ // that set it — an env-only override that went unpinned would leave the build
941
+ // re-deriving autolinking.json with the default command. null pins nothing and
942
+ // preserves any earlier pin. An invalid env value throws, as the flag does,
943
+ // though `add` has already failed closed on it by this point.
944
+ function resolveConfigCommandToPin(
945
+ args /*: SetupArgs */,
946
+ ) /*: ?Array<string> */ {
947
+ return args.configCommand ?? resolveEnvConfigCommand();
948
+ }
949
+
950
+ // Generate autolinking.json, failing closed on a config-command error.
951
+ //
952
+ // generateAutolinkingConfig throws ONLY when the config command itself fails —
953
+ // a non-zero exit, unparseable output, or a config missing
954
+ // project.ios.sourceDir. Swallowing that (the old behavior) let the run proceed
955
+ // and emit an empty Autolinked package, which only surfaced much later as an
956
+ // inscrutable `unable to resolve module dependency` at build time. Instead we
957
+ // set process.exitCode = 2 (a hard Xcode build-phase error, matching the
958
+ // RemoteVersionError path) and return null so the caller stops.
959
+ //
960
+ // A genuinely native-module-free app does NOT reach the error path: its command
961
+ // exits 0 with valid, empty-dependency JSON, so generateAutolinkingConfig
962
+ // returns normally and the empty-package path downstream stays valid.
963
+ function generateAutolinkingConfigOrFailClosed(
964
+ opts /*: {
965
+ projectRoot: string,
966
+ configCommand?: Array<string>,
967
+ generate?: typeof generateAutolinkingConfig,
968
+ } */,
969
+ ) /*: ?AutolinkingConfigResult */ {
970
+ const generate = opts.generate ?? generateAutolinkingConfig;
971
+ try {
972
+ return generate({
973
+ projectRoot: opts.projectRoot,
974
+ configCommand: opts.configCommand,
975
+ });
976
+ } catch (e) {
977
+ logError(
978
+ `Failed to generate autolinking.json: ${e.message}\n` +
979
+ 'The autolinking config command failed. If this app replaces ' +
980
+ '@react-native-community/cli autolinking (e.g. an Expo app), set ' +
981
+ 'RCT_SPM_AUTOLINKING_CONFIG_COMMAND (or pass --config-command) to a ' +
982
+ 'JSON argv array whose command prints the React Native CLI config, ' +
983
+ 'e.g. \'["npx","expo-modules-autolinking","react-native-config",' +
984
+ '"--json","--platform","ios"]\'. An earlier `add`/`update` may also ' +
985
+ `have pinned a command in ${SPM_INJECTED_MARKER}; re-run with ` +
986
+ '--config-command to replace a stale one.',
987
+ );
988
+ process.exitCode = 2;
989
+ return null;
990
+ }
991
+ }
992
+
895
993
  async function main(argv /*:: ?: Array<string> */) /*: Promise<void> */ {
896
994
  let appRoot = process.cwd();
897
995
  const projectRoot = findProjectRoot(appRoot);
@@ -981,16 +1079,16 @@ async function main(argv /*:: ?: Array<string> */) /*: Promise<void> */ {
981
1079
  let autolinkingConfigResult /*: ?AutolinkingConfigResult */ = null;
982
1080
  if (needsCliConfig) {
983
1081
  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
- );
1082
+ autolinkingConfigResult = generateAutolinkingConfigOrFailClosed({
1083
+ projectRoot,
1084
+ configCommand: resolveExplicitConfigCommand(args, appRoot),
1085
+ });
1086
+ if (autolinkingConfigResult == null) {
1087
+ // Fail closed: the config command errored and the helper already set
1088
+ // process.exitCode = 2. Stop rather than emit an empty Autolinked package.
1089
+ return;
993
1090
  }
1091
+ log(`Wrote ${path.relative(appRoot, autolinkingConfigResult.outputPath)}`);
994
1092
  }
995
1093
  const reactNativeRoot = resolveReactNativeRoot(
996
1094
  autolinkingConfigResult,
@@ -1159,7 +1257,11 @@ module.exports = {
1159
1257
  main,
1160
1258
  detectStandardRnLayoutRedirect,
1161
1259
  findInjectedXcodeproj,
1260
+ generateAutolinkingConfigOrFailClosed,
1261
+ parseArgs,
1162
1262
  resolveAction,
1263
+ resolveConfigCommandToPin,
1264
+ resolveExplicitConfigCommand,
1163
1265
  shouldAutoDeintegrate,
1164
1266
  ensureBothArtifactFlavors,
1165
1267
  };
@@ -137,6 +137,40 @@ accepts kebab-case equivalents (e.g. `--skip-codegen`).
137
137
  | `--artifacts <path>` | [advanced] Local artifact root containing complete `debug/` and `release/` cache slots |
138
138
  | `--download <auto\|skip\|force>` | [advanced] Artifact download policy (default: auto) |
139
139
  | `--skipCodegen` | [advanced] Skip the codegen step |
140
+ | `--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"]'` |
141
+
142
+ ### The autolinking config command is remembered
143
+
144
+ An app that replaces `@react-native-community/cli` autolinking (an Expo app,
145
+ for example) has to tell `spm` how to produce `autolinking.json`. Pass the
146
+ command once, on `add` or `update`:
147
+
148
+ ```bash
149
+ npx react-native spm add --configCommand '["npx","expo-modules-autolinking","react-native-config","--json","--platform","ios"]'
150
+ ```
151
+
152
+ Every action that needs `autolinking.json` — `add`, `update`, `scaffold`, and
153
+ the build-time `sync` — resolves the command in this order:
154
+
155
+ 1. `--configCommand`
156
+ 2. `RCT_SPM_AUTOLINKING_CONFIG_COMMAND`
157
+ 3. the `configCommand` pinned in `MyApp.xcodeproj/.spm-injected.json` by an
158
+ earlier `add`/`update`
159
+ 4. the default `@react-native-community/cli config`
160
+
161
+ `add`/`update` pin whichever of the first two routes supplied the command,
162
+ validated as an argv array; a later run that passes neither keeps the existing
163
+ pin, and passing `--configCommand` again replaces it. The pin exists because
164
+ the **Sync SPM Autolinking** build phase inherits neither your flag nor the
165
+ shell that exported the env var — without it, a successful `add` is followed by
166
+ failing builds, because the phase re-derives `autolinking.json` with the
167
+ default command. A pin never shadows the env var, so an override in your shell
168
+ still takes effect, and a pin that no longer parses is ignored in favor of the
169
+ default.
170
+
171
+ `deinit` deletes `.spm-injected.json`, and the pin with it. A later `add`
172
+ therefore falls back to the default command unless you pass `--configCommand`
173
+ (or export the env var) again.
140
174
 
141
175
  ### Debug/Release flavor is automatic
142
176
 
@@ -161,7 +195,7 @@ package graph, or require a second build.
161
195
  | Path | Commit? | Why |
162
196
  |------|---------|-----|
163
197
  | `MyApp.xcodeproj/` | Yes | Your project, with SwiftPM injected in place. Holds your signing, capabilities, Build Phases — `add` only adds SwiftPM refs/settings, additively. |
164
- | `MyApp.xcodeproj/.spm-injected.json` | Yes | Marker recording every edit `add` made, so `deinit` can surgically reverse it and re-runs stay idempotent. |
198
+ | `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). |
165
199
  | `build/generated/` | No | Codegen/autolinking output; regenerated |
166
200
  | `build/xcframeworks/` | No | Symlinks to the machine-local artifact cache |
167
201
  | `Package.resolved` | No | SwiftPM resolution file; machine-specific |
@@ -335,6 +369,7 @@ across apps; refresh it with `react-native spm update --download force`.
335
369
  | "not contained in target" | Re-run setup (regenerates file-level symlinks) |
336
370
  | Codegen fails | Use `--skipCodegen` to iterate on other parts |
337
371
  | "SPM sync failed" warning | Check Xcode build log for details; node may not be in PATH — ensure `with-environment.sh` is present |
372
+ | "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). |
338
373
  | Autolinking not updating on build | Touch `package.json` to force a sync, or delete `build/generated/autolinking/.spm-sync-stamp` |
339
374
  | Stale SwiftPM state or corrupted build | `rm -rf build/ .build/`, then `react-native spm update`, then reopen Xcode |
340
375
  | Want to revert to CocoaPods | `react-native spm deinit`, then `pod install` |
@@ -117,7 +117,7 @@ function discoverPlugins(
117
117
  );
118
118
  }
119
119
  const pluginPath = path.resolve(dep.root, rel);
120
- let fn: unknown;
120
+ let fn /*: unknown */ = null;
121
121
  try {
122
122
  // $FlowFixMe[unsupported-syntax] dynamic require by computed path
123
123
  fn = require(pluginPath);
@@ -175,7 +175,7 @@ function invokePlugins(
175
175
  const seenFrameworkNames /*: Set<string> */ = new Set();
176
176
 
177
177
  for (const {depName, pluginPath, plugin} of plugins) {
178
- let result: unknown;
178
+ let result /*: unknown */ = null;
179
179
  try {
180
180
  result = plugin(context);
181
181
  } catch (e) {
@@ -943,7 +943,7 @@ async function processArtifact(
943
943
  return localPath;
944
944
  };
945
945
 
946
- let tarPath: string;
946
+ let tarPath /*: string */ = '';
947
947
  let fromShared = false;
948
948
  if (isLocalTarball) {
949
949
  tarPath = url;
@@ -970,7 +970,7 @@ async function processArtifact(
970
970
  onProgress(xcframeworkName, 0, 0, 0, false, 0);
971
971
  }
972
972
  const tmpExtractDir = path.join(outputDir, '.extract-tmp', label);
973
- let xcfwPath: string;
973
+ let xcfwPath /*: string */ = '';
974
974
  try {
975
975
  xcfwPath = extractXCFramework(tarPath, tmpExtractDir);
976
976
  } catch (e) {
@@ -1349,7 +1349,7 @@ function validateArtifactsCache(
1349
1349
  if (!fs.existsSync(artifactsJsonPath)) {
1350
1350
  return `artifacts.json missing in ${artifactsDir}`;
1351
1351
  }
1352
- let json: {[string]: {xcframeworkPath: string, url: string}};
1352
+ let json /*: {[string]: {xcframeworkPath: string, url: string}} */ = {};
1353
1353
  try {
1354
1354
  // $FlowFixMe[unclear-type] JSON.parse returns any
1355
1355
  const parsed /*: any */ = JSON.parse(
@@ -16,6 +16,8 @@
16
16
  *
17
17
  * Invokes the React Native community CLI to produce its config and writes the
18
18
  * raw JSON to <project.ios.sourceDir>/build/generated/autolinking/autolinking.json.
19
+ * The config command can be overridden by `--config-command` or
20
+ * `RCT_SPM_AUTOLINKING_CONFIG_COMMAND`, in that order, before the default.
19
21
  *
20
22
  * No filtering or reshaping happens here — the downstream consumer
21
23
  * (generate-spm-autolinking.js) does its own iOS-only filtering when reading
@@ -60,6 +62,32 @@ const FALLBACK_CONFIG_COMMAND = [
60
62
  'config',
61
63
  ];
62
64
 
65
+ function parseConfigCommandJson(
66
+ raw /*: string */,
67
+ source /*: string */,
68
+ ) /*: Array<string> */ {
69
+ let parsed;
70
+ try {
71
+ parsed = JSON.parse(raw);
72
+ } catch {
73
+ throw new Error(
74
+ `${source}: config command must be a JSON array of strings. Example: '["npx","@react-native-community/cli","config"]'`,
75
+ );
76
+ }
77
+
78
+ if (
79
+ !Array.isArray(parsed) ||
80
+ parsed.length === 0 ||
81
+ !parsed.every(value => typeof value === 'string' && value.length > 0)
82
+ ) {
83
+ throw new Error(
84
+ `${source}: config command must be a non-empty JSON array of non-empty strings`,
85
+ );
86
+ }
87
+
88
+ return parsed;
89
+ }
90
+
63
91
  function resolveDefaultConfigCommand(
64
92
  projectRoot /*: string */,
65
93
  ) /*: Array<string> */ {
@@ -94,6 +122,31 @@ function resolveDefaultConfigCommand(
94
122
  return FALLBACK_CONFIG_COMMAND;
95
123
  }
96
124
 
125
+ const ENV_CONFIG_COMMAND = 'RCT_SPM_AUTOLINKING_CONFIG_COMMAND';
126
+
127
+ // The raw env override, or null when unset or blank. Exported so callers that
128
+ // only need to know whether the override is in play (setup-apple-spm.js) share
129
+ // this blankness rule instead of re-deriving it.
130
+ function readEnvConfigCommand() /*: ?string */ {
131
+ const raw = process.env[ENV_CONFIG_COMMAND];
132
+ return typeof raw === 'string' && raw.trim().length > 0 ? raw : null;
133
+ }
134
+
135
+ // The env override, parsed and validated, or null when unset or blank. Throws
136
+ // on a set-but-invalid value — never silently degrades to the default.
137
+ function resolveEnvConfigCommand() /*: ?Array<string> */ {
138
+ const raw = readEnvConfigCommand();
139
+ return raw == null ? null : parseConfigCommandJson(raw, ENV_CONFIG_COMMAND);
140
+ }
141
+
142
+ // Env-var / default resolution for the autolinking config command. An explicit
143
+ // `configCommand` (e.g. from `--config-command`) is handled upstream by
144
+ // generateAutolinkingConfig's destructuring default, so it never reaches here —
145
+ // this only decides between the env-var override and the built-in default.
146
+ function resolveConfigCommand(projectRoot /*: string */) /*: Array<string> */ {
147
+ return resolveEnvConfigCommand() ?? resolveDefaultConfigCommand(projectRoot);
148
+ }
149
+
97
150
  function defaultCliRunner(
98
151
  command /*: Array<string> */,
99
152
  opts /*: {cwd: string} */,
@@ -116,7 +169,7 @@ function generateAutolinkingConfig(
116
169
  ) /*: GenerateAutolinkingConfigResult */ {
117
170
  const {
118
171
  projectRoot,
119
- configCommand = resolveDefaultConfigCommand(projectRoot),
172
+ configCommand = resolveConfigCommand(projectRoot),
120
173
  cliRunner = defaultCliRunner,
121
174
  } = opts;
122
175
 
@@ -158,4 +211,11 @@ function generateAutolinkingConfig(
158
211
  return {config, outputPath: outPath, rawJson};
159
212
  }
160
213
 
161
- module.exports = {generateAutolinkingConfig, resolveDefaultConfigCommand};
214
+ module.exports = {
215
+ generateAutolinkingConfig,
216
+ parseConfigCommandJson,
217
+ readEnvConfigCommand,
218
+ resolveConfigCommand,
219
+ resolveDefaultConfigCommand,
220
+ resolveEnvConfigCommand,
221
+ };
@@ -426,7 +426,7 @@ function hasMixedLanguageSources(absSource /*: string */) /*: boolean */ {
426
426
  let hasClang = false;
427
427
  const walk = (dir /*: string */, depth /*: number */) => {
428
428
  if (depth > 6 || (hasSwift && hasClang)) return;
429
- let entries: Array<{name: string, isDirectory(): boolean}>;
429
+ let entries /*: Array<{name: string, isDirectory(): boolean}> */ = [];
430
430
  try {
431
431
  // $FlowFixMe[incompatible-type] Dirent typing
432
432
  entries = fs.readdirSync(dir, {withFileTypes: true});
@@ -22,6 +22,7 @@
22
22
  */
23
23
 
24
24
  const {readFlavoredFrameworksManifest} = require('./flavored-frameworks');
25
+ const {parseConfigCommandJson} = require('./generate-spm-autolinking-config');
25
26
  const {
26
27
  addArrayMembers,
27
28
  addArrayStringValues,
@@ -1799,13 +1800,13 @@ function readGeneratedSourcesManifest(
1799
1800
  appRoot /*: string */,
1800
1801
  ) /*: Array<GeneratedSource> */ {
1801
1802
  const manifestPath = path.join(appRoot, SPM_GENERATED_SOURCES_MANIFEST);
1802
- let raw: string;
1803
+ let raw /*: string */ = '';
1803
1804
  try {
1804
1805
  raw = fs.readFileSync(manifestPath, 'utf8');
1805
1806
  } catch {
1806
1807
  return [];
1807
1808
  }
1808
- let entries: unknown;
1809
+ let entries /*: unknown */ = null;
1809
1810
  try {
1810
1811
  entries = JSON.parse(raw);
1811
1812
  } catch {
@@ -1844,7 +1845,7 @@ function readGeneratedSourcesManifest(
1844
1845
  */
1845
1846
  function readMarker(
1846
1847
  xcodeprojPath /*: string */,
1847
- ) /*: ?{generatedSources?: {[string]: Array<string>}, artifactsVersionOverride?: ?string, buildSettingChanges?: Array<BuildSettingChange>, ...} */ {
1848
+ ) /*: ?{generatedSources?: {[string]: Array<string>}, artifactsVersionOverride?: ?string, configCommand?: ?Array<string>, buildSettingChanges?: Array<BuildSettingChange>, ...} */ {
1848
1849
  const markerPath = path.join(xcodeprojPath, SPM_INJECTED_MARKER);
1849
1850
  try {
1850
1851
  // $FlowFixMe[incompatible-return] JSON.parse returns any
@@ -1857,10 +1858,11 @@ function readMarker(
1857
1858
  // Returns the `*.xcodeproj` under `appRoot` carrying a `.spm-injected.json`
1858
1859
  // marker (the user-owned project SPM packages were injected into in place),
1859
1860
  // 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.
1861
+ // marker readers below, and for callers that only locate the project (setup-
1862
+ // apple-spm.js's action defaulting and `deinit`), to call without exercising
1863
+ // any pbxproj-editing machinery.
1862
1864
  function findInjectedXcodeproj(appRoot /*: string */) /*: string | null */ {
1863
- let entries: Array<{name: string, isDirectory(): boolean}>;
1865
+ let entries /*: Array<{name: string, isDirectory(): boolean}> */ = [];
1864
1866
  try {
1865
1867
  // $FlowFixMe[incompatible-type] Dirent typing
1866
1868
  entries = fs.readdirSync(appRoot, {withFileTypes: true});
@@ -1884,11 +1886,13 @@ function findInjectedXcodeproj(appRoot /*: string */) /*: string | null */ {
1884
1886
  * update --version` pinned into the injected xcodeproj's `.spm-injected.json`
1885
1887
  * marker (see the field's doc comment in injectSpmIntoExistingXcodeproj
1886
1888
  * 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.
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.
1892
1896
  */
1893
1897
  function readArtifactsVersionOverride(appRoot /*: string */) /*: ?string */ {
1894
1898
  const xcodeprojPath = findInjectedXcodeproj(appRoot);
@@ -1899,13 +1903,39 @@ function readArtifactsVersionOverride(appRoot /*: string */) /*: ?string */ {
1899
1903
  return typeof override === 'string' && override.length > 0 ? override : null;
1900
1904
  }
1901
1905
 
1906
+ /**
1907
+ * Read the autolinking config command a previous `spm add`/`update` pinned into
1908
+ * the injected xcodeproj's `.spm-injected.json` marker, or null when nothing
1909
+ * usable is pinned. Pure fs reads, like readArtifactsVersionOverride above, but
1910
+ * this one IS wired: setup-apple-spm.js's resolveExplicitConfigCommand reads it
1911
+ * on add/update/scaffold and on the build-time `sync`. Re-validated through the
1912
+ * same parseConfigCommandJson the flag goes through, and never throws, so a
1913
+ * hand-edited marker degrades to the env-var/default command instead of
1914
+ * injecting a bogus argv into the build.
1915
+ */
1916
+ function readPinnedConfigCommand(appRoot /*: string */) /*: ?Array<string> */ {
1917
+ const xcodeprojPath = findInjectedXcodeproj(appRoot);
1918
+ if (xcodeprojPath == null) {
1919
+ return null;
1920
+ }
1921
+ const pinned = readMarker(xcodeprojPath)?.configCommand;
1922
+ if (pinned == null) {
1923
+ return null;
1924
+ }
1925
+ try {
1926
+ return parseConfigCommandJson(JSON.stringify(pinned), SPM_INJECTED_MARKER);
1927
+ } catch {
1928
+ return null;
1929
+ }
1930
+ }
1931
+
1902
1932
  /**
1903
1933
  * Add SPM packages to a user's EXISTING xcodeproj in place. Returns
1904
1934
  * {status: 'injected', target} on success, or {status: 'refused', reason}
1905
1935
  * when the project can't be safely edited (caller surfaces it; fail-loud).
1906
1936
  */
1907
1937
  function injectSpmIntoExistingXcodeproj(
1908
- opts /*: {appRoot: string, reactNativeRoot: string, xcodeprojPath: string, appName?: ?string, artifactsVersionOverride?: ?string} */,
1938
+ opts /*: {appRoot: string, reactNativeRoot: string, xcodeprojPath: string, appName?: ?string, artifactsVersionOverride?: ?string, configCommand?: ?Array<string>} */,
1909
1939
  ) /*: {status: 'injected', target: string} | {status: 'refused', reason: string} */ {
1910
1940
  const {appRoot, reactNativeRoot, xcodeprojPath} = opts;
1911
1941
  const pbxprojPath = path.join(xcodeprojPath, 'project.pbxproj');
@@ -2022,6 +2052,16 @@ function injectSpmIntoExistingXcodeproj(
2022
2052
  prevMarker?.artifactsVersionOverride ??
2023
2053
  null;
2024
2054
 
2055
+ // Same set-or-preserve contract as the version pin above, for the autolinking
2056
+ // config command `add`/`update` resolved (`--config-command` or
2057
+ // RCT_SPM_AUTOLINKING_CONFIG_COMMAND) — the build-time sync sees neither the
2058
+ // flag nor the developer's shell environment, so without the pin it
2059
+ // re-derives autolinking.json with the default @react-native-community/cli
2060
+ // command and breaks apps that replace it. Read back by
2061
+ // readPinnedConfigCommand (above). No "clear" verb yet either; `deinit` drops
2062
+ // the whole marker, this field with it.
2063
+ const configCommand = opts.configCommand ?? prevMarker?.configCommand ?? null;
2064
+
2025
2065
  // Marker: idempotency signal + the exact, reversible record of every edit so
2026
2066
  // `deinit` (removeSpmInjection) can undo precisely what was added.
2027
2067
  writeIfChanged(
@@ -2038,6 +2078,7 @@ function injectSpmIntoExistingXcodeproj(
2038
2078
  // `update` to reconcile away entries that left the manifest.
2039
2079
  generatedSources: generatedSourceUuids,
2040
2080
  artifactsVersionOverride,
2081
+ configCommand,
2041
2082
  scheme: {
2042
2083
  file: schemeResult.file,
2043
2084
  created: schemeResult.status === 'created',
@@ -2220,5 +2261,6 @@ module.exports = {
2220
2261
  removePreActionFromScheme,
2221
2262
  findInjectedXcodeproj,
2222
2263
  readArtifactsVersionOverride,
2264
+ readPinnedConfigCommand,
2223
2265
  SPM_INJECTED_MARKER,
2224
2266
  };
@@ -197,7 +197,7 @@ function collectSubdirs(
197
197
  ]);
198
198
  const out /*: Array<string> */ = [];
199
199
  const walk = (absDir /*: string */, relDir /*: string */) => {
200
- let entries: Array<{name: string, isDirectory(): boolean}>;
200
+ let entries /*: Array<{name: string, isDirectory(): boolean}> */ = [];
201
201
  try {
202
202
  // $FlowFixMe[incompatible-type] Dirent typing
203
203
  entries = fs.readdirSync(absDir, {withFileTypes: true});
@@ -930,7 +930,7 @@ function scaffoldPackageSwiftForDep(
930
930
  };
931
931
  }
932
932
 
933
- let model: PodspecModel;
933
+ let model;
934
934
  try {
935
935
  model = readPodspec(podspecPath);
936
936
  } catch (e) {
@@ -1126,7 +1126,7 @@ function scaffoldAll(
1126
1126
  directDeps.push({name, root, platforms: {ios: iosPlatform}});
1127
1127
  }
1128
1128
 
1129
- let allDeps: Array<AutolinkedDep>;
1129
+ let allDeps /*: Array<AutolinkedDep> */ = [];
1130
1130
  try {
1131
1131
  allDeps = expandSpmDependencies(directDeps, {
1132
1132
  readConfig: defaultReadConfig,
@@ -16,6 +16,9 @@ export type SetupArgs = {
16
16
  // `debug/` and `release/` cache slots, each with artifacts.json.
17
17
  artifacts: string | null,
18
18
  skipCodegen: boolean,
19
+ // Overrides the autolinking config command; also settable via
20
+ // RCT_SPM_AUTOLINKING_CONFIG_COMMAND.
21
+ configCommand: Array<string> | null,
19
22
  // Artifact download policy: 'auto' fetches when missing, 'skip' never
20
23
  // fetches, 'force' clears the cache slot and re-downloads.
21
24
  downloadPolicy: 'auto' | 'skip' | 'force',