Skip to content

Commit 15fefc2

Browse files
chrfalchclaude
andcommitted
SPM: restore a promoted scalar build setting on deinit
`spm add` merges its array build settings (`HEADER_SEARCH_PATHS`, `OTHER_LDFLAGS`, `FRAMEWORK_SEARCH_PATHS`, `LD_RUNPATH_SEARCH_PATHS`) via `addArrayStringValues`, which has three add paths: create the field, append to an existing array, or promote an existing SCALAR into a `( … )` array. Only the first two were reversible. A promotion was recorded as `appendedArrayValues`, whose reversal only strips the injected members — so `spm deinit` left the promoted array and its `"$(inherited)"` seed behind, and the original scalar was never recorded anywhere to restore from. A scalar is the ordinary shape in a real project (`HEADER_SEARCH_PATHS = "$(inherited)";` in the Debug config), so this broke the byte-identical restore `deinit` promises on a common input. Record the pre-injection value in a new `promotedArrayScalars` field on the marker's `BuildSettingChange` and restore it in place. Notes on the details: - The recorded value is kept RAW. `findField`'s token for a bare scalar runs to the `;`, so it carries any whitespace before it, and deinit has to write those bytes back. The value emitted as an array MEMBER is still trimmed — a member with trailing whitespace would be malformed. The two differ deliberately. - The record is gated on the merge having actually changed the text. `addArrayStringValues` no-ops when its value list is empty (as `FRAMEWORK_SEARCH_PATHS` is with no flavored frameworks) or when every value is already present, and a recorded-but-untouched field would have deinit clobber whatever the user has there by then. - Restoration is skipped when the field is absent at deinit time: it was deleted after injection, and re-adding it would resurrect it at the top of the dictionary, matching neither the original nor the user's intent. - Promotion no longer re-emits a prior value that is itself `"$(inherited)"` (the seed) or empty (a bare `,` is not a valid plist element). Reversing a promotion rewrites the whole field, because the injected members and the seed are indistinguishable from the user's own once folded together. Members hand-added to a promoted array afterwards are therefore lost; the removal-helper banner in spm-pbxproj.js now names that exception. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 3bfb277 commit 15fefc2

5 files changed

Lines changed: 301 additions & 6 deletions

File tree

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

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,16 @@ const PODS = PLAIN.replace(
2929
'AA0000000000000000000901 /* Debug */ = {\n\t\t\tisa = XCBuildConfiguration;\n\t\t\tbaseConfigurationReference = BB0000000000000000000001 /* Pods-MyApp.debug.xcconfig */;\n\t\t\tbuildSettings = {',
3030
);
3131

32+
// Derive a variant whose app-target configs already carry HEADER_SEARCH_PATHS
33+
// as a plain scalar (ordinary, valid pbxproj) — the state injection promotes to
34+
// an array.
35+
function withScalarHeaderSearchPaths(value) {
36+
return PLAIN.replaceAll(
37+
'PRODUCT_BUNDLE_IDENTIFIER = com.example.MyApp;',
38+
`HEADER_SEARCH_PATHS = ${value};\n\t\t\t\tPRODUCT_BUNDLE_IDENTIFIER = com.example.MyApp;`,
39+
);
40+
}
41+
3242
const RN_PATH = '../node_modules/react-native';
3343

3444
// Absolute, mirroring resolveHermesCliPathSetting (a `..`-relative path through
@@ -227,6 +237,36 @@ describe('injectSpmIntoPbxproj — Tier 2 (build settings + phase)', () => {
227237
expect(syncIdx).toBeLessThan(sourcesIdx);
228238
});
229239

