Skip to content

Commit 8ac3e2f

Browse files
chrfalchmeta-codesync[bot]
authored andcommitted
fix(swiftpm): fix two ways array build settings were mishandled (#57744)
Summary: Two bugs in `addArrayStringValues`, which adds members to an array build setting (`HEADER_SEARCH_PATHS`, `OTHER_LDFLAGS`, `FRAMEWORK_SEARCH_PATHS`, `LD_RUNPATH_SEARCH_PATHS`). Both hit real projects; neither was visible from the existing fixture. **1. A promoted scalar was never restored.** A setting that already exists as a scalar gets promoted to a `( … )` array, but that was recorded as a plain member-append — so `deinit` stripped the members and left the array shell plus its injected `"$(inherited)"` behind. Stock Xcode projects hit this: the app template sets `LD_RUNPATH_SEARCH_PATHS = "$(inherited) executable_path/Frameworks";` as a target-level scalar. Fixed by pinning the pre-injection value in `.spm-injected.json` and restoring it in place. It is stored raw (a bare scalar's token runs to the `;`, carrying whitespace that must come back), recorded only if the merge actually changed the field, and not restored if the field is gone. **2. A one-line array was corrupted.** The append anchored on `lastIndexOf('\n', tokenEnd - 1)`, which assumes multi-line. With no newline in the value that lands on the *previous* line, so members were spliced above the field, outside the array: ``` { "/new", ← bare entry in the dict body: invalid pbxproj HEADER_SEARCH_PATHS = ("/vendor", ); ← member never added } ``` The result is a project Xcode cannot open, and `deinit` could not remove the stray line. Xcode writes multi-line arrays, but hand-edited projects and other generators (XcodeGen, Tuist) emit compact ones. Fixed by splicing inline ahead of the `)`; removal gained matching delimiter-anchored patterns, so the span removed is the span inserted. The dedupe parse was also quote-blind — a member holding a quoted comma parsed as two tokens — and is now quote-aware. **Tradeoff:** reversing a promotion rewrites the whole field, so members hand-added to a promoted array afterwards are lost. **Rebase note:** `main` has since grown an overlapping guard (`buildSettingValueTokens`) that skips the append when every value is already present, avoiding the *no-op* promotion. It is kept and complements this change: main still promotes irreversibly when there *is* a fresh value to add to a scalar, which is what the restore here covers. The two records stay mutually exclusive per key, pinned by a test. ## Changelog: [Internal] [Fixed] - SwiftPM: `spm deinit` restores a promoted scalar build setting, and `spm add` no longer corrupts a one-line array Pull Request resolved: #57744 Test Plan: `yarn jest packages/react-native/scripts` → **963 tests, 32 suites** green; eslint, prettier and flow clean. Written red first: byte-identical `add` → `deinit` round-trips for each pre-existing shape (absent, multi-line, bare and quoted scalars, and the one-line forms), plus `add` → `update` → `deinit`. The multi-line path is unchanged byte-for-byte, verified by a differential harness over 48 add/remove cases against the previous implementation. Re-verified after the rebase on the committed `HelloWorld.xcodeproj`, driving the real `injectSpmIntoExistingXcodeproj` / `removeSpmInjection`. On `main` a one-line `HEADER_SEARCH_PATHS` gains a bare `"…/autolinking/headers",` entry above the field and never receives the member; with this change it lands inside the array, and a pre-existing scalar comes back exactly. Reviewed By: fabriziocucci Differential Revision: D114317839 Pulled By: cipolleschi fbshipit-source-id: 08bc81f80855721bf2123143063462109a99ac25
1 parent 385fa84 commit 8ac3e2f

6 files changed

Lines changed: 579 additions & 29 deletions

File tree

packages/react-native/scripts/spm/__doc__/spm-scripts.md

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -222,7 +222,7 @@ left alone.
222222
| Path | Commit? | Why |
223223
|------|---------|-----|
224224
| `MyApp.xcodeproj/` | Yes | Your project, with SwiftPM injected in place. Holds your signing, capabilities, Build Phases — `add` only adds SwiftPM refs/settings, additively. |
225-
| `MyApp.xcodeproj/.spm-injected.json` | Yes | Marker recording every edit `add` made, so `deinit` can surgically reverse it and re-runs stay idempotent. Also pins settings later runs and Xcode builds must reuse: the `--version` pin (`artifactsVersionOverride`) that keeps later runs on the same artifact slots, and the [autolinking config command](#the-autolinking-config-command-is-remembered). |
225+
| `MyApp.xcodeproj/.spm-injected.json` | Yes | Marker recording every edit `add` made — plus the pre-injection value of any build setting it rewrote — so `deinit` can surgically reverse it and re-runs stay idempotent. Also pins settings later runs and Xcode builds must reuse: the `--version` pin (`artifactsVersionOverride`) that keeps later runs on the same artifact slots, and the [autolinking config command](#the-autolinking-config-command-is-remembered). |
226226
| `build/generated/` | No | Codegen/autolinking output; regenerated |
227227
| `build/xcframeworks/` | No | Symlinks to the machine-local artifact cache |
228228
| `Package.resolved` | No | SwiftPM resolution file; machine-specific |
@@ -234,7 +234,22 @@ stays untouched, and a re-run is a no-op. The injected refs point at three
234234
stable sub-package paths under `build/`; adding or removing community deps
235235
changes the sub-package contents (gitignored) and never re-injects. `deinit`
236236
removes exactly what was injected (using the marker), leaving the project
237-
byte-identical to its pre-`add` state.
237+
byte-identical to its pre-`add` state — with one exception, described next.
238+
239+
**Build settings that already exist** are edited in place. The four array
240+
settings `add` merges into — `HEADER_SEARCH_PATHS`, `OTHER_LDFLAGS`,
241+
`FRAMEWORK_SEARCH_PATHS`, `LD_RUNPATH_SEARCH_PATHS` — keep the shape they were
242+
written in: Xcode's multi-line form as well as the compact one-line form hand
243+
edits and other generators (XcodeGen, Tuist) emit. One that exists as a plain
244+
*scalar* is promoted to a `( … )` array — the shape an Xcode-authored target can
245+
carry, e.g. a
246+
`LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";` written
247+
as a scalar rather than a list. `add` records the pre-injection value in the
248+
marker and
249+
`deinit` restores it by rewriting the whole field — once folded together, the
250+
injected members and your own are indistinguishable — so **members you add to
251+
a promoted array by hand afterwards are lost**. That applies to `update` too,
252+
which reverts to the recorded baseline before re-injecting.
238253

239254
Because everything under `build/` is gitignored, a clean checkout has no
240255
resolvable Swift packages until they are regenerated — see the next section.

packages/react-native/scripts/spm/__tests__/inject-spm-xcodeproj-test.js

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,15 @@ function buildSettingsOf(text, configUuid) {
5656
);
5757
return text.slice(open, text.indexOf('};', open));
5858
}
59+
// Derive a variant whose app-target configs already carry HEADER_SEARCH_PATHS,
60+
// set to any valid pbxproj value: a plain scalar (which injection promotes to an
61+
// array) or an array injection appends to.
62+
function withHeaderSearchPaths(value) {
63+
return PLAIN.replaceAll(
64+
'PRODUCT_BUNDLE_IDENTIFIER = com.example.MyApp;',
65+
`HEADER_SEARCH_PATHS = ${value};\n\t\t\t\tPRODUCT_BUNDLE_IDENTIFIER = com.example.MyApp;`,
66+
);
67+
}
5968

6069
const RN_PATH = '../node_modules/react-native';
6170

@@ -289,6 +298,50 @@ describe('injectSpmIntoPbxproj — Tier 2 (build settings + phase)', () => {
289298
expect(syncIdx).toBeLessThan(sourcesIdx);
290299
});
291300

301+
it.each([
302+
[
303+
'"$(inherited)"',
304+
['"$(inherited)"', '"$(SRCROOT)/build/generated/autolinking/headers"'],
305+
],
306+
[
307+
'"$(inherited) $(SRCROOT)/vendor/include"',
308+
[
309+
'"$(inherited)"',
310+
'"$(inherited) $(SRCROOT)/vendor/include"',
311+
'"$(SRCROOT)/build/generated/autolinking/headers"',
312+
],
313+
],
314+
])(
315+
'promotes a pre-existing HEADER_SEARCH_PATHS scalar (%s) to an array, keeping its value and one $(inherited)',
316+
(scalar, expectedMembers) => {
317+
const {text} = inject(withHeaderSearchPaths(scalar));
318+
const arrays = [
319+
...text.matchAll(/HEADER_SEARCH_PATHS = \(\n([\s\S]*?)\t+\);/g),
320+
].map(m =>
321+
m[1]
322+
.split('\n')
323+
.map(line => line.trim().replace(/,$/, ''))
324+
.filter(member => member.length > 0),
325+
);
326+
// Both app-target configs (Debug + Release).
327+
expect(arrays).toEqual([expectedMembers, expectedMembers]);
328+
},
329+
);
330+
331+
it('appends to a pre-existing ONE-LINE HEADER_SEARCH_PATHS array in place', () => {
332+
const {text} = inject(withHeaderSearchPaths('("$(inherited)", )'));
333+
expect(isBalanced(text)).toBe(true);
334+
const arrays = [
335+
...text.matchAll(/HEADER_SEARCH_PATHS = \(([^\n]*)\);/g),
336+
].map(m => m[1]);
337+
// Both app-target configs, each keeping the one-line shape it was written in.
338+
expect(arrays).toEqual(
339+
Array(2).fill(
340+
'"$(inherited)", "$(SRCROOT)/build/generated/autolinking/headers", ',
341+
),
342+
);
343+
});
344+
292345
it('adds one generated embed phase immediately after Frameworks', () => {
293346
const {text} = inject(PLAIN);
294347
expect(text).not.toContain('Fix SPM Embedded Flavor');

packages/react-native/scripts/spm/__tests__/remove-spm-injection-test.js

Lines changed: 200 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,12 +39,41 @@ afterEach(() => {
3939
// Build a throwaway app dir: <tmp>/MyApp.xcodeproj/project.pbxproj seeded with
4040
// the plain (SPM-only) fixture, and a node_modules/react-native sibling so the
4141
// relative reactNativePath resolves.
42-
function scaffoldApp() {
42+
// Pre-existing values for an injected array setting (HEADER_SEARCH_PATHS),
43+
// seeded into both app-target configs. The plain fixture has none, so it only
44+
// ever exercises the create-from-absent path; deinit must restore each of
45+
// these forms — a plain scalar is ordinary, valid pbxproj.
46+
const PRE_EXISTING_HEADER_SEARCH_PATHS = {
47+
'a bare $(inherited) scalar': '"$(inherited)"',
48+
'a scalar with real content': '"$(inherited) $(SRCROOT)/vendor/include"',
49+
'an array': '(\n\t\t\t\t"$(inherited)",\n\t\t\t)',
50+
// What hand edits and other generators (XcodeGen, Tuist) write.
51+
'a one-line array': '("$(inherited)", )',
52+
};
53+
54+
// Seed a whole `KEY = value;` field (comments and stray whitespace included)
55+
// into both app-target configs.
56+
function withSetting(field /*: string */) {
57+
return PLAIN.replaceAll(
58+
'PRODUCT_BUNDLE_IDENTIFIER = com.example.MyApp;',
59+
`${field}\n\t\t\t\tPRODUCT_BUNDLE_IDENTIFIER = com.example.MyApp;`,
60+
);
61+
}
62+
63+
function withHeaderSearchPaths(value /*: string */) {
64+
return withSetting(`HEADER_SEARCH_PATHS = ${value};`);
65+
}
66+
67+
function scaffoldApp(pbxproj /*: string */ = PLAIN) {
4368
const appRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'spm-deinit-'));
4469
scaffoldedAppRoots.push(appRoot);
4570
const xcodeprojPath = path.join(appRoot, 'MyApp.xcodeproj');
4671
fs.mkdirSync(xcodeprojPath, {recursive: true});
47-
fs.writeFileSync(path.join(xcodeprojPath, 'project.pbxproj'), PLAIN, 'utf8');
72+
fs.writeFileSync(
73+
path.join(xcodeprojPath, 'project.pbxproj'),
74+
pbxproj,
75+
'utf8',
76+
);
4877
const rnRoot = path.join(appRoot, 'node_modules', 'react-native');
4978
fs.mkdirSync(rnRoot, {recursive: true});
5079
const artifactRoot = path.join(appRoot, 'build', 'xcframeworks');
@@ -1179,3 +1208,172 @@ describe('readPinnedConfigCommand', () => {
11791208
expect(readPinnedConfigCommand(appRoot)).toBeNull();
11801209
});
11811210
});
1211+
1212+
describe.each(Object.entries(PRE_EXISTING_HEADER_SEARCH_PATHS))(
1213+
'removeSpmInjection with HEADER_SEARCH_PATHS already set to %s',
1214+
(_label, value) => {
1215+
it('restores the pre-existing value byte-for-byte', () => {
1216+
const {appRoot, xcodeprojPath, rnRoot} = scaffoldApp(
1217+
withHeaderSearchPaths(value),
1218+
);
1219+
const before = pbxprojOf(xcodeprojPath);
1220+
1221+
injectSpmIntoExistingXcodeproj({
1222+
appRoot,
1223+
reactNativeRoot: rnRoot,
1224+
xcodeprojPath,
1225+
});
1226+
expect(pbxprojOf(xcodeprojPath)).not.toBe(before);
1227+
1228+
expect(removeSpmInjection({appRoot, xcodeprojPath}).status).toBe(
1229+
'removed',
1230+
);
1231+
expect(pbxprojOf(xcodeprojPath)).toBe(before);
1232+
});
1233+
1234+
it('re-syncing is byte-for-byte identical', () => {
1235+
const {appRoot, xcodeprojPath, rnRoot} = scaffoldApp(
1236+
withHeaderSearchPaths(value),
1237+
);
1238+
injectSpmIntoExistingXcodeproj({
1239+
appRoot,
1240+
reactNativeRoot: rnRoot,
1241+
xcodeprojPath,
1242+
});
1243+
const first = pbxprojOf(xcodeprojPath);
1244+
injectSpmIntoExistingXcodeproj({
1245+
appRoot,
1246+
reactNativeRoot: rnRoot,
1247+
xcodeprojPath,
1248+
});
1249+
expect(pbxprojOf(xcodeprojPath)).toBe(first);
1250+
});
1251+
},
1252+
);
1253+
1254+
// findField's token for a BARE scalar ends AT the `;`, so it includes any
1255+
// whitespace before it. Deinit must put those bytes back exactly, not a
1256+
// tidied-up version of them.
1257+
describe.each([
1258+
'HEADER_SEARCH_PATHS = $(inherited) ; /* note */',
1259+
'HEADER_SEARCH_PATHS = ;',
1260+
])('removeSpmInjection with the untrimmed scalar `%s`', field => {
1261+
it('restores it byte-for-byte', () => {
1262+
const {appRoot, xcodeprojPath, rnRoot} = scaffoldApp(withSetting(field));
1263+
const before = pbxprojOf(xcodeprojPath);
1264+
1265+
injectSpmIntoExistingXcodeproj({
1266+
appRoot,
1267+
reactNativeRoot: rnRoot,
1268+
xcodeprojPath,
1269+
});
1270+
expect(pbxprojOf(xcodeprojPath)).not.toBe(before);
1271+
1272+
expect(removeSpmInjection({appRoot, xcodeprojPath}).status).toBe('removed');
1273+
expect(pbxprojOf(xcodeprojPath)).toBe(before);
1274+
});
1275+
});
1276+
1277+
describe('a scalar array setting injection has nothing to add to', () => {
1278+
const SCALAR = 'FRAMEWORK_SEARCH_PATHS = "$(inherited)";';
1279+
const EDITED = 'FRAMEWORK_SEARCH_PATHS = "$(inherited) $(SRCROOT)/Vendor";';
1280+
1281+
// The fixture's flavored-frameworks manifest is empty, so
1282+
// FRAMEWORK_SEARCH_PATHS is injected with no values at all.
1283+
it('is left untouched, unrecorded, and survives a later user edit', () => {
1284+
const {appRoot, xcodeprojPath, rnRoot} = scaffoldApp(withSetting(SCALAR));
1285+
1286+
injectSpmIntoExistingXcodeproj({
1287+
appRoot,
1288+
reactNativeRoot: rnRoot,
1289+
xcodeprojPath,
1290+
});
1291+
1292+
const injected = pbxprojOf(xcodeprojPath);
1293+
expect(injected).toContain(SCALAR);
1294+
expect(injected).not.toMatch(/FRAMEWORK_SEARCH_PATHS = \(/);
1295+
for (const change of readMarker(xcodeprojPath).buildSettingChanges) {
1296+
expect(change.promotedArrayScalars ?? {}).not.toHaveProperty(
1297+
'FRAMEWORK_SEARCH_PATHS',
1298+
);
1299+
}
1300+
1301+
fs.writeFileSync(
1302+
path.join(xcodeprojPath, 'project.pbxproj'),
1303+
injected.replaceAll(SCALAR, EDITED),
1304+
'utf8',
1305+
);
1306+
removeSpmInjection({appRoot, xcodeprojPath});
1307+
1308+
const after = pbxprojOf(xcodeprojPath);
1309+
expect(after).toContain(EDITED);
1310+
expect(after).not.toContain(SCALAR);
1311+
});
1312+
});
1313+
1314+
describe('a promoted array setting the user deleted after add', () => {
1315+
const SCALAR = '"$(inherited) $(SRCROOT)/vendor/include"';
1316+
1317+
it('is not resurrected by deinit', () => {
1318+
const {appRoot, xcodeprojPath, rnRoot} = scaffoldApp(
1319+
withHeaderSearchPaths(SCALAR),
1320+
);
1321+
const before = pbxprojOf(xcodeprojPath);
1322+
1323+
injectSpmIntoExistingXcodeproj({
1324+
appRoot,
1325+
reactNativeRoot: rnRoot,
1326+
xcodeprojPath,
1327+
});
1328+
1329+
const deleted = pbxprojOf(xcodeprojPath).replace(
1330+
/\n\t+HEADER_SEARCH_PATHS = \(\n[\s\S]*?\n\t+\);/g,
1331+
'',
1332+
);
1333+
expect(deleted).not.toContain('HEADER_SEARCH_PATHS');
1334+
fs.writeFileSync(
1335+
path.join(xcodeprojPath, 'project.pbxproj'),
1336+
deleted,
1337+
'utf8',
1338+
);
1339+
1340+
removeSpmInjection({appRoot, xcodeprojPath});
1341+
1342+
// Everything else is back to its pre-injection bytes; only the setting the
1343+
// user deleted stays gone.
1344+
expect(pbxprojOf(xcodeprojPath)).toBe(
1345+
before.replaceAll(`\n\t\t\t\tHEADER_SEARCH_PATHS = ${SCALAR};`, ''),
1346+
);
1347+
});
1348+
});
1349+
1350+
// `deinit` removes appendedArrayValues before it restores promotedArrayScalars,
1351+
// so recording a key under both happens to come out right today: the scalar
1352+
// restore rewrites the whole value last. That makes the exclusivity below
1353+
// invisible to a round-trip test, which is why it is asserted on the marker
1354+
// directly — reversing those two loops would otherwise silently start removing
1355+
// array members from an already-restored scalar.
1356+
describe('a promoted scalar is recorded once, not twice', () => {
1357+
it('records promotedArrayScalars and not appendedArrayValues for the key', () => {
1358+
const {appRoot, xcodeprojPath, rnRoot} = scaffoldApp(
1359+
withHeaderSearchPaths('"$(inherited) $(SRCROOT)/vendor/include"'),
1360+
);
1361+
1362+
injectSpmIntoExistingXcodeproj({
1363+
appRoot,
1364+
reactNativeRoot: rnRoot,
1365+
xcodeprojPath,
1366+
});
1367+
1368+
const changes = readMarker(xcodeprojPath).buildSettingChanges;
1369+
expect(changes.length).toBeGreaterThan(0);
1370+
for (const change of changes) {
1371+
expect(Object.keys(change.promotedArrayScalars ?? {})).toContain(
1372+
'HEADER_SEARCH_PATHS',
1373+
);
1374+
expect(Object.keys(change.appendedArrayValues ?? {})).not.toContain(
1375+
'HEADER_SEARCH_PATHS',
1376+
);
1377+
}
1378+
});
1379+
});

0 commit comments

Comments
 (0)