From 17692ce203052ebed5e28f6674c42f3e5d6e11ec Mon Sep 17 00:00:00 2001 From: Vitali Zaidman Date: Wed, 11 Nov 2026 04:14:42 -0800 Subject: [PATCH 1/2] Pre-warm the 3 most recently used bundles on server start Pre-build the 3 most recently used bundles when Metro starts serving, most recent first. A request for a bundle being pre-built joins that build instead of starting a second one, and any bundle request cancels the remaining pre-warm queue while an in-flight pre-build keeps going and serves the request. Recency is recorded only for successfully served bundle requests and persists across restarts. --- packages/metro/src/Server.js | 203 ++++++++++++++++++++++++++++++++++- 1 file changed, 202 insertions(+), 1 deletion(-) diff --git a/packages/metro/src/Server.js b/packages/metro/src/Server.js index bb99ebaf67..c1bf8bf837 100644 --- a/packages/metro/src/Server.js +++ b/packages/metro/src/Server.js @@ -78,7 +78,9 @@ import debugModule from 'debug'; import * as jscSafeUrl from 'jsc-safe-url'; import {Logger} from 'metro-core'; import mime from 'mime-types'; +import crypto from 'node:crypto'; import fs from 'node:fs'; +import os from 'node:os'; import path from 'node:path'; import {performance} from 'node:perf_hooks'; import querystring from 'node:querystring'; @@ -138,6 +140,7 @@ export type ServerOptions = Readonly<{ const DELTA_ID_HEADER = 'X-Metro-Delta-ID'; const FILES_CHANGED_COUNT_HEADER = 'X-Metro-Files-Changed-Count'; +const PREWARM_COUNT = 3; type FetchTiming = { graphId: GraphId, @@ -160,6 +163,10 @@ export default class Server { _rootUrlMap: RootUrlMap; _fetchTimings: Array; _activeFetchCount: number; + _prewarmQueue: Array; + _prewarmInFlight: Map>; + _prewarmActive: boolean; + _prewarmCancelled: boolean; constructor(config: ConfigT, options?: ServerOptions) { this._config = config; @@ -190,6 +197,10 @@ export default class Server { this._isEnded = false; this._fetchTimings = []; this._activeFetchCount = 0; + this._prewarmQueue = []; + this._prewarmInFlight = new Map(); + this._prewarmActive = true; + this._prewarmCancelled = false; // TODO(T34760917): These two properties should eventually be instantiated // elsewhere and passed as parameters, since they are also needed by @@ -201,15 +212,196 @@ export default class Server { watch: options ? options.watch : undefined, }); this._nextBundleBuildNumber = 1; + this._startPrewarm(); } async end() { + this._cancelPrewarmRest(); if (!this._isEnded) { await this._bundler.end(); this._isEnded = true; } } + _prewarmStorePath(): string { + const projectHash = crypto + .createHash('sha1') + .update(this._config.projectRoot) + .digest('hex'); + return path.join( + os.tmpdir(), + 'metro-cache', + `recent-bundles-${projectHash}.json`, + ); + } + + _normalizeBundleOptions(options: BundleOptions): BundleOptions { + return { + ...Server.DEFAULT_BUNDLE_OPTIONS, + customResolverOptions: options.customResolverOptions, + customTransformOptions: options.customTransformOptions, + dev: options.dev, + entryFile: options.entryFile, + excludeSource: options.excludeSource, + inlineSourceMap: options.inlineSourceMap, + lazy: options.lazy, + minify: options.minify, + modulesOnly: options.modulesOnly, + onProgress: null, + platform: options.platform, + runModule: options.runModule, + shallow: options.shallow, + sourceMapUrl: null, + sourceUrl: null, + sourcePaths: options.sourcePaths, + unstable_transformProfile: options.unstable_transformProfile, + }; + } + + _bundleOptionsKey(options: BundleOptions): string { + return JSON.stringify(this._normalizeBundleOptions(options)); + } + + _readRecentBundles(): Array { + try { + const parsed = JSON.parse( + fs.readFileSync(this._prewarmStorePath(), 'utf8'), + ); + if (!Array.isArray(parsed)) { + return []; + } + const recent = []; + for (const entry of parsed) { + if ( + entry != null && + typeof entry === 'object' && + typeof entry.entryFile === 'string' + ) { + recent.push(this._normalizeBundleOptions(entry)); + } + } + return recent; + } catch { + return []; + } + } + + _recordBundleUse(bundleOptions: BundleOptions): void { + try { + const key = this._bundleOptionsKey(bundleOptions); + const recent = this._readRecentBundles().filter( + entry => this._bundleOptionsKey(entry) !== key, + ); + recent.unshift(this._normalizeBundleOptions(bundleOptions)); + const storePath = this._prewarmStorePath(); + fs.mkdirSync(path.dirname(storePath), {recursive: true}); + fs.writeFileSync( + storePath, + JSON.stringify(recent.slice(0, PREWARM_COUNT)), + ); + } catch {} + } + + _cancelPrewarmRest(): void { + this._prewarmCancelled = true; + this._prewarmQueue = []; + } + + _startPrewarm(): void { + this._prewarmQueue = this._readRecentBundles().slice(0, PREWARM_COUNT); + this._runPrewarm().catch(() => {}); + } + + async _runPrewarm(): Promise { + try { + await this._bundler.ready(); + } catch { + this._prewarmActive = false; + return; + } + if (this._prewarmCancelled) { + this._prewarmActive = false; + return; + } + while (this._prewarmQueue.length > 0) { + if (this._prewarmCancelled) { + break; + } + const options = this._prewarmQueue[0]; + const {entryFile, graphOptions, resolverOptions, transformOptions} = + splitBundleOptions(options); + let resolvedEntryFilePath; + let graphId; + try { + resolvedEntryFilePath = await this._resolveRelativePath(entryFile, { + relativeTo: 'server', + resolverOptions, + transformOptions, + }); + graphId = getGraphId(resolvedEntryFilePath, transformOptions, { + unstable_allowRequireContext: + this._config.transformer.unstable_allowRequireContext, + resolverOptions, + shallow: graphOptions.shallow, + lazy: graphOptions.lazy, + }); + } catch { + this._prewarmQueue.shift(); + continue; + } + if (this._prewarmCancelled || this._prewarmQueue[0] !== options) { + break; + } + if (this._bundler.getRevisionByGraphId(graphId) != null) { + this._prewarmQueue.shift(); + continue; + } + const build = this._bundler + .initializeGraph( + resolvedEntryFilePath, + transformOptions, + resolverOptions, + { + onProgress: null, + shallow: graphOptions.shallow, + lazy: graphOptions.lazy, + }, + ) + .then(() => {}); + this._prewarmInFlight.set(graphId, build); + this._prewarmQueue.shift(); + try { + await build; + } catch {} + this._prewarmInFlight.delete(graphId); + } + this._prewarmQueue = []; + this._prewarmActive = false; + } + + async _joinPrewarm( + graphId: GraphId, + bundleOptions: BundleOptions, + ): Promise { + if (!this._prewarmActive) { + return; + } + const key = this._bundleOptionsKey(bundleOptions); + const queuedIndex = this._prewarmQueue.findIndex( + entry => this._bundleOptionsKey(entry) === key, + ); + if (queuedIndex !== -1) { + this._prewarmQueue.splice(queuedIndex, 1); + } + this._cancelPrewarmRest(); + const inFlight = this._prewarmInFlight.get(graphId); + if (inFlight != null) { + try { + await inFlight; + } catch {} + } + } + getBundler(): IncrementalBundler { return this._bundler; } @@ -957,6 +1149,7 @@ export default class Server { this._fetchTimings.push(fetchTiming); this._activeFetchCount++; + await this._joinPrewarm(graphId, bundleOptions); result = await build(startContext); } catch (error) { const formattedError = formatBundlingError(error); @@ -1184,7 +1377,15 @@ export default class Server { bundle: bundleCode, }; }, - finish({req, mres, serializerOptions, result, bundlePerfLogger}) { + finish: ({ + req, + mres, + serializerOptions, + result, + bundleOptions, + bundlePerfLogger, + }) => { + this._recordBundleUse(bundleOptions); const bundleByteLength = Buffer.byteLength(result.bundle); bundlePerfLogger.annotate({ int: { From 4c12c170ffb282118d56a12056bc839cf467e429 Mon Sep 17 00:00:00 2001 From: Vitali Zaidman Date: Wed, 11 Nov 2026 04:22:40 -0800 Subject: [PATCH 2/2] Add tests for bundle pre-warming Cover pre-building the 3 most recently used bundles on start, sharing an in-flight pre-build with a concurrent request, cancelling queued pre-warms on a bundle request, taking over a queued pre-warmed bundle, surviving a failed pre-warm build, and keeping recency across restarts with only successful requests updating it. --- .../Server/__tests__/Server-prewarm-test.js | 469 ++++++++++++++++++ 1 file changed, 469 insertions(+) create mode 100644 packages/metro/src/Server/__tests__/Server-prewarm-test.js diff --git a/packages/metro/src/Server/__tests__/Server-prewarm-test.js b/packages/metro/src/Server/__tests__/Server-prewarm-test.js new file mode 100644 index 0000000000..6b5e86dde4 --- /dev/null +++ b/packages/metro/src/Server/__tests__/Server-prewarm-test.js @@ -0,0 +1,469 @@ +/** + * 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 + * @oncall react_native + */ + +import type {Graph} from '../../DeltaBundler/Graph'; +import type { + Dependency, + Module, + Options, + ReadOnlyGraph, +} from '../../DeltaBundler/types'; +import type {InputConfigT} from 'metro-config'; + +import CountingSet from '../../lib/CountingSet'; +import {mergeConfig} from 'metro-config'; +import {posixToSystemPath as p} from 'metro-resolver/private/__tests__/utils'; +// $FlowFixMe[untyped-import] +import MockRequest from 'mock-req'; +// $FlowFixMe[untyped-import] +import MockResponse from 'mock-res'; + +const { + getDefaultConfig: {getDefaultValues}, +} = require('metro-config'); +const path = require('node:path'); + +jest + .mock('jest-worker', () => ({})) + .mock('fs') + .mock('../../Bundler') + .mock('../../DeltaBundler') + .mock('../../node-haste/DependencyGraph') + .mock('metro-core/src/Logger'); + +const mockConsoleError = jest + .spyOn(console, 'error') + .mockImplementation(() => {}); +const mockConsoleWarn = jest.spyOn(console, 'warn').mockImplementation(() => {}); + +const NativeDate = global.Date; + +describe('pre-warm', () => { + let Bundler; + let Server; + let fs; + let buildGraph; + let getDelta; + + let callOrder: Array; + let buildCounts: Map; + let deferredByEntry: Map< + string, + {resolve: () => void, reject: mixed => void}, + >; + let failOnceEntries: Set; + let failAlwaysEntries: Set; + + async function tick(times: number = 20): Promise { + for (let i = 0; i < times; i++) { + await Promise.resolve(); + } + } + + async function settle(): Promise { + for (let round = 0; round < 10; round++) { + const before = callOrder.length; + await tick(30); + if (callOrder.length === before) { + return; + } + } + throw new Error('builds did not settle'); + } + + function entryFromPath(entryPath: string): string { + return path.basename(entryPath, '.js'); + } + + function graphFor( + entryPath: string, + transformOptions: mixed = {}, + ): ReadOnlyGraph<> { + const entry = entryFromPath(entryPath); + const dependencies = new Map>([ + [ + entryPath, + { + path: entryPath, + dependencies: new Map(), + inverseDependencies: new CountingSet([]), + getSource: () => Buffer.from(`code-${entry}`), + output: [ + { + type: 'js/module', + data: { + code: `__d(function() {${entry}();});`, + lineCount: 1, + map: [[1, 16, 1, 0]], + }, + }, + ], + }, + ], + ]); + return { + entryPoints: new Set([entryPath]), + dependencies, + transformOptions, + }; + } + + beforeEach(() => { + jest.resetModules(); + jest.clearAllMocks(); + global.Date = NativeDate; + + callOrder = []; + buildCounts = new Map(); + deferredByEntry = new Map(); + failOnceEntries = new Set(); + failAlwaysEntries = new Set(); + + let i = 0; + jest.doMock('node:crypto', () => ({ + ...jest.requireActual('node:crypto'), + randomBytes: jest.fn().mockImplementation(() => `XXXXX-${i++}`), + })); + + const mockFs = new (require('metro-memory-fs'))({ + platform: process.platform === 'win32' ? 'win32' : 'posix', + }); + jest.doMock('fs', () => mockFs); + jest.doMock('node:fs', () => mockFs); + fs = mockFs; + fs.realpath = jest.fn((file, cb) => { + cb?.(null, file); + }); + + buildGraph = jest.fn(); + getDelta = jest.fn(); + jest.doMock('../../lib/getPrependedScripts', () => + jest.fn().mockResolvedValue([]), + ); + jest.doMock('../../lib/transformHelpers', () => ({ + ...jest.requireActual('../../lib/transformHelpers'), + getTransformFn: jest.fn().mockReturnValue(() => {}), + getResolveDependencyFn: jest.fn().mockReturnValue( + (a: string, b: {name: string, ...}) => ({ + type: 'sourceFile', + filePath: path.resolve(a, `${b.name}.js`), + }), + ), + })); + + Bundler = require('../../Bundler').default; + const getDependencyGraph = jest.fn().mockReturnValue( + Promise.resolve({ + getHasteMap: jest.fn().mockReturnValue({on: jest.fn()}), + load: jest.fn(() => Promise.resolve()), + getWatcher: jest.fn(() => ({})), + doesFileExist: jest.fn().mockReturnValue(true), + getOrComputeSha1: jest.fn(() => Promise.resolve({sha1: 'abcdef'})), + }), + ); + jest + .spyOn(Bundler.prototype, 'getDependencyGraph') + .mockImplementation(getDependencyGraph); + jest + .spyOn(Bundler.prototype, 'getDynamicRoots') + .mockImplementation(jest.fn().mockReturnValue([])); + + const DeltaBundler = require('../../DeltaBundler').default; + buildGraph.mockImplementation( + async ( + entryPoints: ReadonlyArray, + options: {transformOptions: mixed, ...}, + ) => { + const entryPath = entryPoints[0]; + const entry = entryFromPath(entryPath); + callOrder.push(entry); + buildCounts.set(entry, (buildCounts.get(entry) ?? 0) + 1); + if (failAlwaysEntries.has(entry)) { + throw new Error(`always fails: ${entry}`); + } + if (failOnceEntries.has(entry)) { + failOnceEntries.delete(entry); + throw new Error(`fails once: ${entry}`); + } + const pending = deferredByEntry.get(entry); + if (pending != null) { + await new Promise((resolve, reject) => { + pending.resolve = resolve; + pending.reject = reject; + }); + } + const graph: ReadOnlyGraph<> = graphFor( + entryPath, + options.transformOptions, + ); + return graph; + }, + ); + jest + .spyOn(DeltaBundler.prototype, 'buildGraph') + .mockImplementation(buildGraph); + getDelta.mockImplementation(async () => ({ + added: new Map>(), + modified: new Map>(), + deleted: new Set(), + reset: false, + })); + jest.spyOn(DeltaBundler.prototype, 'getDelta').mockImplementation(getDelta); + + Server = require('../../Server').default; + }); + + afterEach(() => { + expect(mockConsoleWarn).not.toHaveBeenCalled(); + expect(mockConsoleError).not.toHaveBeenCalled(); + }); + + function makeConfig(): mixed { + return mergeConfig(getDefaultValues(p('/')), { + projectRoot: p('/root'), + watchFolders: [p('/root')], + resolver: {blockList: []}, + cacheVersion: '', + serializer: { + getRunModuleStatement: moduleId => + `require(${JSON.stringify(moduleId)});`, + polyfillModuleNames: [], + getModulesRunBeforeMainModule: () => ['InitializeCore'], + isThirdPartyModule: module => module.path === p('/root/foo.js'), + }, + reporter: require('../../lib/reporting').nullReporter, + server: { + rewriteRequestUrl: (requrl: string) => requrl, + }, + } as InputConfigT); + } + + function makeServer(): mixed { + return new Server(makeConfig()); + } + + function makeRequest( + server: mixed, + requrl: string, + ): Promise { + return new Promise((resolve, reject) => { + const req = new MockRequest({ + url: requrl, + method: 'GET', + headers: {host: 'localhost:8081'}, + }); + req.socket = {}; + const res = new MockResponse(() => { + resolve(res); + }); + res.on('error', reject); + server.processRequest(req, res, reject); + }); + } + + async function requestBundle( + server: mixed, + entry: string, + ): Promise { + return makeRequest( + server, + `${entry}.bundle?platform=ios&dev=true&minify=false&runModule=true`, + ); + } + + function deferEntry(entry: string): void { + deferredByEntry.set(entry, {resolve: () => {}, reject: () => {}}); + } + + function resolveEntry(entry: string): void { + const pending = deferredByEntry.get(entry); + if (pending != null) { + pending.resolve(); + } + } + + test('pre-builds the 3 most recently used bundles, most recent first', async () => { + const first = makeServer(); + await requestBundle(first, 'delta'); + await requestBundle(first, 'gamma'); + await requestBundle(first, 'beta'); + await requestBundle(first, 'alpha'); + await first.end(); + + const callOrderBefore = callOrder.length; + const second = makeServer(); + await settle(); + await second.end(); + + expect(callOrder.slice(callOrderBefore)).toEqual([ + 'alpha', + 'beta', + 'gamma', + ]); + expect(buildCounts.get('delta')).toBe(1); + }); + + test('shares an in-flight pre-build with a request arriving while it runs', async () => { + const first = makeServer(); + await requestBundle(first, 'alpha'); + await requestBundle(first, 'beta'); + await requestBundle(first, 'gamma'); + await first.end(); + + deferEntry('gamma'); + const orderBefore = callOrder.length; + const countsBefore = new Map(buildCounts); + const second = makeServer(); + await settle(); + const delta = (entry: string) => + (buildCounts.get(entry) ?? 0) - (countsBefore.get(entry) ?? 0); + expect(callOrder.slice(orderBefore)).toEqual(['gamma']); + expect(delta('gamma')).toBe(1); + + const responsePromise = requestBundle(second, 'gamma'); + await tick(); + expect(delta('gamma')).toBe(1); + expect(delta('beta')).toBe(0); + expect(delta('alpha')).toBe(0); + + resolveEntry('gamma'); + const response = await responsePromise; + expect(response._getString()).toContain('gamma();'); + expect(delta('gamma')).toBe(1); + expect(delta('beta')).toBe(0); + expect(delta('alpha')).toBe(0); + await second.end(); + }); + + test('an unrelated request cancels queued pre-warms without waiting for the in-flight one', async () => { + const first = makeServer(); + await requestBundle(first, 'beta'); + await requestBundle(first, 'gamma'); + await requestBundle(first, 'alpha'); + await first.end(); + + deferEntry('alpha'); + const countsBefore = new Map(buildCounts); + const second = makeServer(); + await settle(); + const delta = (entry: string) => + (buildCounts.get(entry) ?? 0) - (countsBefore.get(entry) ?? 0); + expect(delta('alpha')).toBe(1); + + let settled = false; + const responsePromise = requestBundle(second, 'delta').then(response => { + settled = true; + return response; + }); + await tick(30); + expect(settled).toBe(true); + const response = await responsePromise; + expect(response._getString()).toContain('delta();'); + + resolveEntry('alpha'); + await settle(); + expect(delta('alpha')).toBe(1); + expect(delta('delta')).toBe(1); + expect(delta('beta')).toBe(0); + expect(delta('gamma')).toBe(0); + await second.end(); + }); + + test('a request for a queued pre-warmed bundle builds it once for the request', async () => { + const first = makeServer(); + await requestBundle(first, 'alpha'); + await requestBundle(first, 'beta'); + await requestBundle(first, 'gamma'); + await first.end(); + + deferEntry('beta'); + const countsBefore = new Map(buildCounts); + const second = makeServer(); + await settle(); + const delta = (entry: string) => + (buildCounts.get(entry) ?? 0) - (countsBefore.get(entry) ?? 0); + expect(delta('gamma')).toBe(1); + expect(delta('beta')).toBe(1); + expect(delta('alpha')).toBe(0); + + const responsePromise = requestBundle(second, 'alpha'); + await tick(); + expect(delta('alpha')).toBe(1); + + resolveEntry('beta'); + const response = await responsePromise; + expect(response._getString()).toContain('alpha();'); + await settle(); + expect(delta('gamma')).toBe(1); + expect(delta('beta')).toBe(1); + expect(delta('alpha')).toBe(1); + await second.end(); + }); + + test('a failed pre-warm build does not stop the rest or a later request', async () => { + const first = makeServer(); + await requestBundle(first, 'alpha'); + await requestBundle(first, 'beta'); + await first.end(); + + failOnceEntries.add('beta'); + const countsBefore = new Map(buildCounts); + const second = makeServer(); + await settle(); + const delta = (entry: string) => + (buildCounts.get(entry) ?? 0) - (countsBefore.get(entry) ?? 0); + expect(delta('beta')).toBe(1); + expect(delta('alpha')).toBe(1); + + const response = await requestBundle(second, 'beta'); + expect(response._getString()).toContain('beta();'); + expect(delta('beta')).toBe(2); + await second.end(); + }); + + test('pre-warming keeps recency and only successful requests update it', async () => { + const first = makeServer(); + await requestBundle(first, 'alpha'); + await requestBundle(first, 'beta'); + await first.end(); + + const second = makeServer(); + await settle(); + await requestBundle(second, 'gamma'); + await second.end(); + + failAlwaysEntries.add('nope'); + const orderBeforeThird = callOrder.length; + const third = makeServer(); + await settle(); + expect(callOrder.slice(orderBeforeThird)).toEqual([ + 'gamma', + 'beta', + 'alpha', + ]); + const failing = await makeRequest( + third, + 'nope.bundle?platform=ios&dev=true&minify=false&runModule=true', + ); + expect(failing.statusCode).toBe(500); + await third.end(); + + const orderBeforeFourth = callOrder.length; + const fourth = makeServer(); + await settle(); + expect(callOrder.slice(orderBeforeFourth)).toEqual([ + 'gamma', + 'beta', + 'alpha', + ]); + await fourth.end(); + }); +});