Skip to content

Commit 19609e1

Browse files
chrfalchclaude
andcommitted
fix(spm): migration fixes surfaced by rn-tester + helloworld SPM builds
Migrating rn-tester (RNTesterPods.xcodeproj, in-place) and helloworld to SwiftPM surfaced five latent bugs: - scaffold: podspecs with root-level sources (s.source_files = "*.{h,m,mm}") got no publicHeadersPath, so SPM rejected the target over the nonexistent default include/. The scaffolder now mirrors root headers into a generated include/<SwiftName>/ shim dir (publicHeadersPath: "include"), giving dependents the CocoaPods header-map spelling `#import <SwiftName/Header.h>`. - scaffold: root podspec globs matched the scaffolder's own output on re-runs — Package.swift's `.swift` extension tripped the mixed-language gate into DELETING the manifest it had just written (runs alternated between scaffolding and self-destructing). Scaffold artifacts (Package.swift, the prefix header, include/ shims) are now excluded from source expansion. - autolinking: the one-time legacy-layout cleanup unconditionally removed `<dep>/include/`, nuking the scaffolder's shim dirs on every sync. It now only removes include/ together with a marker-bearing legacy in-place Package.swift. - scaffold: transitive spm.dependencies entries carry no podspecPath, so podspec-name sibling deps (s.dependency "TestLibraryCommon") could not be wired ("Unknown dependency"). The sibling map now discovers podspecs at those deps' roots. - add --deintegrate: a ${PODS_ROOT}-anchored REACT_NATIVE_PATH (the CocoaPods template default) dangles once CocoaPods is deintegrated, breaking the "Bundle React Native code and images" phase. The injection now replaces it with the SPM-computed path, recording the original in the marker so deinit restores it exactly. Also declares TestLibraryApple's real dependency on TestLibraryCommon in its podspec (CocoaPods resolved the undeclared edge leniently through the shared Public headers dir; SPM needs it for sibling package wiring). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 3167024 commit 19609e1

5 files changed

Lines changed: 132 additions & 11 deletions

File tree

packages/react-native-test-library/apple/TestLibraryApple.podspec

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,5 +19,11 @@ Pod::Spec.new do |s|
1919
s.source_files = "*.{h,m,mm,swift}"
2020
s.requires_arc = true
2121

22+
# TestLibraryApple.mm imports <ReactNativeTestLibraryCommon/TestLibraryCommon.h>.
23+
# CocoaPods resolves it leniently through the shared Public headers dir, but the
24+
# dependency edge must be declared for SwiftPM (the scaffolder wires sibling
25+
# packages from podspec dependencies).
26+
s.dependency "TestLibraryCommon"
27+
2228
install_modules_dependencies(s)
2329
end

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

