react-native-tvos 0.87.0-0rc3 → 0.87.1-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.
Files changed (52) hide show
  1. package/Libraries/Animated/AnimatedEvent.js +1 -1
  2. package/Libraries/Animated/AnimatedImplementation.js +6 -2
  3. package/Libraries/Animated/nodes/AnimatedNode.js +1 -1
  4. package/Libraries/Animated/nodes/AnimatedValue.js +2 -2
  5. package/Libraries/Components/ScrollView/AndroidHorizontalScrollViewNativeComponent.js +2 -0
  6. package/Libraries/Components/ScrollView/ScrollView.d.ts +26 -0
  7. package/Libraries/Components/ScrollView/ScrollView.js +32 -0
  8. package/Libraries/Components/ScrollView/ScrollViewNativeComponent.js +2 -0
  9. package/Libraries/Components/ScrollView/ScrollViewNativeComponentType.js +11 -0
  10. package/Libraries/Components/ScrollView/processScrollAnimationEasing.js +40 -0
  11. package/Libraries/Components/TV/TVFocusGuideView.js +31 -0
  12. package/Libraries/Components/TV/useFocusEnterLeave.js +112 -0
  13. package/Libraries/Core/ReactNativeVersion.js +2 -2
  14. package/README.md +17 -0
  15. package/React/Base/RCTVersion.m +2 -2
  16. package/React/Fabric/Mounting/ComponentViews/View/RCTViewComponentView.mm +30 -0
  17. package/ReactAndroid/gradle.properties +1 -1
  18. package/ReactAndroid/src/main/java/com/facebook/react/modules/intent/IntentModule.kt +7 -3
  19. package/ReactAndroid/src/main/java/com/facebook/react/modules/systeminfo/ReactNativeVersion.kt +2 -2
  20. package/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactHorizontalScrollView.kt +36 -13
  21. package/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactHorizontalScrollViewManager.kt +13 -0
  22. package/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactScrollView.kt +25 -3
  23. package/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactScrollViewHelper.kt +62 -0
  24. package/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactScrollViewManager.kt +13 -0
  25. package/ReactCommon/cxxreact/ReactNativeVersion.h +3 -3
  26. package/ReactCommon/react/renderer/components/scrollview/platform/android/react/renderer/components/scrollview/HostPlatformScrollViewProps.cpp +26 -0
  27. package/ReactCommon/react/renderer/components/scrollview/platform/android/react/renderer/components/scrollview/HostPlatformScrollViewProps.h +3 -0
  28. package/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTFontUtils.mm +1 -1
  29. package/package.json +11 -9
  30. package/scripts/cocoapods/rncore.rb +1 -0
  31. package/scripts/cocoapods/rndependencies.rb +31 -25
  32. package/scripts/replace-rncore-version.js +69 -129
  33. package/scripts/setup-apple-spm.js +139 -21
  34. package/scripts/spm/autolinking-plugins.js +82 -2
  35. package/scripts/spm/download-spm-artifacts.js +67 -9
  36. package/scripts/spm/expand-spm-dependencies.js +249 -30
  37. package/scripts/spm/generate-spm-autolinking-config.js +62 -2
  38. package/scripts/spm/generate-spm-autolinking.js +158 -41
  39. package/scripts/spm/generate-spm-package.js +30 -18
  40. package/scripts/spm/generate-spm-xcodeproj.js +641 -75
  41. package/scripts/spm/scaffold-package-swift.js +68 -27
  42. package/scripts/spm/spm-pbxproj.js +158 -28
  43. package/scripts/spm/spm-types.js +32 -3
  44. package/scripts/spm/spm-utils.js +117 -2
  45. package/sdks/.hermesv1version +1 -1
  46. package/sdks/hermes-engine/version.properties +1 -1
  47. package/types/public/ReactNativeTVTypes.d.ts +40 -1
  48. package/scripts/spm/__doc__/rfc-spm-xcframework.md +0 -707
  49. package/scripts/spm/__doc__/spm-autolinking-plugins.md +0 -244
  50. package/scripts/spm/__doc__/spm-header-paths-contract.md +0 -97
  51. package/scripts/spm/__doc__/spm-plugins-assessment.md +0 -128
  52. package/scripts/spm/__doc__/spm-scripts.md +0 -451
