Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
115 changes: 115 additions & 0 deletions packages/react-native/scripts/__tests__/replace-rncore-version-test.js
Original file line number Diff line number Diff line change
@@ -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'), '<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'), '<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/);
});
});
128 changes: 26 additions & 102 deletions packages/react-native/scripts/replace-rncore-version.js
Original file line number Diff line number Diff line change
Expand Up @@ -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`;

Expand All @@ -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(
Expand All @@ -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 <react/...>, <yoga/...>, 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 <react/...> 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);
}
}
}

Expand Down Expand Up @@ -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};