@depup/react-native 0.87.0-depup.0 → 0.87.1-depup.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 (37) 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/Core/ReactNativeVersion.js +1 -1
  6. package/README.md +3 -3
  7. package/React/Base/RCTVersion.m +1 -1
  8. package/ReactAndroid/gradle.properties +1 -1
  9. package/ReactAndroid/src/main/java/com/facebook/react/modules/intent/IntentModule.kt +7 -3
  10. package/ReactAndroid/src/main/java/com/facebook/react/modules/systeminfo/ReactNativeVersion.kt +1 -1
  11. package/ReactCommon/cxxreact/ReactNativeVersion.h +2 -2
  12. package/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTFontUtils.mm +1 -1
  13. package/changes.json +2 -2
  14. package/package.json +12 -12
  15. package/scripts/cocoapods/rndependencies.rb +31 -25
  16. package/scripts/replace-rncore-version.js +18 -2
  17. package/scripts/setup-apple-spm.js +28 -12
  18. package/scripts/spm/autolinking-plugins.js +80 -0
  19. package/scripts/spm/download-spm-artifacts.js +64 -6
  20. package/scripts/spm/expand-spm-dependencies.js +249 -30
  21. package/scripts/spm/generate-spm-autolinking.js +157 -40
  22. package/scripts/spm/generate-spm-package.js +30 -18
  23. package/scripts/spm/generate-spm-xcodeproj.js +595 -71
  24. package/scripts/spm/scaffold-package-swift.js +65 -24
  25. package/scripts/spm/spm-pbxproj.js +158 -28
  26. package/scripts/spm/spm-types.js +29 -3
  27. package/scripts/spm/spm-utils.js +117 -2
  28. package/sdks/.hermesv1version +1 -1
  29. package/sdks/hermes-engine/version.properties +1 -1
  30. package/types_generated/Libraries/Animated/AnimatedImplementation.d.ts +2 -2
  31. package/types_generated/Libraries/Animated/nodes/AnimatedNode.d.ts +4 -1
  32. package/types_generated/Libraries/Animated/nodes/AnimatedValue.d.ts +3 -3
  33. package/scripts/spm/__doc__/rfc-spm-xcframework.md +0 -707
  34. package/scripts/spm/__doc__/spm-autolinking-plugins.md +0 -244
  35. package/scripts/spm/__doc__/spm-header-paths-contract.md +0 -97
  36. package/scripts/spm/__doc__/spm-plugins-assessment.md +0 -128
  37. package/scripts/spm/__doc__/spm-scripts.md +0 -486
