Refactor runner to use a single instance of Metro for each run (#52777)

Summary:
Pull Request resolved: https://github.com/facebook/react-native/pull/52777

Changelog: [internal]

This significantly speeds up test execution in Fantom (around 2x in OSS and 6x at Meta) by starting a Metro server before all tests runs and reusing it across all tests to build test bundles, instead of spinning up a new Metro instance every time we run each test.

The architecture change (also considering the previous change in buck prebuilds) looks like this:
{F1980689532}

This is how is impacts execution times (compared to the baseline):
* OSS
  * Before: 62s {F1980564286}
  * After: 30s (**2x faster**) {F1980564265}

Reviewed By: andrewdacenko

Differential Revision: D78741903

fbshipit-source-id: b209f88925e49cc2a2067e8df9b7fa9a29b4c8d2
This commit is contained in:
Rubén Norte
2025-08-04 05:56:51 -07:00
committed by Facebook GitHub Bot
parent 3ecd48fa91
commit 5c2b9eda69
8 changed files with 221 additions and 14 deletions
@@ -35,4 +35,6 @@ module.exports = {
watchPathIgnorePatterns: ['<rootDir>/private/react-native-fantom/build/'],
globalSetup:
'<rootDir>/private/react-native-fantom/runner/global-setup/setup.js',
globalTeardown:
'<rootDir>/private/react-native-fantom/runner/global-setup/teardown.js',
};
+117
View File
@@ -0,0 +1,117 @@
/**
* 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
*/
import type {RunBuildOptions} from 'metro';
import fs from 'fs';
import path from 'path';
type CreateBundleOptions = {
...RunBuildOptions,
out: $NonMaybeType<RunBuildOptions['out']>,
testPath: string,
};
const PROJECT_ROOT = path.resolve(__dirname, '..', '..', '..');
export default async function createBundle({
testPath,
entry,
out,
platform,
minify,
dev,
sourceMap,
sourceMapUrl,
}: CreateBundleOptions): Promise<void> {
if (process.env.__FANTOM_METRO_PORT__ == null) {
throw new Error(
'Could not find Metro server port (process.env.__FANTOM_METRO_PORT__ not set by Fantom)',
);
}
const port = Number(process.env.__FANTOM_METRO_PORT__);
if (!Number.isFinite(port) || port <= 0 || port > 65535) {
throw new Error(`Invalid port for Metro server: ${port}`);
}
const requestPath = path
.relative(PROJECT_ROOT, entry)
.replace(/\.js$/, '.bundle');
const bundleURL = new URL(`http://localhost:${port}/${requestPath}`);
if (platform != null) {
bundleURL.searchParams.append('platform', platform);
}
if (minify != null) {
bundleURL.searchParams.append('minify', minify ? 'true' : 'false');
}
if (dev != null) {
bundleURL.searchParams.append('dev', dev ? 'true' : 'false');
}
if (sourceMap != null) {
bundleURL.searchParams.append('sourceMap', sourceMap ? 'true' : 'false');
}
if (sourceMapUrl != null) {
bundleURL.searchParams.append('sourceMapUrl', sourceMapUrl);
}
let bundleResult;
let bundleError;
// Retry in case Metro hasn't seen the changes in the filesystem yet.
// TODO(T231910841): Remove this when Metro fixes consistency issues when resolving HTTP requests.
let attemps = 0;
do {
if (attemps > 0) {
await sleep(500);
}
try {
bundleResult = await fetch(bundleURL);
} catch (e) {
bundleError = e;
}
attemps++;
} while (attemps < 3 && (bundleError || bundleResult?.status === 404));
if (bundleError || bundleResult?.ok !== true) {
throw new Error(
`Failed to request bundle from Metro: ${bundleError?.message ?? (await bundleResult?.text()) ?? ''}`,
);
}
await fs.promises.writeFile(out, await bundleResult.text(), 'utf8');
try {
const sourceMapURL = new URL(bundleURL.toString());
sourceMapURL.pathname = sourceMapURL.pathname.replace(/\.bundle$/, '.map');
const sourceMapResult = await fetch(sourceMapURL);
if (!sourceMapResult.ok) {
throw new Error();
}
await fs.promises.writeFile(
out.replace(/\.js$/, '.map'),
await sourceMapResult.text(),
'utf8',
);
} catch (e) {
console.error(`Could not fetch source map from Metro for test ${testPath}`);
}
}
async function sleep(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}
+3 -6
View File
@@ -8,6 +8,7 @@
* @format
*/
import createBundle from '../createBundle';
import {isCI} from '../EnvironmentOptions';
import {build as buildHermesCompiler} from '../executables/hermesc';
import {build as buildFantomTester} from '../executables/tester';
@@ -15,7 +16,6 @@ import {getNativeBuildOutputPath} from '../executables/utils';
import {HermesVariant} from '../utils';
// $FlowExpectedError[untyped-import]
import fs from 'fs';
import Metro from 'metro';
import os from 'os';
import path from 'path';
@@ -58,10 +58,6 @@ export default async function build(): Promise<void> {
}
async function warmUpMetro(isOptimizedMode: boolean): Promise<void> {
const metroConfig = await Metro.loadConfig({
config: path.resolve(__dirname, '..', '..', 'config', 'metro.config.js'),
});
const entrypointPath = path.resolve(
__dirname,
'..',
@@ -75,7 +71,8 @@ async function warmUpMetro(isOptimizedMode: boolean): Promise<void> {
`fantom-warmup-bundle-${Date.now()}.js`,
);
await Metro.runBuild(metroConfig, {
await createBundle({
testPath: '(warmup bundle - no test path)',
entry: entrypointPath,
out: bundlePath,
platform: 'android',
@@ -10,6 +10,8 @@
import {isOSS, validateEnvironmentVariables} from '../EnvironmentOptions';
import build from './build';
import Metro from 'metro';
import path from 'path';
export default async function globalSetup(
globalConfig: {...},
@@ -19,7 +21,38 @@ export default async function globalSetup(
validateEnvironmentVariables();
await startMetroServer();
if (!isOSS) {
await build();
}
}
async function startMetroServer() {
const metroConfig = await Metro.loadConfig({
config: path.resolve(__dirname, '..', '..', 'config', 'metro.config.js'),
});
// We need to reuse the same port across runs because can only set environment
// variables for workers in the first one.
// $FlowExpectedError[cannot-write]
metroConfig.server.port =
process.env.__FANTOM_METRO_PORT__ != null
? Number(process.env.__FANTOM_METRO_PORT__)
: // Any available port
0;
const server = await Metro.runServer(metroConfig, {
waitForBundler: true,
watch: true,
});
if (process.env.__FANTOM_METRO_PORT__ == null) {
process.env.__FANTOM_METRO_PORT__ = String(
server.httpServer.address().port,
);
}
// $FlowExpectedError[prop-missing]
globalThis.__METRO_SERVER__ = server;
}
@@ -0,0 +1,43 @@
/**
* 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
*/
import type {RunServerResult} from 'metro';
type MetroServer = $NonMaybeType<RunServerResult?.['httpServer']>;
declare var __METRO_SERVER__: ?RunServerResult;
function getMetroServer(): ?MetroServer {
return typeof __METRO_SERVER__ !== 'undefined' && __METRO_SERVER__ != null
? __METRO_SERVER__.httpServer
: null;
}
export default async function globalTeardown(
globalConfig: {...},
projectConfig: {...},
): Promise<void> {
const metroServer = getMetroServer();
if (metroServer) {
await stopMetroServer(metroServer);
}
}
async function stopMetroServer(metroServer: MetroServer): Promise<void> {
return new Promise((resolve, reject) => {
metroServer.close(error => {
if (error) {
reject(error);
} else {
resolve();
}
});
});
}
@@ -0,0 +1,13 @@
/**
* 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
*/
require('../../../../scripts/shared/babelRegister').registerForMonorepo();
module.exports = require('./globalTeardown');
+3 -6
View File
@@ -20,6 +20,7 @@ import type {
HermesVariant,
} from './utils';
import createBundle from './createBundle';
import entrypointTemplate from './entrypoint-template';
import * as EnvironmentOptions from './EnvironmentOptions';
import {run as runHermesCompiler} from './executables/hermesc';
@@ -41,7 +42,6 @@ import fs from 'fs';
// $FlowExpectedError[untyped-import]
import {formatResultsErrors} from 'jest-message-util';
import {SnapshotState, buildSnapshotResolver} from 'jest-snapshot';
import Metro from 'metro';
import nullthrows from 'nullthrows';
import path from 'path';
import readline from 'readline';
@@ -210,10 +210,6 @@ module.exports = async function runTest(
const testContents = fs.readFileSync(testPath, 'utf8');
const testConfigs = getFantomTestConfigs(testPath, testContents);
const metroConfig = await Metro.loadConfig({
config: path.resolve(__dirname, '..', 'config', 'metro.config.js'),
});
const setupModulePath = path.resolve(__dirname, '../runtime/setup.js');
const featureFlagsModulePath = path.resolve(
__dirname,
@@ -305,7 +301,8 @@ module.exports = async function runTest(
path.basename(testJSBundlePath, '.js') + '.map',
);
await Metro.runBuild(metroConfig, {
await createBundle({
testPath,
entry: entrypointPath,
out: testJSBundlePath,
platform: 'android',
+7 -2
View File
@@ -6,9 +6,14 @@
set -e
if [[ -f "BUCK" && -z "$FANTOM_FORCE_OSS_BUILD" ]]; then
JS_DIR='..' yarn jest --config private/react-native-fantom/config/jest.config.js "$@"
export JS_DIR='..'
else
yarn workspace @react-native/fantom build
FANTOM_FORCE_OSS_BUILD=1 yarn jest --config private/react-native-fantom/config/jest.config.js "$@"
export FANTOM_FORCE_OSS_BUILD=1
fi
export NODE_OPTIONS='--max-old-space-size=8192'
yarn jest --config private/react-native-fantom/config/jest.config.js "$@"