diff --git a/packages/react-native/scripts/__tests__/replace-rncore-version-test.js b/packages/react-native/scripts/__tests__/replace-rncore-version-test.js
new file mode 100644
index 00000000000..cc831940320
--- /dev/null
+++ b/packages/react-native/scripts/__tests__/replace-rncore-version-test.js
@@ -0,0 +1,115 @@
+/**
+ * Copyright (c) Meta Platforms, Inc. and affiliates.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ *
+ * @format
+ * @noflow
+ */
+
+'use strict';
+
+const {replaceRNCoreConfiguration} = require('../replace-rncore-version');
+const {execFileSync} = require('node:child_process');
+const fs = require('node:fs');
+const os = require('node:os');
+const path = require('node:path');
+
+const VERSION = '0.87.0-test';
+const SLICE = 'ios-arm64_x86_64-simulator';
+const BINARY = path.join(SLICE, 'React.framework', 'React');
+
+function writeFile(filePath, contents) {
+ fs.mkdirSync(path.dirname(filePath), {recursive: true});
+ fs.writeFileSync(filePath, contents);
+}
+
+function buildTarball(podsRoot, configuration) {
+ const stage = fs.mkdtempSync(path.join(podsRoot, `stage-${configuration}-`));
+ writeFile(path.join(stage, 'React.xcframework', 'Info.plist'), '');
+ writeFile(
+ path.join(stage, 'React.xcframework', BINARY),
+ `binary-${configuration}`,
+ );
+ const artifacts = path.join(podsRoot, 'ReactNativeCore-artifacts');
+ fs.mkdirSync(artifacts, {recursive: true});
+ execFileSync('tar', [
+ '-czf',
+ path.join(
+ artifacts,
+ `reactnative-core-${VERSION.toLowerCase()}-${configuration.toLowerCase()}.tar.gz`,
+ ),
+ '-C',
+ stage,
+ '.',
+ ]);
+ fs.rmSync(stage, {recursive: true, force: true});
+}
+
+describe('replaceRNCoreConfiguration', () => {
+ let podsRoot;
+ let pod;
+ let cwd;
+
+ beforeEach(() => {
+ podsRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'rncore-test-'));
+ pod = path.join(podsRoot, 'React-Core-prebuilt');
+ // What the podspec prepare_command leaves behind after `pod install`.
+ writeFile(
+ path.join(pod, 'Headers', 'module.modulemap'),
+ 'module yoga {}\n',
+ );
+ writeFile(path.join(pod, 'React.xcframework', 'Info.plist'), '');
+ writeFile(path.join(pod, 'React.xcframework', BINARY), 'binary-Debug');
+ buildTarball(podsRoot, 'Release');
+ cwd = process.cwd();
+ // The script phase runs with Pods/ as its working directory.
+ process.chdir(podsRoot);
+ });
+
+ afterEach(() => {
+ process.chdir(cwd);
+ fs.rmSync(podsRoot, {recursive: true, force: true});
+ });
+
+ it('installs the framework for the requested configuration', () => {
+ replaceRNCoreConfiguration('Release', VERSION, podsRoot);
+
+ expect(
+ fs.readFileSync(path.join(pod, 'React.xcframework', BINARY), 'utf8'),
+ ).toBe('binary-Release');
+ });
+
+ // Regression test for #57803: recreating the module map mid-build lets a
+ // concurrent dependency scan miss it, and the React module then precompiles
+ // without -fmodule-map-file and fails on non-modular includes.
+ it('leaves Headers/module.modulemap untouched', () => {
+ const moduleMap = path.join(pod, 'Headers', 'module.modulemap');
+ const before = fs.statSync(moduleMap).ino;
+
+ replaceRNCoreConfiguration('Release', VERSION, podsRoot);
+
+ expect(fs.statSync(moduleMap).ino).toBe(before);
+ });
+
+ it('fails when the tarball has no React.xcframework', () => {
+ const stage = fs.mkdtempSync(path.join(podsRoot, 'stage-bad-'));
+ writeFile(path.join(stage, 'unrelated.txt'), 'nope');
+ execFileSync('tar', [
+ '-czf',
+ path.join(
+ podsRoot,
+ 'ReactNativeCore-artifacts',
+ `reactnative-core-${VERSION.toLowerCase()}-release.tar.gz`,
+ ),
+ '-C',
+ stage,
+ '.',
+ ]);
+
+ expect(() =>
+ replaceRNCoreConfiguration('Release', VERSION, podsRoot),
+ ).toThrow(/Extraction verification failed/);
+ });
+});
diff --git a/packages/react-native/scripts/replace-rncore-version.js b/packages/react-native/scripts/replace-rncore-version.js
index 52d2218c4ae..b9c4cd36f12 100644
--- a/packages/react-native/scripts/replace-rncore-version.js
+++ b/packages/react-native/scripts/replace-rncore-version.js
@@ -59,7 +59,7 @@ function replaceRNCoreConfiguration(
configuration /*: string */,
version /*: string */,
podsRoot /*: string */,
-) {
+) /*: void */ {
// Filename comes from rncore.rb
const tarballURLPath = `${podsRoot}/ReactNativeCore-artifacts/reactnative-core-${version.toLowerCase()}-${configuration.toLowerCase()}.tar.gz`;
@@ -73,18 +73,6 @@ function replaceRNCoreConfiguration(
const tmpExtractDir = path.join(tmpDir, 'React-Core-prebuilt');
fs.mkdirSync(tmpExtractDir, {recursive: true});
- // Preserve Expo-generated modulemap before replacing directories
- const useFrameworksModulemapName = 'React-use-frameworks.modulemap';
- const useFrameworksModulemapPath = path.join(
- finalLocation,
- useFrameworksModulemapName,
- );
- let savedModulemap = null;
- if (fs.existsSync(useFrameworksModulemapPath)) {
- console.log('Preserving', useFrameworksModulemapName);
- savedModulemap = fs.readFileSync(useFrameworksModulemapPath);
- }
-
try {
console.log('Extracting the tarball to temp dir', tarballURLPath);
const result = spawnSync(
@@ -110,98 +98,30 @@ function replaceRNCoreConfiguration(
);
}
- // Delete only directories in finalLocation (e.g. the React.xcframework) -
- // not files, so any sibling files written during pod install are preserved.
- const dirs = fs
- .readdirSync(finalLocation, {withFileTypes: true})
- .filter(dirent => dirent.isDirectory());
- for (const dirent of dirs) {
- const direntName =
- typeof dirent.name === 'string' ? dirent.name : dirent.name.toString();
- const dirPath = `${finalLocation}/${direntName}`;
- console.log('Removing directory', dirPath);
- fs.rmSync(dirPath, {force: true, recursive: true});
- }
-
- // Move extracted directories from temp to final location
- const extractedEntries = fs
- .readdirSync(tmpExtractDir, {withFileTypes: true})
- .filter(dirent => dirent.isDirectory());
- for (const dirent of extractedEntries) {
- const direntName =
- typeof dirent.name === 'string' ? dirent.name : dirent.name.toString();
- const src = path.join(tmpExtractDir, direntName);
- const dst = path.join(finalLocation, direntName);
- const mvResult = spawnSync('mv', [src, dst], {stdio: 'inherit'});
- if (mvResult.status !== 0) {
- // Fallback: copy recursively then remove source
- console.log(`mv failed for ${direntName}, falling back to cp -R`);
- const cpResult = spawnSync('cp', ['-R', src, dst], {
- stdio: 'inherit',
- });
- if (cpResult.status !== 0) {
- throw new Error(
- `cp fallback failed with exit code ${cpResult.status}`,
- );
- }
+ // Replace only the compiled framework. Headers/ is flattened from
+ // ReactNativeHeaders by the podspec prepare_command, and the prebuild
+ // compose job emits one set of those headers for both configurations, so a
+ // config switch leaves them identical. Leaving them alone keeps
+ // Headers/module.modulemap — which consumers activate through
+ // -fmodule-map-file — in place for the whole build; deleting and recreating
+ // it mid-build lets a concurrent dependency scan miss it, and the React
+ // module then precompiles without it (#57803).
+ const dest = path.join(finalLocation, 'React.xcframework');
+ console.log('Replacing', dest);
+ fs.rmSync(dest, {force: true, recursive: true});
+ const mvResult = spawnSync('mv', [xcfwPath, dest], {stdio: 'inherit'});
+ if (mvResult.status !== 0) {
+ // Fallback: copy recursively then remove source
+ console.log('mv failed for React.xcframework, falling back to cp -R');
+ const cpResult = spawnSync('cp', ['-R', xcfwPath, dest], {
+ stdio: 'inherit',
+ });
+ if (cpResult.status !== 0) {
+ throw new Error(`cp fallback failed with exit code ${cpResult.status}`);
}
}
-
- // The podspec prepare_command flattens ReactNativeHeaders' headers into a
- // top-level Headers/ dir, but it does not re-run on a config swap. Mirror
- // it here: re-flatten the headers (identical across slices) and drop the
- // now-redundant xcframework so $(PODS_ROOT)/React-Core-prebuilt/Headers
- // keeps resolving , , etc.
- //
- // Fail closed when the swapped-in tarball lacks ReactNativeHeaders: the
- // directory purge above already deleted the previous Headers/, so
- // continuing silently would leave the injected -fmodule-map-file flag
- // dangling and break every include only on a config switch —
- // with no pointer to the version-skewed artifact that caused it.
- const rnhXcfw = path.join(finalLocation, 'ReactNativeHeaders.xcframework');
- if (!fs.existsSync(rnhXcfw)) {
- throw new Error(
- `ReactNativeHeaders.xcframework not found in the extracted tarball at ${finalLocation}. ` +
- 'The downloaded artifact predates the headers-spec layout (or is incomplete); ' +
- 'use a prebuilt tarball matching this react-native version.',
- );
- }
- const slice = fs
- .readdirSync(rnhXcfw, {withFileTypes: true})
- .find(
- dirent =>
- dirent.isDirectory() &&
- fs.existsSync(path.join(rnhXcfw, dirent.name.toString(), 'Headers')),
- );
- if (!slice) {
- throw new Error(
- `No slice with a Headers directory found inside ${rnhXcfw}.`,
- );
- }
- const headersDest = path.join(finalLocation, 'Headers');
- fs.rmSync(headersDest, {force: true, recursive: true});
- const cpHeaders = spawnSync(
- 'cp',
- ['-R', path.join(rnhXcfw, slice.name.toString(), 'Headers'), headersDest],
- {stdio: 'inherit'},
- );
- if (cpHeaders.status !== 0) {
- throw new Error(
- `Flattening ReactNativeHeaders failed with exit code ${cpHeaders.status}`,
- );
- }
- fs.rmSync(rnhXcfw, {force: true, recursive: true});
} finally {
- // Clean up temp directory
fs.rmSync(tmpDir, {force: true, recursive: true});
-
- // Restore Expo-generated modulemap after directory replacement.
- // Runs in finally so it is not skipped if mv/cp partially fails.
- if (savedModulemap != null) {
- const restoredPath = path.join(finalLocation, useFrameworksModulemapName);
- fs.writeFileSync(restoredPath, savedModulemap);
- console.log('Restored', useFrameworksModulemapName);
- }
}
}
@@ -252,4 +172,8 @@ const version = argv.reactNativeVersion;
// $FlowFixMe[prop-missing]
const podsRoot = argv.podsRoot;
-main(configuration, version, podsRoot);
+if (require.main === module) {
+ main(configuration, version, podsRoot);
+}
+
+module.exports = {replaceRNCoreConfiguration};