Skip to content

Commit 62782b8

Browse files
chrfalchclaude
andcommitted
feat(spm): header-map + recursive HSP emulation, robust pod-ipc parse, fail-closed on mixed-language
Four scaffolder fixes, each unblocking a real community library: 1. Header-map emulation (read-podspec + scaffold) — CocoaPods USE_HEADERMAP lets `#import "Flat.h"` resolve a header anywhere in the target. SPM has no header map, so flat-include libs (react-native-svg) failed with "X.h not found". We now add every header-containing subdir to HEADER_SEARCH_PATHS. 2. Recursive + multi-path HEADER_SEARCH_PATHS parsing — a single pod_target_xcconfig HSP value can hold several space-separated, individually quoted paths, some ending in `/**` (recursive). We shell-tokenize the value, strip quotes per token, and expand a `/**` marker into the base dir plus all its subdirs (skia needs cpp, cpp/skia, … on the search path). 3. Robust pod-ipc JSON extraction — some podspecs print diagnostics to stdout before the JSON (skia: `-- SK_GRAPHITE: OFF …`), so JSON.parse(stdout) threw and we silently fell back to the regex parser (partial: true → HSP dropped). We now slice from the first `{` to the last `}` before parsing. 4. Fail-closed on mixed-language deps — SPM can't compile Swift + ObjC/C++ in one target, and bidirectional-interop RN libs (react-native-screens) can't be split into two targets without a circular dependency. Instead of emitting a manifest that dies with a cryptic SPM resolve error, the scaffolder now skips (status: skipped-mixed-language, removing any stale manifest) and the autolinker reports a distinct, actionable error with an opt-out (react-native.config.js platforms: { ios: null }) and a binary alternative. Verified end-to-end against a real RN 0.87 app: react-native-skia and react-native-svg now build via the scaffolder; react-native-screens reports the clean fail-closed error. SCAFFOLDER_VERSION bumped to 16 to force regen. 399 spm tests green (8 new). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 98ecbf2 commit 62782b8

7 files changed

Lines changed: 355 additions & 29 deletions

File tree

packages/react-native/scripts/spm/__tests__/generate-spm-autolinking-test.js

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ const {
1818
findSelfManagedPackageDir,
1919
generateAutolinkedPackageSwift,
2020
generateSynthPackageSwift,
21+
hasMixedLanguageSources,
2122
hasPodspec,
2223
linkHeaderTree,
2324
reportMissingManifests,
@@ -872,4 +873,58 @@ describe('MissingManifestError + reportMissingManifests', () => {
872873
expect(line).toContain('no podspec');
873874
expect(line).toContain('react-native-baz');
874875
});
876+
877+
it('gives a mixed-language dep a DISTINCT error (not "run scaffold") with an opt-out + binary path', () => {
878+
reportMissingManifests([
879+
{
880+
name: 'Screens',
881+
npmName: 'react-native-screens',
882+
hasPodspec: true,
883+
mixed: true,
884+
},
885+
]);
886+
const line = errSpy.mock.calls[0][0];
887+
expect(line.startsWith('error: ')).toBe(true);
888+
expect(line).toContain('mixed Swift');
889+
// Must NOT tell them to scaffold — scaffolding can't fix mixed-language.
890+
expect(line).not.toContain('react-native spm scaffold');
891+
// The two real escape hatches:
892+
expect(line).toContain('react-native.config.js'); // opt out of autolinking
893+
expect(line).toContain('platforms: { ios: null }');
894+
expect(line).toContain('xcframework'); // or consume as a prebuilt binary
895+
});
896+
});
897+
898+
describe('hasMixedLanguageSources', () => {
899+
let root;
900+
901+
beforeEach(() => {
902+
root = fs.mkdtempSync(path.join(os.tmpdir(), 'spm-mixed-'));
903+
});
904+
905+
afterEach(() => {
906+
fs.rmSync(root, {recursive: true, force: true});
907+
});
908+
909+
it('is true when both .swift and .mm exist under the source dir (screens shape)', () => {
910+
fs.mkdirSync(path.join(root, 'ios'), {recursive: true});
911+
fs.writeFileSync(path.join(root, 'ios', 'RNSScreen.swift'), '');
912+
fs.writeFileSync(path.join(root, 'ios', 'RNSScreen.mm'), '');
913+
expect(hasMixedLanguageSources(root)).toBe(true);
914+
});
915+
916+
it('is false for a pure-ObjC++ lib (svg/skia shape)', () => {
917+
fs.mkdirSync(path.join(root, 'apple'), {recursive: true});
918+
fs.writeFileSync(path.join(root, 'apple', 'A.mm'), '');
919+
fs.writeFileSync(path.join(root, 'apple', 'B.h'), '');
920+
expect(hasMixedLanguageSources(root)).toBe(false);
921+
});
922+
923+
it('ignores .swift that lives only under example/ or __tests__ (not real sources)', () => {
924+
fs.mkdirSync(path.join(root, 'ios'), {recursive: true});
925+
fs.writeFileSync(path.join(root, 'ios', 'A.mm'), '');
926+
fs.mkdirSync(path.join(root, 'example', 'ios'), {recursive: true});
927+
fs.writeFileSync(path.join(root, 'example', 'ios', 'App.swift'), '');
928+
expect(hasMixedLanguageSources(root)).toBe(false);
929+
});
875930
});