Lines changed: 18 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1570,27 +1570,36 @@ function main(argv /*:: ?: Array<string> */) /*: void */ {
15701570
}
15711571
for (const absSource of entryAbsDirs.values()) {
15721572
const legacyPkg = path.join(absSource, 'Package.swift');
1573+
let removedLegacyPkg = false;
15731574
try {
15741575
const content = fs.readFileSync(legacyPkg, 'utf8');
15751576
if (content.includes(AUTOGEN_MARKER)) {
15761577
fs.unlinkSync(legacyPkg);
1578+
removedLegacyPkg = true;
15771579
log(
15781580
`Removed legacy in-place synth: ${path.relative(appRoot, legacyPkg)}`,
15791581
);
15801582
}
15811583
} catch {
15821584
// not present – fine
15831585
}
1584-
const legacyInclude = path.join(absSource, 'include');
1585-
try {
1586-
if (fs.lstatSync(legacyInclude).isDirectory()) {
1587-
fs.rmSync(legacyInclude, {recursive: true, force: true});
1588-
log(
1589-
`Removed legacy in-place include/: ${path.relative(appRoot, legacyInclude)}`,
1590-
);
1586+
// Only treat include/ as a legacy leftover when the in-place synth
1587+
// manifest was just removed alongside it: a SCAFFOLDED dep legitimately
1588+
// owns include/<SwiftName>/ (the namespaced shim headers behind its
1589+
// publicHeadersPath: "include") — nuking it unconditionally would break
1590+
// every dependent's `#import <SwiftName/Header.h>` on the next sync.
1591+
if (removedLegacyPkg) {
1592+
const legacyInclude = path.join(absSource, 'include');
1593+
try {
1594+
if (fs.lstatSync(legacyInclude).isDirectory()) {
1595+
fs.rmSync(legacyInclude, {recursive: true, force: true});
1596+
log(
1597+
`Removed legacy in-place include/: ${path.relative(appRoot, legacyInclude)}`,
1598+
);
1599+
}
1600+
} catch {
1601+
// not present – fine
15911602
}
1592-
} catch {
1593-
// not present – fine
15941603
}
15951604
}
15961605
}

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

Lines changed: 42 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,10 @@ type BuildSettingChange = {
9696
createdArrayKeys: Array<string>,
9797
appendedArrayValues: {[string]: Array<string>},
9898
createdScalars: Array<string>,
99+
// Scalars whose pre-injection value was replaced (key → original raw
100+
// value), e.g. a ${PODS_ROOT}-anchored REACT_NATIVE_PATH that dangles once
101+
// CocoaPods is deintegrated. Deinit restores the original.
102+
replacedScalars?: {[string]: string},
99103
};
100104
type SpmGraph = {
101105
uniquePackages: Array<{packagePath: string, packageName: string}>,
@@ -942,19 +946,44 @@ function mergeReactBuildSettings(
942946
}
943947
text = addArrayStringValues(text, d, key, values);
944948
}
949+
const replacedScalars /*: {[string]: string} */ = {};
945950
for (const {key, value} of scalars) {
946951
const d = dict();
947952
if (d == null) {
948953
continue;
949954
}
950-
if (findField(text, d, key) == null) {
955+
const existing = findField(text, d, key);
956+
if (existing == null) {
951957
createdScalars.push(key);
958+
} else if (
959+
key === 'REACT_NATIVE_PATH' &&
960+
existing.value.includes('PODS_ROOT')
961+
) {
962+
// A ${PODS_ROOT}-anchored REACT_NATIVE_PATH (the CocoaPods template
963+
// default) dangles once CocoaPods is deintegrated: PODS_ROOT resolves
964+
// empty at build time, so the Bundle React Native code and images
965+
// phase looks for "/../…/scripts/xcode/with-environment.sh". Replace
966+
// it with the SPM-computed path, recording the original for deinit.
967+
replacedScalars[key] = existing.value;
968+
text = removeField(text, d, key);
969+
const d2 = dict();
970+
if (d2 == null) {
971+
continue;
972+
}
973+
text = ensureScalarField(text, d2, key, value);
974+
continue;
952975
}
953976
text = ensureScalarField(text, d, key, value);
954977
}
955978
return {
956979
text,
957-
change: {configUuid, createdArrayKeys, appendedArrayValues, createdScalars},
980+
change: {
981+
configUuid,
982+
createdArrayKeys,
983+
appendedArrayValues,
984+
createdScalars,
985+
replacedScalars,
986+
},
958987
};
959988
}
960989

@@ -1270,6 +1299,17 @@ function removeSpmInjection(
12701299
text = removeField(text, d, key);
12711300
}
12721301
}
1302+
const replaced = change.replacedScalars ?? {};
1303+
for (const key of Object.keys(replaced)) {
1304+
const d = dict();
1305+
if (d != null) {
1306+
text = removeField(text, d, key);
1307+
const d2 = dict();
1308+
if (d2 != null) {
1309+
text = ensureScalarField(text, d2, key, replaced[key]);
1310+
}
1311+
}
1312+
}
12731313
}
12741314

12751315
writeIfChanged(pbxprojPath, text);

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

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -390,6 +390,18 @@ function translatePodspecToSpmTarget(
390390
);
391391
}
392392
}
393+
// Never self-ingest scaffold artifacts: a re-scaffold runs with the
394+
// previous run's output on disk, and root-level podspec globs sweep it
395+
// into sources — `*.{h,...,swift}` matches Package.swift itself, whose
396+
// `.swift` extension then trips the mixed-language gate into DELETING the
397+
// manifest it just wrote (alternating scaffold/self-destruct runs); the
398+
// prefix header and the include/ shim mirror would similarly feed back.
399+
expandedSources = expandedSources.filter(
400+
f =>
401+
f !== SCAFFOLD_PREFIX_HEADER &&
402+
f !== 'Package.swift' &&
403+
!f.startsWith('include/'),
404+
);
393405

