From 8e01d9e708dbdfd844812f93d2680545942f3e5b Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Tue, 28 Jul 2026 22:47:44 +0200 Subject: [PATCH 1/2] Cut Metro's memory and CPU footprint during harness runs Metro's transform pool is sized for bundling a whole app, but a harness run transforms far fewer modules and reuses a persistent transform cache. Run the pool as worker threads instead of child processes and halve Metro's own default worker count, which cuts peak RSS for a cold bundle from ~3.5 GB to ~1.8 GB on a 12-core host at no cost to build time. On a 3-vCPU runner the device-core reservation now lands on a single worker, taking Metro's in-band path so nothing competes with the simulator. Stop discarding `resolver.blockList`. It was cleared outright because Metro's stock value reduces to `__tests__`, which would hide every harness test, but that also silently dropped whatever the project had configured. Inherit the project's patterns and carve the protected paths out of them instead, which works even for `exclusionList([...])`, where the project's own exclusions and the `__tests__` rule are fused into a single regex. Harness adds only `.harness/cache` on its own behalf -- scoped to the cache rather than all of `.harness`, since `.harness/manifest.js` is served as a polyfill and must stay in the file map. The playground declares its own `.nx` exclusion, as befits an artifact of this repo rather than of Harness. Also drop Metro's stdin hotkey, and only start the file watcher when Jest is actually in watch mode. --- apps/playground/metro.config.js | 7 + .../src/__tests__/metro-block-list.test.ts | 198 ++++++++++++++++++ .../src/__tests__/metro-workers.test.ts | 54 +++-- .../src/__tests__/withRnHarness.test.ts | 63 ++++++ packages/bundler-metro/src/factory.ts | 9 +- .../bundler-metro/src/metro-block-list.ts | 119 +++++++++++ packages/bundler-metro/src/metro-workers.ts | 38 +++- packages/bundler-metro/src/types.ts | 7 + packages/bundler-metro/src/withRnHarness.ts | 24 ++- packages/jest/src/harness-session.ts | 1 + 10 files changed, 489 insertions(+), 31 deletions(-) create mode 100644 packages/bundler-metro/src/__tests__/metro-block-list.test.ts create mode 100644 packages/bundler-metro/src/metro-block-list.ts diff --git a/apps/playground/metro.config.js b/apps/playground/metro.config.js index 8ecd3feb..dab61302 100644 --- a/apps/playground/metro.config.js +++ b/apps/playground/metro.config.js @@ -1,5 +1,7 @@ const { withNxMetro } = require('@nx/react-native'); const { getDefaultConfig, mergeConfig } = require('@react-native/metro-config'); +const exclusionList = + require('metro-config/private/defaults/exclusionList').default; const path = require('path'); const fs = require('fs'); @@ -41,6 +43,11 @@ const customConfig = { cacheVersion: '@react-native-harness/playground', resolver: { unstable_enablePackageExports: true, + // `withNxMetro` puts the monorepo root in watchFolders, so Nx's local + // cache and workspace data (~6k files with watched extensions) would + // otherwise be crawled and held in Metro's file map. Nothing resolves + // through it. + blockList: exclusionList([/\.nx\/.*/]), }, server: { ...(defaultConfig.server || {}), diff --git a/packages/bundler-metro/src/__tests__/metro-block-list.test.ts b/packages/bundler-metro/src/__tests__/metro-block-list.test.ts new file mode 100644 index 00000000..a4cd0270 --- /dev/null +++ b/packages/bundler-metro/src/__tests__/metro-block-list.test.ts @@ -0,0 +1,198 @@ +import path from 'node:path'; +import { createRequire } from 'node:module'; +import { describe, expect, it } from 'vitest'; +import type { MetroConfig } from 'metro-config'; +import { getHarnessBlockList } from '../metro-block-list.js'; +import { getHarnessManifestPath } from '../paths.js'; + +// `exclusionList` is what produces Metro's stock `resolver.blockList`, but it +// is not reachable through metro-config's `exports` map -- load it by path so +// these tests assert against Metro's real output rather than a copy of it. +const require = createRequire(import.meta.url); +const exclusionList = require( + path.join( + path.dirname(require.resolve('metro-config/package.json')), + 'src/defaults/exclusionList.js' + ) +).default as (extras?: (RegExp | string)[]) => RegExp; + +const withBlockList = ( + blockList: NonNullable['blockList'] +): MetroConfig => ({ resolver: { blockList } }) as MetroConfig; + +describe('getHarnessBlockList', () => { + describe('harness-owned exclusions', () => { + it('excludes the cache harness creates under .harness', () => { + const { blockList } = getHarnessBlockList(withBlockList(undefined)); + + expect(blockList.test('/p/.harness/cache/metro/ab/cdef')).toBe(true); + expect(blockList.test('/p/.harness/cache/metro-file-map/map.v1')).toBe( + true + ); + }); + + it('keeps the harness manifest crawlable', () => { + // The manifest is injected via `serializer.getPolyfills`, so a module + // missing from the file map fails with `Failed to get the SHA-1`. + const { blockList } = getHarnessBlockList(withBlockList(undefined)); + + expect(blockList.test(getHarnessManifestPath('/p'))).toBe(false); + expect(blockList.test('/p/.harness/manifest.js')).toBe(false); + }); + + it('adds nothing else of its own', () => { + const { blockList } = getHarnessBlockList(withBlockList(undefined)); + + for (const path of [ + '/p/.nx/cache/a.js', + '/p/.gradle/caches/a.json', + '/p/ios/Pods/Foo/foo.json', + '/p/ios/build/Release/x.json', + '/p/android/build/outputs/y.json', + '/p/src/__image_snapshots__/a.png', + '/p/packages/runtime/dist/index.js', + '/p/node_modules/@jest/globals/build/index.js', + ]) { + expect(blockList.test(path), path).toBe(false); + } + }); + }); + + describe('inheriting the project blockList', () => { + it('honours a plain project pattern', () => { + const { blockList, dropped } = getHarnessBlockList( + withBlockList(/[/\\]fixtures[/\\]/) + ); + + expect(dropped).toEqual([]); + expect(blockList.test('/p/src/fixtures/big.json')).toBe(true); + expect(blockList.test('/p/src/app.tsx')).toBe(false); + }); + + it('honours an array of project patterns', () => { + const { blockList, dropped } = getHarnessBlockList( + withBlockList([/[/\\]fixtures[/\\]/, /\.snap$/]) + ); + + expect(dropped).toEqual([]); + expect(blockList.test('/p/src/fixtures/big.json')).toBe(true); + expect(blockList.test('/p/src/a.snap')).toBe(true); + }); + + it('honours an anchored project pattern', () => { + const { blockList } = getHarnessBlockList( + withBlockList(/^\/p\/vendor\//) + ); + + expect(blockList.test('/p/vendor/lib.js')).toBe(true); + expect(blockList.test('/other/p/vendor/lib.js')).toBe(false); + }); + + it('preserves group numbering so backreferences still work', () => { + const { blockList } = getHarnessBlockList( + withBlockList(/([/\\])dup\1/) + ); + + expect(blockList.test('/p/dup/dup')).toBe(true); + expect(blockList.test('/p/dup\\dup')).toBe(false); + }); + + it('adopts project flags so a case-insensitive pattern keeps working', () => { + const { blockList, dropped } = getHarnessBlockList( + withBlockList(/[/\\]FIXTURES[/\\]/i) + ); + + expect(dropped).toEqual([]); + expect(blockList.flags).toBe('i'); + expect(blockList.test('/p/src/fixtures/big.json')).toBe(true); + }); + + it('drops minority-flag patterns instead of letting Metro throw', () => { + // Metro's array handling throws when combining mismatched flags. + const minority = /[/\\]other[/\\]/i; + const { blockList, dropped } = getHarnessBlockList( + withBlockList([/[/\\]a[/\\]/, /[/\\]b[/\\]/, minority]) + ); + + expect(dropped).toEqual([ + { pattern: minority, reason: 'incompatible-flags' }, + ]); + expect(blockList.flags).toBe(''); + expect(blockList.test('/p/a/x.js')).toBe(true); + expect(blockList.test('/p/b/x.js')).toBe(true); + }); + + it('is stateless across calls when given a global pattern', () => { + const { blockList } = getHarnessBlockList( + withBlockList(/[/\\]fixtures[/\\]/g) + ); + + expect(blockList.test('/p/src/fixtures/a.json')).toBe(true); + expect(blockList.test('/p/src/fixtures/a.json')).toBe(true); + }); + }); + + describe('carving __tests__ out of the inherited blockList', () => { + it("keeps tests crawlable under Metro's stock blockList", () => { + const { blockList, dropped } = getHarnessBlockList( + withBlockList(exclusionList()) + ); + + expect(dropped).toEqual([]); + expect(blockList.test('/p/src/__tests__/smoke.harness.ts')).toBe(false); + }); + + it("keeps a project's own exclusions while still crawling tests", () => { + // exclusionList fuses the project's patterns and the __tests__ rule into + // a single alternation, so the two cannot be separated by inspection. + const { blockList, dropped } = getHarnessBlockList( + withBlockList(exclusionList([/ios\/build\/.*/])) + ); + + expect(dropped).toEqual([]); + expect(blockList.test('/p/ios/build/Release/x.json')).toBe(true); + expect(blockList.test('/p/src/__tests__/smoke.harness.ts')).toBe(false); + expect(blockList.test(getHarnessManifestPath('/p'))).toBe(false); + }); + + it('keeps tests crawlable even inside an otherwise excluded directory', () => { + const { blockList } = getHarnessBlockList( + withBlockList(/[/\\]generated[/\\]/) + ); + + expect(blockList.test('/p/generated/a.js')).toBe(true); + expect(blockList.test('/p/generated/__tests__/a.harness.ts')).toBe(false); + }); + + it('matches `inherited && !protected` for every pattern shape', () => { + const protectedPaths = /[/\\]__tests__[/\\]/; + const patterns = [ + exclusionList(), + exclusionList([/ios\/build\/.*/]), + exclusionList(['ios/build']), + /[/\\]fixtures[/\\]/, + /^\/p\/vendor\//, + ]; + const paths = [ + '/p/src/__tests__/a.harness.ts', + '/p/ios/build/Release/x.json', + '/p/src/fixtures/big.json', + '/p/vendor/lib.js', + '/p/src/app.tsx', + '/p/ios/build/__tests__/nested.harness.ts', + ]; + + for (const pattern of patterns) { + const { blockList } = getHarnessBlockList(withBlockList(pattern)); + + for (const path of paths) { + const expected = + new RegExp(pattern.source, pattern.flags).test(path) && + !protectedPaths.test(path); + + expect(blockList.test(path), `${pattern} :: ${path}`).toBe(expected); + } + } + }); + }); +}); diff --git a/packages/bundler-metro/src/__tests__/metro-workers.test.ts b/packages/bundler-metro/src/__tests__/metro-workers.test.ts index 0360a2dd..5906f7dd 100644 --- a/packages/bundler-metro/src/__tests__/metro-workers.test.ts +++ b/packages/bundler-metro/src/__tests__/metro-workers.test.ts @@ -2,51 +2,63 @@ import { describe, expect, it } from 'vitest'; import { getCappedMaxWorkers } from '../metro-workers.js'; describe('getCappedMaxWorkers', () => { - it('caps to 1 worker when only 2 host cores are available', () => { + it('runs in-band on a 2-core host', () => { expect( - getCappedMaxWorkers({ configuredMaxWorkers: undefined, hostParallelism: 2 }) + getCappedMaxWorkers({ configuredMaxWorkers: 1, hostParallelism: 2 }) ).toBe(1); }); - it('caps to 1 worker when only 3 host cores are available', () => { + it('runs in-band on a 3-vCPU CI runner, leaving cores for the device', () => { + // Metro's own default here is 2; the device budget claims 2 of 3 cores. expect( - getCappedMaxWorkers({ configuredMaxWorkers: undefined, hostParallelism: 3 }) + getCappedMaxWorkers({ configuredMaxWorkers: 2, hostParallelism: 3 }) ).toBe(1); }); - it('caps to 2 workers when 4 host cores are available', () => { + it("halves Metro's default on a 12-core host", () => { expect( - getCappedMaxWorkers({ configuredMaxWorkers: undefined, hostParallelism: 4 }) - ).toBe(2); + getCappedMaxWorkers({ configuredMaxWorkers: 8, hostParallelism: 12 }) + ).toBe(4); }); - it('reserves the four cores granted to the device on an 8-core host', () => { + it("halves Metro's default on a 16-core host", () => { expect( - getCappedMaxWorkers({ configuredMaxWorkers: 6, hostParallelism: 8 }) - ).toBe(4); + getCappedMaxWorkers({ configuredMaxWorkers: 10, hostParallelism: 16 }) + ).toBe(5); }); - it('respects a configured value well below the cap on a large host', () => { + it('applies the device-core reservation when it binds harder than halving', () => { + // 8 cores: Metro's default is 6 (halved: 3), device cap is 8 - 4 = 4. expect( - getCappedMaxWorkers({ configuredMaxWorkers: 10, hostParallelism: 16 }) - ).toBe(10); + getCappedMaxWorkers({ configuredMaxWorkers: 6, hostParallelism: 8 }) + ).toBe(3); }); - it('never lowers a configured value of 1', () => { + it('never raises a configured value of 1', () => { expect( - getCappedMaxWorkers({ configuredMaxWorkers: 1, hostParallelism: 8 }) + getCappedMaxWorkers({ configuredMaxWorkers: 1, hostParallelism: 16 }) ).toBe(1); }); - it('falls back to the cap when maxWorkers is undefined', () => { + it('lowers a configured value that exceeds both limits', () => { expect( - getCappedMaxWorkers({ configuredMaxWorkers: undefined, hostParallelism: 8 }) - ).toBe(4); + getCappedMaxWorkers({ configuredMaxWorkers: 64, hostParallelism: 4 }) + ).toBe(2); }); - it('lowers a configured value that exceeds the cap', () => { + it('never returns less than one worker', () => { expect( - getCappedMaxWorkers({ configuredMaxWorkers: 8, hostParallelism: 4 }) - ).toBe(2); + getCappedMaxWorkers({ configuredMaxWorkers: 1, hostParallelism: 1 }) + ).toBe(1); + }); + + it("falls back to Metro's own default when maxWorkers is unset", () => { + // Mirrors the 12-core case above, deriving 8 rather than being handed it. + expect( + getCappedMaxWorkers({ + configuredMaxWorkers: undefined, + hostParallelism: 12, + }) + ).toBe(4); }); }); diff --git a/packages/bundler-metro/src/__tests__/withRnHarness.test.ts b/packages/bundler-metro/src/__tests__/withRnHarness.test.ts index ed1aebaf..4f64046f 100644 --- a/packages/bundler-metro/src/__tests__/withRnHarness.test.ts +++ b/packages/bundler-metro/src/__tests__/withRnHarness.test.ts @@ -17,6 +17,15 @@ type MinimalMetroConfig = { collapse: boolean; }>; }; + resolver?: { + blockList?: RegExp; + }; + server?: { + useGlobalHotkey?: boolean; + }; + transformer?: { + unstable_workerThreads?: boolean; + }; }; const { ensureDomainDirectories } = vi.hoisted(() => ({ @@ -124,6 +133,60 @@ describe('withRnHarness', () => { }); }); + it('trims Metro functionality a harness run does not need', async () => { + const { withRnHarness } = await import('../withRnHarness.js'); + + const config = (await withRnHarness( + { + projectRoot: '/tmp/app', + serializer: {}, + symbolicator: { + async customizeFrame() { + return {}; + }, + }, + }, + true, + )()) as unknown as MinimalMetroConfig; + + expect(config.server?.useGlobalHotkey).toBe(false); + expect(config.transformer?.unstable_workerThreads).toBe(true); + }); + + it('inherits the project blockList while keeping test files crawlable', async () => { + const { withRnHarness } = await import('../withRnHarness.js'); + + const config = (await withRnHarness( + { + projectRoot: '/tmp/app', + // What `exclusionList([/ios\/build\/.*/])` produces: the project's own + // exclusion fused with the __tests__ rule that would hide every test. + resolver: { blockList: /(ios\/build\/.*|\/__tests__\/.*)$/ }, + serializer: {}, + symbolicator: { + async customizeFrame() { + return {}; + }, + }, + }, + true, + )()) as unknown as MinimalMetroConfig; + + const blockList = config.resolver?.blockList; + + expect(blockList).toBeInstanceOf(RegExp); + // The project's own exclusion survives. + expect(blockList?.test('/tmp/app/ios/build/Release/a.json')).toBe(true); + // Harness's own cache is excluded; its manifest is not. + expect(blockList?.test('/tmp/app/.harness/cache/metro/ab')).toBe(true); + expect(blockList?.test('/tmp/app/.harness/manifest.js')).toBe(false); + // Tests stay crawlable, and nothing else is excluded on harness's behalf. + expect(blockList?.test('/tmp/app/src/__tests__/smoke.harness.ts')).toBe( + false, + ); + expect(blockList?.test('/tmp/app/.nx/cache/a.js')).toBe(false); + }); + it('caps maxWorkers to leave host cores free for the device under test', async () => { const { withRnHarness } = await import('../withRnHarness.js'); const { getCappedMaxWorkers } = await import('../metro-workers.js'); diff --git a/packages/bundler-metro/src/factory.ts b/packages/bundler-metro/src/factory.ts index 47a561b6..2d6f9bc8 100644 --- a/packages/bundler-metro/src/factory.ts +++ b/packages/bundler-metro/src/factory.ts @@ -93,7 +93,12 @@ export const getMetroInstance = async ( options: MetroOptions, abortSignal: AbortSignal ): Promise => { - const { projectRoot, harnessConfig, websocketEndpoints = {} } = options; + const { + projectRoot, + harnessConfig, + websocketEndpoints = {}, + watchMode = false, + } = options; const metroPort = harnessConfig.metroPort; const metroBindHost = harnessConfig.host?.trim(); metroLogger.debug( @@ -145,7 +150,7 @@ export const getMetroInstance = async ( unstable_extraMiddleware: [middleware], websocketEndpoints, ...(metroBindHost ? { host: metroBindHost } : {}), - watch: process.env.CI ? false : undefined, + watch: watchMode && !process.env.CI, }); // Metro <0.83 returns the server directly, while 0.83+ returns an object with the server as a property. diff --git a/packages/bundler-metro/src/metro-block-list.ts b/packages/bundler-metro/src/metro-block-list.ts new file mode 100644 index 00000000..aa73787c --- /dev/null +++ b/packages/bundler-metro/src/metro-block-list.ts @@ -0,0 +1,119 @@ +import type { MetroConfig } from 'metro-config'; + +type BlockList = NonNullable['blockList']; + +/** + * The only directory harness excludes on its own behalf: the cache it creates + * and maintains under `.harness`, which Metro never needs to resolve or read. + * + * Scoped to `.harness/cache` rather than all of `.harness` on purpose -- + * `.harness/manifest.js` is served as a polyfill via + * `serializer.getPolyfills`, and a module that harness injects into the graph + * but that is missing from the file map fails the build with + * `Failed to get the SHA-1 for: `. + * + * Nothing else is added here. Excluding third-party directories (build output, + * tool caches, native dependency trees) is the project's call, expressed + * through `resolver.blockList` in its own `metro.config.js`, which harness + * inherits below. + */ +const HARNESS_OWNED_EXCLUSIONS = /[/\\]\.harness[/\\]cache(?:[/\\]|$)/; + +/** + * Paths harness must be able to crawl, carved out of whatever the project + * excludes. Metro's stock `blockList` is `exclusionList()`, which reduces to + * `__tests__` -- the directory Jest's default `testMatch` looks in -- so + * inheriting a project's blockList verbatim would hide its tests. + */ +const HARNESS_PROTECTED_PATHS = /[/\\]__tests__[/\\]/; + +/** `test()` advances `lastIndex` on global/sticky patterns, which would make + * the crawl's ignore decisions depend on call order. Dropping `g`/`y` does not + * change what a `test()` matches, only that it stops being stateful. */ +const stripStatefulFlags = (flags: string): string => + flags.replace(/[gy]/g, ''); + +const toPatternArray = (blockList: BlockList): RegExp[] => { + if (!blockList) { + return []; + } + + return Array.isArray(blockList) ? blockList.filter(Boolean) : [blockList]; +}; + +/** + * Rewrites `pattern` so it keeps its meaning except that it never matches a + * protected path -- i.e. `pattern.test(p) && !protected.test(p)` as a single + * regex, which is required because metro-file-map rebuilds whatever it is + * given via `new RegExp(source, flags)`. + * + * This matters for the common `exclusionList([...])` case, where a project's + * own exclusions and the `__tests__` rule are fused into one alternation and + * cannot be separated by inspecting the source. + * + * Both sides are expressed as anchored lookarounds so they are evaluated once, + * at position 0, rather than retried at every offset: `(?!...)` rejects any + * path containing a protected segment, and `(?=...)` re-applies the original + * pattern with unanchored-search semantics. + * + * Every group introduced here is non-capturing, and + * `HARNESS_OWNED_EXCLUSIONS` captures nothing, so a lone project pattern keeps + * its group numbering and its backreferences. A project passing an *array* of + * patterns where a later one uses backreferences would still see them shift -- + * an inherent consequence of combining alternations, and one Metro's own array + * handling shares, since it joins the sources the same way. + */ +const carveOutProtectedPaths = (source: string): string => + `^(?![\\s\\S]*(?:${HARNESS_PROTECTED_PATHS.source}))(?=[\\s\\S]*(?:${source}))`; + +export type BlockListDrop = { + pattern: RegExp; + reason: 'incompatible-flags'; +}; + +/** + * Builds the `blockList` for a harness run: harness's own cache directory, + * plus everything the project already excludes, minus the paths harness must + * keep crawlable. + * + * Returns a single plain `RegExp`. Two constraints force that shape: + * - metro-file-map rebuilds the pattern via `new RegExp(source, flags)`, so a + * subclass with an overridden `test()` would be discarded. + * - Metro's own array handling (`createFileMap.js`) *throws* when combining + * patterns whose flags differ, so combining here lets us resolve flag + * conflicts by dropping a pattern instead of failing the run. + */ +export const getHarnessBlockList = ( + metroConfig: MetroConfig +): { blockList: RegExp; dropped: BlockListDrop[] } => { + const dropped: BlockListDrop[] = []; + const userPatterns = toPatternArray(metroConfig.resolver?.blockList); + + // A single regex carries one flag set. Adopt whichever the project's own + // patterns agree on so e.g. a case-insensitive blockList keeps working; + // our own pattern is flagless and unaffected either way. + const flagCounts = new Map(); + for (const { flags } of userPatterns) { + const normalized = stripStatefulFlags(flags); + flagCounts.set(normalized, (flagCounts.get(normalized) ?? 0) + 1); + } + const flags = + [...flagCounts.entries()].sort((a, b) => b[1] - a[1])[0]?.[0] ?? ''; + + const sources = [HARNESS_OWNED_EXCLUSIONS.source]; + for (const pattern of userPatterns) { + if (stripStatefulFlags(pattern.flags) === flags) { + sources.push(carveOutProtectedPaths(pattern.source)); + } else { + dropped.push({ pattern, reason: 'incompatible-flags' }); + } + } + + return { + blockList: new RegExp( + sources.map((source) => `(?:${source})`).join('|'), + flags + ), + dropped, + }; +}; diff --git a/packages/bundler-metro/src/metro-workers.ts b/packages/bundler-metro/src/metro-workers.ts index 5b4ade16..b7f96ea9 100644 --- a/packages/bundler-metro/src/metro-workers.ts +++ b/packages/bundler-metro/src/metro-workers.ts @@ -1,11 +1,32 @@ import { getDeviceCoreBudget } from '@react-native-harness/tools'; /** - * Caps Metro's resolved `maxWorkers` so it never exceeds the host - * parallelism minus the cores granted to the device under test. This only - * ever lowers the resolved value -- it never raises it -- so a user's - * explicit, lower `maxWorkers` and Metro's own default on many-core - * machines are both respected. + * Mirrors Metro's own `getMaxWorkers` default (metro-config's + * `src/defaults/getMaxWorkers.js`), which is not reachable through + * metro-config's `exports` map. Only used when the loaded Metro config has no + * resolved `maxWorkers` -- `Metro.loadConfig` normally always supplies one. + */ +const getMetroDefaultMaxWorkers = (hostParallelism: number): number => + Math.max( + 1, + Math.ceil( + hostParallelism * (0.5 + 0.5 * Math.exp(-hostParallelism * 0.07)) - 1 + ) + ); + +/** + * Resolves Metro's `maxWorkers` for a harness run. This only ever *lowers* + * Metro's resolved value -- an explicit, lower `maxWorkers` is always + * respected -- and applies two independent reductions: + * + * 1. Reserve the cores granted to the device under test, so Metro never + * competes with the emulator/simulator. On a 3-vCPU runner this alone + * lands on a single worker, which takes Metro's in-band transform path + * and spawns no child workers at all. + * 2. Halve Metro's own default. Metro sizes its pool for bundling a whole + * app; a harness run transforms far fewer modules and reuses a persistent + * transform cache across runs, so the extra workers cost memory (each is + * a full Babel runtime) without shortening the build. */ export const getCappedMaxWorkers = ({ configuredMaxWorkers, @@ -14,10 +35,13 @@ export const getCappedMaxWorkers = ({ configuredMaxWorkers: number | undefined; hostParallelism: number; }): number => { - const cap = Math.max( + const deviceCap = Math.max( 1, hostParallelism - getDeviceCoreBudget(hostParallelism) ); + const metroResolved = + configuredMaxWorkers ?? getMetroDefaultMaxWorkers(hostParallelism); + const testBundleWorkers = Math.ceil(metroResolved / 2); - return Math.min(configuredMaxWorkers ?? cap, cap); + return Math.max(1, Math.min(deviceCap, testBundleWorkers)); }; diff --git a/packages/bundler-metro/src/types.ts b/packages/bundler-metro/src/types.ts index f2584a56..3991a38a 100644 --- a/packages/bundler-metro/src/types.ts +++ b/packages/bundler-metro/src/types.ts @@ -13,6 +13,13 @@ export type MetroOptions = { projectRoot: string; harnessConfig: HarnessConfig; websocketEndpoints?: MetroWebSocketEndpoints; + /** + * Whether Jest is running in watch mode (`--watch` / `--watchAll`). Only + * then does Metro need a file watcher; a one-shot run bundles once and + * exits, so watching just costs a watchman subscription and the file-map + * auto-save timer. + */ + watchMode?: boolean; }; export type WaitForMetroHealthOptions = { diff --git a/packages/bundler-metro/src/withRnHarness.ts b/packages/bundler-metro/src/withRnHarness.ts index b023e9be..00e444ed 100644 --- a/packages/bundler-metro/src/withRnHarness.ts +++ b/packages/bundler-metro/src/withRnHarness.ts @@ -12,6 +12,7 @@ import { logger } from '@react-native-harness/tools'; import { getHarnessBabelTransformerPath } from './babel-transformer.js'; import { getHarnessSerializer } from './getHarnessSerializer.js'; import { getHarnessManifest } from './manifest.js'; +import { getHarnessBlockList } from './metro-block-list.js'; import { getHarnessCacheStores } from './metro-cache.js'; import { getCappedMaxWorkers } from './metro-workers.js'; import { getHarnessResolver } from './resolvers/resolver.js'; @@ -19,6 +20,7 @@ import type { NotReadOnly } from './utils.js'; const require = createRequire(import.meta.url); const metroWorkersLogger = logger.child('metro-workers'); +const metroBlockListLogger = logger.child('metro-block-list'); const INTERNAL_CALLSITES_REGEX = /(^|[\\/])(node_modules[/\\]@react-native-harness)([\\/]|$)/; @@ -52,6 +54,16 @@ export const withRnHarness = ( const harnessBabelTransformerPath = getHarnessBabelTransformerPath(metroConfig); + const { blockList: harnessBlockList, dropped: droppedBlockListPatterns } = + getHarnessBlockList(metroConfig); + + for (const { pattern } of droppedBlockListPatterns) { + metroBlockListLogger.warn( + 'Ignoring `resolver.blockList` pattern %s because its regex flags conflict with the other blockList patterns.', + String(pattern) + ); + } + const hostParallelism = os.availableParallelism(); const cappedMaxWorkers = getCappedMaxWorkers({ configuredMaxWorkers: metroConfig.maxWorkers, @@ -74,6 +86,10 @@ export const withRnHarness = ( server: { ...metroConfig.server, forwardClientLogs: harnessConfig.forwardClientLogs ?? false, + // Metro's global hotkey puts stdin into raw mode to listen for a + // dev-server reload key. A harness run is non-interactive and Jest + // owns stdin in watch mode, so it is pure overhead here. + useGlobalHotkey: false, }, serializer: { ...metroConfig.serializer, @@ -97,13 +113,19 @@ export const withRnHarness = ( }, resolver: { ...metroConfig.resolver, - blockList: undefined, + blockList: harnessBlockList, resolveRequest: harnessResolver, useWatchman: process.env.RN_HARNESS_DEBUG_USE_WATCHMAN !== '0', }, transformer: { ...metroConfig.transformer, babelTransformerPath: harnessBabelTransformerPath, + // Run transform workers as threads rather than child processes. Each + // child process carries its own Node runtime and Babel instance; + // threads share them, which roughly halves peak RSS for a cold + // bundle at no cost to build time. + unstable_workerThreads: + process.env.RN_HARNESS_DEBUG_WORKER_THREADS !== '0', }, symbolicator: { ...metroConfig.symbolicator, diff --git a/packages/jest/src/harness-session.ts b/packages/jest/src/harness-session.ts index 325b9c57..53e88287 100644 --- a/packages/jest/src/harness-session.ts +++ b/packages/jest/src/harness-session.ts @@ -605,6 +605,7 @@ export const createHarnessSession = async ( websocketEndpoints: { [HARNESS_BRIDGE_PATH]: bridge.ws as unknown as MetroWebSocketEndpoint, }, + watchMode: globalConfig.watch || globalConfig.watchAll, }, sessionController.signal, ).then((instance) => { From d7702c468c7381f166879f2a8209f931206954db Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Tue, 28 Jul 2026 22:48:18 +0200 Subject: [PATCH 2/2] Add version plan --- .nx/version-plans/cut-metro-memory-footprint.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .nx/version-plans/cut-metro-memory-footprint.md diff --git a/.nx/version-plans/cut-metro-memory-footprint.md b/.nx/version-plans/cut-metro-memory-footprint.md new file mode 100644 index 00000000..c7ad0d5e --- /dev/null +++ b/.nx/version-plans/cut-metro-memory-footprint.md @@ -0,0 +1,5 @@ +--- +__default__: patch +--- + +Harness test runs now use markedly less memory while Metro bundles, and Metro configs keep the project's own `resolver.blockList` instead of having it discarded.