packages/react-native/scripts/spm/__tests__/read-podspec-test.js

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -237,6 +237,26 @@ describe('flattenSubspecs', () => {
237237
expect(model.preprocessorDefines).toHaveLength(3);
238238
});
239239

240+
it('parses a multi-path HEADER_SEARCH_PATHS string with embedded quotes + recursive /** (skia shape)', () => {
241+
const raw = {
242+
name: 'react-native-skia',
243+
version: '1.0',
244+
pod_target_xcconfig: {
245+
HEADER_SEARCH_PATHS:
246+
'"$(PODS_TARGET_SRCROOT)/cpp/"/** "$(PODS_TARGET_SRCROOT)/cpp" "$(PODS_TARGET_SRCROOT)/cpp/skia" "$(PODS_TARGET_SRCROOT)/cpp/dawn/include"',
247+
},
248+
};
249+
const model = flattenSubspecs(raw);
250+
// Each space-separated, individually-quoted path becomes its own entry
251+
// (quotes stripped); the `/**` recursive marker is preserved for translate.
252+
expect(model.headerSearchPaths).toEqual([
253+
'$(PODS_TARGET_SRCROOT)/cpp//**',
254+
'$(PODS_TARGET_SRCROOT)/cpp',
255+
'$(PODS_TARGET_SRCROOT)/cpp/skia',
256+
'$(PODS_TARGET_SRCROOT)/cpp/dawn/include',
257+
]);
258+
});
259+
240260
it('lifts defines from s.xcconfig too, not just pod_target_xcconfig (reanimated shape)', () => {
241261
const raw = {
242262
name: 'RNReanimated',

packages/react-native/scripts/spm/__tests__/scaffold-package-swift-test.js

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,53 @@ describe('translatePodspecToSpmTarget', () => {
172172
}
173173
});
174174

175+
it('header-map emulation: adds every header-containing subdir to the search path (flat-include libs like svg)', () => {
176+
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'hmap-scaffold-'));
177+
try {
178+
fs.mkdirSync(path.join(root, 'apple', 'Elements'), {recursive: true});
179+
fs.mkdirSync(path.join(root, 'apple', 'Text'), {recursive: true});
180+
fs.writeFileSync(path.join(root, 'apple', 'Elements', 'A.h'), '');
181+
fs.writeFileSync(path.join(root, 'apple', 'Text', 'B.h'), '');
182+
fs.writeFileSync(path.join(root, 'apple', 'C.mm'), '');
183+
const model = podspec({
184+
sourceFiles: [
185+
'apple/Elements/A.h',
186+
'apple/Text/B.h',
187+
'apple/C.mm',
188+
],
189+
});
190+
const spec = translatePodspecToSpmTarget(
191+
model,
192+
autolinkedDep({name: 'react-native-svg', root}),
193+
);
194+
expect(spec.headerSearchPaths).toContain('apple/Elements');
195+
expect(spec.headerSearchPaths).toContain('apple/Text');
196+
} finally {
197+
fs.rmSync(root, {recursive: true, force: true});
198+
}
199+
});
200+
201+
it('expands a recursive `/**` HEADER_SEARCH_PATH into the base dir + all subdirs (skia shape)', () => {
202+
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'rec-scaffold-'));
203+
try {
204+
fs.mkdirSync(path.join(root, 'cpp', 'skia', 'include', 'core'), {
205+
recursive: true,
206+
});
207+
const model = podspec({
208+
headerSearchPaths: ['$(PODS_TARGET_SRCROOT)/cpp//**'],
209+
});
210+
const spec = translatePodspecToSpmTarget(
211+
model,
212+
autolinkedDep({name: 'react-native-skia', root}),
213+
);
214+
expect(spec.headerSearchPaths).toContain('cpp'); // base
215+
expect(spec.headerSearchPaths).toContain('cpp/skia'); // makes <include/core/X.h> resolve
216+
expect(spec.headerSearchPaths).toContain('cpp/skia/include/core');
217+
} finally {
218+
fs.rmSync(root, {recursive: true, force: true});
219+
}
220+
});
221+
175222
it('flags needsObjCPrefix (and adds "." to the search path) when the target has ObjC(++) sources', () => {
176223
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'objc-scaffold-'));
177224
try {
@@ -644,6 +691,29 @@ end
644691
expect(content).toContain(SCAFFOLDER_MARKER);
645692
});
646693