@@ -26,6 +26,7 @@ const {parseConfigCommandJson} = require('./generate-spm-autolinking-config');
26
26
  const {
27
27
  addArrayMembers,
28
28
  addArrayStringValues,
29
+ commentSafe,
29
30
  ensureScalarField,
30
31
  findApplicationTargets,
31
32
  findField,
@@ -42,13 +43,25 @@ const {
42
43
  removeObjectByUuid,
43
44
  serializeEntry,
44
45
  setScalarField,
46
+ uuidComment,
45
47
  } = require('./spm-pbxproj');
46
- 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');
47
59
  const fs = require('fs');
48
60
  const path = require('path');
49
61
 
50
62
  /*:: import type {
51
63
  FlavoredFrameworkManifestEntry,
64
+ PluginScriptPhase,
52
65
  XcframeworkSlice,
53
66
  } from './spm-types'; */
54
67
 
@@ -73,6 +86,17 @@ const SPM_GENERATED_SOURCES_MANIFEST = path.join(
73
86
  '.spm-plugin-generated-sources.json',
74
87
  );
75
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
+
76
100
  // The single navigator group all injected generated sources are parented under
77
101
  // (created on first use). Its namespacedUUID id + display name.
78
102
  const SPM_GENERATED_SOURCES_GROUP_ID = 'SPMGeneratedSources';
@@ -92,36 +116,21 @@ const GENERATED_SOURCE_FILE_TYPES /*: {[string]: string} */ = {
92
116
  // resolve the product dependencies — SPM doesn't expose transitive products.
93
117
  const SPM_PRODUCT_PACKAGES /*: Array<{product: string, packagePath: string, packageName: string}> */ =
94
118
  [
95
- {
96
- product: 'ReactHeaders',
97
- packagePath: 'build/xcframeworks',
98
- packageName: 'ReactNative',
99
- },
100
- {
101
- product: 'ReactNativeHeaders',
102
- packagePath: 'build/xcframeworks',
103
- packageName: 'ReactNative',
104
- },
105
- {
106
- product: 'ReactNativeDependenciesHeaders',
119
+ ...REACT_NATIVE_PRODUCTS.map(product => ({
120
+ product,
107
121
  packagePath: 'build/xcframeworks',
108
- packageName: 'ReactNative',
109
- },
122
+ packageName: REACT_NATIVE_PACKAGE_NAME,
123
+ })),
110
124
  {
111
- product: 'Autolinked',
125
+ product: AUTOLINKED_PACKAGE_NAME,
112
126
  packagePath: 'build/generated/autolinking',
113
- packageName: 'Autolinked',
127
+ packageName: AUTOLINKED_PACKAGE_NAME,
114
128
  },
115
- {
116
- product: 'ReactCodegen',
117
- packagePath: 'build/generated/ios',
118
- packageName: 'React-GeneratedCode',
119
- },
120
- {
121
- product: 'ReactAppDependencyProvider',
129
+ ...REACT_CODEGEN_APP_PRODUCTS.map(product => ({
130
+ product,
122
131
  packagePath: 'build/generated/ios',
123
- packageName: 'React-GeneratedCode',
124
- },
132
+ packageName: REACT_CODEGEN_PACKAGE_NAME,
133
+ })),
125
134
  ];
126
135
 
127
136
  /*::
@@ -137,7 +146,15 @@ type BuildSettingChange = {
137
146
  // value), e.g. a ${PODS_ROOT}-anchored REACT_NATIVE_PATH that dangles once
138
147
  // CocoaPods is deintegrated. Deinit restores the original.
139
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},
140
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};
141
158
  // A plugin-contributed source, normalized for pbxproj emission. `path` is
142
159
  // SRCROOT-relative when under the app root, else absolute; `sourceTree` is the
143
160
  // matching pbxproj token ('SOURCE_ROOT' or '"<absolute>"').
@@ -265,30 +282,42 @@ function spmGraphToEntries(
265
282
  return {localRefs, remoteRef, productDeps, buildFiles};
266
283
  }
267
284
 
268
- // Sync SPM Autolinking: timestamp check + conditional node re-run. Shared by
269
- // the build phase (safety net) and the scheme pre-action (the one that
270
- // actually fires before SPM resolution, so a single build picks up
271
- // dep-graph changes from `npm install`).
272
- // 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
+ */
273
293
  function shellScriptPhase(
274
294
  phaseUUID /*: string */,
275
295
  name /*: string */,
276
296
  script /*: string */,
277
- options /*: {inputPaths?: string, outputPaths?: string} */ = {},
297
+ options /*: {inputPaths?: ?(string | ReadonlyArray<string>), outputPaths?: ?(string | ReadonlyArray<string>), alwaysOutOfDate?: ?boolean, comment?: string} */ = {},
278
298
  ) /*: {uuid: string, comment: string, fields: {[string]: string}} */ {
279
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
+ };
280
308
  return {
281
309
  uuid: phaseUUID,
282
- comment: name,
310
+ comment: options.comment ?? name,
283
311
  fields: {
284
312
  isa: 'PBXShellScriptBuildPhase',
313
+ ...(options.alwaysOutOfDate === true ? {alwaysOutOfDate: '1'} : {}),
285
314
  buildActionMask: '2147483647',
286
315
  files: empty,
287
316
  inputFileListPaths: empty,
288
- inputPaths: options.inputPaths ?? empty,
317
+ inputPaths: pathList(options.inputPaths),
289
318
  name: quoteIfNeeded(name),
290
319
  outputFileListPaths: empty,
291
- outputPaths: options.outputPaths ?? empty,
320
+ outputPaths: pathList(options.outputPaths),
292
321
  runOnlyForDeploymentPostprocessing: '0',
293
322
  shellPath: '/bin/sh',
294
323
  shellScript: quoteIfNeeded(script),
@@ -468,11 +497,126 @@ ${copies}
468
497
  `;
469
498
  }
470
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
+
471
615
  function addBuildPhaseAfter(
472
616
  text /*: string */,
473
617
  target /*: {bodyOpen: number, bodyClose: number, ...} */,
474
618
  afterUuid /*: string */,
475
- member /*: {uuid: string, comment: string} */,
619
+ member /*: {uuid: string, comment: string, ...} */,
476
620
  ) /*: string */ {
477
621
  const field = findField(text, target, 'buildPhases');
478
622
  if (field == null || field.value.includes(member.uuid)) {
@@ -487,7 +631,7 @@ function addBuildPhaseAfter(
487
631
  const absoluteStart = field.valueStart + after.index;
488
632
  const lineEnd = text.indexOf('\n', absoluteStart + after[0].length);
489
633
  const indent = after[2];
490
- const line = `\n${indent}${member.uuid} /* ${member.comment} */,`;
634
+ const line = `\n${indent}${member.uuid}${uuidComment(member.comment)},`;
491
635
  return text.slice(0, lineEnd) + line + text.slice(lineEnd);
492
636
  }
493
637
 
@@ -724,6 +868,18 @@ function escapeXmlAttribute(s /*: string */) /*: string */ {
724
868
  .replace(/'/g, '&apos;');
725
869
  }
726
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
+
727
883
  function generateXcscheme(
728
884
  appName /*: string */,
729
885
  targetUUID /*: string */,
@@ -932,6 +1088,25 @@ const INJECTED_ARRAY_SETTINGS = [
932
1088
  },
933
1089
  ];
934
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
+
935
1110
  /** The XCBuildConfiguration UUIDs of a target (via its buildConfigurationList). */
936
1111
  function targetBuildConfigUuids(
937
1112
  text /*: string */,
@@ -1078,7 +1253,8 @@ function injectSpmIntoPbxproj(
1078
1253
  hermesCliPath /*: ?string */ = null,
1079
1254
  generatedSources /*: ReadonlyArray<GeneratedSource> */ = [],
1080
1255
  flavoredFrameworks /*: ReadonlyArray<FlavoredFrameworkManifestEntry> */ = [],
1081
- ) /*: {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}} */ {
1082
1258
  let text = input;
1083
1259
  const mkUuid = (section /*: string */, id /*: string */) =>
1084
1260
  namespacedUUID(plan.rootUuid, section, id);
@@ -1111,10 +1287,7 @@ function injectSpmIntoPbxproj(
1111
1287
  insertObjects('XCSwiftPackageProductDependency', entries.productDeps);
1112
1288
  insertObjects('PBXBuildFile', entries.buildFiles);
1113
1289
 
1114
- // Track array fields we CREATE (vs. append to a pre-existing one) so deinit
1115
- // can remove the whole field and land byte-identical to the original.
1116
- const createdArrayFields /*: Array<{container: 'project' | 'target', key: string}> */ =
1117
- [];
1290
+ const createdArrayFields /*: Array<CreatedArrayField> */ = [];
1118
1291
 
1119
1292
  // 2. packageReferences on the PBXProject.
1120
1293
  const pkgRefMembers = [
@@ -1305,9 +1478,14 @@ function injectSpmIntoPbxproj(
1305
1478
  const fileRefUuid = mkUuid('PBXFileReference', `gensrc:${src.path}`);
1306
1479
  const buildFileUuid = mkUuid('PBXBuildFile', `gensrc:${src.path}`);
1307
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`;
1308
1486
  fileRefs.push({
1309
1487
  uuid: fileRefUuid,
1310
- comment: src.name,
1488
+ comment: label,
1311
1489
  fields: {
1312
1490
  isa: 'PBXFileReference',
1313
1491
  lastKnownFileType: src.fileType,
@@ -1318,17 +1496,14 @@ function injectSpmIntoPbxproj(
1318
1496
  });
1319
1497
  buildFiles.push({
1320
1498
  uuid: buildFileUuid,
1321
- comment: `${src.name} in Sources`,
1499
+ comment: inSources,
1322
1500
  fields: {
1323
1501
  isa: 'PBXBuildFile',
1324
- fileRef: `${fileRefUuid} /* ${src.name} */`,
1502
+ fileRef: `${fileRefUuid}${uuidComment(label)}`,
1325
1503
  },
1326
1504
  });
1327
- sourcesMembers.push({
1328
- uuid: buildFileUuid,
1329
- comment: `${src.name} in Sources`,
1330
- });
1331
- groupChildren.push({uuid: fileRefUuid, comment: src.name});
1505
+ sourcesMembers.push({uuid: buildFileUuid, comment: inSources});
1506
+ groupChildren.push({uuid: fileRefUuid, comment: label});
1332
1507
  }
1333
1508
  insertObjects('PBXFileReference', fileRefs);
1334
1509
  insertObjects('PBXBuildFile', buildFiles);
@@ -1388,12 +1563,87 @@ function injectSpmIntoPbxproj(
1388
1563
  }
1389
1564
  }
1390
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
+
1391
1640
  return {
1392
1641
  text,
1393
1642
  injectedUuids,
1394
1643
  createdArrayFields,
1395
1644
  buildSettingChanges,
1396
1645
  generatedSourceUuids,
1646
+ scriptPhaseUuids,
1397
1647
  };
1398
1648
  }
1399
1649
 
@@ -1451,6 +1701,28 @@ function resolveHermesCliPathSetting(
1451
1701
  }
1452
1702
  }
1453
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
+
1454
1726
  function mergeReactBuildSettings(
1455
1727
  input /*: string */,
1456
1728
  configUuid /*: string */,
@@ -1493,9 +1765,13 @@ function mergeReactBuildSettings(
1493
1765
  };
1494
1766
  const createdArrayKeys /*: Array<string> */ = [];
1495
1767
  const appendedArrayValues /*: {[string]: Array<string>} */ = {};
1768
+ const promotedArrayScalars /*: {[string]: string} */ = {};
1496
1769
  const createdScalars /*: Array<string> */ = [];
1497
1770
  const arraySettings = [
1498
1771
  ...INJECTED_ARRAY_SETTINGS,
1772
+ ...(flavorForBuildConfiguration(configurationName) === 'debug'
1773
+ ? DEBUG_ARRAY_SETTINGS
1774
+ : []),
1499
1775
  ...frameworkArrayBuildSettings(flavoredFrameworks),
1500
1776
  ];
1501
1777
  for (const {key, values} of arraySettings) {
@@ -1504,15 +1780,41 @@ function mergeReactBuildSettings(
1504
1780
  continue;
1505
1781
  }
1506
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;
1507
1791
  if (existing == null) {
1508
1792
  createdArrayKeys.push(key);
1509
1793
  } else {
1510
- const fresh = values.filter(v => !existing.value.includes(v));
1511
- 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) {
1512
1805
  appendedArrayValues[key] = fresh;
1513
1806
  }
1514
1807
  }
1808
+ const beforeAdd = text;
1515
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
+ }
1516
1818
  }
1517
1819
  const replacedScalars /*: {[string]: string} */ = {};
1518
1820
  for (const {key, value} of scalars) {
@@ -1571,6 +1873,9 @@ function mergeReactBuildSettings(
1571
1873
  appendedArrayValues,
1572
1874
  createdScalars,
1573
1875
  replacedScalars,
1876
+ ...(Object.keys(promotedArrayScalars).length > 0
1877
+ ? {promotedArrayScalars}
1878
+ : {}),
1574
1879
  },
1575
1880
  };
1576
1881
  }
