Skip to content

Commit 3fc7ab3

Browse files
chrfalchclaude
andcommitted
fix(iOS): keep prebuilt Headers/ in place on a Debug/Release swap
An iOS Release build can fail in `PrecompileModule React` with seven `include of non-modular header inside framework module` errors, but only when the build follows a Debug/Release configuration switch (#57803). `replace-rncore-version.js` deleted and recreated `Pods/React-Core-prebuilt/Headers/` on a swap. That directory holds `module.modulemap`, which `rncore.rb` activates on every target through `-fmodule-map-file`. Nothing orders an unrelated target's dependency scan against this script phase, so a scan can run while the module map is missing; the React module is then precompiled without it and `<yoga/...>`, `<react/...>` and `<RCTDeprecation/...>` resolve non-modularly. Those headers never needed replacing. The prebuild compose job emits one set of ReactNativeHeaders for both configurations, so they are identical in the Debug and Release tarballs — only the compiled framework differs. Replace `React.xcframework` and nothing else. ## Changelog: [IOS] [FIXED] - Keep the prebuilt `Headers/` in place on a Debug/Release configuration switch so the React explicit module still resolves its module map Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent f2a250a commit 3fc7ab3

2 files changed

Lines changed: 141 additions & 102 deletions

File tree

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
/**
2+
* Copyright (c) Meta Platforms, Inc. and affiliates.
3+
*
4+
* This source code is licensed under the MIT license found in the
5+
* LICENSE file in the root directory of this source tree.
6+
*
7+
* @format
8+
* @noflow
9+
*/
10+
11+
'use strict';
12+
13+
const {replaceRNCoreConfiguration} = require('../replace-rncore-version');
14+
const {execFileSync} = require('node:child_process');
15+
const fs = require('node:fs');
16+
const os = require('node:os');
17+
const path = require('node:path');
18+
19+
const VERSION = '0.87.0-test';
20+
const SLICE = 'ios-arm64_x86_64-simulator';
21+
const BINARY = path.join(SLICE, 'React.framework', 'React');
22+
23+
function writeFile(filePath, contents) {
24+
fs.mkdirSync(path.dirname(filePath), {recursive: true});
25+
fs.writeFileSync(filePath, contents);
26+
}
27+
28+
function buildTarball(podsRoot, configuration) {
29+
const stage = fs.mkdtempSync(path.join(podsRoot, `stage-${configuration}-`));
30+
writeFile(path.join(stage, 'React.xcframework', 'Info.plist'), '<plist/>');
31+
writeFile(
32+
path.join(stage, 'React.xcframework', BINARY),
33+
`binary-${configuration}`,
34+
);
35+
const artifacts = path.join(podsRoot, 'ReactNativeCore-artifacts');
36+
fs.mkdirSync(artifacts, {recursive: true});
37+
execFileSync('tar', [
38+
'-czf',
39+
path.join(
40+
artifacts,
41+
`reactnative-core-${VERSION.toLowerCase()}-${configuration.toLowerCase()}.tar.gz`,
42+
),
43+
'-C',
44+
stage,
45+
'.',
46+
]);
47+
fs.rmSync(stage, {recursive: true, force: true});
48+
}
49+
50+
describe('replaceRNCoreConfiguration', () => {
51+
let podsRoot;
52+
let pod;
53+
let cwd;
54+
55+
beforeEach(() => {
56+
podsRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'rncore-test-'));
57+
pod = path.join(podsRoot, 'React-Core-prebuilt');
58+
// What the podspec prepare_command leaves behind after `pod install`.
59+
writeFile(
60+
path.join(pod, 'Headers', 'module.modulemap'),
61+
'module yoga {}\n',
62+
);
63+
writeFile(path.join(pod, 'React.xcframework', 'Info.plist'), '<plist/>');
64+
writeFile(path.join(pod, 'React.xcframework', BINARY), 'binary-Debug');
65+
buildTarball(podsRoot, 'Release');
66+
cwd = process.cwd();
67+
// The script phase runs with Pods/ as its working directory.
68+
process.chdir(podsRoot);
69+
});
70+
71+
afterEach(() => {
72+
process.chdir(cwd);
73+
fs.rmSync(podsRoot, {recursive: true, force: true});
74+
});
75+
76+
it('installs the framework for the requested configuration', () => {
77+
replaceRNCoreConfiguration('Release', VERSION, podsRoot);
78+
79+
expect(
80+
fs.readFileSync(path.join(pod, 'React.xcframework', BINARY), 'utf8'),
81+
).toBe('binary-Release');
82+
});
83+
84+
// Regression test for #57803: recreating the module map mid-build lets a
85+
// concurrent dependency scan miss it, and the React module then precompiles
86+
// without -fmodule-map-file and fails on non-modular includes.
87+
it('leaves Headers/module.modulemap untouched', () => {
88+
const moduleMap = path.join(pod, 'Headers', 'module.modulemap');
89+
const before = fs.statSync(moduleMap).ino;
90+
91+
replaceRNCoreConfiguration('Release', VERSION, podsRoot);
92+
93+
expect(fs.statSync(moduleMap).ino).toBe(before);
94+
});
95+
96+
it('fails when the tarball has no React.xcframework', () => {
97+
const stage = fs.mkdtempSync(path.join(podsRoot, 'stage-bad-'));
98+
writeFile(path.join(stage, 'unrelated.txt'), 'nope');
99+
execFileSync('tar', [
100+
'-czf',
101+
path.join(
102+
podsRoot,
103+
'ReactNativeCore-artifacts',
104+
`reactnative-core-${VERSION.toLowerCase()}-release.tar.gz`,
105+
),
106+
'-C',
107+
stage,
108+
'.',
109+
]);
110+
111+
expect(() =>
112+
replaceRNCoreConfiguration('Release', VERSION, podsRoot),
113+
).toThrow(/Extraction verification failed/);
114+
});
115+
});