240+
it.each([
241+
[
242+
'"$(inherited)"',
243+
['"$(inherited)"', '"$(SRCROOT)/build/generated/autolinking/headers"'],
244+
],
245+
[
246+
'"$(inherited) $(SRCROOT)/vendor/include"',
247+
[
248+
'"$(inherited)"',
249+
'"$(inherited) $(SRCROOT)/vendor/include"',
250+
'"$(SRCROOT)/build/generated/autolinking/headers"',
251+
],
252+
],
253+
])(
254+
'promotes a pre-existing HEADER_SEARCH_PATHS scalar (%s) to an array, keeping its value and one $(inherited)',
255+
(scalar, expectedMembers) => {
256+
const {text} = inject(withScalarHeaderSearchPaths(scalar));
257+
const arrays = [
258+
...text.matchAll(/HEADER_SEARCH_PATHS = \(\n([\s\S]*?)\t+\);/g),
259+
].map(m =>
260+
m[1]
261+
.split('\n')
262+
.map(line => line.trim().replace(/,$/, ''))
263+
.filter(member => member.length > 0),
264+
);
265+
// Both app-target configs (Debug + Release).
266+
expect(arrays).toEqual([expectedMembers, expectedMembers]);
267+
},
268+
);
269+
230270
it('adds one generated embed phase immediately after Frameworks', () => {
231271
const {text} = inject(PLAIN);
232272
expect(text).not.toContain('Fix SPM Embedded Flavor');

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

Lines changed: 167 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,15 +34,42 @@ afterEach(() => {
3434
scaffoldedAppRoots = [];
3535
});
3636

37+
// Pre-existing values for an injected array setting (HEADER_SEARCH_PATHS),
38+
// seeded into both app-target configs. The plain fixture has none, so it only
39+
// ever exercises the create-from-absent path; deinit must restore each of
40+
// these forms — a plain scalar is ordinary, valid pbxproj.
41+
const PRE_EXISTING_HEADER_SEARCH_PATHS = {
42+
'a bare $(inherited) scalar': '"$(inherited)"',
43+
'a scalar with real content': '"$(inherited) $(SRCROOT)/vendor/include"',
44+
'an array': '(\n\t\t\t\t"$(inherited)",\n\t\t\t)',
45+
};
46+
47+
// Seed a whole `KEY = value;` field (comments and stray whitespace included)
48+
// into both app-target configs.
49+
function withSetting(field /*: string */) {
50+
return PLAIN.replaceAll(
51+
'PRODUCT_BUNDLE_IDENTIFIER = com.example.MyApp;',
52+
`${field}\n\t\t\t\tPRODUCT_BUNDLE_IDENTIFIER = com.example.MyApp;`,
53+
);
54+
}
55+
56+
function withHeaderSearchPaths(value /*: string */) {
57+
return withSetting(`HEADER_SEARCH_PATHS = ${value};`);
58+
}
59+
3760
// Build a throwaway app dir: <tmp>/MyApp.xcodeproj/project.pbxproj seeded with
3861
// the plain (SPM-only) fixture, and a node_modules/react-native sibling so the
3962
// relative reactNativePath resolves.
40-
function scaffoldApp() {
63+
function scaffoldApp(pbxproj = PLAIN) {
4164
const appRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'spm-deinit-'));
4265
scaffoldedAppRoots.push(appRoot);
4366
const xcodeprojPath = path.join(appRoot, 'MyApp.xcodeproj');
4467
fs.mkdirSync(xcodeprojPath, {recursive: true});
45-
fs.writeFileSync(path.join(xcodeprojPath, 'project.pbxproj'), PLAIN, 'utf8');
68+
fs.writeFileSync(
69+
path.join(xcodeprojPath, 'project.pbxproj'),
70+
pbxproj,
71+
'utf8',
72+
);
4673
const rnRoot = path.join(appRoot, 'node_modules', 'react-native');
4774
fs.mkdirSync(rnRoot, {recursive: true});
4875
const artifactRoot = path.join(appRoot, 'build', 'xcframeworks');
@@ -189,6 +216,144 @@ describe('removeSpmInjection — the surgical inverse of add', () => {
189216
});
190217
});
191218

