From d3378f1a20baee0986acd455c01460ff49186c93 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Mon, 13 Jul 2026 16:25:48 +0200 Subject: [PATCH 1/7] Harden skip-already-included-modules capture (phase 1) The harness serializer used to overwrite its module set on ANY full-bundle serialization (debugger, browser, other entry points), causing over-exclusion and "Requiring unknown module" crashes on device, and it kept a single flat set shared across platforms/dev-prod variants even though their module sets differ. It also built the set with Metro's getAllFiles helper, which expands asset modules to physical variant files on disk instead of the module paths processModuleFilter/createModuleId actually operate on. Only capture when the serialized entry is positively identified as the harness main entry point (matched against the runtime's resolved entry-point module, which both a project's real entryPoint and Expo's virtual entry resolve to), key the captured sets by graph identity (platform/dev/minify/transform profile/custom transform options), and build the set from preModules + graph.dependencies keys. Delegate non-modulesOnly serialization to the user's own customSerializer when present so this stays non-invasive for consumers like Expo. modulesOnly requests fail open (no exclusion) whenever no set was captured for their graph key. Still uses filter-based exclusion for modulesOnly bundles; phase 2 replaces that with blanking to keep source maps and /symbolicate correct. --- .../bundler-metro/src/getHarnessSerializer.ts | 218 +++++++++++++++--- packages/bundler-metro/src/withRnHarness.ts | 7 +- 2 files changed, 192 insertions(+), 33 deletions(-) diff --git a/packages/bundler-metro/src/getHarnessSerializer.ts b/packages/bundler-metro/src/getHarnessSerializer.ts index 25fde3db..aba5cfb8 100644 --- a/packages/bundler-metro/src/getHarnessSerializer.ts +++ b/packages/bundler-metro/src/getHarnessSerializer.ts @@ -1,5 +1,9 @@ import { createRequire } from 'node:module'; import type { MetroConfig } from 'metro-config'; +import type { + ReadOnlyGraph, + Module, +} from 'metro/private/DeltaBundler/types'; const require = createRequire(import.meta.url); @@ -7,42 +11,192 @@ export type Serializer = NonNullable< NonNullable['customSerializer'] >; -const getBaseSerializer = (): Serializer => { - const baseJSBundle = require( - 'metro/private/DeltaBundler/Serializers/baseJSBundle' - ); - const bundleToString = require('metro/private/lib/bundleToString'); +type PreModule = Parameters[1][number]; +type Graph = ReadOnlyGraph; +type BundleOptions = Parameters[3]; - return (entryPoint, prepend, graph, bundleOptions) => - bundleToString(baseJSBundle(entryPoint, prepend, graph, bundleOptions)); +const baseJSBundle: ( + entryPoint: string, + preModules: ReadonlyArray, + graph: Graph, + options: BundleOptions +) => { + pre: string; + post: string; + modules: Array<[number, string]>; +} = require('metro/private/DeltaBundler/Serializers/baseJSBundle'); + +// Metro ships no .d.ts for this module. Its actual (synchronous) return shape +// is `{code, metadata}`, which doesn't line up with the `customSerializer` +// type's `{code, map}` -- but Metro's Server only reads `.code` off of +// whatever a custom serializer returns and computes `.map` itself when it's +// absent (see Server.js's `_serializeGraph`), so the mismatch is harmless in +// practice. `asSerializerResult` documents/contains that cast. +const bundleToString: (bundle: { + pre: string; + post: string; + modules: Array<[number, string]>; +}) => { code: string; metadata: unknown } = require( + 'metro/private/lib/bundleToString' +); + +const asSerializerResult = (result: { + code: string; + metadata: unknown; +}): Awaited> => + result as unknown as Awaited>; + +export type GetHarnessSerializerOptions = { + /** + * Absolute path Metro resolves the harness main entry point to (the + * runtime's `entry-point` module -- see + * `resolvers/resolver.ts#createHarnessEntryPointResolver`). Both the app's + * configured `entryPoint` and Expo's virtual entry + * (`.expo/.virtual-metro-entry`) resolve to this exact same absolute path, + * so matching against it also transparently covers the Expo case. + */ + harnessEntryPointPath: string; + /** + * The consuming project's own `serializer.customSerializer`, if any (e.g. + * Expo's). Used for every non-modulesOnly (main bundle) serialization so + * this feature stays non-invasive -- capturing the already-included module + * set is a read-only side effect that never changes what gets served. + */ + customSerializer?: Serializer; }; -const getAllFiles = require('metro/private/DeltaBundler/Serializers/getAllFiles'); - -export const getHarnessSerializer = (): Serializer => { - const baseSerializer = getBaseSerializer(); - let mainEntryPointModules = new Set(); - - return async (entryPoint, preModules, graph, options) => { - if (options.modulesOnly) { - return baseSerializer(entryPoint, preModules, graph, { - ...options, - processModuleFilter: (mod) => { - if ( - options.processModuleFilter && - !options.processModuleFilter(mod) - ) { - return false; - } - - return !mainEntryPointModules.has(mod.path); - }, - }); - } +/** + * Derives a stable key from the parts of `graph.transformOptions` that + * change what ends up in the graph (platform, dev/prod, minify, + * transform profile, and any custom transform options). Module sets differ + * across these -- e.g. a `.ios.ts` file's dependencies are not the same set + * as `.android.ts` -- so the captured "already included" set must never be + * shared across them. + */ +const getGraphKey = (graph: Graph): string => { + const { + platform, + dev, + minify, + unstable_transformProfile, + customTransformOptions, + } = graph.transformOptions; + + return JSON.stringify({ + platform: platform ?? null, + dev, + minify, + unstable_transformProfile, + customTransformOptions: customTransformOptions ?? {}, + }); +}; + +const isMainEntrySerialization = ( + entryPoint: string, + graph: Graph, + harnessEntryPointPath: string +): boolean => + entryPoint === harnessEntryPointPath || + graph.entryPoints.has(harnessEntryPointPath); + +/** + * Builds the set of module paths already shipped in the main bundle. + * + * Deliberately does NOT use Metro's `getAllFiles` serializer helper: for + * asset modules that helper expands to the physical variant files on disk + * (e.g. `icon@2x.png`) rather than the module path Metro actually uses to + * identify the module in the graph (`mod.path`), so assets would never + * match and the helper does pointless async fs work besides. Building the + * set directly from `preModules` and `graph.dependencies` keys gives us + * exactly the identifiers `processModuleFilter`/`createModuleId` operate on. + */ +const collectIncludedModulePaths = ( + preModules: ReadonlyArray, + graph: Graph +): Set => { + const paths = new Set(); + + for (const preModule of preModules) { + paths.add(preModule.path); + } + + for (const path of graph.dependencies.keys()) { + paths.add(path); + } + + return paths; +}; + +/** + * Serializer that skips re-sending modules already served in the harness + * main bundle when Metro serves a `?modulesOnly=true` per-test-file bundle. + * + * Failure-direction rule: under-exclusion (a fatter test bundle) is always + * safe; over-exclusion crashes the device with "Requiring unknown module". + * Every branch below is written to fail open toward inclusion whenever the + * main-bundle module set for a given graph is unknown or ambiguous. + */ +export const getHarnessSerializer = ( + options: GetHarnessSerializerOptions +): Serializer => { + const { harnessEntryPointPath, customSerializer } = options; - mainEntryPointModules = new Set( - await getAllFiles(preModules, graph, options) + const defaultSerializer: Serializer = async ( + entryPoint, + preModules, + graph, + bundleOptions + ) => + asSerializerResult( + bundleToString(baseJSBundle(entryPoint, preModules, graph, bundleOptions)) ); - return baseSerializer(entryPoint, preModules, graph, options); + + // One set of "already included" module paths per graph identity (see + // `getGraphKey`). Only ever written from a positively-identified main + // bundle serialization (see below), so a debugger or browser hitting an + // arbitrary bundle URL, or another entry point entirely, can never + // clobber it. + const includedModulesByGraphKey = new Map>(); + + return async (entryPoint, preModules, graph, bundleOptions) => { + if (!bundleOptions.modulesOnly) { + if (isMainEntrySerialization(entryPoint, graph, harnessEntryPointPath)) { + includedModulesByGraphKey.set( + getGraphKey(graph), + collectIncludedModulePaths(preModules, graph) + ); + } + + const serialize = customSerializer ?? defaultSerializer; + return serialize(entryPoint, preModules, graph, bundleOptions); + } + + const includedModules = includedModulesByGraphKey.get(getGraphKey(graph)); + + if (!includedModules || includedModules.size === 0) { + // Fail open: we have never observed a main bundle for this exact + // graph identity, so we don't know what the device already has. + // Serialize normally rather than risk stripping a module it never + // received. + return defaultSerializer(entryPoint, preModules, graph, bundleOptions); + } + + // Phase 2 replaces this filter-based exclusion with post-hoc blanking + // of module code so source maps and `/symbolicate` (which only see + // Metro's config-level `processModuleFilter`, not this per-call + // override) stay in sync with what's actually sent to the device. + return defaultSerializer(entryPoint, preModules, graph, { + ...bundleOptions, + processModuleFilter: (mod: Module) => { + if ( + bundleOptions.processModuleFilter && + !bundleOptions.processModuleFilter(mod) + ) { + return false; + } + + return !includedModules.has(mod.path); + }, + }); }; }; diff --git a/packages/bundler-metro/src/withRnHarness.ts b/packages/bundler-metro/src/withRnHarness.ts index 7ecb4589..0912a931 100644 --- a/packages/bundler-metro/src/withRnHarness.ts +++ b/packages/bundler-metro/src/withRnHarness.ts @@ -118,7 +118,12 @@ export const withRnHarness = ( patchedConfig.serializer as NonNullable< NotReadOnly > - ).customSerializer = getHarnessSerializer(); + ).customSerializer = getHarnessSerializer({ + harnessEntryPointPath: require.resolve( + '@react-native-harness/runtime/entry-point' + ), + customSerializer: metroConfig.serializer?.customSerializer ?? undefined, + }); } return patchedConfig as T; From d9db233d6e4a3f18397eacbbb850238fb1041840 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Mon, 13 Jul 2026 16:27:55 +0200 Subject: [PATCH 2/7] Blank skipped module code instead of omitting it (phase 2) Metro's .map endpoint and /symbolicate compute line offsets from the full, unfiltered graph using only the config-level processModuleFilter -- they never see a custom serializer's per-call filter override. Omitting already-included modules from the modulesOnly bundle shifted every subsequent module's line offset, producing wrong code frames for failing tests. Instead, serialize the full graph via baseJSBundle with the user's processModuleFilter passed through untouched, then replace each already-included module's code with the same number of blank lines. This keeps the bundle's line count byte-for-byte identical to an unfiltered bundle (so Metro's separately-computed source maps stay correct) while still dropping the __d() definitions that make the test bundle smaller. Also tightens what counts as "already included": the capture pass now mirrors processModules' own isJsModule + config-level processModuleFilter checks, so a module the user's filter rejected from the main bundle (and so never reached the device) is not later blanked out of a test bundle that legitimately needs it. --- .../bundler-metro/src/getHarnessSerializer.ts | 110 ++++++++++++++---- 1 file changed, 85 insertions(+), 25 deletions(-) diff --git a/packages/bundler-metro/src/getHarnessSerializer.ts b/packages/bundler-metro/src/getHarnessSerializer.ts index aba5cfb8..5f144625 100644 --- a/packages/bundler-metro/src/getHarnessSerializer.ts +++ b/packages/bundler-metro/src/getHarnessSerializer.ts @@ -46,6 +46,41 @@ const asSerializerResult = (result: { }): Awaited> => result as unknown as Awaited>; +// Same filter Metro's own `processModules` helper (used internally by +// baseJSBundle) applies before a module gets wrapped into a `__d()` call. +const isJsModule: (mod: Module) => boolean = require( + 'metro/private/DeltaBundler/Serializers/helpers/js' +).isJsModule; + +const countLines = (code: string): number => + (code.match(/\n/g)?.length ?? 0) + 1; + +/** + * Replaces a skipped module's code with blank lines instead of omitting it. + * + * Metro's `.map` endpoint and `/symbolicate` (Server.js's + * `_processSourceMapRequest` / `_explodedSourceMapForBundleOptions`) compute + * line offsets from the full, unfiltered graph -- they only ever see the + * config-level `processModuleFilter`, never a custom serializer's per-call + * override. If we simply left already-included modules out of the test + * bundle, every module's line offset after the first omitted one would + * shift, producing wrong code frames for test failures. Blanking preserves + * the exact line count instead, so an unfiltered `.map`/`/symbolicate` + * response is still correct for the (blanked) bundle we actually send. + */ +const blankModuleCode = (code: string): string => { + const newlineCount = countLines(code) - 1; + const blank = '\n'.repeat(newlineCount); + + // bundleToString (metro/private/lib/bundleToString.js) skips appending a + // module's code entirely -- not even a trailing newline -- when its + // length is 0. The original module code is never empty, so a blank of '' + // (i.e. a module with no internal newlines) would drop the one line that + // module used to occupy and desync every subsequent module's offset. A + // single space keeps the length > 0 without adding an extra line. + return blank.length > 0 ? blank : ' '; +}; + export type GetHarnessSerializerOptions = { /** * Absolute path Metro resolves the harness main entry point to (the @@ -100,28 +135,45 @@ const isMainEntrySerialization = ( graph.entryPoints.has(harnessEntryPointPath); /** - * Builds the set of module paths already shipped in the main bundle. + * Builds the set of module paths already shipped (as a `__d()` definition) + * in the main bundle. * * Deliberately does NOT use Metro's `getAllFiles` serializer helper: for * asset modules that helper expands to the physical variant files on disk * (e.g. `icon@2x.png`) rather than the module path Metro actually uses to * identify the module in the graph (`mod.path`), so assets would never * match and the helper does pointless async fs work besides. Building the - * set directly from `preModules` and `graph.dependencies` keys gives us - * exactly the identifiers `processModuleFilter`/`createModuleId` operate on. + * set directly from `preModules` and `graph.dependencies` gives us exactly + * the identifiers `processModuleFilter`/`createModuleId` operate on. + * + * Mirrors the exact filter Metro's `processModules` helper applies + * (`isJsModule` + the user's own config-level `processModuleFilter`) before + * a module is wrapped into the main bundle. A module the user's filter + * rejects from the main bundle can still be a real dependency of a test + * file (it simply never reached the device the first time), so it must + * NOT be recorded as "already included" -- doing so would make the + * modulesOnly branch blank out a module the device never actually got, + * which is the over-exclusion crash this feature must never cause. */ const collectIncludedModulePaths = ( preModules: ReadonlyArray, - graph: Graph + graph: Graph, + processModuleFilter: BundleOptions['processModuleFilter'] ): Set => { const paths = new Set(); + const isIncludedInMainBundle = (mod: Module): boolean => + isJsModule(mod) && (!processModuleFilter || processModuleFilter(mod)); for (const preModule of preModules) { - paths.add(preModule.path); + if (isIncludedInMainBundle(preModule)) { + paths.add(preModule.path); + } } - for (const path of graph.dependencies.keys()) { - paths.add(path); + for (const mod of graph.dependencies.values()) { + if (isIncludedInMainBundle(mod)) { + paths.add(mod.path); + } } return paths; @@ -163,7 +215,11 @@ export const getHarnessSerializer = ( if (isMainEntrySerialization(entryPoint, graph, harnessEntryPointPath)) { includedModulesByGraphKey.set( getGraphKey(graph), - collectIncludedModulePaths(preModules, graph) + collectIncludedModulePaths( + preModules, + graph, + bundleOptions.processModuleFilter + ) ); } @@ -181,22 +237,26 @@ export const getHarnessSerializer = ( return defaultSerializer(entryPoint, preModules, graph, bundleOptions); } - // Phase 2 replaces this filter-based exclusion with post-hoc blanking - // of module code so source maps and `/symbolicate` (which only see - // Metro's config-level `processModuleFilter`, not this per-call - // override) stay in sync with what's actually sent to the device. - return defaultSerializer(entryPoint, preModules, graph, { - ...bundleOptions, - processModuleFilter: (mod: Module) => { - if ( - bundleOptions.processModuleFilter && - !bundleOptions.processModuleFilter(mod) - ) { - return false; - } - - return !includedModules.has(mod.path); - }, - }); + // Serialize the FULL graph -- options.processModuleFilter is passed + // through untouched, with no per-call override -- so Metro's + // independent `.map`/`/symbolicate` code paths (which only ever see + // that config-level filter) compute line offsets that match what we + // actually send byte-for-byte. Already-included modules are blanked out + // afterwards instead of omitted; see `blankModuleCode`. + const bundle = baseJSBundle(entryPoint, preModules, graph, bundleOptions); + + const excludedModuleIds = new Set(); + for (const path of includedModules) { + excludedModuleIds.add(bundleOptions.createModuleId(path)); + } + + const blankedModules: Array<[number, string]> = bundle.modules.map( + ([id, code]) => + excludedModuleIds.has(id) ? [id, blankModuleCode(code)] : [id, code] + ); + + return asSerializerResult( + bundleToString({ ...bundle, modules: blankedModules }) + ); }; }; From 8722fe531003c69ab4e18326b4bc863332596eb6 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Mon, 13 Jul 2026 16:31:03 +0200 Subject: [PATCH 3/7] Graduate skipAlreadyIncludedModules out of unstable, default it on (phase 3) Add `skipAlreadyIncludedModules`, defaulting to true, as the stable replacement for `unstable__skipAlreadyIncludedModules`. The deprecated alias keeps working (with a deprecation warning) and still serves as the escape hatch (`unstable__skipAlreadyIncludedModules: false`), but the new flag wins whenever both are set. Neither field carries a zod `.default()`: doing so would make "not set" and "explicitly set to the default" indistinguishable, which is exactly the information `resolveSkipAlreadyIncludedModules` needs to apply the new default without breaking the alias's escape-hatch semantics. The default is applied in that resolver instead, which withRnHarness.ts now calls in place of reading the raw config field directly. --- .../src/__tests__/withRnHarness.test.ts | 3 +- packages/bundler-metro/src/withRnHarness.ts | 7 ++- packages/config/src/index.ts | 1 + packages/config/src/types.ts | 62 ++++++++++++++++++- 4 files changed, 69 insertions(+), 4 deletions(-) diff --git a/packages/bundler-metro/src/__tests__/withRnHarness.test.ts b/packages/bundler-metro/src/__tests__/withRnHarness.test.ts index 77f7e3b2..64cd2cb0 100644 --- a/packages/bundler-metro/src/__tests__/withRnHarness.test.ts +++ b/packages/bundler-metro/src/__tests__/withRnHarness.test.ts @@ -14,7 +14,8 @@ type MinimalMetroConfig = { }; }; -vi.mock('@react-native-harness/config', () => ({ +vi.mock('@react-native-harness/config', async (importOriginal) => ({ + ...(await importOriginal()), getConfig: vi.fn(async () => ({ config: {}, })), diff --git a/packages/bundler-metro/src/withRnHarness.ts b/packages/bundler-metro/src/withRnHarness.ts index 0912a931..439814e2 100644 --- a/packages/bundler-metro/src/withRnHarness.ts +++ b/packages/bundler-metro/src/withRnHarness.ts @@ -1,7 +1,10 @@ import { createRequire } from 'node:module'; import os from 'node:os'; import type { MetroConfig } from 'metro-config'; -import { getConfig } from '@react-native-harness/config'; +import { + getConfig, + resolveSkipAlreadyIncludedModules, +} from '@react-native-harness/config'; import { logger } from '@react-native-harness/tools'; import { getHarnessBabelTransformerPath } from './babel-transformer.js'; import { getHarnessSerializer } from './getHarnessSerializer.js'; @@ -113,7 +116,7 @@ export const withRnHarness = ( getHarnessCacheStores(); } - if (harnessConfig.unstable__skipAlreadyIncludedModules) { + if (resolveSkipAlreadyIncludedModules(harnessConfig)) { ( patchedConfig.serializer as NonNullable< NotReadOnly diff --git a/packages/config/src/index.ts b/packages/config/src/index.ts index 97e4a505..edaffdd0 100644 --- a/packages/config/src/index.ts +++ b/packages/config/src/index.ts @@ -4,6 +4,7 @@ export { ConfigSchema, DEFAULT_METRO_PORT, isDiagnosticsEnabled, + resolveSkipAlreadyIncludedModules, } from './types.js'; export { ConfigValidationError, diff --git a/packages/config/src/types.ts b/packages/config/src/types.ts index 9ad63a6b..f0bb43d3 100644 --- a/packages/config/src/types.ts +++ b/packages/config/src/types.ts @@ -1,9 +1,12 @@ import { z } from 'zod'; import type { HarnessPlugin } from '@react-native-harness/plugins'; import { isHarnessPlugin } from '@react-native-harness/plugins'; +import { logger } from '@react-native-harness/tools'; export const DEFAULT_METRO_PORT = 8081; +const configLogger = logger.child('config'); + const RunnerSchema = z.object({ name: z .string() @@ -91,7 +94,26 @@ export const ConfigSchema = z 'process restart if the reload fails or the app does not reconnect in time. `false` disables ' + 'resetting the environment between test files entirely.' ), - unstable__skipAlreadyIncludedModules: z.boolean().optional().default(false), + skipAlreadyIncludedModules: z + .boolean() + .optional() + .describe( + 'Skip re-sending modules already served in the main app bundle when Metro serves ' + + "a per-test-file bundle. Defaults to true; set to false as an escape hatch if it " + + 'causes issues. Left undefined (rather than defaulted here) so `resolveSkipAlreadyIncludedModules` ' + + 'can tell "not set" apart from "explicitly set" when reconciling with the deprecated ' + + '`unstable__skipAlreadyIncludedModules` alias.' + ), + // Deprecated alias for `skipAlreadyIncludedModules` -- see + // `resolveSkipAlreadyIncludedModules`. Left without a `.default()` for the + // same reason: a default value would be indistinguishable from the user + // never having set it, which would break alias-vs-explicit-flag precedence. + unstable__skipAlreadyIncludedModules: z + .boolean() + .optional() + .describe( + 'Deprecated. Use `skipAlreadyIncludedModules` instead.' + ), unstable__enableMetroCache: z.boolean().optional().default(false), permissions: z .boolean() @@ -203,3 +225,41 @@ export const isDiagnosticsEnabled = ( const envValue = env.RN_HARNESS_DIAGNOSTICS; return !!envValue && envValue !== '0' && envValue !== 'false'; }; + +/** + * Resolves the effective value of `skipAlreadyIncludedModules`, reconciling + * it with the deprecated `unstable__skipAlreadyIncludedModules` alias: + * + * - The new flag, when explicitly set, always wins over the alias. + * - Otherwise, the alias's value is used (with a deprecation warning). + * - Otherwise, defaults to `true`. + * + * The default is applied here rather than in the zod schema so that "unset" + * and "explicitly set to the default value" remain distinguishable -- that + * distinction is what lets the deprecated alias keep working as an escape + * hatch (e.g. `unstable__skipAlreadyIncludedModules: false`) even though the + * new flag now defaults to `true`. + */ +export const resolveSkipAlreadyIncludedModules = ( + config: Pick< + Config, + 'skipAlreadyIncludedModules' | 'unstable__skipAlreadyIncludedModules' + > +): boolean => { + if (config.unstable__skipAlreadyIncludedModules !== undefined) { + configLogger.warn( + '`unstable__skipAlreadyIncludedModules` is deprecated and will be removed in a future release. ' + + 'Use `skipAlreadyIncludedModules` instead.' + ); + } + + if (config.skipAlreadyIncludedModules !== undefined) { + return config.skipAlreadyIncludedModules; + } + + if (config.unstable__skipAlreadyIncludedModules !== undefined) { + return config.unstable__skipAlreadyIncludedModules; + } + + return true; +}; From 9df60d685f6c441825199fee8d0a8a385404cfa8 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Mon, 13 Jul 2026 16:41:12 +0200 Subject: [PATCH 4/7] Add unit tests for the skip-already-included-modules serializer (phase 4) Covers: capture only on a matching main entry (including the Expo virtual entry, resolved via graph.entryPoints); a non-matching full bundle serialization never clobbers a previously captured set; graph-identity keying (an iOS capture doesn't leak into an Android modulesOnly request, which fails open); blanking preserves the bundle's exact total line count while removing a skipped module's code and leaving other modules' wrapped code byte-identical; the captured set is built from graph.dependencies (so an asset module path is captured directly, without getAllFiles-style expansion); delegation to a user-supplied customSerializer for main-bundle requests; and the modulesOnly fail-open path when no set was ever captured for a graph key. Also fixes a real bug the new tests caught: `metro/private/DeltaBundler/ Serializers/baseJSBundle` and `metro/private/lib/bundleToString` are CJS modules compiled with `exports.default = ...`, so `require()` returns `{default: fn}`, not a callable -- the serializer was calling the require() result directly and would have thrown at runtime the first time the feature actually ran. Unwrap `.default` for both. Adds packages/config/vite.config.ts and an `ignoredDependencies` entry in its eslint config (matching bundler-metro's) so the config package gets a `test` target (it had none), plus packages/config/src/__tests__/resolveSkipAlreadyIncludedModules.test.ts covering the default-true, alias resolution, deprecation warning, and explicit-flag-wins-over-alias cases. --- .../__tests__/getHarnessSerializer.test.ts | 444 ++++++++++++++++++ .../bundler-metro/src/getHarnessSerializer.ts | 9 +- packages/config/eslint.config.mjs | 1 + .../resolveSkipAlreadyIncludedModules.test.ts | 74 +++ packages/config/vite.config.ts | 18 + 5 files changed, 544 insertions(+), 2 deletions(-) create mode 100644 packages/bundler-metro/src/__tests__/getHarnessSerializer.test.ts create mode 100644 packages/config/src/__tests__/resolveSkipAlreadyIncludedModules.test.ts create mode 100644 packages/config/vite.config.ts diff --git a/packages/bundler-metro/src/__tests__/getHarnessSerializer.test.ts b/packages/bundler-metro/src/__tests__/getHarnessSerializer.test.ts new file mode 100644 index 00000000..900e4c4e --- /dev/null +++ b/packages/bundler-metro/src/__tests__/getHarnessSerializer.test.ts @@ -0,0 +1,444 @@ +import { createRequire } from 'node:module'; +import { describe, expect, it, vi } from 'vitest'; +import { + getHarnessSerializer as getHarnessSerializerImpl, + type GetHarnessSerializerOptions, + type Serializer, +} from '../getHarnessSerializer.js'; + +const require = createRequire(import.meta.url); +const baseJSBundle: (...args: unknown[]) => { + pre: string; + post: string; + modules: Array<[number, string]>; +} = require('metro/private/DeltaBundler/Serializers/baseJSBundle').default; +const bundleToString: (bundle: unknown) => { code: string } = require( + 'metro/private/lib/bundleToString' +).default; + +type FakeModule = { + path: string; + dependencies: Map; + inverseDependencies: Set; + getSource: () => Buffer; + output: ReadonlyArray<{ + type: string; + data: { + code: string; + lineCount: number; + map: unknown[]; + functionMap: null; + }; + }>; +}; + +const countLines = (code: string): number => + (code.match(/\n/g)?.length ?? 0) + 1; + +const makeModule = ( + path: string, + code: string, + jsType = 'js/module' +): FakeModule => ({ + path, + dependencies: new Map(), + inverseDependencies: new Set(), + getSource: () => Buffer.from(code), + output: [ + { + type: jsType, + data: { + code, + lineCount: countLines(code), + map: [], + functionMap: null, + }, + }, + ], +}); + +type FakeGraph = { + entryPoints: Set; + transformOptions: { + type: 'module'; + platform?: string; + dev: boolean; + minify: boolean; + unstable_transformProfile: string; + customTransformOptions?: Record; + }; + dependencies: Map; +}; + +// Metro's real `ReadOnlyGraph`/`TransformInputOptions` types pull in a lot of +// incidental fields (`type`, a closed `TransformProfile` union, etc.) that +// don't matter for these tests, and `entryPoints` is a mutable `Set` here +// purely for test setup convenience (tests still add to it after creation) +// even though the real type is a `ReadonlySet`. `getHarnessSerializer` below +// casts once to Metro's real types at the call boundary via `TestSerializer`. +const makeGraph = ( + modules: FakeModule[], + overrides?: Partial +): FakeGraph => ({ + entryPoints: new Set(), + transformOptions: { + type: 'module', + platform: 'ios', + dev: true, + minify: false, + unstable_transformProfile: 'default', + customTransformOptions: {}, + ...overrides, + }, + dependencies: new Map(modules.map((mod) => [mod.path, mod])), +}); + +// A version of `Serializer` typed against our fake graph/module/options +// shapes instead of Metro's real (much larger) types, so call sites in the +// tests below don't need a cast on every argument. +type TestSerializer = ( + entryPoint: string, + preModules: ReadonlyArray, + graph: FakeGraph, + bundleOptions: ReturnType +) => ReturnType; + +const getHarnessSerializer = ( + options: GetHarnessSerializerOptions +): TestSerializer => + getHarnessSerializerImpl(options) as unknown as TestSerializer; + +const makeCreateModuleId = () => { + const ids = new Map(); + let nextId = 0; + return (path: string): number => { + if (!ids.has(path)) { + ids.set(path, nextId++); + } + return ids.get(path) as number; + }; +}; + +const makeBundleOptions = ( + overrides: { + modulesOnly?: boolean; + createModuleId?: (path: string) => number; + processModuleFilter?: (mod: FakeModule) => boolean; + } = {} +) => ({ + asyncRequireModulePath: 'asyncRequire', + createModuleId: overrides.createModuleId ?? makeCreateModuleId(), + dev: true, + getRunModuleStatement: (moduleId: number | string) => + `require(${JSON.stringify(moduleId)});`, + globalPrefix: '', + includeAsyncPaths: false, + inlineSourceMap: false, + modulesOnly: overrides.modulesOnly ?? false, + processModuleFilter: overrides.processModuleFilter ?? (() => true), + projectRoot: '/repo', + runBeforeMainModule: [], + runModule: false, + serverRoot: '/repo', + shouldAddToIgnoreList: () => false, + sourceMapUrl: undefined, + sourceUrl: undefined, + getSourceUrl: () => undefined, +}); + +const HARNESS_ENTRY = '/repo/node_modules/@react-native-harness/runtime/entry-point.js'; +const APP_MODULE = '/repo/src/App.js'; +const ASSET_MODULE = '/repo/assets/icon@2x.png'; +const TEST_MODULE = '/repo/src/__tests__/example.test.js'; + +describe('getHarnessSerializer', () => { + it('captures the main bundle module set only when the entry matches the harness entry point', async () => { + const serializer = getHarnessSerializer({ + harnessEntryPointPath: HARNESS_ENTRY, + }); + + const appModule = makeModule(APP_MODULE, 'console.log("app");\n'); + const testModule = makeModule(TEST_MODULE, 'test("x", () => {});\n'); + + // Main bundle: entry matches the harness entry point -> should capture. + const mainGraph = makeGraph([appModule]); + mainGraph.entryPoints.add(HARNESS_ENTRY); + const bundleOptions = makeBundleOptions(); + + await serializer(HARNESS_ENTRY, [], mainGraph, bundleOptions); + + // modulesOnly request that reuses the SAME graph key as the main bundle: + // appModule should be blanked out since it was captured. + const testGraph = makeGraph([appModule, testModule]); + const result = await serializer( + TEST_MODULE, + [], + testGraph, + makeBundleOptions({ + modulesOnly: true, + createModuleId: bundleOptions.createModuleId, + }) + ); + + expect((result as { code: string }).code).not.toContain('console.log("app"'); + expect((result as { code: string }).code).toContain('test("x", () => {}'); + }); + + it('does not let a non-matching full bundle serialization clobber the captured set', async () => { + const serializer = getHarnessSerializer({ + harnessEntryPointPath: HARNESS_ENTRY, + }); + + const appModule = makeModule(APP_MODULE, 'console.log("app");\n'); + const testModule = makeModule(TEST_MODULE, 'test("x", () => {});\n'); + const debuggerOnlyModule = makeModule( + '/repo/src/DebuggerOnly.js', + 'console.log("debugger-only");\n' + ); + + const bundleOptions = makeBundleOptions(); + + const mainGraph = makeGraph([appModule]); + mainGraph.entryPoints.add(HARNESS_ENTRY); + await serializer(HARNESS_ENTRY, [], mainGraph, bundleOptions); + + // A debugger/browser hitting an arbitrary bundle URL: entry doesn't match + // the harness entry point, and its graph doesn't even contain appModule. + // This must NOT overwrite the captured set for this graph key. + const debuggerGraph = makeGraph([debuggerOnlyModule], { + // same graph key as the main bundle + }); + await serializer( + '/repo/some/other/entry.js', + [], + debuggerGraph, + makeBundleOptions({ createModuleId: bundleOptions.createModuleId }) + ); + + const testGraph = makeGraph([appModule, testModule]); + const result = await serializer( + TEST_MODULE, + [], + testGraph, + makeBundleOptions({ + modulesOnly: true, + createModuleId: bundleOptions.createModuleId, + }) + ); + + // appModule should still be blanked -- the debugger pass must not have + // cleared or replaced the captured set. + expect((result as { code: string }).code).not.toContain('console.log("app"'); + expect((result as { code: string }).code).toContain('test("x", () => {}'); + }); + + it('keys captured sets by graph identity: an iOS capture does not affect an Android modulesOnly request (fail-open)', async () => { + const serializer = getHarnessSerializer({ + harnessEntryPointPath: HARNESS_ENTRY, + }); + + const appModule = makeModule(APP_MODULE, 'console.log("app");\n'); + const testModule = makeModule(TEST_MODULE, 'test("x", () => {});\n'); + + const iosBundleOptions = makeBundleOptions(); + const iosGraph = makeGraph([appModule], { platform: 'ios' }); + iosGraph.entryPoints.add(HARNESS_ENTRY); + await serializer(HARNESS_ENTRY, [], iosGraph, iosBundleOptions); + + // Same module paths, but an Android graph -- no capture exists for this + // key, so the branch must fail open and include everything. + const androidGraph = makeGraph([appModule, testModule], { + platform: 'android', + }); + const androidBundleOptions = makeBundleOptions({ + modulesOnly: true, + createModuleId: makeCreateModuleId(), + }); + const result = await serializer( + TEST_MODULE, + [], + androidGraph, + androidBundleOptions + ); + + expect((result as { code: string }).code).toContain('console.log("app"'); + expect((result as { code: string }).code).toContain('test("x", () => {}'); + }); + + it('blanks skipped modules while preserving the exact total line count and leaving other modules byte-identical', async () => { + const serializer = getHarnessSerializer({ + harnessEntryPointPath: HARNESS_ENTRY, + }); + + const appModule = makeModule( + APP_MODULE, + 'console.log("line1");\nconsole.log("line2");\nconsole.log("line3");\n' + ); + const testModule = makeModule( + TEST_MODULE, + 'test("keeps this exact code", () => {\n expect(1).toBe(1);\n});\n' + ); + + const bundleOptions = makeBundleOptions(); + const mainGraph = makeGraph([appModule]); + mainGraph.entryPoints.add(HARNESS_ENTRY); + await serializer(HARNESS_ENTRY, [], mainGraph, bundleOptions); + + const testGraph = makeGraph([appModule, testModule]); + const testBundleOptions = makeBundleOptions({ + modulesOnly: true, + createModuleId: bundleOptions.createModuleId, + }); + + const blankedResult = (await serializer( + TEST_MODULE, + [], + testGraph, + testBundleOptions + )) as { code: string }; + + // Compute the unfiltered baseline the same way Metro would, to compare + // line counts against. + const unfilteredResult = bundleToString( + baseJSBundle(TEST_MODULE, [], testGraph, testBundleOptions) + ); + + expect(countLines(blankedResult.code)).toBe(countLines(unfilteredResult.code)); + + // appModule's own code should be gone... + expect(blankedResult.code).not.toContain('line1'); + expect(blankedResult.code).not.toContain('line2'); + expect(blankedResult.code).not.toContain('line3'); + + // ...but testModule's code must be untouched (byte-identical wrapping). + expect(blankedResult.code).toContain('keeps this exact code'); + expect(blankedResult.code).toContain('expect(1).toBe(1)'); + }); + + it('builds the captured set from graph.dependencies (asset module paths included, not expanded to variant files)', async () => { + const serializer = getHarnessSerializer({ + harnessEntryPointPath: HARNESS_ENTRY, + }); + + const assetModule = makeModule( + ASSET_MODULE, + 'module.exports = require("./icon.png");\n', + 'js/module/asset' + ); + const testModule = makeModule(TEST_MODULE, 'test("x", () => {});\n'); + + const bundleOptions = makeBundleOptions(); + const mainGraph = makeGraph([assetModule]); + mainGraph.entryPoints.add(HARNESS_ENTRY); + await serializer(HARNESS_ENTRY, [], mainGraph, bundleOptions); + + const testGraph = makeGraph([assetModule, testModule]); + const result = (await serializer( + TEST_MODULE, + [], + testGraph, + makeBundleOptions({ + modulesOnly: true, + createModuleId: bundleOptions.createModuleId, + }) + )) as { code: string }; + + expect(result.code).not.toContain('require("./icon.png")'); + expect(result.code).toContain('test("x", () => {}'); + }); + + it('delegates non-modulesOnly serialization to the user-supplied customSerializer', async () => { + const customSerializer = vi.fn( + async () => ({ code: 'custom-output', map: '' }) + ) as unknown as Serializer; + + const serializer = getHarnessSerializer({ + harnessEntryPointPath: HARNESS_ENTRY, + customSerializer, + }); + + const appModule = makeModule(APP_MODULE, 'console.log("app");\n'); + const mainGraph = makeGraph([appModule]); + mainGraph.entryPoints.add(HARNESS_ENTRY); + const bundleOptions = makeBundleOptions(); + + const result = await serializer(HARNESS_ENTRY, [], mainGraph, bundleOptions); + + expect(customSerializer).toHaveBeenCalledTimes(1); + expect(result).toEqual({ code: 'custom-output', map: '' }); + + // Capture must still have happened (read-only, alongside delegation), so + // a subsequent modulesOnly request still blanks the already-included + // module. + const testModule = makeModule(TEST_MODULE, 'test("x", () => {});\n'); + const testGraph = makeGraph([appModule, testModule]); + const testResult = (await serializer( + TEST_MODULE, + [], + testGraph, + makeBundleOptions({ + modulesOnly: true, + createModuleId: bundleOptions.createModuleId, + }) + )) as { code: string }; + + expect(testResult.code).not.toContain('console.log("app"'); + expect(testResult.code).toContain('test("x", () => {}'); + }); + + it('fails open (serializes normally) for modulesOnly requests when no set was ever captured', async () => { + const serializer = getHarnessSerializer({ + harnessEntryPointPath: HARNESS_ENTRY, + }); + + const appModule = makeModule(APP_MODULE, 'console.log("app");\n'); + const testModule = makeModule(TEST_MODULE, 'test("x", () => {});\n'); + const testGraph = makeGraph([appModule, testModule]); + + const result = (await serializer( + TEST_MODULE, + [], + testGraph, + makeBundleOptions({ modulesOnly: true }) + )) as { code: string }; + + expect(result.code).toContain('console.log("app"'); + expect(result.code).toContain('test("x", () => {}'); + }); + + it('honors the Expo virtual entry by matching it via graph.entryPoints', async () => { + const serializer = getHarnessSerializer({ + harnessEntryPointPath: HARNESS_ENTRY, + }); + + const appModule = makeModule(APP_MODULE, 'console.log("app");\n'); + const testModule = makeModule(TEST_MODULE, 'test("x", () => {});\n'); + + // Expo requests the virtual entry, which the harness resolver maps to + // the same harness entry-point module; graph.entryPoints reflects the + // resolved path even when the literal `entryPoint` string argument + // doesn't. + const bundleOptions = makeBundleOptions(); + const mainGraph = makeGraph([appModule]); + mainGraph.entryPoints.add(HARNESS_ENTRY); + await serializer( + '/repo/.expo/.virtual-metro-entry', + [], + mainGraph, + bundleOptions + ); + + const testGraph = makeGraph([appModule, testModule]); + const result = (await serializer( + TEST_MODULE, + [], + testGraph, + makeBundleOptions({ + modulesOnly: true, + createModuleId: bundleOptions.createModuleId, + }) + )) as { code: string }; + + expect(result.code).not.toContain('console.log("app"'); + }); +}); diff --git a/packages/bundler-metro/src/getHarnessSerializer.ts b/packages/bundler-metro/src/getHarnessSerializer.ts index 5f144625..832cb2ba 100644 --- a/packages/bundler-metro/src/getHarnessSerializer.ts +++ b/packages/bundler-metro/src/getHarnessSerializer.ts @@ -15,6 +15,11 @@ type PreModule = Parameters[1][number]; type Graph = ReadOnlyGraph; type BundleOptions = Parameters[3]; +// Both of these are CJS modules compiled with `exports.default = ...` +// (Babel/Flow's ESM-interop output), not modules that assign their export +// directly to `module.exports`. A plain `require()` therefore returns +// `{default: fn}`, not `fn` itself -- unwrap `.default` explicitly rather +// than calling the require() result as a function. const baseJSBundle: ( entryPoint: string, preModules: ReadonlyArray, @@ -24,7 +29,7 @@ const baseJSBundle: ( pre: string; post: string; modules: Array<[number, string]>; -} = require('metro/private/DeltaBundler/Serializers/baseJSBundle'); +} = require('metro/private/DeltaBundler/Serializers/baseJSBundle').default; // Metro ships no .d.ts for this module. Its actual (synchronous) return shape // is `{code, metadata}`, which doesn't line up with the `customSerializer` @@ -38,7 +43,7 @@ const bundleToString: (bundle: { modules: Array<[number, string]>; }) => { code: string; metadata: unknown } = require( 'metro/private/lib/bundleToString' -); +).default; const asSerializerResult = (result: { code: string; diff --git a/packages/config/eslint.config.mjs b/packages/config/eslint.config.mjs index c334bc0b..97a5fc1f 100644 --- a/packages/config/eslint.config.mjs +++ b/packages/config/eslint.config.mjs @@ -9,6 +9,7 @@ export default [ 'error', { ignoredFiles: ['{projectRoot}/eslint.config.{js,cjs,mjs,ts,cts,mts}'], + ignoredDependencies: ['vite', 'vitest'], }, ], }, diff --git a/packages/config/src/__tests__/resolveSkipAlreadyIncludedModules.test.ts b/packages/config/src/__tests__/resolveSkipAlreadyIncludedModules.test.ts new file mode 100644 index 00000000..7941c698 --- /dev/null +++ b/packages/config/src/__tests__/resolveSkipAlreadyIncludedModules.test.ts @@ -0,0 +1,74 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { resolveSkipAlreadyIncludedModules } from '../types.js'; + +describe('resolveSkipAlreadyIncludedModules', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('defaults to true when neither the new flag nor the alias is set', () => { + expect( + resolveSkipAlreadyIncludedModules({ + skipAlreadyIncludedModules: undefined, + unstable__skipAlreadyIncludedModules: undefined, + }) + ).toBe(true); + }); + + it('uses the deprecated alias when only it is set', () => { + expect( + resolveSkipAlreadyIncludedModules({ + skipAlreadyIncludedModules: undefined, + unstable__skipAlreadyIncludedModules: false, + }) + ).toBe(false); + + expect( + resolveSkipAlreadyIncludedModules({ + skipAlreadyIncludedModules: undefined, + unstable__skipAlreadyIncludedModules: true, + }) + ).toBe(true); + }); + + it('logs a deprecation warning when the alias is set', () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + + resolveSkipAlreadyIncludedModules({ + skipAlreadyIncludedModules: undefined, + unstable__skipAlreadyIncludedModules: false, + }); + + expect(warnSpy).toHaveBeenCalledTimes(1); + expect(warnSpy.mock.calls[0]?.[0]).toContain( + 'unstable__skipAlreadyIncludedModules' + ); + }); + + it('does not log a deprecation warning when only the new flag is set', () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + + resolveSkipAlreadyIncludedModules({ + skipAlreadyIncludedModules: false, + unstable__skipAlreadyIncludedModules: undefined, + }); + + expect(warnSpy).not.toHaveBeenCalled(); + }); + + it('lets the explicit new flag win over the alias in either direction', () => { + expect( + resolveSkipAlreadyIncludedModules({ + skipAlreadyIncludedModules: false, + unstable__skipAlreadyIncludedModules: true, + }) + ).toBe(false); + + expect( + resolveSkipAlreadyIncludedModules({ + skipAlreadyIncludedModules: true, + unstable__skipAlreadyIncludedModules: false, + }) + ).toBe(true); + }); +}); diff --git a/packages/config/vite.config.ts b/packages/config/vite.config.ts new file mode 100644 index 00000000..90e679c8 --- /dev/null +++ b/packages/config/vite.config.ts @@ -0,0 +1,18 @@ +/// +import { defineConfig } from 'vite'; + +export default defineConfig(() => ({ + root: __dirname, + cacheDir: '../../node_modules/.vite/packages/config', + test: { + watch: false, + globals: true, + environment: 'node', + include: ['{src,tests}/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'], + reporters: ['default'], + coverage: { + reportsDirectory: './test-output/vitest/coverage', + provider: 'v8' as const, + }, + }, +})); From ede2e70260e42602217bbf1e8d582c519ef03f25 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Mon, 13 Jul 2026 16:48:39 +0200 Subject: [PATCH 5/7] Key captured module sets by platform/dev/minify only; add fail-open logging Keying the captured "already included" sets by unstable_transformProfile and customTransformOptions broke the feature on Expo: the Expo client's main-bundle request carries transform.engine=hermes, transform.bytecode=1, transform.routerRoot=app, transform.reactCompiler=true, and unstable_transformProfile=hermes-stable (see bundle-url.ts), while the runtime's ?modulesOnly=true test-bundle requests send only modulesOnly + platform, so their graph has default/empty transform options. The lookup therefore always missed, failed open, and silently turned the feature into a permanent no-op on Expo -- a regression vs. the old flat-set behavior. Key by platform, dev, and minify only. Platform keying stays because it is load-bearing (.ios.ts/.android.ts resolve to different paths). Dropping the transform dimensions is still crash-safe: blanking a path only requires that the device holds *a* __d() definition for it, module IDs are path-based via Metro's single server-wide createModuleId counter, and exclusion only applies to paths captured from a main bundle actually served to the device -- transform options change module content, not the presence of a definition. Regression-tested with a hermes-stable/Expo customTransformOptions main graph against a default-options test graph (verified the test fails against the old key). Also add debug logging on capture (graph key + module count) and on a modulesOnly lookup miss (requested key + held keys) so a future entry-point or keying regression degrades with a diagnostic breadcrumb instead of silently serving fat test bundles, and document the capture boundary when serialization is delegated to a user customSerializer (a tree-shaking serializer could invalidate the captured set, but the harness always serves dev-mode bundles, which don't tree-shake). --- .../__tests__/getHarnessSerializer.test.ts | 44 ++++++++++ .../bundler-metro/src/getHarnessSerializer.ts | 84 ++++++++++++++----- 2 files changed, 105 insertions(+), 23 deletions(-) diff --git a/packages/bundler-metro/src/__tests__/getHarnessSerializer.test.ts b/packages/bundler-metro/src/__tests__/getHarnessSerializer.test.ts index 900e4c4e..02f21865 100644 --- a/packages/bundler-metro/src/__tests__/getHarnessSerializer.test.ts +++ b/packages/bundler-metro/src/__tests__/getHarnessSerializer.test.ts @@ -406,6 +406,50 @@ describe('getHarnessSerializer', () => { expect(result.code).toContain('test("x", () => {}'); }); + it('still blanks when the main bundle was built with Expo transform options but the test bundle uses defaults', async () => { + const serializer = getHarnessSerializer({ + harnessEntryPointPath: HARNESS_ENTRY, + }); + + const appModule = makeModule(APP_MODULE, 'console.log("app");\n'); + const testModule = makeModule(TEST_MODULE, 'test("x", () => {});\n'); + + // Expo main-bundle requests carry transform.* params and a Hermes + // transform profile (see bundle-url.ts), but the runtime's + // ?modulesOnly=true test-bundle requests send only modulesOnly + + // platform, so their graph has default/empty transform options. The + // graph key must ignore those dimensions -- keying on them would make + // the lookup miss on every Expo test bundle and silently turn the + // feature into a permanent no-op. + const bundleOptions = makeBundleOptions(); + const mainGraph = makeGraph([appModule], { + unstable_transformProfile: 'hermes-stable', + customTransformOptions: { + engine: 'hermes', + bytecode: '1', + routerRoot: 'app', + reactCompiler: 'true', + }, + }); + mainGraph.entryPoints.add(HARNESS_ENTRY); + await serializer(HARNESS_ENTRY, [], mainGraph, bundleOptions); + + // Same platform/dev/minify, default transform options. + const testGraph = makeGraph([appModule, testModule]); + const result = (await serializer( + TEST_MODULE, + [], + testGraph, + makeBundleOptions({ + modulesOnly: true, + createModuleId: bundleOptions.createModuleId, + }) + )) as { code: string }; + + expect(result.code).not.toContain('console.log("app"'); + expect(result.code).toContain('test("x", () => {}'); + }); + it('honors the Expo virtual entry by matching it via graph.entryPoints', async () => { const serializer = getHarnessSerializer({ harnessEntryPointPath: HARNESS_ENTRY, diff --git a/packages/bundler-metro/src/getHarnessSerializer.ts b/packages/bundler-metro/src/getHarnessSerializer.ts index 832cb2ba..df6d7277 100644 --- a/packages/bundler-metro/src/getHarnessSerializer.ts +++ b/packages/bundler-metro/src/getHarnessSerializer.ts @@ -4,8 +4,10 @@ import type { ReadOnlyGraph, Module, } from 'metro/private/DeltaBundler/types'; +import { logger } from '@react-native-harness/tools'; const require = createRequire(import.meta.url); +const serializerLogger = logger.child('metro-serializer'); export type Serializer = NonNullable< NonNullable['customSerializer'] @@ -107,27 +109,43 @@ export type GetHarnessSerializerOptions = { /** * Derives a stable key from the parts of `graph.transformOptions` that - * change what ends up in the graph (platform, dev/prod, minify, - * transform profile, and any custom transform options). Module sets differ - * across these -- e.g. a `.ios.ts` file's dependencies are not the same set - * as `.android.ts` -- so the captured "already included" set must never be - * shared across them. + * change which module PATHS end up in the graph: platform, dev, and minify. + * + * Platform keying is load-bearing: `.ios.ts` and `.android.ts` resolve to + * different paths, so an iOS capture must never be used to blank an Android + * test bundle (or vice versa). dev/minify are kept for the same "different + * request class" hygiene. + * + * `unstable_transformProfile` and `customTransformOptions` are deliberately + * EXCLUDED, for two reasons: + * + * (a) Including them makes the two sides of the lookup permanently + * mismatched on Expo: the Expo client's main-bundle request carries + * `transform.engine=hermes`, `transform.bytecode=1`, + * `transform.routerRoot=app`, `transform.reactCompiler=true`, and + * `unstable_transformProfile=hermes-stable` (see `bundle-url.ts`), + * while the harness runtime's `?modulesOnly=true` test-bundle requests + * send only `modulesOnly` + `platform` (packages/runtime/src/bundler/ + * bundle.ts), so its graph gets default/empty transform options. The + * modulesOnly lookup would then always miss, fail open, and silently + * turn the feature into a no-op forever. + * + * (b) Excluding them is still crash-safe. Blanking a path only requires + * that the device already holds *a* `__d()` definition for that path's + * module ID -- and module IDs are path-based via Metro's single + * server-wide `createModuleId` counter, while exclusion only ever + * applies to paths captured from a main bundle actually served to the + * device. Transform options change a module's *content*, not the + * presence of its definition, so a profile/custom-options difference + * between the two graphs cannot produce "Requiring unknown module". */ const getGraphKey = (graph: Graph): string => { - const { - platform, - dev, - minify, - unstable_transformProfile, - customTransformOptions, - } = graph.transformOptions; + const { platform, dev, minify } = graph.transformOptions; return JSON.stringify({ platform: platform ?? null, dev, minify, - unstable_transformProfile, - customTransformOptions: customTransformOptions ?? {}, }); }; @@ -218,13 +236,26 @@ export const getHarnessSerializer = ( return async (entryPoint, preModules, graph, bundleOptions) => { if (!bundleOptions.modulesOnly) { if (isMainEntrySerialization(entryPoint, graph, harnessEntryPointPath)) { - includedModulesByGraphKey.set( - getGraphKey(graph), - collectIncludedModulePaths( - preModules, - graph, - bundleOptions.processModuleFilter - ) + // Capture assumption: we read `preModules`/`graph.dependencies` + // even when serialization below is delegated to the user's + // customSerializer (e.g. Expo's). If that serializer dropped + // modules beyond the config-level processModuleFilter (as some do + // for production tree-shaking), this set could over-include and + // blank a module the device never received. That's fine in + // practice: the harness always serves dev-mode bundles, and + // dev-mode serving doesn't tree-shake -- but it is the boundary of + // what this capture can guarantee. + const graphKey = getGraphKey(graph); + const capturedModules = collectIncludedModulePaths( + preModules, + graph, + bundleOptions.processModuleFilter + ); + includedModulesByGraphKey.set(graphKey, capturedModules); + serializerLogger.debug( + 'captured %d main-bundle module paths for graph key %s', + capturedModules.size, + graphKey ); } @@ -232,13 +263,20 @@ export const getHarnessSerializer = ( return serialize(entryPoint, preModules, graph, bundleOptions); } - const includedModules = includedModulesByGraphKey.get(getGraphKey(graph)); + const graphKey = getGraphKey(graph); + const includedModules = includedModulesByGraphKey.get(graphKey); if (!includedModules || includedModules.size === 0) { // Fail open: we have never observed a main bundle for this exact // graph identity, so we don't know what the device already has. // Serialize normally rather than risk stripping a module it never - // received. + // received. The debug log below is the diagnostic breadcrumb for + // "why are my test bundles fat". + serializerLogger.debug( + 'no captured main-bundle module set for graph key %s (held keys: %s); serving the test bundle unfiltered', + graphKey, + [...includedModulesByGraphKey.keys()].join(', ') || '' + ); return defaultSerializer(entryPoint, preModules, graph, bundleOptions); } From 04cd26286c520beb4e95f577043711965ae3fc32 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Mon, 13 Jul 2026 19:41:12 +0200 Subject: [PATCH 6/7] Enable skipAlreadyIncludedModules by default in playground config Removes the escape-hatch override now that the serializer rework (PR #158) graduated the feature to stable, default-on. --- apps/playground/rn-harness.config.mjs | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/playground/rn-harness.config.mjs b/apps/playground/rn-harness.config.mjs index 6506b16c..cd466cc9 100644 --- a/apps/playground/rn-harness.config.mjs +++ b/apps/playground/rn-harness.config.mjs @@ -124,7 +124,6 @@ export default { detectNativeCrashes: true, resetEnvironmentBetweenTestFiles: 'runtime', unstable__enableMetroCache: true, - unstable__skipAlreadyIncludedModules: false, forwardClientLogs: true, disableViewFlattening: true, }; From 32b732511ee71c4f94b7a6e43ca2700aba920664 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Mon, 13 Jul 2026 19:58:10 +0200 Subject: [PATCH 7/7] Add version plan for skipAlreadyIncludedModules graduation --- .nx/version-plans/version-plan-skip-included-modules.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .nx/version-plans/version-plan-skip-included-modules.md diff --git a/.nx/version-plans/version-plan-skip-included-modules.md b/.nx/version-plans/version-plan-skip-included-modules.md new file mode 100644 index 00000000..70405c2e --- /dev/null +++ b/.nx/version-plans/version-plan-skip-included-modules.md @@ -0,0 +1,5 @@ +--- +__default__: minor +--- + +Test files now load faster, since Harness no longer resends code your app already has. This was previously an experimental opt-in and is now enabled by default; the old experimental flag still works but is deprecated in favor of the new `skipAlreadyIncludedModules` option.