@@ -18,6 +18,9 @@ const yargs = require('yargs');
18
18
 
19
19
  const LAST_BUILD_FILENAME = 'React-Core-prebuilt/.last_build_configuration';
20
20
 
21
+ // Not a valid configuration, so finding it means the swap did not finish.
22
+ const REPLACEMENT_IN_PROGRESS = 'in-progress';
23
+
21
24
  function validateBuildConfiguration(configuration /*: string */) {
22
25
  if (!['Debug', 'Release'].includes(configuration)) {
23
26
  throw new Error(`Invalid configuration ${configuration}`);
@@ -42,10 +45,12 @@ function shouldReplaceRnCoreConfiguration(configuration /*: string */) {
42
45
  );
43
46
  return false;
44
47
  }
48
+ return true;
45
49
  }
46
50
 
47
- // Assumption: if there is no stored last build, we assume that it was build for debug.
48
- if (!fileExists && configuration === 'Debug') {
51
+ // With no marker the on-disk flavor is Debug: the podspec installs the debug
52
+ // tarball (see resolve_podspec_source in scripts/cocoapods/rncore.rb).
53
+ if (configuration === 'Debug') {
49
54
  console.log(
50
55
  'No previous build detected, but Debug Configuration. No need to replace React-Core-prebuilt',
51
56
  );
@@ -59,7 +64,7 @@ function replaceRNCoreConfiguration(
59
64
  configuration /*: string */,
60
65
  version /*: string */,
61
66
  podsRoot /*: string */,
62
- ) {
67
+ ) /*: void */ {
63
68
  // Filename comes from rncore.rb
64
69
  const tarballURLPath = `${podsRoot}/ReactNativeCore-artifacts/reactnative-core-${version.toLowerCase()}-${configuration.toLowerCase()}.tar.gz`;
65
70
 
@@ -73,18 +78,6 @@ function replaceRNCoreConfiguration(
73
78
  const tmpExtractDir = path.join(tmpDir, 'React-Core-prebuilt');
74
79
  fs.mkdirSync(tmpExtractDir, {recursive: true});
75
80
 
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
81
  try {
89
82
  console.log('Extracting the tarball to temp dir', tarballURLPath);
90
83
  const result = spawnSync(
@@ -110,98 +103,30 @@ function replaceRNCoreConfiguration(
110
103
  );
111
104
  }
112
105
 
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
- }
106
+ // Replace only the compiled framework. Headers/ is flattened from
107
+ // ReactNativeHeaders by the podspec prepare_command, and the prebuild
108
+ // compose job emits one set of those headers for both configurations, so a
109
+ // config switch leaves them identical. Leaving them alone keeps
110
+ // Headers/module.modulemap — which consumers activate through
111
+ // -fmodule-map-file — in place for the whole build; deleting and recreating
112
+ // it mid-build lets a concurrent dependency scan miss it, and the React
113
+ // module then precompiles without it (#57803).
114
+ const dest = path.join(finalLocation, 'React.xcframework');
115
+ console.log('Replacing', dest);
116
+ fs.rmSync(dest, {force: true, recursive: true});
117
+ const mvResult = spawnSync('mv', [xcfwPath, dest], {stdio: 'inherit'});
118
+ if (mvResult.status !== 0) {
119
+ // Fallback: copy recursively then remove source
120
+ console.log('mv failed for React.xcframework, falling back to cp -R');
121
+ const cpResult = spawnSync('cp', ['-R', xcfwPath, dest], {
122
+ stdio: 'inherit',
123
+ });
124
+ if (cpResult.status !== 0) {
125
+ throw new Error(`cp fallback failed with exit code ${cpResult.status}`);
147
126
  }
148
127
  }
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
128
  } finally {
195
- // Clean up temp directory
196
129
  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
130
  }
206
131
  }
207
132
 
@@ -210,6 +135,10 @@ function updateLastBuildConfiguration(configuration /*: string */) {
210
135
  fs.writeFileSync(LAST_BUILD_FILENAME, configuration);
211
136
  }
212
137
 
138
+ function markReplacementInProgress() /*: void */ {
139
+ fs.writeFileSync(LAST_BUILD_FILENAME, REPLACEMENT_IN_PROGRESS);
140
+ }
141
+
213
142
  function main(
214
143
  configuration /*: string */,
215
144
  version /*: string */,
@@ -219,37 +148,48 @@ function main(
219
148
  validateVersion(version);
220
149
 
221
150
  if (!shouldReplaceRnCoreConfiguration(configuration)) {
151
+ // A fresh install leaves no marker; record the flavor we skipped on.
152
+ if (!fs.existsSync(LAST_BUILD_FILENAME)) {
153
+ updateLastBuildConfiguration(configuration);
154
+ }
222
155
  return;
223
156
  }
224
157
 
158
+ // Invalidate before touching the framework so an interrupted swap is
159
+ // detectable on the next run.
160
+ markReplacementInProgress();
225
161
  replaceRNCoreConfiguration(configuration, version, podsRoot);
226
162
  updateLastBuildConfiguration(configuration);
227
163
  console.log('Done replacing React Native prebuilt');
228
164
  }
229
165
 
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);
166
+ if (require.main === module) {
167
+ // This script is executed in the Pods folder, which is usually not synched to Github, so it should be ok
168
+ const argv = yargs
169
+ .option('c', {
170
+ alias: 'configuration',
171
+ description:
172
+ 'Configuration to use to download the right React-Core prebuilt version. Allowed values are "Debug" and "Release".',
173
+ })
174
+ .option('r', {
175
+ alias: 'reactNativeVersion',
176
+ description:
177
+ 'The Version of React Native associated with the React-Core prebuilt tarball.',
178
+ })
179
+ .option('p', {
180
+ alias: 'podsRoot',
181
+ description: 'The path to the Pods root folder',
182
+ })
183
+ .usage('Usage: $0 -c Debug -r <version> -p <path/to/react-native>').argv;
184
+
185
+ // $FlowFixMe[prop-missing]
186
+ const configuration = argv.configuration;
187
+ // $FlowFixMe[prop-missing]
188
+ const version = argv.reactNativeVersion;
189
+ // $FlowFixMe[prop-missing]
190
+ const podsRoot = argv.podsRoot;
191
+
192
+ main(configuration, version, podsRoot);
193
+ }
194
+
195
+ module.exports = {replaceRNCoreConfiguration};
@@ -44,8 +44,10 @@
44
44
  * directing you to `--deintegrate`).
45
45
  *
46
46
  * 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).
