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. 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) => {