packages/react-native/scripts/replace-rncore-version.js

Lines changed: 26 additions & 102 deletions
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ function replaceRNCoreConfiguration(
5959
configuration /*: string */,
6060
version /*: string */,
6161
podsRoot /*: string */,
62-
) {
62+
) /*: void */ {
6363
// Filename comes from rncore.rb
6464
const tarballURLPath = `${podsRoot}/ReactNativeCore-artifacts/reactnative-core-${version.toLowerCase()}-${configuration.toLowerCase()}.tar.gz`;
6565

@@ -73,18 +73,6 @@ function replaceRNCoreConfiguration(
7373
const tmpExtractDir = path.join(tmpDir, 'React-Core-prebuilt');
7474
fs.mkdirSync(tmpExtractDir, {recursive: true});
7575

76-
// Preserve Expo-generated modulemap before replacing directories
77-
const useFrameworksModulemapName = 'React-use-frameworks.modulemap';
78-
const useFrameworksModulemapPath = path.join(
79-
finalLocation,
80-
useFrameworksModulemapName,
81-
);
82-
let savedModulemap = null;
83-
if (fs.existsSync(useFrameworksModulemapPath)) {
84-
console.log('Preserving', useFrameworksModulemapName);
85-
savedModulemap = fs.readFileSync(useFrameworksModulemapPath);
86-
}
87-
8876
try {
8977
console.log('Extracting the tarball to temp dir', tarballURLPath);
9078
const result = spawnSync(
@@ -110,98 +98,30 @@ function replaceRNCoreConfiguration(
11098
);
11199
}
112100

113-
// Delete only directories in finalLocation (e.g. the React.xcframework) -
114-
// not files, so any sibling files written during pod install are preserved.
115-
const dirs = fs
116-
.readdirSync(finalLocation, {withFileTypes: true})
117-
.filter(dirent => dirent.isDirectory());
118-
for (const dirent of dirs) {
119-
const direntName =
120-
typeof dirent.name === 'string' ? dirent.name : dirent.name.toString();
121-
const dirPath = `${finalLocation}/${direntName}`;
122-
console.log('Removing directory', dirPath);
123-
fs.rmSync(dirPath, {force: true, recursive: true});
124-
}
125-
126-
// Move extracted directories from temp to final location
127-
const extractedEntries = fs
128-
.readdirSync(tmpExtractDir, {withFileTypes: true})
129-
.filter(dirent => dirent.isDirectory());
130-
for (const dirent of extractedEntries) {
131-
const direntName =
132-
typeof dirent.name === 'string' ? dirent.name : dirent.name.toString();
133-
const src = path.join(tmpExtractDir, direntName);
134-
const dst = path.join(finalLocation, direntName);
135-
const mvResult = spawnSync('mv', [src, dst], {stdio: 'inherit'});
136-
if (mvResult.status !== 0) {
137-
// Fallback: copy recursively then remove source
138-
console.log(`mv failed for ${direntName}, falling back to cp -R`);
139-
const cpResult = spawnSync('cp', ['-R', src, dst], {
140-
stdio: 'inherit',
141-
});
142-
if (cpResult.status !== 0) {
143-
throw new Error(
144-
`cp fallback failed with exit code ${cpResult.status}`,
145-
);
146-
}
101+
// Replace only the compiled framework. Headers/ is flattened from
102+
// ReactNativeHeaders by the podspec prepare_command, and the prebuild
103+
// compose job emits one set of those headers for both configurations, so a
104+
// config switch leaves them identical. Leaving them alone keeps
105+
// Headers/module.modulemap — which consumers activate through
106+
// -fmodule-map-file — in place for the whole build; deleting and recreating
107+
// it mid-build lets a concurrent dependency scan miss it, and the React
108+
// module then precompiles without it (#57803).
109+
const dest = path.join(finalLocation, 'React.xcframework');
110+
console.log('Replacing', dest);
111+
fs.rmSync(dest, {force: true, recursive: true});
112+
const mvResult = spawnSync('mv', [xcfwPath, dest], {stdio: 'inherit'});
113+
if (mvResult.status !== 0) {
114+
// Fallback: copy recursively then remove source
115+
console.log('mv failed for React.xcframework, falling back to cp -R');
116+
const cpResult = spawnSync('cp', ['-R', xcfwPath, dest], {
117+
stdio: 'inherit',
118+
});
119+
if (cpResult.status !== 0) {
120+
throw new Error(`cp fallback failed with exit code ${cpResult.status}`);
147121
}
148122
}
149-
150-
// The podspec prepare_command flattens ReactNativeHeaders' headers into a
151-
// top-level Headers/ dir, but it does not re-run on a config swap. Mirror
152-
// it here: re-flatten the headers (identical across slices) and drop the
153-
// now-redundant xcframework so $(PODS_ROOT)/React-Core-prebuilt/Headers
154-
// keeps resolving <react/...>, <yoga/...>, etc.
155-
//
156-
// Fail closed when the swapped-in tarball lacks ReactNativeHeaders: the
157-
// directory purge above already deleted the previous Headers/, so
158-
// continuing silently would leave the injected -fmodule-map-file flag
159-
// dangling and break every <react/...> include only on a config switch —
160-
// with no pointer to the version-skewed artifact that caused it.
161-
const rnhXcfw = path.join(finalLocation, 'ReactNativeHeaders.xcframework');
162-
if (!fs.existsSync(rnhXcfw)) {
163-
throw new Error(
164-
`ReactNativeHeaders.xcframework not found in the extracted tarball at ${finalLocation}. ` +
165-
'The downloaded artifact predates the headers-spec layout (or is incomplete); ' +
166-
'use a prebuilt tarball matching this react-native version.',
167-
);
168-
}
169-
const slice = fs
170-
.readdirSync(rnhXcfw, {withFileTypes: true})
171-
.find(
172-
dirent =>
173-
dirent.isDirectory() &&
174-
fs.existsSync(path.join(rnhXcfw, dirent.name.toString(), 'Headers')),
175-
);
176-
if (!slice) {
177-
throw new Error(
178-
`No slice with a Headers directory found inside ${rnhXcfw}.`,
179-
);
180-
}
181-
const headersDest = path.join(finalLocation, 'Headers');
182-
fs.rmSync(headersDest, {force: true, recursive: true});
183-
const cpHeaders = spawnSync(
184-
'cp',
185-
['-R', path.join(rnhXcfw, slice.name.toString(), 'Headers'), headersDest],
186-
{stdio: 'inherit'},
187-
);
188-
if (cpHeaders.status !== 0) {
189-
throw new Error(
190-
`Flattening ReactNativeHeaders failed with exit code ${cpHeaders.status}`,
191-
);
192-
}
193-
fs.rmSync(rnhXcfw, {force: true, recursive: true});
194123
} finally {
195-
// Clean up temp directory
196124
fs.rmSync(tmpDir, {force: true, recursive: true});
197-
198-
// Restore Expo-generated modulemap after directory replacement.
199-
// Runs in finally so it is not skipped if mv/cp partially fails.
200-
if (savedModulemap != null) {
201-
const restoredPath = path.join(finalLocation, useFrameworksModulemapName);
202-
fs.writeFileSync(restoredPath, savedModulemap);
203-
console.log('Restored', useFrameworksModulemapName);
204-
}
205125
}
206126
}
207127

@@ -252,4 +172,8 @@ const version = argv.reactNativeVersion;
252172
// $FlowFixMe[prop-missing]
253173
const podsRoot = argv.podsRoot;
254174

255-
main(configuration, version, podsRoot);
175+
if (require.main === module) {
176+
main(configuration, version, podsRoot);
177+
}
178+
179+
module.exports = {replaceRNCoreConfiguration};

0 commit comments

Comments
 (0)