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
@@ -22,9 +22,11 @@
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,
29
+ commentSafe,
28
30
  ensureScalarField,
29
31
  findApplicationTargets,
30
32
  findField,
@@ -41,13 +43,25 @@ const {
41
43
  removeObjectByUuid,
42
44
  serializeEntry,
43
45
  setScalarField,
46
+ uuidComment,
44
47
  } = require('./spm-pbxproj');
45
- const {makeLogger, remotePackageConfig} = require('./spm-utils');
48
+ const {
49
+ AUTOLINKED_PACKAGE_NAME,
50
+ REACT_CODEGEN_APP_PRODUCTS,
51
+ REACT_CODEGEN_PACKAGE_NAME,
52
+ REACT_NATIVE_PACKAGE_NAME,
53
+ REACT_NATIVE_PRODUCTS,
54
+ isValidScriptPhaseId,
55
+ isValidScriptPhaseName,
56
+ makeLogger,
57
+ remotePackageConfig,
58
+ } = require('./spm-utils');
46
59
  const fs = require('fs');
47
60
  const path = require('path');
48
61
 
49
62
  /*:: import type {
50
63
  FlavoredFrameworkManifestEntry,
64
+ PluginScriptPhase,
51
65
  XcframeworkSlice,
52
66
  } from './spm-types'; */
53
67
 
@@ -72,6 +86,17 @@ const SPM_GENERATED_SOURCES_MANIFEST = path.join(
72
86
  '.spm-plugin-generated-sources.json',
73
87
  );
74
88
 
89
+ // Manifest of plugin-contributed build-time shell phases for the app target —
90
+ // SwiftPM has no `script_phase`, so a framework that must generate content into
91
+ // the app bundle (expo-constants' `EXConstants.bundle/app.config`) declares one
92
+ // through the plugin contract.
93
+ const SPM_SCRIPT_PHASES_MANIFEST = path.join(
94
+ 'build',
95
+ 'generated',
96
+ 'autolinking',
97
+ '.spm-plugin-script-phases.json',
98
+ );
99
+
75
100
  // The single navigator group all injected generated sources are parented under
76
101
  // (created on first use). Its namespacedUUID id + display name.
77
102
  const SPM_GENERATED_SOURCES_GROUP_ID = 'SPMGeneratedSources';
