mirror of
https://github.com/react/react-native.git
synced 2026-09-28 13:23:09 +08:00
Summary:
Fixes https://github.com/react/react-native/issues/57803. 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.
`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. This replaces `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
Pull Request resolved: https://github.com/react/react-native/pull/57814
Test Plan:
The premise, on the published 0.87.0-rc.3 artifacts (`ios-arm64_x86_64-simulator`):
| compared between the Debug and Release tarballs | result |
| --- | --- |
| `ReactNativeHeaders…/Headers/module.modulemap` | identical |
| `React.framework/Modules/module.modulemap` | identical |
| `ReactNativeHeaders…/Headers` tree (`diff -rq`) | 0 differences |
| `React.framework/Headers` tree (`diff -rq`) | 0 differences |
The reproducer from https://github.com/react/react-native/issues/57803, on Xcode 26.3 with CocoaPods 1.15.2:
| build | result |
| --- | --- |
| 0.87.0-rc.3 | **FAIL** — exit 65, 7 errors |
| 0.87.0-rc.3 + this PR | **PASS** — `** BUILD SUCCEEDED **`, 0 errors |
The swap still does its job in the passing build — it logs `Replacing React-Core-prebuilt/React.xcframework`, and the installed binary is the Release one:
```
installed: 55225ccbc283c57c614ff4caf263cb63bad3828240e62cee8893e7001774bd6c
rc3 release: 55225ccbc283c57c614ff4caf263cb63bad3828240e62cee8893e7001774bd6c
rc3 debug: 516215801a6f8a86640aae13c2f2de1bbdb95189edf124e208f528b1497c7e4c
```
A Release→Debug swap was verified the same way. Across a swap, `Headers/module.modulemap` keeps its inode while `React.xcframework` gets a new one.
## Unit tests
Adds a unit test for the script, 4 cases: correct framework installed, `Headers/module.modulemap` untouched, an Expo-generated `React-use-frameworks.modulemap` left in place, and a fail-closed case on a tarball with no `React.xcframework`. The script needed a `require.main === module` guard and one export to be importable.
```
js1 test xplat/js/react-native-github/packages/react-native/scripts/__tests__/replace-rncore-version-test.js
→ 4 passed, 4 total
```
The module-map case is a real regression test, not just a pin. Restoring the pre-fix delete-and-recreate makes it fail on the inode assertion while the other three keep passing:
```
✕ leaves Headers/module.modulemap untouched
Expected: 735095485
Received: 735095515
```
That only works because the fixture tarball also ships `ReactNativeHeaders.xcframework`. Without it the pre-fix code throws its fail-closed error before reaching the assertion, so the test would go red for the wrong reason and would not actually be guarding #57803.
The Expo case covers behaviour this diff removes the explicit protection for. The old save-and-restore of `React-use-frameworks.modulemap` (ada39220a0) is unnecessary now that only `React.xcframework` is replaced, but nothing else pins it.
## Argument parsing
`yargs` parsing moved inside the `require.main === module` guard, so importing the module no longer parses `process.argv`. Verified in both directions.
The command line still performs the swap end to end:
```
$ node replace-rncore-version.js -c Release -r <version> -p <podsRoot>
Replacing React-Core-prebuilt/React.xcframework
Updating React-Core-prebuilt/.last_build_configuration with Release
Done replacing React Native prebuilt
installed binary: binary-Release
module.modulemap inode before=735141703 after=735141703
last_build marker: Release
```
Importing with hostile argv (`-c` collides with jest's `--config`) has no side effects:
```
$ node -e "process.argv = ['node','jest','-c','jest.config.js','--version']; require('./replace-rncore-version.js')"
imported OK, exports: replaceRNCoreConfiguration
```
`arc lint` is clean on both files.
Reviewed By: zeyap
Differential Revision: D114735639
Pulled By: fabriziocucci
fbshipit-source-id: 35ead7dae9ce7ad7160005a15ecb3975817ae728
180 lines
6.0 KiB
JavaScript
180 lines
6.0 KiB
JavaScript
/**
|
|
* 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.
|
|
*
|
|
* @flow strict-local
|
|
* @format
|
|
*/
|
|
|
|
'use strict';
|
|
|
|
const {spawnSync} = require('child_process');
|
|
const fs = require('fs');
|
|
const os = require('os');
|
|
const path = require('path');
|
|
const yargs = require('yargs');
|
|
|
|
const LAST_BUILD_FILENAME = 'React-Core-prebuilt/.last_build_configuration';
|
|
|
|
function validateBuildConfiguration(configuration /*: string */) {
|
|
if (!['Debug', 'Release'].includes(configuration)) {
|
|
throw new Error(`Invalid configuration ${configuration}`);
|
|
}
|
|
}
|
|
|
|
function validateVersion(version /*: ?string */) {
|
|
if (version == null || version === '') {
|
|
throw new Error('Version cannot be empty');
|
|
}
|
|
}
|
|
|
|
function shouldReplaceRnCoreConfiguration(configuration /*: string */) {
|
|
const fileExists = fs.existsSync(LAST_BUILD_FILENAME);
|
|
|
|
if (fileExists) {
|
|
console.log(`Found ${LAST_BUILD_FILENAME} file`);
|
|
const oldConfiguration = fs.readFileSync(LAST_BUILD_FILENAME).toString();
|
|
if (oldConfiguration === configuration) {
|
|
console.log(
|
|
'Same config of the previous build. No need to replace React-Core-prebuilt',
|
|
);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// Assumption: if there is no stored last build, we assume that it was build for debug.
|
|
if (!fileExists && configuration === 'Debug') {
|
|
console.log(
|
|
'No previous build detected, but Debug Configuration. No need to replace React-Core-prebuilt',
|
|
);
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
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`;
|
|
|
|
const finalLocation = 'React-Core-prebuilt';
|
|
|
|
// Extract to a temporary directory on a regular filesystem first, then move
|
|
// into the final location. This avoids issues with partial tar extraction on
|
|
// certain filesystems (e.g. EdenFS) where extracting directly can silently
|
|
// produce incomplete results.
|
|
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'rncore-'));
|
|
const tmpExtractDir = path.join(tmpDir, 'React-Core-prebuilt');
|
|
fs.mkdirSync(tmpExtractDir, {recursive: true});
|
|
|
|
try {
|
|
console.log('Extracting the tarball to temp dir', tarballURLPath);
|
|
const result = spawnSync(
|
|
'tar',
|
|
['-xf', tarballURLPath, '-C', tmpExtractDir],
|
|
{
|
|
stdio: 'inherit',
|
|
},
|
|
);
|
|
|
|
if (result.status !== 0) {
|
|
throw new Error(`tar extraction failed with exit code ${result.status}`);
|
|
}
|
|
|
|
// Verify extraction produced the expected xcframework structure. The
|
|
// module map now lives per-slice inside React.framework, so check the
|
|
// xcframework's Info.plist instead of a root Modules/module.modulemap.
|
|
const xcfwPath = path.join(tmpExtractDir, 'React.xcframework');
|
|
const infoPlistPath = path.join(xcfwPath, 'Info.plist');
|
|
if (!fs.existsSync(infoPlistPath)) {
|
|
throw new Error(
|
|
`Extraction verification failed: ${infoPlistPath} not found`,
|
|
);
|
|
}
|
|
|
|
// 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}`);
|
|
}
|
|
}
|
|
} finally {
|
|
fs.rmSync(tmpDir, {force: true, recursive: true});
|
|
}
|
|
}
|
|
|
|
function updateLastBuildConfiguration(configuration /*: string */) {
|
|
console.log(`Updating ${LAST_BUILD_FILENAME} with ${configuration}`);
|
|
fs.writeFileSync(LAST_BUILD_FILENAME, configuration);
|
|
}
|
|
|
|
function main(
|
|
configuration /*: string */,
|
|
version /*: string */,
|
|
podsRoot /*: string */,
|
|
) {
|
|
validateBuildConfiguration(configuration);
|
|
validateVersion(version);
|
|
|
|
if (!shouldReplaceRnCoreConfiguration(configuration)) {
|
|
return;
|
|
}
|
|
|
|
replaceRNCoreConfiguration(configuration, version, podsRoot);
|
|
updateLastBuildConfiguration(configuration);
|
|
console.log('Done replacing React Native prebuilt');
|
|
}
|
|
|
|
if (require.main === module) {
|
|
// This script is executed in the Pods folder, which is usually not synched to Github, so it should be ok
|
|
const argv = yargs
|
|
.option('c', {
|
|
alias: 'configuration',
|
|
description:
|
|
'Configuration to use to download the right React-Core prebuilt version. Allowed values are "Debug" and "Release".',
|
|
})
|
|
.option('r', {
|
|
alias: 'reactNativeVersion',
|
|
description:
|
|
'The Version of React Native associated with the React-Core prebuilt tarball.',
|
|
})
|
|
.option('p', {
|
|
alias: 'podsRoot',
|
|
description: 'The path to the Pods root folder',
|
|
})
|
|
.usage('Usage: $0 -c Debug -r <version> -p <path/to/react-native>').argv;
|
|
|
|
// $FlowFixMe[prop-missing]
|
|
const configuration = argv.configuration;
|
|
// $FlowFixMe[prop-missing]
|
|
const version = argv.reactNativeVersion;
|
|
// $FlowFixMe[prop-missing]
|
|
const podsRoot = argv.podsRoot;
|
|
|
|
main(configuration, version, podsRoot);
|
|
}
|
|
|
|
module.exports = {replaceRNCoreConfiguration};
|