react-native-tvos 0.87.0-0 → 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 (50) 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 +1 -1
  14. package/README.md +17 -0
  15. package/React/Base/RCTVersion.m +1 -1
  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 +1 -1
  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 +2 -2
  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 +8 -8
  30. package/scripts/cocoapods/rndependencies.rb +31 -25
  31. package/scripts/replace-rncore-version.js +18 -2
  32. package/scripts/setup-apple-spm.js +28 -12
  33. package/scripts/spm/autolinking-plugins.js +80 -0
  34. package/scripts/spm/download-spm-artifacts.js +64 -6
  35. package/scripts/spm/expand-spm-dependencies.js +249 -30
  36. package/scripts/spm/generate-spm-autolinking.js +157 -40
  37. package/scripts/spm/generate-spm-package.js +30 -18
  38. package/scripts/spm/generate-spm-xcodeproj.js +595 -71
  39. package/scripts/spm/scaffold-package-swift.js +65 -24
  40. package/scripts/spm/spm-pbxproj.js +158 -28
  41. package/scripts/spm/spm-types.js +29 -3
  42. package/scripts/spm/spm-utils.js +117 -2
  43. package/sdks/.hermesv1version +1 -1
  44. package/sdks/hermes-engine/version.properties +1 -1
  45. package/types/public/ReactNativeTVTypes.d.ts +40 -1
  46. package/scripts/spm/__doc__/rfc-spm-xcframework.md +0 -707
  47. package/scripts/spm/__doc__/spm-autolinking-plugins.md +0 -244
  48. package/scripts/spm/__doc__/spm-header-paths-contract.md +0 -97
  49. package/scripts/spm/__doc__/spm-plugins-assessment.md +0 -128
  50. package/scripts/spm/__doc__/spm-scripts.md +0 -486
@@ -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
  */
@@ -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,10 +185,12 @@ 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
196
  let result /*: unknown */ = null;
@@ -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
 
@@ -393,15 +393,63 @@ async function resolveRNDepsArtifact(
393
393
  return {url: snapshotUrl, version};
394
394
  }
395
395
 
396
+ /**
397
+ * Resolves the `hermes-compiler` npm package's version from THIS project's own
398
+ * node_modules — the exact same lookup generate-spm-xcodeproj.js's
399
+ * resolveHermesCliPathSetting() uses to find the hermesc binary that will
400
+ * compile the JS bundle, and the same one react-native-xcode.sh falls back to
401
+ * for SwiftPM builds. Returns null when the package isn't resolvable (e.g.
402
+ * USE_HERMES=false apps that never installed it) so the caller can fall back
403
+ * to the npm dist-tag lookup.
404
+ */
405
+ function resolveLocalHermesCompilerVersion(
406
+ rnRoot /*: string */,
407
+ ) /*: string | null */ {
408
+ try {
409
+ const pkgPath = require.resolve('hermes-compiler/package.json', {
410
+ paths: [rnRoot],
411
+ });
412
+ // $FlowFixMe[incompatible-type] JSON.parse returns any
413
+ const pkg /*: {version: string} */ = JSON.parse(
414
+ fs.readFileSync(pkgPath, 'utf8'),
415
+ );
416
+ assertSafeVersion(pkg.version, 'local hermes-compiler/package.json');
417
+ return pkg.version;
418
+ } catch (error) {
419
+ // A MODULE_NOT_FOUND resolution failure is the expected case (e.g.
420
+ // USE_HERMES=false apps that never installed hermes-compiler) — fall back
421
+ // silently. Any other failure means hermes-compiler IS installed but its
422
+ // package.json is unreadable/malformed or carries an unsafe version; warn
423
+ // loudly rather than silently regressing to the live latest-v1 dist-tag,
424
+ // which would re-introduce the version-skew crash this resolves (#57917).
425
+ if (error.code !== 'MODULE_NOT_FOUND') {
426
+ log(
427
+ ` WARNING: hermes-compiler is installed but its version could not be resolved (${error.message}); falling back to the latest-v1 dist-tag, which may not match the pinned hermesc and can crash at launch with "Wrong bytecode version".`,
428
+ );
429
+ }
430
+ return null;
431
+ }
432
+ }
433
+
396
434
  /**
397
435
  * Returns {url, version} for Hermes. Hermes uses its own version space
398
436
  * decoupled from React Native's nightly cadence — RN's `hermes-compiler`
399
437
  * npm package publishes a `latest-v1` dist-tag that always resolves to a
400
- * binary that's been built and uploaded to Maven. Our default mirrors RN's
401
- * CocoaPods prebuild path (see scripts/ios-prebuild/hermes.js):
438
+ * binary that's been built and uploaded to Maven.
402
439
  *
403
- * HERMES_VERSION unset → 'latest-v1' dist-tag
404
- * HERMES_VERSION=latest-v1 → same (explicit)
440
+ * HERMES_VERSION unset → version pinned by the locally installed
441
+ * hermes-compiler package (node_modules).
442
+ * This is the SAME source
443
+ * resolveHermesCliPathSetting() reads for
444
+ * HERMES_CLI_PATH, so the downloaded VM and
445
+ * the hermesc that compiles the JS bundle
446
+ * always agree — a mismatched pair crashes at
447
+ * launch with "Wrong bytecode version" (#57917).
448
+ * Falls back to the 'latest-v1' npm dist-tag
449
+ * (RN's CocoaPods prebuild default; see
450
+ * scripts/ios-prebuild/hermes.js) only when
451
+ * hermes-compiler isn't locally resolvable.
452
+ * HERMES_VERSION=latest-v1 → 'latest-v1' dist-tag (explicit)
405
453
  * HERMES_VERSION=nightly → hermes-compiler@nightly dist-tag
406
454
  * HERMES_VERSION=<literal> → use that version verbatim
407
455
  *
@@ -413,8 +461,17 @@ async function resolveHermesArtifact(
413
461
  rnVersion /*: string */,
