Skip to content

Commit 2a3ae9a

Browse files
committed
refactor(spm): replace VFS overlay with a materialized merged header tree
Consuming Package.swift manifests previously re-derived a block of header config (locate React.xcframework, resolve its cache-slot symlink, compute xcfwHeaders/depsHeaders, reference a per-app React-VFS.yaml overlay, and pass -ivfsoverlay + several -I per target). This duplicated fragile path logic across every generated, scaffolded, and hand-authored manifest. Materialize a single natural-layout merged header tree (build/xcframeworks/ReactHeadersAll) at spm setup/sync time and have every consumer reference it with one `-I`. The tree is built by reusing the React-VFS-template.yaml already shipped in React.xcframework, with the ReactNativeDependencies, codegen, and autolinking headers folded in. One canonical file per import path means a single header identity, so the VFS overlay is no longer needed for SPM. - spm-utils.js: add buildMergedHeaderTree() + shared rnHeaders flag helpers; remove resolveAndWriteVFSOverlay() and the codegen slot-path baking. - generate-spm-autolinking.js / scaffold-package-swift.js / the codegen Package.swift template: emit a single `-I rnHeaders` (+ -fno-implicit-module-maps for the clang library targets) instead of the VFS overlay. - generate-spm-xcodeproj.js: wire the app target to the merged tree for the `import React` path (no -fno-implicit-module-maps here — it breaks SDK module resolution); drop the Prepare VFS Overlay phase. - setup-apple-spm.js / sync-spm-autolinking.js: build the tree where the VFS overlay was resolved before. CocoaPods and the xcframework build are untouched. Verified by building rn-tester (package-root layout) and helloworld (template layout), plus the SPM jest suite.
1 parent 5c0e60f commit 2a3ae9a

10 files changed

Lines changed: 456 additions & 348 deletions

packages/react-native/scripts/codegen/templates/Package.swift.spm-template