49
51
  * --yes Skip the dirty-pbxproj confirmation prompt.
50
52
  * [add] --xcodeproj <path> Which .xcodeproj to inject into (when several).
51
53
  * [add] --product-name <name> Which app target to inject into (when several).
@@ -55,6 +57,7 @@
55
57
  * must contain debug/ and release/ cache slots.
56
58
  * [advanced] --download <auto|skip|force> Artifact policy (default: auto).
57
59
  * [advanced] --skip-codegen Skip the react-native codegen step.
60
+ * [advanced] --config-command <json> Override the autolinking config command.
58
61
  *
59
62
  * Steps performed (add/update):
60
63
  * 1. react-native codegen → build/generated/ios/ + install SPM codegen template
@@ -83,14 +86,20 @@ const {
83
86
  } = require('./spm/generate-spm-autolinking');
84
87
  const {
85
88
  generateAutolinkingConfig,
89
+ parseConfigCommandJson,
90
+ readEnvConfigCommand,
91
+ resolveEnvConfigCommand,
86
92
  } = require('./spm/generate-spm-autolinking-config');
87
93
  const {main: generatePackage} = require('./spm/generate-spm-package');
88
94
  const {findSourcePath} = require('./spm/generate-spm-package');
89
95
  const {
96
+ SPM_INJECTED_MARKER,
90
97
  cleanupDanglingJavaScriptCoreRef,
91
98
  cleanupLeftoverPodsGroup,
92
99
  findInjectedXcodeproj,
93
100
  injectSpmIntoExistingXcodeproj,
101
+ readArtifactsVersionOverride,
102
+ readPinnedConfigCommand,
94
103
  removeSpmInjection,
95
104
  } = require('./spm/generate-spm-xcodeproj');
96
105
  const {scaffoldAll} = require('./spm/scaffold-package-swift');