@@ -91,36 +116,21 @@ const GENERATED_SOURCE_FILE_TYPES /*: {[string]: string} */ = {
91
116
  // resolve the product dependencies — SPM doesn't expose transitive products.
92
117
  const SPM_PRODUCT_PACKAGES /*: Array<{product: string, packagePath: string, packageName: string}> */ =
93
118
  [
94
- {
95
- product: 'ReactHeaders',
96
- packagePath: 'build/xcframeworks',
97
- packageName: 'ReactNative',
98
- },
99
- {
100
- product: 'ReactNativeHeaders',
101
- packagePath: 'build/xcframeworks',
102
- packageName: 'ReactNative',
103
- },
104
- {
105
- product: 'ReactNativeDependenciesHeaders',
119
+ ...REACT_NATIVE_PRODUCTS.map(product => ({
120
+ product,
106
121
  packagePath: 'build/xcframeworks',
107
- packageName: 'ReactNative',
108
- },
122
+ packageName: REACT_NATIVE_PACKAGE_NAME,
123
+ })),
109
124
  {
110
- product: 'Autolinked',
125
+ product: AUTOLINKED_PACKAGE_NAME,
111
126
  packagePath: 'build/generated/autolinking',
112
- packageName: 'Autolinked',
113
- },
114
- {
115
- product: 'ReactCodegen',
116
- packagePath: 'build/generated/ios',
117
- packageName: 'React-GeneratedCode',
127
+ packageName: AUTOLINKED_PACKAGE_NAME,
118
128
  },
119
- {
120
- product: 'ReactAppDependencyProvider',
129
+ ...REACT_CODEGEN_APP_PRODUCTS.map(product => ({
130
+ product,
121
131
  packagePath: 'build/generated/ios',
122
- packageName: 'React-GeneratedCode',
123
- },
132
+ packageName: REACT_CODEGEN_PACKAGE_NAME,
133
+ })),
124
134
  ];
125
135
 
126
136
  /*::
@@ -136,7 +146,15 @@ type BuildSettingChange = {
136
146
  // value), e.g. a ${PODS_ROOT}-anchored REACT_NATIVE_PATH that dangles once
137
147
  // CocoaPods is deintegrated. Deinit restores the original.
138
148
  replacedScalars?: {[string]: string},
149
+ // Array settings that existed as a SCALAR and were promoted to a `( … )`
150
+ // array (key → the pre-injection raw value text, quotes included). Deinit
151
+ // restores that value verbatim; removing the injected members would leave
152
+ // the promoted array and its `"$(inherited)"` seed behind.
153
+ promotedArrayScalars?: {[string]: string},
139
154
  };
155
+ // An array field injection CREATED (rather than appended to a pre-existing
156
+ // one), so deinit removes the whole field and lands byte-identical.
157
+ type CreatedArrayField = {container: 'project' | 'target', key: string};
140
158
  // A plugin-contributed source, normalized for pbxproj emission. `path` is
141
159
  // SRCROOT-relative when under the app root, else absolute; `sourceTree` is the
142
160
  // matching pbxproj token ('SOURCE_ROOT' or '"<absolute>"').
@@ -264,30 +282,42 @@ function spmGraphToEntries(
264
282
  return {localRefs, remoteRef, productDeps, buildFiles};
265
283
  }
266
284
 
267
- // Sync SPM Autolinking: timestamp check + conditional node re-run. Shared by
268
- // the build phase (safety net) and the scheme pre-action (the one that
269
- // actually fires before SPM resolution, so a single build picks up
270
- // dep-graph changes from `npm install`).
271
- // Build a PBXShellScriptBuildPhase entry (the "Sync SPM Autolinking" phase).
285
+ /**
286
+ * Build a PBXShellScriptBuildPhase entry. `inputPaths`/`outputPaths` accept
287
+ * either a plain path array or an already-serialized pbxproj list.
288
+ * `alwaysOutOfDate` emits Xcode's own `alwaysOutOfDate = 1;` (unquoted, right
289
+ * after `isa`, so a project Xcode rewrites stays diff-free) and is omitted
290
+ * entirely when false. `comment` overrides the cosmetic `/* … *​/` label, which
291
+ * otherwise derives from `name`.
292
+ */
272
293
  function shellScriptPhase(
273
294
  phaseUUID /*: string */,
274
295
  name /*: string */,
275
296
  script /*: string */,
276
- options /*: {inputPaths?: string, outputPaths?: string} */ = {},
297
+ options /*: {inputPaths?: ?(string | ReadonlyArray<string>), outputPaths?: ?(string | ReadonlyArray<string>), alwaysOutOfDate?: ?boolean, comment?: string} */ = {},
277
298
  ) /*: {uuid: string, comment: string, fields: {[string]: string}} */ {
278
299
  const empty = '(\n\t\t\t)';
300
+ const pathList = (
301
+ value /*: ?(string | ReadonlyArray<string>) */,
302
+ ) /*: string */ => {
303
+ if (value == null) {
304
+ return empty;
305
+ }
306
+ return typeof value === 'string' ? value : pbxPathList(value);
307
+ };
279
308
  return {
280
309
  uuid: phaseUUID,
281
- comment: name,
310
+ comment: options.comment ?? name,
282
311
  fields: {
283
312
  isa: 'PBXShellScriptBuildPhase',
313
+ ...(options.alwaysOutOfDate === true ? {alwaysOutOfDate: '1'} : {}),
284
314
  buildActionMask: '2147483647',
285
315
  files: empty,
286
316
  inputFileListPaths: empty,
287
- inputPaths: options.inputPaths ?? empty,
317
+ inputPaths: pathList(options.inputPaths),
288
318
  name: quoteIfNeeded(name),
289
319
  outputFileListPaths: empty,
290
- outputPaths: options.outputPaths ?? empty,
320
+ outputPaths: pathList(options.outputPaths),
291
321
  runOnlyForDeploymentPostprocessing: '0',
292
322
  shellPath: '/bin/sh',
293
323
  shellScript: quoteIfNeeded(script),
@@ -467,11 +497,126 @@ ${copies}
467
497
  `;
468
498
  }
469
499
 
500
+ /**
501
+ * The app target's `buildPhases` members, in the order the file lists them.
502
+ * Line-leading UUIDs only: a member's trailing comment carries a plugin-supplied
503
+ * phase name, and one that happens to look like a UUID would otherwise read as an
504
+ * extra member — leaving the actual order permanently at odds with the declared
505
+ * one, and offering a non-member as a re-seating anchor.
506
+ */
507
+ function buildPhaseOrder(
508
+ text /*: string */,
509
+ target /*: {bodyOpen: number, bodyClose: number, ...} */,
510
+ ) /*: Array<string> */ {
511
+ const field = findField(text, target, 'buildPhases');
512
+ if (field == null) {
513
+ return [];
514
+ }
515
+ return [...field.value.matchAll(/^[\t ]*([0-9A-Fa-f]{24})\b/gm)].map(
516
+ m => m[1],
517
+ );
518
+ }
519
+
520
+ /**
521
+ * Rewrite the trailing comment Xcode keeps beside a UUID — on the object's own
522
+ * definition line and on every array-member line referencing it. Xcode
523
+ * normalizes those comments on its next write, so leaving a stale one behind
524
+ * (after a plugin renames a phase) plants a spurious diff in the user's repo.
525
+ * No-op when the comment already reads `comment`.
526
+ */
527
+ function setUuidComment(
528
+ text /*: string */,
529
+ uuid /*: string */,
530
+ comment /*: string */,
531
+ ) /*: string */ {
532
+ return text.replace(
533
+ new RegExp(`(\\n[\\t ]*${uuid})(?: /\\* [^\\n]*? \\*/)?( = \\{|,)`, 'g'),
534
+ (_match, head, tail) => `${head}${uuidComment(comment)}${tail}`,
535
+ );
536
+ }
537
+
538
+ /*:: type SeatedPhase = {uuid: string, comment: string, position: 'beforeCompile' | 'end'}; */
539
+
540
+ /**
541
+ * Seat the plugin phases in the order the manifest declares: `beforeCompile`
542
+ * ones directly after the Sync SPM Autolinking phase (which stays first — it
543
+ * regenerates the content everything else reads) and always before Sources,
544
+ * `end` ones at the true end of `buildPhases`, after the app's own JS-bundle
545
+ * phase. The other members keep their relative order.
546
+ *
547
+ * `beforeCompile` is anchored on Sources, not merely on the sync phase: RN never
548
+ * re-seats its own sync phase, so a user who drags it below Sources would
549
+ * otherwise have every `beforeCompile` phase seated after compilation — the one
550
+ * thing the position promises not to do. Without a Sources phase the sync phase
551
+ * is the anchor.
552
+ *
553
+ * Rewrites the membership lines ONLY when the actual order differs from that,
554
+ * which is what keeps an unchanged sync byte-identical — `addBuildPhaseAfter`
555
+ * and `addArrayMembers` both short-circuit on a UUID that is already a member,
556
+ * so re-placing has to be driven from here. A phase a user dragged elsewhere in
557
+ * Xcode is therefore moved back: the declared position wins.
558
+ */
559
+ function seatScriptPhases(
560
+ input /*: string */,
561
+ targetUuid /*: string */,
562
+ syncPhaseUuid /*: string */,
563
+ phases /*: ReadonlyArray<SeatedPhase> */,
564
+ sourcesPhaseUuid /*: ?string */,
565
+ ) /*: string */ {
566
+ const pluginUuids = new Set(phases.map(p => p.uuid));
567
+ const actual = buildPhaseOrder(
568
+ input,
569
+ findApplicationTargetByUuid(input, targetUuid),
570
+ );
571
+ const others = actual.filter(uuid => !pluginUuids.has(uuid));
572
+ const beforeCompile = phases.filter(p => p.position === 'beforeCompile');
573
+ const atEnd = phases.filter(p => p.position !== 'beforeCompile');
574
+ const sourcesAt =
575
+ sourcesPhaseUuid != null ? others.indexOf(sourcesPhaseUuid) : -1;
576
+ const afterSyncAt = others.indexOf(syncPhaseUuid) + 1;
577
+ // 0 — no sync phase member, or Sources ahead of it — makes the beforeCompile
578
+ // phases lead the array, matching addArrayMembers' prepend fallback below.
579
+ const insertAt =
580
+ sourcesAt >= 0 ? Math.min(afterSyncAt, sourcesAt) : afterSyncAt;
581
+ const desired = [
582
+ ...others.slice(0, insertAt),
583
+ ...beforeCompile.map(p => p.uuid),
584
+ ...others.slice(insertAt),
585
+ ...atEnd.map(p => p.uuid),
586
+ ];
587
+ if (
588
+ desired.length === actual.length &&
589
+ desired.every((uuid, i) => uuid === actual[i])
590
+ ) {
591
+ return input;
592
+ }
593
+ // File-wide removal is safe for a shell-phase UUID: it appears on its own
594
+ // definition line (which ends `= {`, never matched) and on `buildPhases`
595
+ // member lines. The uncovered case is a user who copied the same phase object
596
+ // into a SECOND target's buildPhases — it is stripped there too.
597
+ let text = removeArrayMembersByUuid(input, [...pluginUuids]);
598
+ const target = () => findApplicationTargetByUuid(text, targetUuid);
599
+ let anchor = insertAt > 0 ? others[insertAt - 1] : null;
600
+ for (const member of beforeCompile) {
601
+ text =
602
+ anchor == null
603
+ ? addArrayMembers(text, target(), 'buildPhases', [member], {
604
+ prepend: true,
605
+ })
606
+ : addBuildPhaseAfter(text, target(), anchor, member);
607
+ anchor = member.uuid;
608
+ }
609
+ for (const member of atEnd) {
610
+ text = addArrayMembers(text, target(), 'buildPhases', [member]);
611
+ }
612
+ return text;
613
+ }
614
+
470
615
  function addBuildPhaseAfter(
471
616
  text /*: string */,
472
617
  target /*: {bodyOpen: number, bodyClose: number, ...} */,
473
618
  afterUuid /*: string */,
474
- member /*: {uuid: string, comment: string} */,
619
+ member /*: {uuid: string, comment: string, ...} */,
475
620
  ) /*: string */ {
476
621
  const field = findField(text, target, 'buildPhases');
477
622
  if (field == null || field.value.includes(member.uuid)) {
@@ -486,7 +631,7 @@ function addBuildPhaseAfter(
486
631
  const absoluteStart = field.valueStart + after.index;
487
632
  const lineEnd = text.indexOf('\n', absoluteStart + after[0].length);
488
633
  const indent = after[2];
489
- const line = `\n${indent}${member.uuid} /* ${member.comment} */,`;
634
+ const line = `\n${indent}${member.uuid}${uuidComment(member.comment)},`;
490
635
  return text.slice(0, lineEnd) + line + text.slice(lineEnd);
491
636
  }
492
637
 
@@ -723,6 +868,18 @@ function escapeXmlAttribute(s /*: string */) /*: string */ {
723
868
  .replace(/'/g, '&apos;');
724
869
  }
725
870
 
871
+ // The inverse of escapeXmlAttribute. `&amp;` is expanded LAST so an entity that
872
+ // was itself escaped (`&lt;` → `&amp;lt;`) round-trips back to its own text
873
+ // rather than to `<`.
874
+ function unescapeXmlAttribute(s /*: string */) /*: string */ {
875
+ return s
876
+ .replace(/&lt;/g, '<')
877
+ .replace(/&gt;/g, '>')
878
+ .replace(/&quot;/g, '"')
879
+ .replace(/&apos;/g, "'")
880
+ .replace(/&amp;/g, '&');
881
+ }
882
+
726
883
  function generateXcscheme(
727
884
  appName /*: string */,
728
885
  targetUUID /*: string */,
@@ -931,6 +1088,25 @@ const INJECTED_ARRAY_SETTINGS = [
931
1088
  },
932
1089
  ];
933
1090
 
1091
+ // Array build settings injected only into debug-flavored configurations.
1092
+ //
1093
+ // Swift's `#if DEBUG` is gated by SWIFT_ACTIVE_COMPILATION_CONDITIONS, NOT by
1094
+ // GCC_PREPROCESSOR_DEFINITIONS (which only reaches C/ObjC/C++). The app
1095
+ // template does not commit the setting: CocoaPods injects it at `pod install`
1096
+ // time (react_native_post_install → set_build_setting
1097
+ // SWIFT_ACTIVE_COMPILATION_CONDITIONS = ["$(inherited)", "DEBUG"] on Debug).
1098
+ // An SPM app never runs CocoaPods, so without this `#if DEBUG` is false even
1099
+ // in a Debug build — AppDelegate.swift's `bundleURL()` skips the Metro URL,
1100
+ // falls back to a main.jsbundle that a Debug build never produced, and the app
1101
+ // dies at launch with "No script url provided … unsanitizedScriptURLString =
1102
+ // (null)" while Metro is running right there.
1103
+ //
1104
+ // Paired with RN_SPM_FLAVOR via flavorForBuildConfiguration, so a config that
1105
+ // links the debug xcframeworks also compiles its Swift with DEBUG.
1106
+ const DEBUG_ARRAY_SETTINGS = [
1107
+ {key: 'SWIFT_ACTIVE_COMPILATION_CONDITIONS', values: ['DEBUG']},
1108
+ ];
1109
+
934
1110
  /** The XCBuildConfiguration UUIDs of a target (via its buildConfigurationList). */
935
1111
  function targetBuildConfigUuids(
936
1112
  text /*: string */,
@@ -1077,7 +1253,8 @@ function injectSpmIntoPbxproj(
1077
1253
  hermesCliPath /*: ?string */ = null,
1078
1254
  generatedSources /*: ReadonlyArray<GeneratedSource> */ = [],
1079
1255
  flavoredFrameworks /*: ReadonlyArray<FlavoredFrameworkManifestEntry> */ = [],
1080
- ) /*: {text: string, injectedUuids: Array<string>, createdArrayFields: Array<{container: 'project' | 'target', key: string}>, buildSettingChanges: Array<BuildSettingChange>, generatedSourceUuids: {[string]: Array<string>}} */ {
1256
+ scriptPhases /*: ReadonlyArray<PluginScriptPhase> */ = [],
1257
+ ) /*: {text: string, injectedUuids: Array<string>, createdArrayFields: Array<CreatedArrayField>, buildSettingChanges: Array<BuildSettingChange>, generatedSourceUuids: {[string]: Array<string>}, scriptPhaseUuids: {[string]: string}} */ {
1081
1258
  let text = input;
1082
1259
  const mkUuid = (section /*: string */, id /*: string */) =>
1083
1260
  namespacedUUID(plan.rootUuid, section, id);
@@ -1110,10 +1287,7 @@ function injectSpmIntoPbxproj(
1110
1287
  insertObjects('XCSwiftPackageProductDependency', entries.productDeps);
1111
1288
  insertObjects('PBXBuildFile', entries.buildFiles);
1112
1289
 
1113
- // Track array fields we CREATE (vs. append to a pre-existing one) so deinit
1114
- // can remove the whole field and land byte-identical to the original.
1115
- const createdArrayFields /*: Array<{container: 'project' | 'target', key: string}> */ =
1116
- [];
1290
+ const createdArrayFields /*: Array<CreatedArrayField> */ = [];
1117
1291
 
1118
1292
  // 2. packageReferences on the PBXProject.
1119
1293
  const pkgRefMembers = [
@@ -1304,9 +1478,14 @@ function injectSpmIntoPbxproj(
1304
1478
  const fileRefUuid = mkUuid('PBXFileReference', `gensrc:${src.path}`);
1305
1479
  const buildFileUuid = mkUuid('PBXBuildFile', `gensrc:${src.path}`);
1306
1480
  generatedSourceUuids[src.path] = [fileRefUuid, buildFileUuid];
1481
+ // The name/path VALUES stay verbatim (escaped) — only the cosmetic
1482
+ // comments are normalized, falling back to the normalized path (the
1483
+ // ledger key) and then to no comment at all, which is well-formed.
1484
+ const label = commentSafe(src.name) || commentSafe(src.path);
1485
+ const inSources = label === '' ? '' : `${label} in Sources`;
1307
1486
  fileRefs.push({
1308
1487
  uuid: fileRefUuid,
1309
- comment: src.name,
1488
+ comment: label,
1310
1489
  fields: {
1311
1490
  isa: 'PBXFileReference',
1312
1491
  lastKnownFileType: src.fileType,
@@ -1317,17 +1496,14 @@ function injectSpmIntoPbxproj(
1317
1496
  });
1318
1497
  buildFiles.push({
1319
1498
  uuid: buildFileUuid,
1320
- comment: `${src.name} in Sources`,
1499
+ comment: inSources,
1321
1500
  fields: {
1322
1501
  isa: 'PBXBuildFile',
1323
- fileRef: `${fileRefUuid} /* ${src.name} */`,
1502
+ fileRef: `${fileRefUuid}${uuidComment(label)}`,
1324
1503
  },
1325
1504
  });
1326
- sourcesMembers.push({
1327
- uuid: buildFileUuid,
1328
- comment: `${src.name} in Sources`,
1329
- });
1330
- groupChildren.push({uuid: fileRefUuid, comment: src.name});
1505
+ sourcesMembers.push({uuid: buildFileUuid, comment: inSources});
1506
+ groupChildren.push({uuid: fileRefUuid, comment: label});
1331
1507
  }
1332
1508
  insertObjects('PBXFileReference', fileRefs);
1333
1509
  insertObjects('PBXBuildFile', buildFiles);
@@ -1387,12 +1563,87 @@ function injectSpmIntoPbxproj(
1387
1563
  }
1388
1564
  }
1389
1565
 
1566
+ // 9. Plugin-declared build phases (SwiftPM has no `script_phase`). Each
1567
+ // phase's UUID is keyed on its plugin id, so re-runs refresh in place and
1568
+ // `deinit` reverses it.
1569
+ const scriptPhaseUuids /*: {[string]: string} */ = {};
1570
+ if (scriptPhases.length > 0) {
1571
+ const seated /*: Array<SeatedPhase> */ = [];
1572
+ for (const scriptPhase of scriptPhases) {
1573
+ const uuid = mkUuid(
1574
+ 'PBXShellScriptBuildPhase',
1575
+ `plugin:${scriptPhase.id}`,
1576
+ );
1577
+ scriptPhaseUuids[scriptPhase.id] = uuid;
1578
+ // The full name goes into the `name` field (escaped) — that is what Xcode
1579
+ // displays. The cosmetic comments get it normalized, falling back to the
1580
+ // normalized id and then to no comment at all, which is well-formed. The
1581
+ // fallback is sanitized rather than trusted to the id charset, so widening
1582
+ // that charset can never reach a comment.
1583
+ const comment =
1584
+ commentSafe(scriptPhase.name) || commentSafe(scriptPhase.id);
1585
+ const entry = shellScriptPhase(
1586
+ uuid,
1587
+ scriptPhase.name,
1588
+ scriptPhase.script,
1589
+ {
1590
+ inputPaths: scriptPhase.inputPaths,
1591
+ outputPaths: scriptPhase.outputPaths,
1592
+ alwaysOutOfDate: scriptPhase.alwaysOutOfDate,
1593
+ comment,
1594
+ },
1595
+ );
1596
+ if (!text.includes(uuid)) {
1597
+ text = insertObjectsIntoSection(
1598
+ text,
1599
+ 'PBXShellScriptBuildPhase',
1600
+ serializeEntry(entry),
1601
+ );
1602
+ } else {
1603
+ // Rewrite the fields we own unconditionally — byte-identical when the
1604
+ // plugin's declaration hasn't changed, so no content hashing is needed.
1605
+ // Each write shifts offsets, hence the re-lookup per field.
1606
+ for (const key of [
1607
+ 'name',
1608
+ 'shellScript',
1609
+ 'inputPaths',
1610
+ 'outputPaths',
1611
+ ]) {
1612
+ const current = findObjectByUuid(text, uuid);
1613
+ if (current != null) {
1614
+ text = setScalarField(text, current, key, entry.fields[key]);
1615
+ }
1616
+ }
1617
+ // Flipping alwaysOutOfDate back off means REMOVING the field —
1618
+ // setScalarField would only ever write a value.
1619
+ const current = findObjectByUuid(text, uuid);
1620
+ if (current != null) {
1621
+ text =
1622
+ scriptPhase.alwaysOutOfDate === true
1623
+ ? setScalarField(text, current, 'alwaysOutOfDate', '1')
1624
+ : removeField(text, current, 'alwaysOutOfDate');
1625
+ }
1626
+ }
1627
+ injectedUuids.push(uuid);
1628
+ text = setUuidComment(text, uuid, comment);
1629
+ seated.push({uuid, comment, position: scriptPhase.position});
1630
+ }
1631
+ text = seatScriptPhases(
1632
+ text,
1633
+ plan.targetUuid,
1634
+ syncPhaseUuid,
1635
+ seated,
1636
+ sourcesPhaseUuid,
1637
+ );
1638
+ }
1639
+
1390
1640
  return {
1391
1641
  text,
1392
1642
  injectedUuids,
1393
1643
  createdArrayFields,
1394
1644
  buildSettingChanges,
1395
1645
  generatedSourceUuids,
1646
+ scriptPhaseUuids,
1396
1647
  };
1397
1648
  }
1398
1649
 
@@ -1450,6 +1701,28 @@ function resolveHermesCliPathSetting(
1450
1701
  }
1451
1702
  }
1452
1703
 
1704
+ /** Strip the surrounding plist quotes from a build-setting token, if any. */
1705
+ function unquotePlist(s /*: string */) /*: string */ {
1706
+ return s.replace(/^"/, '').replace(/"$/, '');
1707
+ }
1708
+
1709
+ /**
1710
+ * The individual values a build setting already carries, unquoted — for both
1711
+ * shapes a pbxproj uses: the array form Xcode writes for a multi-value setting
1712
+ * (`("$(inherited)", DEBUG)`) and the scalar form the app template and
1713
+ * hand-edits use (`"$(inherited) DEBUG"`). Membership, not substring: the
1714
+ * latter would read `MY_DEBUG_FLAG` as `DEBUG` already being set and silently
1715
+ * skip the injection.
1716
+ */
1717
+ function buildSettingValueTokens(value /*: string */) /*: Set<string> */ {
1718
+ return new Set(
1719
+ value
1720
+ .split(/[\s,()]+/)
1721
+ .filter(Boolean)
1722
+ .map(unquotePlist),
1723
+ );
1724
+ }
1725
+
1453
1726
  function mergeReactBuildSettings(
1454
1727
  input /*: string */,
1455
1728
  configUuid /*: string */,
@@ -1492,9 +1765,13 @@ function mergeReactBuildSettings(
1492
1765
  };
1493
1766
  const createdArrayKeys /*: Array<string> */ = [];
1494
1767
  const appendedArrayValues /*: {[string]: Array<string>} */ = {};
1768
+ const promotedArrayScalars /*: {[string]: string} */ = {};
1495
1769
  const createdScalars /*: Array<string> */ = [];
1496
1770
  const arraySettings = [
1497
1771
  ...INJECTED_ARRAY_SETTINGS,
1772
+ ...(flavorForBuildConfiguration(configurationName) === 'debug'
1773
+ ? DEBUG_ARRAY_SETTINGS
1774
+ : []),
1498
1775
  ...frameworkArrayBuildSettings(flavoredFrameworks),
1499
1776
  ];
1500
1777
  for (const {key, values} of arraySettings) {
@@ -1503,15 +1780,41 @@ function mergeReactBuildSettings(
1503
1780
  continue;
1504
1781
  }
1505
1782
  const existing = findField(text, d, key);
1783
+ // Non-null only for a scalar addArrayStringValues would promote to an array
1784
+ // (same array-vs-scalar test it uses). Kept RAW: findField's token for a
1785
+ // bare scalar ends at the `;`, so it carries any whitespace before it, and
1786
+ // deinit has to write those bytes back verbatim.
1787
+ const priorScalar =
1788
+ existing != null && !existing.value.trimStart().startsWith('(')
1789
+ ? existing.value
1790
+ : null;
1506
1791
  if (existing == null) {
1507
1792
  createdArrayKeys.push(key);
1508
1793
  } else {
1509
- const fresh = values.filter(v => !existing.value.includes(v));
1510
- if (fresh.length > 0) {
1794
+ const present = buildSettingValueTokens(existing.value);
1795
+ const fresh = values.filter(v => !present.has(unquotePlist(v)));
1796
+ if (fresh.length === 0) {
1797
+ // Nothing to add. Skip addArrayStringValues entirely: its dedupe is by
1798
+ // EXACT array member, so a value the user carries in the scalar form
1799
+ // (`SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited) DEBUG"`) would
1800
+ // otherwise be promoted to an array and re-appended — an edit `deinit`
1801
+ // has no record of and so could never reverse.
1802
+ continue;
1803
+ }
1804
+ if (priorScalar == null) {
1511
1805
  appendedArrayValues[key] = fresh;
1512
1806
  }
1513
1807
  }
1808
+ const beforeAdd = text;
1514
1809
  text = addArrayStringValues(text, d, key, values);
1810
+ // Record only a promotion that actually happened: addArrayStringValues
1811
+ // no-ops when `values` is empty or every value is already a member, and a
1812
+ // recorded-but-untouched field would have deinit clobber whatever the user
1813
+ // has there by then. Restoring the scalar subsumes removing the injected
1814
+ // members, so the two records stay mutually exclusive per key.
1815
+ if (priorScalar != null && text !== beforeAdd) {
1816
+ promotedArrayScalars[key] = priorScalar;
1817
+ }
1515
1818
  }
1516
1819
  const replacedScalars /*: {[string]: string} */ = {};
1517
1820
  for (const {key, value} of scalars) {
@@ -1570,6 +1873,9 @@ function mergeReactBuildSettings(
1570
1873
  appendedArrayValues,
1571
1874
  createdScalars,
1572
1875
  replacedScalars,
1876
+ ...(Object.keys(promotedArrayScalars).length > 0
1877
+ ? {promotedArrayScalars}
1878
+ : {}),
1573
1879
  },
1574
1880
  };
1575
1881
  }
@@ -1799,13 +2105,13 @@ function readGeneratedSourcesManifest(
1799
2105
  appRoot /*: string */,
1800
2106
  ) /*: Array<GeneratedSource> */ {
1801
2107
  const manifestPath = path.join(appRoot, SPM_GENERATED_SOURCES_MANIFEST);
1802
- let raw: string;
2108
+ let raw /*: string */ = '';
1803
2109
  try {
1804
2110
  raw = fs.readFileSync(manifestPath, 'utf8');
1805
2111
  } catch {
1806
2112
  return [];
1807
2113
  }
1808
- let entries: unknown;
2114
+ let entries /*: unknown */ = null;
1809
2115
  try {
1810
2116
  entries = JSON.parse(raw);
1811
2117
  } catch {
@@ -1837,6 +2143,86 @@ function readGeneratedSourcesManifest(
1837
2143
  return out;
1838
2144
  }
1839
2145
 
2146
+ const nonEmptyStrings = (value /*: unknown */) /*: ?Array<string> */ =>
2147
+ Array.isArray(value)
2148
+ ? value.filter(p => typeof p === 'string' && p.length > 0)
2149
+ : null;
2150
+
2151
+ /**
2152
+ * Read the plugin script-phases manifest at
2153
+ * `<appRoot>/build/generated/autolinking/.spm-plugin-script-phases.json`.
2154
+ * Absent, unparseable, or malformed → `[]`. Lenient by design even though the
2155
+ * plugin contract validates these entries fatally at invoke time: the sidecar
2156
+ * legitimately does not exist yet on a first `spm add`, and a stale or
2157
+ * hand-edited file must not break injection. This reader is the ONLY gate on
2158
+ * such a file, so it applies the same id/name rules (from spm-utils) that
2159
+ * invokePlugins enforces fatally.
2160
+ */
2161
+ function readScriptPhasesManifest(
2162
+ appRoot /*: string */,
2163
+ ) /*: Array<PluginScriptPhase> */ {
2164
+ const manifestPath = path.join(appRoot, SPM_SCRIPT_PHASES_MANIFEST);
2165
+ let raw /*: string */ = '';
2166
+ try {
2167
+ raw = fs.readFileSync(manifestPath, 'utf8');
2168
+ } catch {
2169
+ return [];
2170
+ }
2171
+ let entries /*: unknown */ = null;
2172
+ try {
2173
+ entries = JSON.parse(raw);
2174
+ } catch {
2175
+ log(
2176
+ `warning: could not parse ${SPM_SCRIPT_PHASES_MANIFEST}; ` +
2177
+ 'skipping script phases.',
2178
+ );
2179
+ return [];
2180
+ }
2181
+ if (!Array.isArray(entries)) {
2182
+ return [];
2183
+ }
2184
+ const out /*: Array<PluginScriptPhase> */ = [];
2185
+ for (const entry of entries) {
2186
+ if (
2187
+ entry == null ||
2188
+ typeof entry !== 'object' ||
2189
+ !isValidScriptPhaseId(entry.id) ||
2190
+ !isValidScriptPhaseName(entry.name) ||
2191
+ typeof entry.script !== 'string' ||
2192
+ entry.script.length === 0 ||
2193
+ // An unknown position is a malformed entry, not something to coerce: it
2194
+ // would silently run somewhere the plugin didn't ask for.
2195
+ (entry.position != null &&
2196
+ entry.position !== 'beforeCompile' &&
2197
+ entry.position !== 'end') ||
2198
+ // Dedupe by id — the id seeds the phase's UUID, so a duplicate would
2199
+ // insert two objects with identical UUIDs.
2200
+ out.some(phase => phase.id === entry.id)
2201
+ ) {
2202
+ continue;
2203
+ }
2204
+ const phase /*: PluginScriptPhase */ = {
2205
+ id: entry.id,
2206
+ name: entry.name,
2207
+ script: entry.script,
2208
+ position: entry.position ?? 'end',
2209
+ };
2210
+ const inputPaths = nonEmptyStrings(entry.inputPaths);
2211
+ const outputPaths = nonEmptyStrings(entry.outputPaths);
2212
+ if (inputPaths != null) {
2213
+ phase.inputPaths = inputPaths;
2214
+ }
2215
+ if (outputPaths != null) {
2216
+ phase.outputPaths = outputPaths;
2217
+ }
2218
+ if (typeof entry.alwaysOutOfDate === 'boolean') {
2219
+ phase.alwaysOutOfDate = entry.alwaysOutOfDate;
2220
+ }
2221
+ out.push(phase);
2222
+ }
2223
+ return out;
2224
+ }
2225
+
1840
2226
  /**
1841
2227
  * Read the `.spm-injected.json` marker of a previously-injected project, or
1842
2228
  * null when absent/unreadable. Used to reconcile generated sources on `update`
@@ -1844,7 +2230,7 @@ function readGeneratedSourcesManifest(
1844
2230
  */
1845
2231
  function readMarker(
1846
2232
  xcodeprojPath /*: string */,
1847
- ) /*: ?{generatedSources?: {[string]: Array<string>}, artifactsVersionOverride?: ?string, buildSettingChanges?: Array<BuildSettingChange>, ...} */ {
2233
+ ) /*: ?{generatedSources?: {[string]: Array<string>}, scriptPhases?: {[string]: string}, artifactsVersionOverride?: ?string, configCommand?: ?Array<string>, buildSettingChanges?: Array<BuildSettingChange>, createdArrayFields?: Array<CreatedArrayField>, scheme?: {file?: ?string, created?: ?boolean}, ...} */ {
1848
2234
  const markerPath = path.join(xcodeprojPath, SPM_INJECTED_MARKER);
1849
2235
  try {
1850
2236
  // $FlowFixMe[incompatible-return] JSON.parse returns any
@@ -1857,10 +2243,11 @@ function readMarker(
1857
2243
  // Returns the `*.xcodeproj` under `appRoot` carrying a `.spm-injected.json`
1858
2244
  // marker (the user-owned project SPM packages were injected into in place),
1859
2245
  // 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.
2246
+ // marker readers below, and for callers that only locate the project (setup-
2247
+ // apple-spm.js's action defaulting and `deinit`), to call without exercising
2248
+ // any pbxproj-editing machinery.
1862
2249
  function findInjectedXcodeproj(appRoot /*: string */) /*: string | null */ {
1863
- let entries: Array<{name: string, isDirectory(): boolean}>;
2250
+ let entries /*: Array<{name: string, isDirectory(): boolean}> */ = [];
1864
2251
  try {
1865
2252
  // $FlowFixMe[incompatible-type] Dirent typing
1866
2253
  entries = fs.readdirSync(appRoot, {withFileTypes: true});
@@ -1884,11 +2271,11 @@ function findInjectedXcodeproj(appRoot /*: string */) /*: string | null */ {
1884
2271
  * update --version` pinned into the injected xcodeproj's `.spm-injected.json`
1885
2272
  * marker (see the field's doc comment in injectSpmIntoExistingXcodeproj
1886
2273
  * 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.
2274
+ * the marker can't be read (never throws). Pure fs reads — setup-apple-spm.js's
2275
+ * determineVersion prefers the pinned version over the one derived from
2276
+ * node_modules/react-native/package.json, so a later flagless `add`/`update`
2277
+ * (and `download`) stays on the SAME artifact slot the explicit `--version`
2278
+ * selected.
1892
2279
  */
1893
2280
  function readArtifactsVersionOverride(appRoot /*: string */) /*: ?string */ {
1894
2281
  const xcodeprojPath = findInjectedXcodeproj(appRoot);
@@ -1899,13 +2286,72 @@ function readArtifactsVersionOverride(appRoot /*: string */) /*: ?string */ {
1899
2286
  return typeof override === 'string' && override.length > 0 ? override : null;
1900
2287
  }
1901
2288
 
2289
+ /**
2290
+ * Read the autolinking config command a previous `spm add`/`update` pinned into
2291
+ * the injected xcodeproj's `.spm-injected.json` marker, or null when nothing
2292
+ * usable is pinned. Pure fs reads, like readArtifactsVersionOverride above, but
2293
+ * this one IS wired: setup-apple-spm.js's resolveExplicitConfigCommand reads it
2294
+ * on add/update/scaffold and on the build-time `sync`. Re-validated through the
2295
+ * same parseConfigCommandJson the flag goes through, and never throws, so a
2296
+ * hand-edited marker degrades to the env-var/default command instead of
2297
+ * injecting a bogus argv into the build.
2298
+ */
2299
+ function readPinnedConfigCommand(appRoot /*: string */) /*: ?Array<string> */ {
2300
+ const xcodeprojPath = findInjectedXcodeproj(appRoot);
2301
+ if (xcodeprojPath == null) {
2302
+ return null;
2303
+ }
2304
+ const pinned = readMarker(xcodeprojPath)?.configCommand;
2305
+ if (pinned == null) {
2306
+ return null;
2307
+ }
2308
+ try {
2309
+ return parseConfigCommandJson(JSON.stringify(pinned), SPM_INJECTED_MARKER);
2310
+ } catch {
2311
+ return null;
2312
+ }
2313
+ }
2314
+
2315
+ /**
2316
+ * Union the array fields RN has created across syncs, deduped by container+key.
2317
+ *
2318
+ * THE CANONICAL STATEMENT of why anything RN created is recorded stickily (the
2319
+ * marker's `scheme.created` carries it forward for the same reason): a re-sync
2320
+ * re-injects from a baseline with only the BUILD SETTINGS reversed, so it finds
2321
+ * whatever the first run created already there and reports creating nothing. Once
2322
+ * RN has created something, that fact has to be carried forward, or the new
2323
+ * marker forgets it and `deinit` leaves an empty `packageReferences` /
2324
+ * `packageProductDependencies` behind and the generated scheme on disk.
2325
+ *
2326
+ * The record licenses removal; it does not order it. `deinit` still checks that
2327
+ * what it is about to remove is RN's (see its step 1 and isGeneratedScheme), so
2328
+ * carrying forward a field the user has since taken over — or deleted by hand —
2329
+ * is safe.
2330
+ */
2331
+ function mergeCreatedArrayFields(
2332
+ previous /*: ReadonlyArray<CreatedArrayField> */,
2333
+ current /*: ReadonlyArray<CreatedArrayField> */,
2334
+ ) /*: Array<CreatedArrayField> */ {
2335
+ const merged = [...previous];
2336
+ for (const field of current) {
2337
+ if (
2338
+ !merged.some(
2339
+ seen => seen.container === field.container && seen.key === field.key,
2340
+ )
2341
+ ) {
2342
+ merged.push(field);
2343
+ }
2344
+ }
2345
+ return merged;
2346
+ }
2347
+
1902
2348
  /**
1903
2349
  * Add SPM packages to a user's EXISTING xcodeproj in place. Returns
1904
2350
  * {status: 'injected', target} on success, or {status: 'refused', reason}
1905
2351
  * when the project can't be safely edited (caller surfaces it; fail-loud).
1906
2352
  */
1907
2353
  function injectSpmIntoExistingXcodeproj(
1908
- opts /*: {appRoot: string, reactNativeRoot: string, xcodeprojPath: string, appName?: ?string, artifactsVersionOverride?: ?string} */,
2354
+ opts /*: {appRoot: string, reactNativeRoot: string, xcodeprojPath: string, appName?: ?string, artifactsVersionOverride?: ?string, configCommand?: ?Array<string>} */,
1909
2355
  ) /*: {status: 'injected', target: string} | {status: 'refused', reason: string} */ {
1910
2356
  const {appRoot, reactNativeRoot, xcodeprojPath} = opts;
1911
2357
  const pbxprojPath = path.join(xcodeprojPath, 'project.pbxproj');
@@ -1924,6 +2370,7 @@ function injectSpmIntoExistingXcodeproj(
1924
2370
  const remote = remotePackageConfig(appRoot);
1925
2371
  const hermesCliPath = resolveHermesCliPathSetting(reactNativeRoot);
1926
2372
  const generatedSources = readGeneratedSourcesManifest(appRoot);
2373
+ const scriptPhases = readScriptPhasesManifest(appRoot);
1927
2374
  const flavoredFrameworks = readFlavoredFrameworksManifest(appRoot).frameworks;
1928
2375
 
1929
2376
  const prevMarker = readMarker(xcodeprojPath);
@@ -1953,6 +2400,15 @@ function injectSpmIntoExistingXcodeproj(
1953
2400
  namespacedUUID(plan.rootUuid, 'PBXGroup', SPM_GENERATED_SOURCES_GROUP_ID),
1954
2401
  );
1955
2402
  }
2403
+ // Same reconciliation for script phases, keyed on the plugin-supplied id.
2404
+ const prevScriptPhases /*: {[string]: string} */ =
2405
+ prevMarker?.scriptPhases ?? {};
2406
+ const currentPhaseIds = new Set(scriptPhases.map(p => p.id));
2407
+ for (const id of Object.keys(prevScriptPhases)) {
2408
+ if (!currentPhaseIds.has(id)) {
2409
+ staleUuids.push(prevScriptPhases[id]);
2410
+ }
2411
+ }
1956
2412
  // Re-apply generated settings from a clean recorded baseline. This removes
1957
2413
  // linker entries for plugin frameworks that disappeared and keeps the new
1958
2414
  // marker a complete inverse after an idempotent update.
@@ -1973,6 +2429,7 @@ function injectSpmIntoExistingXcodeproj(
1973
2429
  createdArrayFields,
1974
2430
  buildSettingChanges,
1975
2431
  generatedSourceUuids,
2432
+ scriptPhaseUuids,
1976
2433
  } = injectSpmIntoPbxproj(
1977
2434
  base,
1978
2435
  {
@@ -1987,6 +2444,7 @@ function injectSpmIntoExistingXcodeproj(
1987
2444
  hermesCliPath,
1988
2445
  generatedSources,
1989
2446
  flavoredFrameworks,
2447
+ scriptPhases,
1990
2448
  );
1991
2449
 
1992
2450
  const changed = writeIfChanged(pbxprojPath, text);
@@ -2014,14 +2472,24 @@ function injectSpmIntoExistingXcodeproj(
2014
2472
  // intentional pin, not something to silently re-derive from
2015
2473
  // node_modules/react-native/package.json. There is no "clear" verb yet;
2016
2474
  // `deinit` (removeSpmInjection) drops the whole marker, including this
2017
- // field. Read back by readArtifactsVersionOverride (above) so the
2018
- // build-time sync (sync-spm-autolinking.js) heals against the SAME slot
2019
- // `add`/`update` selected, even on a version-mismatched setup.
2475
+ // field. Read back by readArtifactsVersionOverride (above) so a later
2476
+ // flagless `add`/`update`/`download` resolves to the SAME slot, even on a
2477
+ // version-mismatched setup.
2020
2478
  const artifactsVersionOverride =
2021
2479
  opts.artifactsVersionOverride ??
2022
2480
  prevMarker?.artifactsVersionOverride ??
2023
2481
  null;
2024
2482
 
2483
+ // Same set-or-preserve contract as the version pin above, for the autolinking
2484
+ // config command `add`/`update` resolved (`--config-command` or
2485
+ // RCT_SPM_AUTOLINKING_CONFIG_COMMAND) — the build-time sync sees neither the
2486
+ // flag nor the developer's shell environment, so without the pin it
2487
+ // re-derives autolinking.json with the default @react-native-community/cli
2488
+ // command and breaks apps that replace it. Read back by
2489
+ // readPinnedConfigCommand (above). No "clear" verb yet either; `deinit` drops
2490
+ // the whole marker, this field with it.
2491
+ const configCommand = opts.configCommand ?? prevMarker?.configCommand ?? null;
2492
+
2025
2493
  // Marker: idempotency signal + the exact, reversible record of every edit so
2026
2494
  // `deinit` (removeSpmInjection) can undo precisely what was added.
2027
2495
  writeIfChanged(
@@ -2032,15 +2500,26 @@ function injectSpmIntoExistingXcodeproj(
2032
2500
  target: plan.target.name,
2033
2501
  targetUuid: plan.target.uuid,
2034
2502
  injectedUuids: Array.from(new Set(injectedUuids)).sort(),
2035
- createdArrayFields,
2503
+ createdArrayFields: mergeCreatedArrayFields(
2504
+ prevMarker?.createdArrayFields ?? [],
2505
+ createdArrayFields,
2506
+ ),
2036
2507
  buildSettingChanges,
2037
2508
  // Normalized path → [fileRefUuid, buildFileUuid]. Read back on the next
2038
2509
  // `update` to reconcile away entries that left the manifest.
2039
2510
  generatedSources: generatedSourceUuids,
2511
+ // Plugin phase id → its PBXShellScriptBuildPhase UUID, reconciled the
2512
+ // same way.
2513
+ scriptPhases: scriptPhaseUuids,
2040
2514
  artifactsVersionOverride,
2515
+ configCommand,
2041
2516
  scheme: {
2042
2517
  file: schemeResult.file,
2043
- created: schemeResult.status === 'created',
2518
+ // Sticky — see mergeCreatedArrayFields for why a later sync cannot
2519
+ // observe this for itself.
2520
+ created:
2521
+ schemeResult.status === 'created' ||
2522
+ prevMarker?.scheme?.created === true,
2044
2523
  },
2045
2524
  },
2046
2525
  null,
@@ -2052,6 +2531,52 @@ function injectSpmIntoExistingXcodeproj(
2052
2531
  return {status: 'injected', target: plan.target.name};
2053
2532
  }
2054
2533
 
2534
+ /** The sync pre-action's script, unescaped, or null when the scheme has none. */
2535
+ function schemePreActionScript(xml /*: string */) /*: ?string */ {
2536
+ const titleIdx = xml.indexOf('title = "Sync SPM Autolinking"');
2537
+ if (titleIdx < 0) {
2538
+ return null;
2539
+ }
2540
+ const marker = 'scriptText = "';
2541
+ const start = xml.indexOf(marker, titleIdx);
2542
+ if (start < 0) {
2543
+ return null;
2544
+ }
2545
+ const valueStart = start + marker.length;
2546
+ // escapeXmlAttribute maps a literal `"` to `&quot;`, so the next `"` is always
2547
+ // the closing delimiter.
2548
+ const valueEnd = xml.indexOf('"', valueStart);
2549
+ return valueEnd < 0
2550
+ ? null
2551
+ : unescapeXmlAttribute(xml.slice(valueStart, valueEnd));
2552
+ }
2553
+
2554
+ /**
2555
+ * Whether `xml` is still, byte for byte, the scheme RN generates for this target
2556
+ * — everything but the pre-action's script, which RN rewrites in place on every
2557
+ * sync and which varies with the app's react-native path, so it cannot be part of
2558
+ * an ownership test.
2559
+ *
2560
+ * `deinit` deletes a scheme the marker says RN created only while this holds. A
2561
+ * user may replace a generated scheme with one of their own under the same name
2562
+ * (same target, so `injectOrCreateScheme` finds and updates it, and the created
2563
+ * record stays), and destroying that is unrecoverable where leaving a scheme
2564
+ * behind is not — so the harmless way of being wrong wins: a generated scheme the
2565
+ * user has since edited leaks, minus its pre-action.
2566
+ */
2567
+ function isGeneratedScheme(
2568
+ xml /*: string */,
2569
+ appName /*: string */,
2570
+ targetUuid /*: string */,
2571
+ projName /*: string */,
2572
+ ) /*: boolean */ {
2573
+ const script = schemePreActionScript(xml);
2574
+ return (
2575
+ script != null &&
2576
+ xml === generateXcscheme(appName, targetUuid, projName, script)
2577
+ );
2578
+ }
2579
+
2055
2580
  /**
2056
2581
  * Remove the "Sync SPM Autolinking" pre-action that addPreActionToScheme added
2057
2582
  * to a scheme, and drop the `<PreActions>` wrapper if it is left empty (the
@@ -2097,6 +2622,25 @@ function removeRecordedBuildSettings(
2097
2622
  );
2098
2623
  }
2099
2624
  }
2625
+ const promotedArrayScalars /*: {[string]: string} */ =
2626
+ change.promotedArrayScalars ?? {};
2627
+ for (const key of Object.keys(promotedArrayScalars)) {
2628
+ const current = dict();
2629
+ const originalValue = promotedArrayScalars[key];
2630
+ // A field that is gone was deleted by the user after injection; restoring
2631
+ // it would resurrect it, at the top of the dict, matching neither state.
2632
+ if (
2633
+ current != null &&
2634
+ typeof originalValue === 'string' &&
2635
+ findField(text, current, key) != null
2636
+ ) {
2637
+ // Rewriting the whole value is what makes the promotion reversible at
2638
+ // all — its members and its `"$(inherited)"` seed are indistinguishable
2639
+ // from the user's own once folded together. The tradeoff: members the
2640
+ // user hand-added to the promoted array afterwards are discarded.
2641
+ text = setScalarField(text, current, key, originalValue);
2642
+ }
2643
+ }
2100
2644
  for (const key of change.createdArrayKeys ?? []) {
2101
2645
  const current = dict();
2102
2646
  if (current != null) {
@@ -2158,7 +2702,15 @@ function removeSpmInjection(
2158
2702
  f.container === 'project'
2159
2703
  ? findProjectObject(text)
2160
2704
  : findObjectByUuid(text, marker.targetUuid);
2161
- if (obj != null) {
2705
+ if (obj == null) {
2706
+ continue;
2707
+ }
2708
+ // The record says the field did not exist before RN created it, which is
2709
+ // necessary but not sufficient: anything still in it after our own members
2710
+ // are gone is the user's (their own SPM package, added to the same field),
2711
+ // and dropping the field would orphan it.
2712
+ const field = findField(text, obj, f.key);
2713
+ if (field != null && /^\(\s*\)$/.test(field.value)) {
2162
2714
  text = removeField(text, obj, f.key);
2163
2715
  }
2164
2716
  }
@@ -2178,7 +2730,8 @@ function removeSpmInjection(
2178
2730
  writeIfChanged(pbxprojPath, text);
2179
2731
  log(`Removed SPM injection from ${path.relative(appRoot, pbxprojPath)}`);
2180
2732
 
2181
- // 3. Scheme: delete it if injection created it, else strip the pre-action.
2733
+ // 3. Scheme: delete it if injection created it AND still owns its contents,
2734
+ // else strip the pre-action and leave the file (see isGeneratedScheme).
2182
2735
  const scheme = marker.scheme;
2183
2736
  if (scheme != null && scheme.file != null) {
2184
2737
  const schemePath = path.join(
@@ -2187,11 +2740,21 @@ function removeSpmInjection(
2187
2740
  'xcschemes',
2188
2741
  scheme.file,
2189
2742
  );
2190
- if (scheme.created === true) {
2191
- fs.rmSync(schemePath, {force: true});
2192
- } else if (fs.existsSync(schemePath)) {
2743
+ if (fs.existsSync(schemePath)) {
2193
2744
  const xml = fs.readFileSync(schemePath, 'utf8');
2194
- writeIfChanged(schemePath, removePreActionFromScheme(xml));
2745
+ const ours =
2746
+ scheme.created === true &&
2747
+ isGeneratedScheme(
2748
+ xml,
2749
+ marker.target,
2750
+ marker.targetUuid,
2751
+ path.basename(xcodeprojPath, '.xcodeproj'),
2752
+ );
2753
+ if (ours) {
2754
+ fs.rmSync(schemePath, {force: true});
2755
+ } else {
2756
+ writeIfChanged(schemePath, removePreActionFromScheme(xml));
2757
+ }
2195
2758
  }
2196
2759
  }
2197
2760
 
@@ -2210,6 +2773,7 @@ module.exports = {
2210
2773
  ensureStubPackages,
2211
2774
  buildSpmDependencyGraph,
2212
2775
  spmGraphToEntries,
2776
+ buildPhaseOrder,
2213
2777
  planInjection,
2214
2778
  injectSpmIntoPbxproj,
2215
2779
  injectSpmIntoExistingXcodeproj,
@@ -2220,5 +2784,7 @@ module.exports = {
2220
2784
  removePreActionFromScheme,
2221
2785
  findInjectedXcodeproj,
2222
2786
  readArtifactsVersionOverride,
2787
+ readPinnedConfigCommand,
2788
+ readScriptPhasesManifest,
2223
2789
  SPM_INJECTED_MARKER,
2224
2790
  };