Lines changed: 10 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -16,14 +16,11 @@ import Foundation
1616
// Derive all paths from this file's location.
1717
let packageDir = URL(fileURLWithPath: #filePath).deletingLastPathComponent().path
1818
let appRoot = URL(fileURLWithPath: packageDir + "/../../..").standardized.path
19-
// xcfwHeaders / depsHeaders: when the install step resolves the current
20-
// cache-slot symlinks, the placeholders below are replaced with absolute
21-
// string literals so SPM's manifest hash bumps on every slot change
22-
// (otherwise the cached `.resolvingSymlinksInPath()` value would stick on
23-
// the prior slot). Unsubstituted, falls back to the runtime expression.
24-
let xcfwHeaders = __SPM_XCFW_HEADERS_EXPR__
25-
let depsHeaders = __SPM_DEPS_HEADERS_EXPR__
26-
let vfsOverlay = appRoot + "/build/xcframeworks/React-VFS.yaml"
19+
// Single natural-layout merged header tree (React + deps + codegen +
20+
// autolinking), materialized at setup/sync time by buildMergedHeaderTree. The
21+
// path is stable across cache slots — only the symlink contents change — so the
22+
// manifest hash never churns.
23+
let rnHeaders = appRoot + "/build/xcframeworks/ReactHeadersAll"
2724

2825
let package = Package(
2926
name: "React-GeneratedCode",
@@ -51,15 +48,15 @@ let package = Package(
5148
publicHeadersPath: ".",
5249
cSettings: [
5350
.headerSearchPath("headers"),
54-
.unsafeFlags(["-ivfsoverlay", vfsOverlay, "-I", xcfwHeaders]),
51+
.unsafeFlags(["-I", rnHeaders]),
5552
],
5653
cxxSettings: [
5754
.headerSearchPath("headers"),
5855
.unsafeFlags(["-std=c++20"]),
5956
// -fno-implicit-module-maps prevents clang from matching <react/...> to
6057
// React.framework (case-insensitive) in the build products dir, allowing
61-
// -I and VFS overlay to resolve C++ headers correctly.
62-
.unsafeFlags(["-fno-implicit-module-maps", "-ivfsoverlay", vfsOverlay, "-I", xcfwHeaders, "-I", depsHeaders]),
58+
// -I to resolve the C++ headers from the merged tree correctly.
59+
.unsafeFlags(["-fno-implicit-module-maps", "-I", rnHeaders]),
6360
],
6461
linkerSettings: [
6562
.linkedFramework("Foundation")
@@ -74,13 +71,13 @@ let package = Package(
7471
cSettings: [
7572
.headerSearchPath(".."),
7673
.headerSearchPath("headers"),
77-
.unsafeFlags(["-ivfsoverlay", vfsOverlay, "-I", xcfwHeaders]),
74+
.unsafeFlags(["-I", rnHeaders]),
7875
],
7976
cxxSettings: [
8077
.headerSearchPath(".."),
8178
.headerSearchPath("headers"),
8279
.unsafeFlags(["-std=c++20"]),
83-
.unsafeFlags(["-fno-implicit-module-maps", "-ivfsoverlay", vfsOverlay, "-I", xcfwHeaders, "-I", depsHeaders]),
80+
.unsafeFlags(["-fno-implicit-module-maps", "-I", rnHeaders]),
8481
],
8582
linkerSettings: [
8683
.linkedFramework("Foundation")

packages/react-native/scripts/setup-apple-spm.js

Lines changed: 5 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -94,14 +94,14 @@ const {
9494
} = require('./spm/generate-spm-xcodeproj');
9595
const {scaffoldAll} = require('./spm/scaffold-package-swift');
9696
const {
97+
buildMergedHeaderTree,
9798
defaultCacheDir,
9899
deriveAppName,
99100
displayPath,
100101
findProjectRoot,
101102
installSpmCodegenTemplate,
102103
makeLogger,
103104
readPackageJson,
104-
resolveAndWriteVFSOverlay,
105105
runCodegenAndInstallTemplate,
106106
} = require('./spm/spm-utils');
107107
const fs = require('fs');
@@ -1510,16 +1510,12 @@ async function main(argv /*:: ?: Array<string> */) /*: Promise<void> */ {
15101510
return;
15111511
}
15121512

1513-
// Re-install the codegen Package.swift template AFTER the xcframework
1514-
// symlinks have been created. The earlier install (during runCodegenStep)
1515-
// hits resolveHeadersAbsolute's catch block because the symlinks don't
1516-
// exist yet — the template falls back to a runtime URL expression. SPM
1517-
// caches that manifest evaluation and never re-resolves on slot changes,
1518-
// so a stale slot path leaks into compile args. Re-installing now bakes
1519-
// the absolute path in, changing the manifest content and the SPM hash.
1513+
// (Re)install the static codegen Package.swift template once build/generated/ios exists.
15201514
installSpmCodegenTemplate(appRoot, reactNativeRoot, {log});
15211515

1522-
resolveAndWriteVFSOverlay(appRoot, reactNativeRoot, {log});
1516+
// Materialize the merged header tree (replaces the VFS overlay). Runs last:
1517+
// it folds in the xcframework, codegen, and autolinking headers.
1518+
buildMergedHeaderTree(appRoot, {log});
15231519

15241520
let migrationRename /*: {from: string, to: string} | null */ = null;
15251521
if (action === 'init') {

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

Lines changed: 22 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -83,8 +83,10 @@ describe('generateAutolinkedPackageSwift (aggregator)', () => {
8383
expect(result).toMatch(
8484
/name: "ScreenshotManager",[\s\S]*?\.product\(name: "ReactNative", package: "ReactNative"\)/,
8585
);
86-
// Inline target gets cFlags / linker frameworks
87-
expect(result).toContain('-ivfsoverlay');
86+
// Inline target gets the merged-header include + linker frameworks, and no
87+
// legacy VFS overlay flag.
88+
expect(result).toContain('"-I", rnHeaders');
89+
expect(result).not.toContain('-ivfsoverlay');
8890
expect(result).toContain('.linkedFramework("CoreGraphics")');
8991
});
9092

@@ -182,20 +184,25 @@ describe('generateSynthPackageSwift', () => {
182184
);
183185
});
184186

185-
it('embeds xcfwHeaders / vfsOverlay flags when hasXcfwHeaders is true', () => {
187+
it('emits a single -I into the merged header tree when hasXcfwHeaders is true', () => {
186188
const result = generateSynthPackageSwift(baseSpec({hasXcfwHeaders: true}));
187-
expect(result).toContain('let xcfwHeaders');
188-
expect(result).toContain('let vfsOverlay');
189-
expect(result).toContain('"-ivfsoverlay"');
190-
expect(result).toContain('"-I", xcfwHeaders');
189+
expect(result).toContain(
190+
'let rnHeaders = appRoot + "/build/xcframeworks/ReactHeadersAll"',
191+
);
192+
expect(result).toContain('"-I", rnHeaders');
193+
// The legacy VFS overlay + per-framework derivation is gone.
194+
expect(result).not.toContain('-ivfsoverlay');
195+
expect(result).not.toContain('let xcfwHeaders');
196+
expect(result).not.toContain('let vfsOverlay');
191197
});
192198

193-
it('adds depsHeaders -I to cxxSettings only when hasDepsHeaders is true', () => {
199+
it('covers deps/codegen via the single merged tree (no separate depsHeaders -I)', () => {
194200
const result = generateSynthPackageSwift(
195201
baseSpec({hasXcfwHeaders: true, hasDepsHeaders: true}),
196202
);
197-
expect(result).toContain('let depsHeaders');
198-
expect(result).toContain('"-I", depsHeaders');
203+
expect(result).toContain('"-I", rnHeaders');
204+
expect(result).not.toContain('let depsHeaders');
205+
expect(result).not.toContain('"-I", depsHeaders');
199206
});
200207

201208
it('emits exclude list when given', () => {
@@ -295,7 +302,7 @@ describe('generateSynthPackageSwift', () => {
295302
expect(result).toContain('path: "root"');
296303
});
297304

298-
it('wrapper-dir mode: autogenHeadersAbsolute adds -I to cSettings + cxxSettings (replaces publicHeadersPath)', () => {
305+
it('wrapper-dir mode: routes all includes through the single merged tree (autolinking headers folded in)', () => {
299306
const result = generateSynthPackageSwift({
300307
swiftName: 'MyDep',
301308
hasReactDep: true,
@@ -304,14 +311,12 @@ describe('generateSynthPackageSwift', () => {
304311
appRootAbsolute: '/abs/app',
305312
autogenHeadersAbsolute: '/abs/app/build/generated/autolinking/headers',
306313
});
307-
expect(result).toContain(
314+
// The autolinking headers dir is folded into ReactHeadersAll, so it is no
315+
// longer a separate -I; only the merged tree include remains.
316+
expect(result).toContain('"-I", rnHeaders');
317+
expect(result).not.toContain(
308318
'"-I", "/abs/app/build/generated/autolinking/headers"',
309319
);
310-
// The -I appears in both cSettings (Obj-C .m) and cxxSettings (.mm/.cpp).
311-
const matches = result.match(
312-
/"-I", "\/abs\/app\/build\/generated\/autolinking\/headers"/g,
313-
);
314-
expect(matches && matches.length).toBeGreaterThanOrEqual(2);
315320
});
316321

317322
it('wrapper-dir mode: omits publicHeadersPath (headers route through -I instead)', () => {

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

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -110,11 +110,12 @@ describe('generatePbxproj', () => {
110110
expect(result).toContain('Sync SPM Autolinking');
111111
expect(result).toContain('npx react-native spm sync');
112112
expect(result).toContain('Build JS Bundle');
113-
expect(result).toContain('Prepare VFS Overlay');
114-
// Sync must run before VFS overlay
113+
// No VFS overlay phase anymore — headers come from the merged tree.
114+
expect(result).not.toContain('Prepare VFS Overlay');
115+
// Sync must run before the JS bundle phase.
115116
const syncIdx = result.indexOf('Sync SPM Autolinking');
116-
const vfsIdx = result.indexOf('Prepare VFS Overlay');
117-
expect(syncIdx).toBeLessThan(vfsIdx);
117+
const bundleIdx = result.indexOf('Build JS Bundle');
118+
expect(syncIdx).toBeLessThan(bundleIdx);
118119
});
119120

120121
it('sync script checks workspace lockfiles and hoisted node_modules', () => {

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

Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
'use strict';
1212

1313
const {
14+
buildMergedHeaderTree,
1415
defaultCacheDir,
1516
displayPath,
1617
makeLogger,
@@ -288,3 +289,164 @@ describe('runCodegenAndInstallTemplate', () => {
288289
expect(fs.existsSync(codegenPkgSwift)).toBe(false);
289290
});
290291
});
292+
293+
// ---------------------------------------------------------------------------
294+
// buildMergedHeaderTree
295+
// ---------------------------------------------------------------------------
296+
describe('buildMergedHeaderTree', () => {
297+
let tempDir;
298+
let appRoot;
299+
let xcfwDir;
300+
let outDir;
301+
302+
function writeFile(p, contents) {
303+
fs.mkdirSync(path.dirname(p), {recursive: true});
304+
fs.writeFileSync(p, contents);
305+
}
306+
307+
beforeEach(() => {
308+
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'spm-merged-test-'));
309+
appRoot = path.join(tempDir, 'app');
310+
xcfwDir = path.join(appRoot, 'build', 'xcframeworks');
311+
outDir = path.join(xcfwDir, 'ReactHeadersAll');
312+
313+
const reactXcfw = path.join(xcfwDir, 'React.xcframework');
314+
// Namespaced physical React headers (as shipped in the xcframework).
315+
writeFile(
316+
path.join(reactXcfw, 'Headers', 'React_Fabric', 'react', 'foo', 'Bar.h'),
317+
'#pragma once\n// react Bar\n',
318+
);
319+
// React_RCTAppDelegate headers — host apps import these BARE, so they must
320+
// also surface at the merged-tree root.
321+
writeFile(
322+
path.join(
323+
reactXcfw,
324+
'Headers',
325+
'React_RCTAppDelegate',
326+
'RCTDefaultReactNativeFactoryDelegate.h',
327+
),
328+
'#pragma once\n// app delegate\n',
329+
);
330+
// VFS template mapping the virtual <react/foo/Bar.h> to that physical file.
331+
writeFile(
332+
path.join(reactXcfw, 'React-VFS-template.yaml'),
333+
[
334+
'version: 0',
335+
"case-sensitive: false",
336+
'roots:',
337+
" - name: '${ROOT_PATH}/Headers'",
338+
" type: 'directory'",
339+
" contents:",
340+
" - name: 'react'",
341+
" type: 'directory'",
342+
" contents:",
343+
" - name: 'foo'",
344+
" type: 'directory'",
345+
" contents:",
346+
" - name: 'Bar.h'",
347+
" type: 'file'",
348+
" external-contents: '${ROOT_PATH}/Headers/React_Fabric/react/foo/Bar.h'",
349+
'',
350+
].join('\n'),
351+
);
352+
// Deps headers (natural layout, folded in).
353+
writeFile(
354+
path.join(
355+
xcfwDir,
356+
'ReactNativeDependencies.xcframework',
357+
'Headers',
358+
'folly',
359+
'dynamic.h',
360+
),
361+
'#pragma once\n// folly\n',
362+
);
363+
// Autolinking header farm — a SYMLINK farm (leaf headers are symlinks to
364+
// the dep's real source). foldDir must follow symlinks, not skip them.
365+
const realProviderHeader = path.join(tempDir, 'src', 'Provider.h');
366+
writeFile(realProviderHeader, '#pragma once\n// provider\n');
367+
const farmHeader = path.join(
368+
appRoot,
369+
'build',
370+
'generated',
371+
'autolinking',
372+
'headers',
373+
'MyLib',
374+
'Provider.h',
375+
);
376+
fs.mkdirSync(path.dirname(farmHeader), {recursive: true});
377+
fs.symlinkSync(realProviderHeader, farmHeader);
378+
// Codegen header (folded in) + a duplicate of the React virtual path with
379+
// DIFFERENT content, to prove React (folded first) wins.
380+
writeFile(
381+
path.join(
382+
appRoot,
383+
'build',
384+
'generated',
385+
'ios',
386+
'ReactCodegen',
387+
'react',
388+
'renderer',
389+
'EventEmitters.h',
390+
),
391+
'#pragma once\n// codegen\n',
392+
);
393+
writeFile(
394+
path.join(appRoot, 'build', 'generated', 'ios', 'react', 'foo', 'Bar.h'),
395+
'#pragma once\n// codegen Bar (should NOT win)\n',
396+
);
397+
});
398+
399+
afterEach(() => {
400+
fs.rmSync(tempDir, {recursive: true, force: true});
401+
});
402+
403+
it('materializes a merged tree of symlinks from the VFS template + folded dirs', () => {
404+
const result = buildMergedHeaderTree(appRoot);
405+
expect(result).toBe(outDir);
406+
407+
const reactHeader = path.join(outDir, 'react', 'foo', 'Bar.h');
408+
const follyHeader = path.join(outDir, 'folly', 'dynamic.h');
409+
const codegenHeader = path.join(
410+
outDir,
411+
'react',
412+
'renderer',
413+
'EventEmitters.h',
414+
);
415+
// React header resolves via its natural import path...
416+
expect(fs.existsSync(reactHeader)).toBe(true);
417+
expect(fs.lstatSync(reactHeader).isSymbolicLink()).toBe(true);
418+
// ...and deps + codegen headers are folded into the same tree.
419+
expect(fs.existsSync(follyHeader)).toBe(true);
420+
expect(fs.existsSync(codegenHeader)).toBe(true);
421+
// ...including the symlinked autolinking-farm header (foldDir follows symlinks).
422+
expect(fs.existsSync(path.join(outDir, 'MyLib', 'Provider.h'))).toBe(true);
423+
});
424+
425+
it('exposes React_RCTAppDelegate headers BARE at the root (host apps import them unprefixed)', () => {
426+
buildMergedHeaderTree(appRoot);
427+
// bare — what `#import <RCTDefaultReactNativeFactoryDelegate.h>` needs.
428+
expect(
429+
fs.existsSync(path.join(outDir, 'RCTDefaultReactNativeFactoryDelegate.h')),
430+
).toBe(true);
431+
});
432+
433+
it('collapses a duplicate virtual path to the first (React) source', () => {
434+
buildMergedHeaderTree(appRoot);
435+
// build/generated/ios also has react/foo/Bar.h, but React is folded first,
436+
// so the merged entry must resolve to the React copy.
437+
const merged = fs.readFileSync(
438+
path.join(outDir, 'react', 'foo', 'Bar.h'),
439+
'utf8',
440+
);
441+
expect(merged).toContain('react Bar');
442+
expect(merged).not.toContain('should NOT win');
443+
});
444+
445+
it('returns null when the xcframework is absent', () => {
446+
fs.rmSync(path.join(xcfwDir, 'React.xcframework'), {
447+
recursive: true,
448+
force: true,
449+
});
450+
expect(buildMergedHeaderTree(appRoot)).toBe(null);
451+
});
452+
});

0 commit comments

Comments
 (0)