@@ -147,7 +156,7 @@ function parseArgs(argv /*: Array<string> */) /*: SetupArgs */ {
147
156
  .option('version', {
148
157
  type: 'string',
149
158
  describe:
150
- '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',
151
160
  })
152
161
  .option('yes', {
153
162
  type: 'boolean',
@@ -187,6 +196,11 @@ function parseArgs(argv /*: Array<string> */) /*: SetupArgs */ {
187
196
  default: false,
188
197
  describe: '[advanced] Skip the react-native codegen step',
189
198
  })
199
+ .option('config-command', {
200
+ type: 'string',
201
+ describe:
202
+ '[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"]\'',
203
+ })
190
204
  .usage(
191
205
  'Usage: $0 [action] [options]\n\nSets up Swift Package Manager support in a React Native app.',
192
206
  )
@@ -214,6 +228,10 @@ function parseArgs(argv /*: Array<string> */) /*: SetupArgs */ {
214
228
  version: parsed.version ?? null,
215
229
  artifacts: parsed.artifacts ?? null,
216
230
  skipCodegen: parsed['skip-codegen'],
231
+ configCommand:
232
+ parsed['config-command'] != null
233
+ ? parseConfigCommandJson(parsed['config-command'], '--config-command')
234
+ : null,
217
235
  downloadPolicy: parsed.download,
218
236
  productName: parsed['product-name'] ?? null,
219
237
  xcodeprojPath: parsed.xcodeproj ?? null,
@@ -362,19 +380,31 @@ function resolveReactNativeRoot(
362
380
  return reactNativeRoot;
363
381
  }
364
382
 
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.
365
387
  function determineVersion(
366
388
  args /*: SetupArgs */,
367
389
  reactNativeRoot /*: string */,
390
+ appRoot /*: string */,
368
391
  ) /*: string */ {
369
- let version = args.version;
370
- if (version == null) {
371
- // $FlowFixMe[incompatible-type] JSON.parse returns any
372
- const pkgJson /*: {version: string} */ = JSON.parse(
373
- 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.',
374
400
  );
375
- version = pkgJson.version;
401
+ return pinned;
376
402
  }
377
- 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;
378
408
  }
379
409
 
380
410
  function runCodegenStep(
@@ -427,7 +457,7 @@ async function runScaffold(
427
457
  // a comment — that's how SPM's manifest hash bumps on slot transitions.
428
458
  let cacheSlotLabel /*: ?string */ = null;
429
459
  try {
430
- const rawVersion = args.version ?? determineVersion(args, reactNativeRoot);
460
+ const rawVersion = determineVersion(args, reactNativeRoot, appRoot);
431
461
  const slotVersion = await resolveCacheSlotVersion(rawVersion);
432
462
  cacheSlotLabel = `${slotVersion}/dual-flavor`;
433
463
  } catch {
@@ -833,6 +863,7 @@ async function setupXcodeproj(
833
863
  // (injectSpmIntoExistingXcodeproj preserves it — see
834
864
  // generate-spm-xcodeproj.js).
835
865
  artifactsVersionOverride: args.version ?? null,
866
+ configCommand: resolveConfigCommandToPin(args),
836
867
  });
837
868
  if (result.status !== 'injected') {
838
869
  logError(`SPM injection failed: ${result.reason}`);
@@ -892,6 +923,88 @@ function logNextSteps(
892
923
  log('To remove SPM later: `npx react-native spm deinit`');
893
924
  }
894
925
 
926
+ // The autolinking config command for this run: an explicit `--config-command`
927
+ // first, then the value a previous `add`/`update` pinned into the injection
928
+ // marker. undefined means "no explicit command", which is what makes
929
+ // generateAutolinkingConfig fall back to RCT_SPM_AUTOLINKING_CONFIG_COMMAND and
930
+ // then to the built-in default — so the pin has to be WITHHELD while the env
931
+ // var is set, or a stale pin would outrank a developer's env override.
932
+ function resolveExplicitConfigCommand(
933
+ args /*: SetupArgs */,
934
+ appRoot /*: string */,
935
+ ) /*: Array<string> | void */ {
936
+ if (args.configCommand != null) {
937
+ return args.configCommand;
938
+ }
939
+ if (readEnvConfigCommand() != null) {
940
+ return undefined;
941
+ }
942
+ const pinned = readPinnedConfigCommand(appRoot);
943
+ if (pinned == null) {
944
+ return undefined;
945
+ }
946
+ log(
947
+ `Autolinking config command (pinned in ${SPM_INJECTED_MARKER}): ` +
948
+ pinned.join(' '),
949
+ );
950
+ return pinned;
951
+ }
952
+
953
+ // The command to record in the injection marker. The env var is resolved here
954
+ // too, because the Xcode build phase inherits neither the flag nor the shell
955
+ // that set it — an env-only override that went unpinned would leave the build
956
+ // re-deriving autolinking.json with the default command. null pins nothing and
957
+ // preserves any earlier pin. An invalid env value throws, as the flag does,
958
+ // though `add` has already failed closed on it by this point.
959
+ function resolveConfigCommandToPin(
960
+ args /*: SetupArgs */,
961
+ ) /*: ?Array<string> */ {
962
+ return args.configCommand ?? resolveEnvConfigCommand();
963
+ }
964
+
965
+ // Generate autolinking.json, failing closed on a config-command error.
966
+ //
967
+ // generateAutolinkingConfig throws ONLY when the config command itself fails —
968
+ // a non-zero exit, unparseable output, or a config missing
969
+ // project.ios.sourceDir. Swallowing that (the old behavior) let the run proceed
970
+ // and emit an empty Autolinked package, which only surfaced much later as an
971
+ // inscrutable `unable to resolve module dependency` at build time. Instead we
972
+ // set process.exitCode = 2 (a hard Xcode build-phase error, matching the
973
+ // RemoteVersionError path) and return null so the caller stops.
974
+ //
975
+ // A genuinely native-module-free app does NOT reach the error path: its command
976
+ // exits 0 with valid, empty-dependency JSON, so generateAutolinkingConfig
977
+ // returns normally and the empty-package path downstream stays valid.
978
+ function generateAutolinkingConfigOrFailClosed(
979
+ opts /*: {
980
+ projectRoot: string,
981
+ configCommand?: Array<string>,
982
+ generate?: typeof generateAutolinkingConfig,
983
+ } */,
984
+ ) /*: ?AutolinkingConfigResult */ {
985
+ const generate = opts.generate ?? generateAutolinkingConfig;
986
+ try {
987
+ return generate({
988
+ projectRoot: opts.projectRoot,
989
+ configCommand: opts.configCommand,
990
+ });
991
+ } catch (e) {
992
+ logError(
993
+ `Failed to generate autolinking.json: ${e.message}\n` +
994
+ 'The autolinking config command failed. If this app replaces ' +
995
+ '@react-native-community/cli autolinking (e.g. an Expo app), set ' +
996
+ 'RCT_SPM_AUTOLINKING_CONFIG_COMMAND (or pass --config-command) to a ' +
997
+ 'JSON argv array whose command prints the React Native CLI config, ' +
998
+ 'e.g. \'["npx","expo-modules-autolinking","react-native-config",' +
999
+ '"--json","--platform","ios"]\'. An earlier `add`/`update` may also ' +
1000
+ `have pinned a command in ${SPM_INJECTED_MARKER}; re-run with ` +
1001
+ '--config-command to replace a stale one.',
1002
+ );
1003
+ process.exitCode = 2;
1004
+ return null;
1005
+ }
1006
+ }
1007
+
895
1008
  async function main(argv /*:: ?: Array<string> */) /*: Promise<void> */ {
896
1009
  let appRoot = process.cwd();
897
1010
  const projectRoot = findProjectRoot(appRoot);
@@ -981,22 +1094,22 @@ async function main(argv /*:: ?: Array<string> */) /*: Promise<void> */ {
981
1094
  let autolinkingConfigResult /*: ?AutolinkingConfigResult */ = null;
982
1095
  if (needsCliConfig) {
983
1096
  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
- );
1097
+ autolinkingConfigResult = generateAutolinkingConfigOrFailClosed({
1098
+ projectRoot,
1099
+ configCommand: resolveExplicitConfigCommand(args, appRoot),
1100
+ });
1101
+ if (autolinkingConfigResult == null) {
1102
+ // Fail closed: the config command errored and the helper already set
1103
+ // process.exitCode = 2. Stop rather than emit an empty Autolinked package.
1104
+ return;
993
1105
  }
1106
+ log(`Wrote ${path.relative(appRoot, autolinkingConfigResult.outputPath)}`);
994
1107
  }
995
1108
  const reactNativeRoot = resolveReactNativeRoot(
996
1109
  autolinkingConfigResult,
997
1110
  projectRoot,
998
1111
  );
999
- const version = determineVersion(args, reactNativeRoot);
1112
+ const version = determineVersion(args, reactNativeRoot, appRoot);
1000
1113
  log(`React Native version: ${version}`);
1001
1114
 
1002
1115
  // Resolve remote SPM mode ONCE up front. remotePackageConfig throws
@@ -1158,8 +1271,13 @@ if (require.main === module) {
1158
1271
  module.exports = {
1159
1272
  main,
1160
1273
  detectStandardRnLayoutRedirect,
1274
+ determineVersion,
1161
1275
  findInjectedXcodeproj,
1276
+ generateAutolinkingConfigOrFailClosed,
1277
+ parseArgs,
1162
1278
  resolveAction,
1279
+ resolveConfigCommandToPin,
1280
+ resolveExplicitConfigCommand,
1163
1281
  shouldAutoDeintegrate,
1164
1282
  ensureBothArtifactFlavors,
1165
1283
  };
@@ -69,6 +69,14 @@
69
69
  * // dir add/remove there re-triggers the sync. Relative/empty/non-string
70
70
  * // entries are dropped with a warning; a non-array is ignored.
71
71
  * watchPaths: ['/abs/path/Package.swift', '/abs/dir'],
72
+ * // Build-time shell phases on the app target — SwiftPM has no
73
+ * // `script_phase`. Only id/name/script are required; `id` is the
74
+ * // ledger key and UUID seed, `position` defaults to 'end'. Recorded
75
+ * // to `.spm-plugin-script-phases.json`, from which the `spm add`/
76
+ * // `update` injector emits a PBXShellScriptBuildPhase per entry.
77
+ * // Malformed entries and duplicate ids are fatal.
78
+ * scriptPhases: [{id, name, script, position, inputPaths, outputPaths,
79
+ * alwaysOutOfDate}],
72
80
  * };
73
81
  * };
74
82
  *
@@ -76,12 +84,14 @@
76
84
  * the merge, so regeneration stays deterministic and idempotent.
77
85
  */
78
86
 
87
+ const {isValidScriptPhaseId, isValidScriptPhaseName} = require('./spm-utils');
79
88
  const path = require('path');
80
89
 
81
90
  /*:: import type {
82
91
  AutolinkedDep,
83
92
  PluginContext,
84
93
  PluginResult,
94
+ PluginScriptPhase,
85
95
  DiscoveredPlugin,
86
96
  } from './spm-types';
87
97
  */
@@ -117,7 +127,7 @@ function discoverPlugins(
117
127
  );
118
128
  }
119
129
  const pluginPath = path.resolve(dep.root, rel);
120
- let fn: unknown;
130
+ let fn /*: unknown */ = null;
121
131
  try {
122
132
  // $FlowFixMe[unsupported-syntax] dynamic require by computed path
123
133
  fn = require(pluginPath);
@@ -149,6 +159,12 @@ function discoverPlugins(
149
159
  return found;
150
160
  }
151
161
 
162
+ const isNonEmptyString = (value /*: unknown */) /*: boolean */ =>
163
+ typeof value === 'string' && value.length > 0;
164
+
165
+ const isOptionalStringList = (value /*: unknown */) /*: boolean */ =>
166
+ value == null || (Array.isArray(value) && value.every(isNonEmptyString));
167
+
152
168
  /**
153
169
  * Invoke discovered plugins and merge their results. Each plugin gets the same
154
170
  * context. Fail-closed: a throwing plugin aborts (named), and a malformed
@@ -169,13 +185,15 @@ function invokePlugins(
169
185
  const flavoredFrameworks /*: Array<{id: string, frameworkName: string, linkage: 'dynamic', flavors: {debug: string, release: string}}> */ =
170
186
  [];
171
187
  const watchPaths /*: Array<string> */ = [];
188
+ const scriptPhases /*: Array<PluginScriptPhase> */ = [];
172
189
  const seenPackages /*: Set<string> */ = new Set();
173
190
  const seenProducts /*: Set<string> */ = new Set();
174
191
  const seenFrameworkIds /*: Set<string> */ = new Set();
175
192
  const seenFrameworkNames /*: Set<string> */ = new Set();
193
+ const seenScriptPhaseIds /*: Set<string> */ = new Set();
176
194
 
177
195
  for (const {depName, pluginPath, plugin} of plugins) {
178
- let result: unknown;
196
+ let result /*: unknown */ = null;
179
197
  try {
180
198
  result = plugin(context);
181
199
  } catch (e) {
@@ -203,11 +221,18 @@ function invokePlugins(
203
221
  const rawFrameworks = result.flavoredFrameworks ?? [];
204
222
  // $FlowFixMe[incompatible-use]
205
223
  const rawWatch = result.watchPaths ?? [];
224
+ // $FlowFixMe[incompatible-use]
225
+ const rawScriptPhases = result.scriptPhases ?? [];
206
226
  if (!Array.isArray(rawFrameworks)) {
207
227
  throw new Error(
208
228
  `react-native spm: '${depName}' returned a non-array flavoredFrameworks.`,
209
229
  );
210
230
  }
231
+ if (!Array.isArray(rawScriptPhases)) {
232
+ throw new Error(
233
+ `react-native spm: '${depName}' returned a non-array scriptPhases.`,
234
+ );
235
+ }
211
236
  // Watch paths are best-effort staleness hints, so
212
237
  // a non-array is ignored (warn, never fatal).
213
238
  if (!Array.isArray(rawWatch)) {
@@ -314,6 +339,60 @@ function invokePlugins(
314
339
  }
315
340
  watchPaths.push(w);
316
341
  }
342
+ // Fatal like flavoredFrameworks, not dropped like watchPaths: a missing
343
+ // phase produces a GREEN build whose generated content was never written.
344
+ for (const phase of rawScriptPhases) {
345
+ if (
346
+ phase == null ||
347
+ !isValidScriptPhaseId(phase.id) ||
348
+ !isNonEmptyString(phase.script) ||
349
+ (phase.position != null &&
350
+ phase.position !== 'beforeCompile' &&
351
+ phase.position !== 'end') ||
352
+ !isOptionalStringList(phase.inputPaths) ||
353
+ !isOptionalStringList(phase.outputPaths) ||
354
+ (phase.alwaysOutOfDate != null &&
355
+ typeof phase.alwaysOutOfDate !== 'boolean')
356
+ ) {
357
+ const named = typeof phase?.id === 'string' ? ` '${phase.id}'` : '';
358
+ throw new Error(
359
+ `react-native spm: '${depName}' returned an invalid scriptPhase${named} ` +
360
+ '(need {id (matching /^[@A-Za-z0-9_./-]+$/, and not __proto__, ' +
361
+ 'constructor or prototype), name, script} plus optional ' +
362
+ '{position: "beforeCompile" | "end", inputPaths, outputPaths, ' +
363
+ 'alwaysOutOfDate}).',
364
+ );
365
+ }
366
+ if (!isValidScriptPhaseName(phase.name)) {
367
+ throw new Error(
368
+ `react-native spm: '${depName}' returned an invalid scriptPhase name for ` +
369
+ `'${phase.id}' (a name must be a non-empty single-line string — it is ` +
370
+ "the phase's display name in Xcode).",
371
+ );
372
+ }
373
+ if (seenScriptPhaseIds.has(phase.id)) {
374
+ throw new Error(
375
+ `react-native spm: duplicate script phase id '${phase.id}'.`,
376
+ );
377
+ }
378
+ seenScriptPhaseIds.add(phase.id);
379
+ const normalized /*: PluginScriptPhase */ = {
380
+ id: phase.id,
381
+ name: phase.name,
382
+ script: phase.script,
383
+ position: phase.position ?? 'end',
384
+ };
385
+ if (phase.inputPaths != null) {
386
+ normalized.inputPaths = [...phase.inputPaths];
387
+ }
388
+ if (phase.outputPaths != null) {
389
+ normalized.outputPaths = [...phase.outputPaths];
390
+ }
391
+ if (phase.alwaysOutOfDate != null) {
392
+ normalized.alwaysOutOfDate = phase.alwaysOutOfDate;
393
+ }
394
+ scriptPhases.push(normalized);
395
+ }
317
396
  }
318
397
 
319
398
  return {
@@ -322,6 +401,7 @@ function invokePlugins(
322
401
  generatedSources,
323
402
  flavoredFrameworks,
324
403
  watchPaths,
404
+ scriptPhases,
325
405
  };
326
406
  }
327
407