694+
it('skips (does not write) a mixed-language dep — Swift + ObjC(++) cannot share one SPM target', () => {
695+
// react-native-screens shape: a single source glob mixing .swift and .mm.
696+
const podspecPath = path.join(depRoot, 'react-native-foo.podspec');
697+
fs.writeFileSync(
698+
podspecPath,
699+
`
700+
Pod::Spec.new do |s|
701+
s.name = "react-native-foo"
702+
s.version = "1.0"
703+
s.source_files = "ios/**/*.{h,m,mm,swift}"
704+
s.dependency "React-Core"
705+
end
706+
`,
707+
);
708+
fs.mkdirSync(path.join(depRoot, 'ios'), {recursive: true});
709+
fs.writeFileSync(path.join(depRoot, 'ios', 'Foo.swift'), '');
710+
fs.writeFileSync(path.join(depRoot, 'ios', 'Foo.mm'), '');
711+
const result = scaffoldPackageSwiftForDep(makeDep(), makeCtx());
712+
expect(result.status).toBe('skipped-mixed-language');
713+
// Fail-closed: no half-baked manifest left behind.
714+
expect(fs.existsSync(path.join(depRoot, 'Package.swift'))).toBe(false);
715+
});
716+
647717
it('computes app paths relative to the libs/<SwiftName> symlink, not dep.root (fresh-resolve correctness)', () => {
648718
makePodspec();
649719
const result = scaffoldPackageSwiftForDep(makeDep(), makeCtx());

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

Lines changed: 67 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -307,6 +307,56 @@ function hasPodspec(absSource /*: string */) /*: boolean */ {
307307
return false;
308308
}
309309

310+
/**
311+
* True when a dep has BOTH Swift and C-family (.m/.mm/.c/.cpp) sources. SPM
312+
* cannot compile mixed-language sources in a single target, and RN libs that
313+
* mix them are typically bidirectionally coupled (ObjC↔Swift) — which can't be
314+
* split into two targets either (it would be a circular dependency). So such a
315+
* dep is unsupportable by the scaffolder; we surface a clear, distinct error
316+
* instead of emitting a manifest that fails with a cryptic SPM resolve error.
317+
* Heuristic filesystem scan (bounded depth; skips examples/tests/build noise).
318+
*/
319+
function hasMixedLanguageSources(absSource /*: string */) /*: boolean */ {
320+
const SKIP /*: Set<string> */ = new Set([
321+
'node_modules',
322+
'Pods',
323+
'build',
324+
'.git',
325+
'__tests__',
326+
'example',
327+
'Example',
328+
'examples',
329+
]);
330+
let hasSwift = false;
331+
let hasClang = false;
332+
const walk = (dir /*: string */, depth /*: number */) => {
333+
if (depth > 6 || (hasSwift && hasClang)) return;
334+
let entries /*: Array<{name: string, isDirectory(): boolean}> */;
335+
try {
336+
// $FlowFixMe[incompatible-type] Dirent typing
337+
entries = fs.readdirSync(dir, {withFileTypes: true});
338+
} catch {
339+
return;
340+
}
341+
for (const e of entries) {
342+
// $FlowFixMe[incompatible-type] Dirent.name is string|Buffer in stubs
343+
const name /*: string */ = e.name;
344+
if (e.isDirectory()) {
345+
if (!name.startsWith('.') && !SKIP.has(name)) {
346+
walk(path.join(dir, name), depth + 1);
347+
}
348+
} else if (/\.swift$/i.test(name)) {
349+
hasSwift = true;
350+
} else if (/\.(mm?|c|cc|cpp|cxx)$/i.test(name)) {
351+
hasClang = true;
352+
}
353+
if (hasSwift && hasClang) return;
354+
}
355+
};
356+
walk(absSource, 0);
357+
return hasSwift && hasClang;
358+
}
359+
310360
/**
311361
* Error thrown when one or more autolinked community npm deps have no Swift
312362
* Package Manager manifest (neither a shipped Package.swift nor a scaffolded
@@ -316,9 +366,9 @@ function hasPodspec(absSource /*: string */) /*: boolean */ {
316366
* (the Xcode build phase keys off it to fail the build).
317367
*/
318368
class MissingManifestError extends Error {
319-
/*:: missingManifests: Array<{name: string, npmName: string, hasPodspec: boolean}>; */
369+
/*:: missingManifests: Array<{name: string, npmName: string, hasPodspec: boolean, mixed?: boolean}>; */
320370
constructor(
321-
deps /*: Array<{name: string, npmName: string, hasPodspec: boolean}> */,
371+
deps /*: Array<{name: string, npmName: string, hasPodspec: boolean, mixed?: boolean}> */,
322372
) {
323373
super(
324374
`${deps.length} autolinked native module(s) have no Package.swift. ` +
@@ -336,9 +386,18 @@ class MissingManifestError extends Error {
336386
* the thrown error never drift.
337387
*/
338388
function reportMissingManifests(
339-
deps /*: Array<{name: string, npmName: string, hasPodspec: boolean}> */,
389+
deps /*: Array<{name: string, npmName: string, hasPodspec: boolean, mixed?: boolean}> */,
340390
) /*: MissingManifestError */ {
341391
for (const d of deps) {
392+
if (d.mixed === true) {
393+
console.error(
394+
`error: "${d.npmName}" has mixed Swift + Objective-C/C++ sources, which Swift Package Manager cannot compile in a single target (and its Swift↔ObjC interop typically can't be split into two targets without a circular dependency).\n` +
395+
` • Opt it out of SPM autolinking in your app's react-native.config.js:\n` +
396+
` module.exports = { dependencies: { '${d.npmName}': { platforms: { ios: null } } } };\n` +
397+
` • Or consume ${d.npmName} as a prebuilt binary (xcframework) instead.`,
398+
);
399+
continue;
400+
}
342401
if (d.hasPodspec) {
343402
console.error(
344403
`error: Package.swift is missing for library "${d.npmName}" — it ships no Swift Package Manager support.\n` +
@@ -1188,7 +1247,7 @@ function main(argv /*:: ?: Array<string> */) /*: void */ {
11881247
// fail with an actionable message after the classification pass. spmModules
11891248
// (app-local, podspec-less, explicitly declared in react-native.config.js)
11901249
// keep their synth wrappers: there is nothing to scaffold for them.
1191-
const missingManifests /*: Array<{name: string, npmName: string, hasPodspec: boolean}> */ =
1250+
const missingManifests /*: Array<{name: string, npmName: string, hasPodspec: boolean, mixed?: boolean}> */ =
11921251
[];
11931252

11941253
for (const entry of entries) {
@@ -1223,10 +1282,13 @@ function main(argv /*:: ?: Array<string> */) /*: void */ {
12231282
}
12241283
if (entry.origin === 'npm') {
12251284
// No shipped or scaffolded manifest — this is the gap we now surface.
1285+
// A mixed-language dep is reported distinctly (it can't be scaffolded at
1286+
// all, so "run spm scaffold" would be misleading).
12261287
missingManifests.push({
12271288
name: target.name,
12281289
npmName: entry.npmName ?? target.name,
12291290
hasPodspec: hasPodspec(absSource),
1291+
mixed: hasMixedLanguageSources(absSource),
12301292
});
12311293
// Drop any stale wrapper from a previous synth-mode run so SPM doesn't
12321294
// resolve against it.
@@ -1551,6 +1613,7 @@ module.exports = {
15511613
expandSpmSourceGlobs,
15521614
findSelfManagedPackageDir,
15531615
hasPodspec,
1616+
hasMixedLanguageSources,
15541617
MissingManifestError,
15551618
reportMissingManifests,
15561619
AUTOGEN_MARKER,

packages/react-native/scripts/spm/read-podspec.js

Lines changed: 37 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -124,8 +124,18 @@ function runPodIpcSpec(podspecPath /*: string */) /*: RawSpec | null */ {
124124
if (typeof result.stdout !== 'string' || result.stdout.length === 0) {
125125
return null;
126126
}
127+
// Some podspecs print diagnostics to stdout during evaluation (e.g. skia:
128+
// `-- SK_GRAPHITE: OFF ...`) before `pod ipc` emits the JSON. Parsing the
129+
// raw stdout then throws and we'd silently fall back to the (much weaker)
130+
// regex parser. Extract just the JSON object (first `{` … last `}`).
131+
const stdout = result.stdout;
132+
const start = stdout.indexOf('{');
133+
const end = stdout.lastIndexOf('}');
134+
if (start < 0 || end <= start) {
135+
return null;
136+
}
127137
try {
128-
return JSON.parse(result.stdout);
138+
return JSON.parse(stdout.slice(start, end + 1));
129139
} catch {
130140
return null;
131141
}
@@ -420,25 +430,33 @@ function flattenSubspecs(rawSpec /*: RawSpec */) /*: PodspecModel */ {
420430

421431
function mergeHeaderSearchPaths() /*: Array<string> */ {
422432
const out /*: Array<string> */ = [];
433+
// HSP can live in any of the xcconfig blocks, and a single value often
434+
// PACKS multiple space-separated paths, each individually quoted, plus a
435+
// CocoaPods `/**` recursive-glob suffix (e.g. skia:
436+
// `"$(SRCROOT)/cpp/"/** "$(SRCROOT)/cpp/skia" ...`). Shell-tokenize to keep
437+
// each path intact, then strip ALL quotes (not just wrapping) per token.
438+
const XCCONFIG_KEYS = [
439+
'pod_target_xcconfig',
440+
'xcconfig',
441+
'user_target_xcconfig',
442+
];
423443
for (const layer of layers) {
424-
// $FlowFixMe[incompatible-use] layer narrowed from `mixed`; runtime-validated below
425-
const xc =
426-
layer != null && typeof layer === 'object'
427-
? layer.pod_target_xcconfig
428-
: null;
429-
if (xc == null || typeof xc !== 'object') continue;
430-
// $FlowFixMe[incompatible-use] xc narrowed from `mixed`; HEADER_SEARCH_PATHS access is intentional
431-
const hsp = xc.HEADER_SEARCH_PATHS;
432-
if (typeof hsp === 'string') {
433-
// Single string — split on whitespace, strip quotes
434-
for (const tok of hsp.split(/\s+/)) {
435-
if (tok.length > 0) out.push(stripWrappingQuotes(tok));
436-
}
437-
} else if (Array.isArray(hsp)) {
438-
for (const v of hsp) {
439-
if (typeof v !== 'string') continue;
440-
for (const tok of v.split(/\s+/)) {
441-
if (tok.length > 0) out.push(stripWrappingQuotes(tok));
444+
if (layer == null || typeof layer !== 'object') continue;
445+
for (const xcKey of XCCONFIG_KEYS) {
446+
// $FlowFixMe[incompatible-use] layer narrowed from `mixed`
447+
const xc = layer[xcKey];
448+
if (xc == null || typeof xc !== 'object') continue;
449+
const hsp = xc.HEADER_SEARCH_PATHS;
450+
const values =
451+
typeof hsp === 'string'
452+
? [hsp]
453+
: Array.isArray(hsp)
454+
? hsp.filter(v => typeof v === 'string')
455+
: [];
456+
for (const v of values) {
457+
for (const tok of shellTokenize(v)) {
458+
const cleaned = tok.replace(/['"]/g, '');
459+
if (cleaned.length > 0) out.push(cleaned);
442460
}
443461
}
444462
}

0 commit comments

Comments
 (0)