From 3fc7ab33099351947237efac76c09e6e8595ad1e Mon Sep 17 00:00:00 2001 From: Christian Falch Date: Tue, 4 Aug 2026 16:35:12 +0200 Subject: [PATCH] fix(iOS): keep prebuilt Headers/ in place on a Debug/Release swap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 ``, `` and `` 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 --- .../__tests__/replace-rncore-version-test.js | 115 ++++++++++++++++ .../scripts/replace-rncore-version.js | 128 ++++-------------- 2 files changed, 141 insertions(+), 102 deletions(-) create mode 100644 packages/react-native/scripts/__tests__/replace-rncore-version-test.js 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 000000000000..cc8319403209 --- /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 52d2218c4ae6..b9c4cd36f120 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};