@@ -1838,6 +2143,86 @@ function readGeneratedSourcesManifest(
1838
2143
  return out;
1839
2144
  }
1840
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
+
1841
2226
  /**
1842
2227
  * Read the `.spm-injected.json` marker of a previously-injected project, or
1843
2228
  * null when absent/unreadable. Used to reconcile generated sources on `update`
@@ -1845,7 +2230,7 @@ function readGeneratedSourcesManifest(
1845
2230
  */
1846
2231
  function readMarker(
1847
2232
  xcodeprojPath /*: string */,
1848
- ) /*: ?{generatedSources?: {[string]: Array<string>}, artifactsVersionOverride?: ?string, configCommand?: ?Array<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}, ...} */ {
1849
2234
  const markerPath = path.join(xcodeprojPath, SPM_INJECTED_MARKER);
1850
2235
  try {
1851
2236
  // $FlowFixMe[incompatible-return] JSON.parse returns any
@@ -1886,13 +2271,11 @@ function findInjectedXcodeproj(appRoot /*: string */) /*: string | null */ {
1886
2271
  * update --version` pinned into the injected xcodeproj's `.spm-injected.json`
1887
2272
  * marker (see the field's doc comment in injectSpmIntoExistingXcodeproj
1888
2273
  * below), or null when no project is injected yet, no override is pinned, or
1889
- * the marker can't be read (never throws). Pure fs reads.
1890
- *
1891
- * Nothing in production calls this yet: the pin is written but never read
1892
- * back, so the build-time sync (sync-spm-autolinking.js) still derives the
1893
- * version from node_modules/react-native/package.json and can heal against a
1894
- * different artifact slot than the explicit `--version` selected. Only the
1895
- * tests cover it.
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.
1896
2279
  */
1897
2280
  function readArtifactsVersionOverride(appRoot /*: string */) /*: ?string */ {
1898
2281
  const xcodeprojPath = findInjectedXcodeproj(appRoot);
@@ -1929,6 +2312,39 @@ function readPinnedConfigCommand(appRoot /*: string */) /*: ?Array<string> */ {
1929
2312
  }
1930
2313
  }
1931
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
+
1932
2348
  /**
1933
2349
  * Add SPM packages to a user's EXISTING xcodeproj in place. Returns
1934
2350
  * {status: 'injected', target} on success, or {status: 'refused', reason}
@@ -1954,6 +2370,7 @@ function injectSpmIntoExistingXcodeproj(
1954
2370
  const remote = remotePackageConfig(appRoot);
1955
2371
  const hermesCliPath = resolveHermesCliPathSetting(reactNativeRoot);
1956
2372
  const generatedSources = readGeneratedSourcesManifest(appRoot);
2373
+ const scriptPhases = readScriptPhasesManifest(appRoot);
1957
2374
  const flavoredFrameworks = readFlavoredFrameworksManifest(appRoot).frameworks;
1958
2375
 
1959
2376
  const prevMarker = readMarker(xcodeprojPath);
@@ -1983,6 +2400,15 @@ function injectSpmIntoExistingXcodeproj(
1983
2400
  namespacedUUID(plan.rootUuid, 'PBXGroup', SPM_GENERATED_SOURCES_GROUP_ID),
1984
2401
  );
1985
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
+ }
1986
2412
  // Re-apply generated settings from a clean recorded baseline. This removes
1987
2413
  // linker entries for plugin frameworks that disappeared and keeps the new
1988
2414
  // marker a complete inverse after an idempotent update.
@@ -2003,6 +2429,7 @@ function injectSpmIntoExistingXcodeproj(
2003
2429
  createdArrayFields,
2004
2430
  buildSettingChanges,
2005
2431
  generatedSourceUuids,
2432
+ scriptPhaseUuids,
2006
2433
  } = injectSpmIntoPbxproj(
2007
2434
  base,
2008
2435
  {
@@ -2017,6 +2444,7 @@ function injectSpmIntoExistingXcodeproj(
2017
2444
  hermesCliPath,
2018
2445
  generatedSources,
2019
2446
  flavoredFrameworks,
2447
+ scriptPhases,
2020
2448
  );
2021
2449
 
2022
2450
  const changed = writeIfChanged(pbxprojPath, text);
@@ -2044,9 +2472,9 @@ function injectSpmIntoExistingXcodeproj(
2044
2472
  // intentional pin, not something to silently re-derive from
2045
2473
  // node_modules/react-native/package.json. There is no "clear" verb yet;
2046
2474
  // `deinit` (removeSpmInjection) drops the whole marker, including this
2047
- // field. Read back by readArtifactsVersionOverride (above) so the
2048
- // build-time sync (sync-spm-autolinking.js) heals against the SAME slot
2049
- // `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.
2050
2478
  const artifactsVersionOverride =
2051
2479
  opts.artifactsVersionOverride ??
2052
2480
  prevMarker?.artifactsVersionOverride ??
@@ -2072,16 +2500,26 @@ function injectSpmIntoExistingXcodeproj(
2072
2500
  target: plan.target.name,
2073
2501
  targetUuid: plan.target.uuid,
2074
2502
  injectedUuids: Array.from(new Set(injectedUuids)).sort(),
2075
- createdArrayFields,
2503
+ createdArrayFields: mergeCreatedArrayFields(
2504
+ prevMarker?.createdArrayFields ?? [],
2505
+ createdArrayFields,
2506
+ ),
2076
2507
  buildSettingChanges,
2077
2508
  // Normalized path → [fileRefUuid, buildFileUuid]. Read back on the next
2078
2509
  // `update` to reconcile away entries that left the manifest.
2079
2510
  generatedSources: generatedSourceUuids,
2511
+ // Plugin phase id → its PBXShellScriptBuildPhase UUID, reconciled the
2512
+ // same way.
2513
+ scriptPhases: scriptPhaseUuids,
2080
2514
  artifactsVersionOverride,
2081
2515
  configCommand,
2082
2516
  scheme: {
2083
2517
  file: schemeResult.file,
2084
- 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,
2085
2523
  },
2086
2524
  },
2087
2525
  null,
@@ -2093,6 +2531,52 @@ function injectSpmIntoExistingXcodeproj(
2093
2531
  return {status: 'injected', target: plan.target.name};
2094
2532
  }
2095
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
+
2096
2580
  /**
2097
2581
  * Remove the "Sync SPM Autolinking" pre-action that addPreActionToScheme added
2098
2582
  * to a scheme, and drop the `<PreActions>` wrapper if it is left empty (the
@@ -2138,6 +2622,25 @@ function removeRecordedBuildSettings(
2138
2622
  );
2139
2623
  }
2140
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
+ }
2141
2644
  for (const key of change.createdArrayKeys ?? []) {
2142
2645
  const current = dict();
2143
2646
  if (current != null) {
@@ -2199,7 +2702,15 @@ function removeSpmInjection(
2199
2702
  f.container === 'project'
2200
2703
  ? findProjectObject(text)
2201
2704
  : findObjectByUuid(text, marker.targetUuid);
2202
- 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)) {
2203
2714
  text = removeField(text, obj, f.key);
2204
2715
  }
2205
2716
  }
@@ -2219,7 +2730,8 @@ function removeSpmInjection(
2219
2730
  writeIfChanged(pbxprojPath, text);
2220
2731
  log(`Removed SPM injection from ${path.relative(appRoot, pbxprojPath)}`);
2221
2732
 
2222
- // 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).
2223
2735
  const scheme = marker.scheme;
2224
2736
  if (scheme != null && scheme.file != null) {
2225
2737
  const schemePath = path.join(
@@ -2228,11 +2740,21 @@ function removeSpmInjection(
2228
2740
  'xcschemes',
2229
2741
  scheme.file,
2230
2742
  );
2231
- if (scheme.created === true) {
2232
- fs.rmSync(schemePath, {force: true});
2233
- } else if (fs.existsSync(schemePath)) {
2743
+ if (fs.existsSync(schemePath)) {
2234
2744
  const xml = fs.readFileSync(schemePath, 'utf8');
2235
- 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
+ }
2236
2758
  }
2237
2759
  }
2238
2760
 
@@ -2251,6 +2773,7 @@ module.exports = {
2251
2773
  ensureStubPackages,
2252
2774
  buildSpmDependencyGraph,
2253
2775
  spmGraphToEntries,
2776
+ buildPhaseOrder,
2254
2777
  planInjection,
2255
2778
  injectSpmIntoPbxproj,
2256
2779
  injectSpmIntoExistingXcodeproj,
@@ -2262,5 +2785,6 @@ module.exports = {
2262
2785
  findInjectedXcodeproj,
2263
2786
  readArtifactsVersionOverride,
2264
2787
  readPinnedConfigCommand,
2788
+ readScriptPhasesManifest,
2265
2789
  SPM_INJECTED_MARKER,
2266
2790
  };