414
462
  flavor /*: string */,
415
463
  rawVersion /*: string | null */,
464
+ rnRoot /*: string */,
416
465
  ) /*: Promise<ResolvedArtifact> */ {
417
- let version = process.env.HERMES_VERSION ?? 'latest-v1';
466
+ let version = process.env.HERMES_VERSION;
467
+
468
+ if (version == null) {
469
+ const localVersion = resolveLocalHermesCompilerVersion(rnRoot);
470
+ if (localVersion != null) {
471
+ log(` Using locally pinned hermes-compiler: ${localVersion}`);
472
+ }
473
+ version = localVersion ?? 'latest-v1';
474
+ }
418
475
 
419
476
  if (version === 'nightly') {
420
477
  version = await resolveNightlyVersion('hermes-compiler');
@@ -1119,7 +1176,7 @@ async function main(argv /*:: ?: Array<string> */) /*: Promise<void> */ {
1119
1176
  label: 'hermes',
1120
1177
  name: 'hermes-engine',
1121
1178
  resolve: () =>
1122
- resolveHermesArtifact(resolvedRnVersion, flavor, rawVersion),
1179
+ resolveHermesArtifact(resolvedRnVersion, flavor, rawVersion, rnRoot),
1123
1180
  sharedName: (v /*: string */) => `hermes-ios-${v}-${flavor}.tar.gz`,
1124
1181
  },
1125
1182
  ];
@@ -1390,6 +1447,7 @@ module.exports = {
1390
1447
  main,
1391
1448
  resolveCacheSlotVersion,
1392
1449
  resolveHermesArtifact,
1450
+ resolveLocalHermesCompilerVersion,
1393
1451
  REQUIRED_ARTIFACTS,
1394
1452
  validateArtifactsCache,
1395
1453
  // Exposed for unit tests (pure / fetch-stubbable helpers).
@@ -10,10 +10,12 @@
10
10
 
11
11
  'use strict';
12
12
 
13
- const {toSwiftName} = require('./spm-utils');
13
+ const {RESERVED_SWIFT_NAMES, makeLogger, toSwiftName} = require('./spm-utils');
14
14
  const fs = require('fs');
15
15
  const path = require('path');
16
16
 
17
+ const {warn} = makeLogger('expand-spm-dependencies');
18
+
17
19
  /**
18
20
  * expand-spm-dependencies.js — Resolves transitive native deps declared via
19
21
  * `spm.dependencies` in a library's react-native.config.js.
@@ -32,7 +34,7 @@ const path = require('path');
32
34
  * list with autolinking-shaped entries so the downstream pipeline can convert
33
35
  * each to an SPM target without further branching.
34
36
  *
35
- * I/O is injected (readConfig, resolveDep) so the logic stays pure and
37
+ * I/O is injected (readConfig, resolveDep, log) so the logic stays pure and
36
38
  * testable.
37
39
  */
38
40
 
@@ -44,52 +46,232 @@ import type {AutolinkedDep} from './spm-types';
44
46
  type RnConfig = {...};
45
47
  type ReadConfig = (root: string) => ?RnConfig;
46
48
  type ResolveDep = (name: string, fromRoot: string) => ?string;
49
+ type Log = (message: string) => void;
50
+ // Keyed by lower case, valued with the canonical spelling: two names differing
51
+ // only in case are not distinct enough for the build to keep the two apart.
52
+ type ReservedNames = ReadonlyMap<string, string>;
47
53
  type Options = {
48
54
  readConfig: ReadConfig,
49
55
  resolveDep: ResolveDep,
56
+ // Names to reserve alongside RESERVED_SWIFT_NAMES, supplied by the caller
57
+ // (remote mode relabels the RN package) since this module reads no config.
58
+ extraReservedNames?: ?ReadonlyArray<string>,
59
+ log?: ?Log,
50
60
  };
51
61
  */
52
62
 
53
- // Validates and returns the Swift target name for a dep. Falls back to
54
- // toSwiftName(npmName) when no override is set. The override is the dep's
55
- // `react-native.config.js` `spm.name`, intended for libraries whose import
56
- // prefix differs from the auto-derived name (e.g. `react-native-worklets`
57
- // publishes headers under `<worklets/...>` via the podspec `s.header_dir`,
58
- // so the SPM target name should be `worklets`, not `ReactNativeWorklets`).
63
+ /**
64
+ * A misconfiguration rather than a resolution failure: scaffoldAll degrades past
65
+ * a transitive dep it cannot find, but must still surface this.
66
+ */
67
+ class SpmNameCollisionError extends Error {
68
+ constructor(message /*: string */) {
69
+ super(message);
70
+ this.name = 'SpmNameCollisionError';
71
+ }
72
+ }
73
+
74
+ // The charset `spm.name` must satisfy — permissive on purpose, since it has to
75
+ // admit header-dir style (lowercase with hyphens) as well as Swift identifiers.
76
+ // Shared with the app's own `spm.modules` names.
77
+ function isValidSwiftName(name /*: unknown */) /*: boolean */ {
78
+ return typeof name === 'string' && /^[A-Za-z_][A-Za-z0-9_-]*$/.test(name);
79
+ }
80
+
81
+ function reservedSwiftNames(
82
+ extraReservedNames /*: ?ReadonlyArray<string> */,
83
+ ) /*: ReservedNames */ {
84
+ return new Map(
85
+ [...RESERVED_SWIFT_NAMES, ...(extraReservedNames ?? [])].map(name => [
86
+ name.toLowerCase(),
87
+ name,
88
+ ]),
89
+ );
90
+ }
91
+
92
+ // The scope-borrowed form of a name: `@powersync/react-native`'s `ReactNative`
93
+ // becomes `PowersyncReactNative`.
94
+ function scopeBorrowedName(
95
+ npmName /*: string */,
96
+ swiftName /*: string */,
97
+ ) /*: ?string */ {
98
+ const scope = /^@([^/]+)\//.exec(npmName)?.[1];
99
+ return scope == null ? null : `${toSwiftName(scope)}${swiftName}`;
100
+ }
101
+
102
+ // The Swift target name for one dep, judged in isolation. `spm.name` is for
103
+ // libraries whose import prefix differs from the derived name:
104
+ // `react-native-worklets` ships headers as `<worklets/...>` (podspec
105
+ // `s.header_dir`), so its target is `worklets`, not `ReactNativeWorklets`. A
106
+ // derived name that lands on a reserved one borrows the npm scope instead.
59
107
  function resolveSwiftName(
60
108
  npmName /*: string */,
61
109
  config /*: ?RnConfig */,
110
+ reserved /*: ReservedNames */,
111
+ log /*:: ?: ?Log */,
62
112
  ) /*: string */ {
63
113
  // $FlowFixMe[prop-missing] config has dynamic shape
64
114
  const override = config?.spm?.name;
65
- if (override == null) {
66
- return toSwiftName(npmName);
115
+ if (override != null) {
116
+ if (typeof override !== 'string' || override.length === 0) {
117
+ throw new Error(
118
+ `react-native autolinking: '${npmName}' has an invalid 'spm.name' override: expected a non-empty string, got ${JSON.stringify(override)}.`,
119
+ );
120
+ }
121
+ if (!isValidSwiftName(override)) {
122
+ throw new Error(
123
+ `react-native autolinking: '${npmName}' has an invalid 'spm.name' override '${override}': must start with a letter or underscore and contain only letters, digits, underscores, or hyphens.`,
124
+ );
125
+ }
126
+ return override;
67
127
  }
68
- if (typeof override !== 'string' || override.length === 0) {
69
- throw new Error(
70
- `react-native autolinking: '${npmName}' has an invalid 'spm.name' override: expected a non-empty string, got ${JSON.stringify(override)}.`,
71
- );
128
+
129
+ const derived = toSwiftName(npmName);
130
+ if (!reserved.has(derived.toLowerCase())) {
131
+ return derived;
72
132
  }
73
- // Accept Swift-identifier style (TitleCase / snake_case) and header-dir
74
- // style (lowercase, optional hyphens). Reject whitespace, slashes, and
75
- // other characters that would break SPM target / module identifiers.
76
- if (!/^[A-Za-z_][A-Za-z0-9_-]*$/.test(override)) {
77
- throw new Error(
78
- `react-native autolinking: '${npmName}' has an invalid 'spm.name' override '${override}': must start with a letter or underscore and contain only letters, digits, underscores, or hyphens.`,
79
- );
133
+ const disambiguated = scopeBorrowedName(npmName, derived);
134
+ if (disambiguated == null || reserved.has(disambiguated.toLowerCase())) {
135
+ return derived;
136
+ }
137
+ log?.(
138
+ `'${npmName}' would take React Native's reserved name '${derived}', so its npm scope is prepended: '${disambiguated}'. ` +
139
+ `Set 'spm.name' in ${npmName}'s react-native.config.js to choose the name yourself.`,
140
+ );
141
+ return disambiguated;
142
+ }
143
+
144
+ function assertNameNotReserved(
145
+ swiftName /*: string */,
146
+ reserved /*: ReservedNames */,
147
+ labels /*: {label: string, remedy: string} */,
148
+ ) /*: void */ {
149
+ const reservedName = reserved.get(swiftName.toLowerCase());
150
+ if (reservedName == null) {
151
+ return;
152
+ }
153
+ // Vaguer about the case clash than the dep-vs-dep message on purpose: this
154
+ // set spans package identities and product names, which collide differently.
155
+ throw new SpmNameCollisionError(
156
+ `react-native autolinking: SPM Swift name collision: ${labels.label} resolves to '${swiftName}', ` +
157
+ (reservedName === swiftName
158
+ ? `which React Native reserves for its own SPM package and products.`
159
+ : `which differs from React Native's reserved '${reservedName}' only in case — not distinct enough for the build to keep the two apart.`) +
160
+ ` ${labels.remedy}`,
161
+ );
162
+ }
163
+
164
+ /**
165
+ * Throws when `swiftName` is one React Native's own manifests use. `remedy` is
166
+ * the fix: a library sets `spm.name`, an app renames its `spm.modules` entry.
167
+ */
168
+ function assertSwiftNameNotReserved(
169
+ swiftName /*: string */,
170
+ options /*: {
171
+ label: string,
172
+ remedy: string,
173
+ extraReservedNames?: ?ReadonlyArray<string>,
174
+ } */,
175
+ ) /*: void */ {
176
+ const {label, remedy, extraReservedNames} = options;
177
+ assertNameNotReserved(swiftName, reservedSwiftNames(extraReservedNames), {
178
+ label,
179
+ remedy,
180
+ });
181
+ }
182
+
183
+ // Reserved-name backstop over the resolved set. Unconditional: a plugin-shipping
184
+ // library is checked like any other, so `spm scaffold` — which knows nothing
185
+ // about plugins — cannot disagree with the autolinker about the same dep.
186
+ function assertNoReservedSwiftNames(
187
+ deps /*: ReadonlyArray<AutolinkedDep> */,
188
+ reserved /*: ReservedNames */,
189
+ ) /*: void */ {
190
+ for (const dep of deps) {
191
+ const swiftName = dep.swiftName;
192
+ if (swiftName == null) {
193
+ continue;
194
+ }
195
+ assertNameNotReserved(swiftName, reserved, {
196
+ label: `'${dep.name}'`,
197
+ remedy: `Set a different 'spm.name' in ${dep.name}'s react-native.config.js.`,
198
+ });
199
+ }
200
+ }
201
+
202
+ // Pulls apart deps that resolved to the same name by borrowing their npm scopes.
203
+ // Every scoped member of a colliding group moves: there is no non-arbitrary
204
+ // winner to keep. Exactly one pass — retrying would trade a diagnosable error
205
+ // for a name nobody can predict.
206
+ function disambiguateSharedSwiftNames(
207
+ deps /*: ReadonlyArray<AutolinkedDep> */,
208
+ autoNamed /*: ReadonlySet<string> */,
209
+ log /*: ?Log */,
210
+ ) /*: void */ {
211
+ const groups /*: Map<string, Array<{dep: AutolinkedDep, swiftName: string}>> */ =
212
+ new Map();
213
+ for (const dep of deps) {
214
+ const swiftName = dep.swiftName;
215
+ if (swiftName == null) {
216
+ continue;
217
+ }
218
+ const key = swiftName.toLowerCase();
219
+ const group = groups.get(key);
220
+ if (group == null) {
221
+ groups.set(key, [{dep, swiftName}]);
222
+ } else {
223
+ group.push({dep, swiftName});
224
+ }
225
+ }
226
+
227
+ for (const group of groups.values()) {
228
+ if (group.length < 2) {
229
+ continue;
230
+ }
231
+ for (const {dep, swiftName} of group) {
232
+ // A name we derived can borrow a second time (`AAReactNative`); the
233
+ // member whose name we did not derive is the incumbent and keeps it.
234
+ if (!autoNamed.has(dep.name)) {
235
+ continue;
236
+ }
237
+ const borrowed = scopeBorrowedName(dep.name, swiftName);
238
+ if (borrowed == null) {
239
+ continue;
240
+ }
241
+ const others = group
242
+ .filter(other => other.dep !== dep)
243
+ .map(other => `'${other.dep.name}'`)
244
+ .join(', ');
245
+ log?.(
246
+ `'${dep.name}' would share the name '${swiftName}' with ${others}, so its npm scope is prepended: '${borrowed}'. ` +
247
+ `Set 'spm.name' in ${dep.name}'s react-native.config.js to choose the name yourself.`,
248
+ );
249
+ dep.swiftName = borrowed;
250
+ }
80
251
  }
81
- return override;
82
252
  }
83
253
 
84
254
  function expandSpmDependencies(
85
255
  directDeps /*: Array<AutolinkedDep> */,
86
256
  options /*: Options */,
87
257
  ) /*: Array<AutolinkedDep> */ {
88
- const {readConfig, resolveDep} = options;
258
+ const {readConfig, resolveDep, extraReservedNames, log} = options;
259
+ const reserved = reservedSwiftNames(extraReservedNames);
89
260
  const byName /*: Map<string, AutolinkedDep> */ = new Map();
90
261
  for (const dep of directDeps) {
91
262
  byName.set(dep.name, {...dep, spmDependencies: []});
92
263
  }
264
+ const autoNamed /*: Set<string> */ = new Set();
265
+ const resolveName = (
266
+ npmName /*: string */,
267
+ config /*: ?RnConfig */,
268
+ ) /*: string */ => {
269
+ // $FlowFixMe[prop-missing] config has dynamic shape
270
+ if (config?.spm?.name == null) {
271
+ autoNamed.add(npmName);
272
+ }
273
+ return resolveSwiftName(npmName, config, reserved, log);
274
+ };
93
275
 
94
276
  const queue /*: Array<string> */ = directDeps.map(d => d.name);
95
277
  while (queue.length > 0) {
@@ -105,7 +287,7 @@ function expandSpmDependencies(
105
287
  // Resolve swiftName lazily from the same config read we already need for
106
288
  // spm.dependencies — saves a duplicate readConfig call per direct dep.
107
289
  if (current.swiftName == null) {
108
- current.swiftName = resolveSwiftName(currentName, config);
290
+ current.swiftName = resolveName(currentName, config);
109
291
  }
110
292
  // $FlowFixMe[prop-missing] config has dynamic shape
111
293
  const transitives /*: Array<string> */ = config?.spm?.dependencies ?? [];
@@ -134,7 +316,7 @@ function expandSpmDependencies(
134
316
  name: transitiveName,
135
317
  root: transitiveRoot,
136
318
  platforms: {ios: iosPlatform},
137
- swiftName: resolveSwiftName(transitiveName, transitiveConfig),
319
+ swiftName: resolveName(transitiveName, transitiveConfig),
138
320
  spmDependencies: [],
139
321
  });
140
322
  queue.push(transitiveName);
@@ -144,6 +326,14 @@ function expandSpmDependencies(
144
326
  current.spmDependencies = currentSpmDeps;
145
327
  }
146
328
 
329
+ const allDeps /*: Array<AutolinkedDep> */ = Array.from(byName.values());
330
+
331
+ disambiguateSharedSwiftNames(allDeps, autoNamed, log);
332
+
333
+ // Both checks below validate the FINAL set, after that pass: a borrowed scope
334
+ // can land on a reserved name, or on one another dep already holds.
335
+ assertNoReservedSwiftNames(allDeps, reserved);
336
+
147
337
  // Collision check: two deps mapping to the same Swift name (whether via
148
338
  // override or auto-derivation) would clobber each other in the synth
149
339
  // package layout and the centralized headers tree. Surface it now with a
@@ -154,7 +344,7 @@ function expandSpmDependencies(
154
344
  // passes but the two still collide as directories on the default
155
345
  // case-insensitive macOS filesystem (synth package layout + headers tree).
156
346
  const seen /*: Map<string, {name: string, swiftName: string}> */ = new Map();
157
- for (const dep of byName.values()) {
347
+ for (const dep of allDeps) {
158
348
  const swiftName = dep.swiftName;
159
349
  if (swiftName == null) {
160
350
  continue;
@@ -163,7 +353,7 @@ function expandSpmDependencies(
163
353
  const existing = seen.get(key);
164
354
  if (existing != null) {
165
355
  const same = existing.swiftName === swiftName;
166
- throw new Error(
356
+ throw new SpmNameCollisionError(
167
357
  `react-native autolinking: SPM Swift name collision: '${existing.name}' ('${existing.swiftName}') and '${dep.name}' ('${swiftName}') ` +
168
358
  (same
169
359
  ? `both resolve to '${swiftName}'.`
@@ -174,7 +364,7 @@ function expandSpmDependencies(
174
364
  seen.set(key, {name: dep.name, swiftName});
175
365
  }
176
366
 
177
- return Array.from(byName.values());
367
+ return allDeps;
178
368
  }
179
369
 
180
370
  // ---------------------------------------------------------------------------
@@ -188,8 +378,34 @@ function defaultReadConfig(root /*: string */) /*: ?RnConfig */ {
188
378
  }
189
379
  try {
190
380
  // $FlowFixMe[unsupported-syntax]
191
- return require(configPath);
192
- } catch {
381
+ const mod = require(configPath);
382
+ // Read both export styles, because the community CLI's two loaders
383
+ // disagree with each other: its sync path (`loadConfig`) requires the
384
+ // module and sees named exports at top level, its async path
385
+ // (`loadConfigAsync`) takes the default export only. Merging covers both,
386
+ // with named exports winning — the shape the sync path already resolves.
387
+ // Every sibling key of the default export is preserved
388
+ // (`dependency.platforms.ios` is read from this result too).
389
+ // A function-style config (`module.exports = () => ({...})`) and other
390
+ // non-objects pass through untouched — there is no default export to
391
+ // unwrap, and nulling them would hide a config that used to be read.
392
+ if (mod == null || typeof mod !== 'object') {
393
+ return mod;
394
+ }
395
+ const dflt = mod.default;
396
+ if (dflt == null || typeof dflt !== 'object') {
397
+ return mod;
398
+ }
399
+ const {default: _unused, ...named} = mod;
400
+ return {...dflt, ...named};
401
+ } catch (e) {
402
+ // A config can fail to load for reasons unrelated to SPM (it may import a
403
+ // devDependency absent in a consumer install), so this stays a warning —
404
+ // but a silent null turns a dropped `spm` block into a link error much
405
+ // later.
406
+ warn(
407
+ `Failed to load ${configPath}: ${e.message}. Any 'spm' settings in it are ignored.`,
408
+ );
193
409
  return null;
194
410
  }
195
411
  }
@@ -209,7 +425,10 @@ function defaultResolveDep(
209
425
  }
210
426
 
211
427
  module.exports = {
428
+ SpmNameCollisionError,
429
+ assertSwiftNameNotReserved,
212
430
  expandSpmDependencies,
431
+ isValidSwiftName,
213
432
  resolveSwiftName,
214
433
  defaultReadConfig,
215
434
  defaultResolveDep,