219+
describe.each(Object.entries(PRE_EXISTING_HEADER_SEARCH_PATHS))(
220+
'removeSpmInjection with HEADER_SEARCH_PATHS already set to %s',
221+
(_label, value) => {
222+
it('restores the pre-existing value byte-for-byte', () => {
223+
const {appRoot, xcodeprojPath, rnRoot} = scaffoldApp(
224+
withHeaderSearchPaths(value),
225+
);
226+
const before = pbxprojOf(xcodeprojPath);
227+
228+
injectSpmIntoExistingXcodeproj({
229+
appRoot,
230+
reactNativeRoot: rnRoot,
231+
xcodeprojPath,
232+
});
233+
expect(pbxprojOf(xcodeprojPath)).not.toBe(before);
234+
235+
expect(removeSpmInjection({appRoot, xcodeprojPath}).status).toBe(
236+
'removed',
237+
);
238+
expect(pbxprojOf(xcodeprojPath)).toBe(before);
239+
});
240+
241+
it('re-syncing is byte-for-byte identical', () => {
242+
const {appRoot, xcodeprojPath, rnRoot} = scaffoldApp(
243+
withHeaderSearchPaths(value),
244+
);
245+
injectSpmIntoExistingXcodeproj({
246+
appRoot,
247+
reactNativeRoot: rnRoot,
248+
xcodeprojPath,
249+
});
250+
const first = pbxprojOf(xcodeprojPath);
251+
injectSpmIntoExistingXcodeproj({
252+
appRoot,
253+
reactNativeRoot: rnRoot,
254+
xcodeprojPath,
255+
});
256+
expect(pbxprojOf(xcodeprojPath)).toBe(first);
257+
});
258+
},
259+
);
260+
261+
// findField's token for a BARE scalar ends AT the `;`, so it includes any
262+
// whitespace before it. Deinit must put those bytes back exactly, not a
263+
// tidied-up version of them.
264+
describe.each([
265+
'HEADER_SEARCH_PATHS = $(inherited) ; /* note */',
266+
'HEADER_SEARCH_PATHS = ;',
267+
])('removeSpmInjection with the untrimmed scalar `%s`', field => {
268+
it('restores it byte-for-byte', () => {
269+
const {appRoot, xcodeprojPath, rnRoot} = scaffoldApp(withSetting(field));
270+
const before = pbxprojOf(xcodeprojPath);
271+
272+
injectSpmIntoExistingXcodeproj({
273+
appRoot,
274+
reactNativeRoot: rnRoot,
275+
xcodeprojPath,
276+
});
277+
expect(pbxprojOf(xcodeprojPath)).not.toBe(before);
278+
279+
expect(removeSpmInjection({appRoot, xcodeprojPath}).status).toBe('removed');
280+
expect(pbxprojOf(xcodeprojPath)).toBe(before);
281+
});
282+
});
283+
284+
describe('a scalar array setting injection has nothing to add to', () => {
285+
const SCALAR = 'FRAMEWORK_SEARCH_PATHS = "$(inherited)";';
286+
const EDITED = 'FRAMEWORK_SEARCH_PATHS = "$(inherited) $(SRCROOT)/Vendor";';
287+
288+
// The fixture's flavored-frameworks manifest is empty, so
289+
// FRAMEWORK_SEARCH_PATHS is injected with no values at all.
290+
it('is left untouched, unrecorded, and survives a later user edit', () => {
291+
const {appRoot, xcodeprojPath, rnRoot} = scaffoldApp(withSetting(SCALAR));
292+
293+
injectSpmIntoExistingXcodeproj({
294+
appRoot,
295+
reactNativeRoot: rnRoot,
296+
xcodeprojPath,
297+
});
298+
299+
const injected = pbxprojOf(xcodeprojPath);
300+
expect(injected).toContain(SCALAR);
301+
expect(injected).not.toMatch(/FRAMEWORK_SEARCH_PATHS = \(/);
302+
for (const change of readMarker(xcodeprojPath).buildSettingChanges) {
303+
expect(change.promotedArrayScalars ?? {}).not.toHaveProperty(
304+
'FRAMEWORK_SEARCH_PATHS',
305+
);
306+
}
307+
308+
fs.writeFileSync(
309+
path.join(xcodeprojPath, 'project.pbxproj'),
310+
injected.replaceAll(SCALAR, EDITED),
311+
'utf8',
312+
);
313+
removeSpmInjection({appRoot, xcodeprojPath});
314+
315+
const after = pbxprojOf(xcodeprojPath);
316+
expect(after).toContain(EDITED);
317+
expect(after).not.toContain(SCALAR);
318+
});
319+
});
320+
321+
describe('a promoted array setting the user deleted after add', () => {
322+
const SCALAR = '"$(inherited) $(SRCROOT)/vendor/include"';
323+
324+
it('is not resurrected by deinit', () => {
325+
const {appRoot, xcodeprojPath, rnRoot} = scaffoldApp(
326+
withHeaderSearchPaths(SCALAR),
327+
);
328+
const before = pbxprojOf(xcodeprojPath);
329+
330+
injectSpmIntoExistingXcodeproj({
331+
appRoot,
332+
reactNativeRoot: rnRoot,
333+
xcodeprojPath,
334+
});
335+
336+
const deleted = pbxprojOf(xcodeprojPath).replace(
337+
/\n\t+HEADER_SEARCH_PATHS = \(\n[\s\S]*?\n\t+\);/g,
338+
'',
339+
);
340+
expect(deleted).not.toContain('HEADER_SEARCH_PATHS');
341+
fs.writeFileSync(
342+
path.join(xcodeprojPath, 'project.pbxproj'),
343+
deleted,
344+
'utf8',
345+
);
346+
347+
removeSpmInjection({appRoot, xcodeprojPath});
348+
349+
// Everything else is back to its pre-injection bytes; only the setting the
350+
// user deleted stays gone.
351+
expect(pbxprojOf(xcodeprojPath)).toBe(
352+
before.replaceAll(`\n\t\t\t\tHEADER_SEARCH_PATHS = ${SCALAR};`, ''),
353+
);
354+
});
355+
});
356+
192357
describe('generated-sources reconciliation on update', () => {
193358
it('removes exactly the UUIDs of an entry dropped from the manifest, keeping the rest', () => {
194359
const {appRoot, xcodeprojPath, rnRoot} = scaffoldApp();

packages/react-native/scripts/spm/__tests__/spm-pbxproj-test.js

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,44 @@ describe('addArrayStringValues', () => {
195195
expect(out).toContain('"-ObjC"');
196196
});
197197

198+
it('promotes a bare "$(inherited)" scalar without emitting it twice', () => {
199+
const scalar = PLAIN_PBXPROJ.replace(
200+
'PRODUCT_NAME = "$(TARGET_NAME)";',
201+
'OTHER_LDFLAGS = "$(inherited)"; PRODUCT_NAME = "$(TARGET_NAME)";',
202+
);
203+
const out = addArrayStringValues(
204+
scalar,
205+
targetDebugDict(scalar),
206+
'OTHER_LDFLAGS',
207+
['"-ObjC"'],
208+
);
209+
const members = /OTHER_LDFLAGS = \(\n([\s\S]*?)\t+\);/
210+
.exec(out)[1]
211+
.split('\n')
212+
.map(line => line.trim().replace(/,$/, ''))
213+
.filter(member => member.length > 0);
214+
// The scalar's value IS the seed the array is created with.
215+
expect(members).toEqual(['"$(inherited)"', '"-ObjC"']);
216+
});
217+
218+
it('promotes an empty scalar without emitting a bare `,` member', () => {
219+
const scalar = PLAIN_PBXPROJ.replace(
220+
'PRODUCT_NAME = "$(TARGET_NAME)";',
221+
'OTHER_LDFLAGS = ; PRODUCT_NAME = "$(TARGET_NAME)";',
222+
);
223+
const out = addArrayStringValues(
224+
scalar,
225+
targetDebugDict(scalar),
226+
'OTHER_LDFLAGS',
227+
['"-ObjC"'],
228+
);
229+
const block = /OTHER_LDFLAGS = \(\n([\s\S]*?)\t+\);/.exec(out)[1];
230+
// Asserted on the raw block: a member list filtered for emptiness (as the
231+
// test above does) would hide the malformed element this guards against.
232+
expect(block.split('\n').filter(line => /^\s*,$/.test(line))).toEqual([]);
233+
expect(block).toContain('"-ObjC"');
234+
});
235+
198236
it('dedups by EXACT token, not substring (adds "-ObjC" even when "-ObjCFoo" is present)', () => {
199237
const withArray = PLAIN_PBXPROJ.replace(
200238
'PRODUCT_NAME = "$(TARGET_NAME)";',

packages/react-native/scripts/spm/generate-spm-xcodeproj.js

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,11 @@ type BuildSettingChange = {
136136
// value), e.g. a ${PODS_ROOT}-anchored REACT_NATIVE_PATH that dangles once
137137
// CocoaPods is deintegrated. Deinit restores the original.
138138
replacedScalars?: {[string]: string},
139+
// Array settings that existed as a SCALAR and were promoted to a `( … )`
140+
// array (key → the pre-injection raw value text, quotes included). Deinit
141+
// restores that value verbatim; removing the injected members would leave
142+
// the promoted array and its `"$(inherited)"` seed behind.
143+
promotedArrayScalars?: {[string]: string},
139144
};
140145
// A plugin-contributed source, normalized for pbxproj emission. `path` is
141146
// SRCROOT-relative when under the app root, else absolute; `sourceTree` is the
@@ -1492,6 +1497,7 @@ function mergeReactBuildSettings(
14921497
};
14931498
const createdArrayKeys /*: Array<string> */ = [];
14941499
const appendedArrayValues /*: {[string]: Array<string>} */ = {};
1500+
const promotedArrayScalars /*: {[string]: string} */ = {};
14951501
const createdScalars /*: Array<string> */ = [];
14961502
const arraySettings = [
14971503
...INJECTED_ARRAY_SETTINGS,
@@ -1503,15 +1509,32 @@ function mergeReactBuildSettings(
15031509
continue;
15041510
}
15051511
const existing = findField(text, d, key);
1512+
// Non-null only for a scalar addArrayStringValues would promote to an array
1513+
// (same array-vs-scalar test it uses). Kept RAW: findField's token for a
1514+
// bare scalar ends at the `;`, so it carries any whitespace before it, and
1515+
// deinit has to write those bytes back verbatim.
1516+
const priorScalar =
1517+
existing != null && !existing.value.trimStart().startsWith('(')
1518+
? existing.value
1519+
: null;
15061520
if (existing == null) {
15071521
createdArrayKeys.push(key);
1508-
} else {
1522+
} else if (priorScalar == null) {
15091523
const fresh = values.filter(v => !existing.value.includes(v));
15101524
if (fresh.length > 0) {
15111525
appendedArrayValues[key] = fresh;
15121526
}
15131527
}
1528+
const beforeAdd = text;
15141529
text = addArrayStringValues(text, d, key, values);
1530+
// Record only a promotion that actually happened: addArrayStringValues
1531+
// no-ops when `values` is empty or every value is already a member, and a
1532+
// recorded-but-untouched field would have deinit clobber whatever the user
1533+
// has there by then. Restoring the scalar subsumes removing the injected
1534+
// members, so the two records stay mutually exclusive per key.
1535+
if (priorScalar != null && text !== beforeAdd) {
1536+
promotedArrayScalars[key] = priorScalar;
1537+
}
15151538
}
15161539
const replacedScalars /*: {[string]: string} */ = {};
15171540
for (const {key, value} of scalars) {
@@ -1570,6 +1593,9 @@ function mergeReactBuildSettings(
15701593
appendedArrayValues,
15711594
createdScalars,
15721595
replacedScalars,
1596+
...(Object.keys(promotedArrayScalars).length > 0
1597+
? {promotedArrayScalars}
1598+
: {}),
15731599
},
15741600
};
15751601
}
@@ -2097,6 +2123,25 @@ function removeRecordedBuildSettings(
20972123
);
20982124
}
20992125
}
2126+
const promotedArrayScalars /*: {[string]: string} */ =
2127+
change.promotedArrayScalars ?? {};
2128+
for (const key of Object.keys(promotedArrayScalars)) {
2129+
const current = dict();
2130+
const originalValue = promotedArrayScalars[key];
2131+
// A field that is gone was deleted by the user after injection; restoring
2132+
// it would resurrect it, at the top of the dict, matching neither state.
2133+
if (
2134+
current != null &&
2135+
typeof originalValue === 'string' &&
2136+
findField(text, current, key) != null
2137+
) {
2138+
// Rewriting the whole value is what makes the promotion reversible at
2139+
// all — its members and its `"$(inherited)"` seed are indistinguishable
2140+
// from the user's own once folded together. The tradeoff: members the
2141+
// user hand-added to the promoted array afterwards are discarded.
2142+
text = setScalarField(text, current, key, originalValue);
2143+
}
2144+
}
21002145
for (const key of change.createdArrayKeys ?? []) {
21012146
const current = dict();
21022147
if (current != null) {

0 commit comments

Comments
 (0)