394406
// Header-map emulation: CocoaPods builds a header map (USE_HEADERMAP=YES by
395407
// default) so a source can `#import "Foo.h"` by bare name regardless of which
@@ -457,6 +469,22 @@ function translatePodspecToSpmTarget(
457469
}
458470
}
459471
}
472+
let namespacedShimHeaders /*: Array<string> */ = [];
473+
if (publicHeadersPath == null) {
474+
// Root-level podspecs (`s.source_files = "*.{h,m,mm}"`) keep their
475+
// headers in the package root. CocoaPods exposes them to dependents as
476+
// `#import <PodName/Header.h>` via header maps; SPM has no equivalent, so
477+
// the scaffold writer mirrors them into a generated include/<SwiftName>/
478+
// shim dir and publicHeadersPath points there (also fixing SPM's
479+
// rejection over the nonexistent default `include/`).
480+
const rootHeaders = expandedSources.filter(
481+
f => f.endsWith('.h') && !f.includes('/'),
482+
);
483+
if (rootHeaders.length > 0) {
484+
namespacedShimHeaders = rootHeaders;
485+
publicHeadersPath = 'include';
486+
}
487+
}
460488
if (publicHeadersPath == null) {
461489
warnings.push(
462490
`Could not infer publicHeadersPath from source globs. SPM may reject the target — add a publicHeadersPath manually to the scaffolded Package.swift.`,
@@ -475,6 +503,7 @@ function translatePodspecToSpmTarget(
475503
weakFrameworks: model.weakFrameworks,
476504
compilerFlags: model.compilerFlags,
477505
publicHeadersPath,
506+
namespacedShimHeaders,
478507
resources: model.resources,
479508
warnings,
480509
};
@@ -960,6 +989,23 @@ function scaffoldPackageSwiftForDep(
960989
'utf8',
961990
);
962991
}
992+
// Mirror root-level headers into the generated include/<SwiftName>/ shim
993+
// dir (publicHeadersPath) so dependents can use the CocoaPods header-map
994+
// spelling `#import <SwiftName/Header.h>`.
995+
if (spec.namespacedShimHeaders.length > 0) {
996+
const shimDir = path.join(dep.root, 'include', spec.swiftName);
997+
fs.mkdirSync(shimDir, {recursive: true});
998+
for (const header of spec.namespacedShimHeaders) {
999+
fs.writeFileSync(
1000+
path.join(shimDir, header),
1001+
`// AUTO-SCAFFOLDED by react-native spm scaffold — the namespaced\n` +
1002+
`// spelling (<${spec.swiftName}/${header}>) of the package-root header,\n` +
1003+
`// matching how CocoaPods header maps expose it.\n` +
1004+
`#import "../../${header}"\n`,
1005+
'utf8',
1006+
);
1007+
}
1008+
}
9631009
}
9641010

9651011
return {
@@ -1070,6 +1116,21 @@ function scaffoldAll(
10701116
const podspecPath = dep.platforms?.ios?.podspecPath;
10711117
if (typeof podspecPath === 'string' && podspecPath.length > 0) {
10721118
podToNpm.set(path.basename(podspecPath, '.podspec'), dep.name);
1119+
} else {
1120+
// Transitive entries synthesized by expandSpmDependencies (declared via
1121+
// `spm.dependencies` in react-native.config.js) carry no podspecPath —
1122+
// discover the podspec at the dep root so podspec-name sibling deps
1123+
// (e.g. `s.dependency "TestLibraryCommon"`) still wire to their npm
1124+
// package.
1125+
try {
1126+
for (const f of fs.readdirSync(dep.root)) {
1127+
if (f.endsWith('.podspec')) {
1128+
podToNpm.set(path.basename(f, '.podspec'), dep.name);
1129+
}
1130+
}
1131+
} catch {
1132+
// unreadable dep root — sibling wiring falls back to the warning path
1133+
}
10731134
}
10741135
}
10751136

packages/react-native/scripts/spm/spm-types.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -338,6 +338,11 @@ export type SpmScaffoldSpec = {
338338
// Public-headers strategy. `mappingsDir` (when set) drives publicHeadersPath
339339
// so the autolinker's centralized headers tree exposes `#import <SwiftName/...>`.
340340
publicHeadersPath: ?string,
341+
// Root-level headers (podspecs like `s.source_files = "*.{h,m,mm}"`) that the
342+
// scaffold writer mirrors into a generated include/<SwiftName>/ shim dir so
343+
// the CocoaPods header-map spelling `#import <SwiftName/Header.h>` resolves
344+
// for dependents (publicHeadersPath is then "include").
345+
namespacedShimHeaders: Array<string>,
341346
// File paths (relative to dep root) the emitter should declare as `.copy(...)`
342347
// resources on the target.
343348
resources: Array<string>,

0 commit comments

Comments
 (0)