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 0000000..70405c2 --- /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. diff --git a/apps/playground/rn-harness.config.mjs b/apps/playground/rn-harness.config.mjs index 6506b16..cd466cc 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, }; 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 0000000..02f2186 --- /dev/null +++ b/packages/bundler-metro/src/__tests__/getHarnessSerializer.test.ts @@ -0,0 +1,488 @@ +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('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, + }); + + 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/__tests__/withRnHarness.test.ts b/packages/bundler-metro/src/__tests__/withRnHarness.test.ts index 77f7e3b..64cd2cb 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/getHarnessSerializer.ts b/packages/bundler-metro/src/getHarnessSerializer.ts index 25fde3d..df6d727 100644 --- a/packages/bundler-metro/src/getHarnessSerializer.ts +++ b/packages/bundler-metro/src/getHarnessSerializer.ts @@ -1,48 +1,305 @@ import { createRequire } from 'node:module'; import type { MetroConfig } from 'metro-config'; +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'] >; -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)); +// 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, + graph: Graph, + options: BundleOptions +) => { + pre: string; + post: string; + modules: Array<[number, string]>; +} = 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` +// 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' +).default; + +const asSerializerResult = (result: { + code: string; + metadata: unknown; +}): 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 + * 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; +}; + +/** + * Derives a stable key from the parts of `graph.transformOptions` that + * 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 } = graph.transformOptions; + + return JSON.stringify({ + platform: platform ?? null, + dev, + minify, + }); +}; + +const isMainEntrySerialization = ( + entryPoint: string, + graph: Graph, + harnessEntryPointPath: string +): boolean => + entryPoint === harnessEntryPointPath || + graph.entryPoints.has(harnessEntryPointPath); + +/** + * 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` 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, + processModuleFilter: BundleOptions['processModuleFilter'] +): Set => { + const paths = new Set(); + const isIncludedInMainBundle = (mod: Module): boolean => + isJsModule(mod) && (!processModuleFilter || processModuleFilter(mod)); + + for (const preModule of preModules) { + if (isIncludedInMainBundle(preModule)) { + paths.add(preModule.path); + } + } + + for (const mod of graph.dependencies.values()) { + if (isIncludedInMainBundle(mod)) { + paths.add(mod.path); + } + } + + return paths; }; -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); - }, - }); +/** + * 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; + + const defaultSerializer: Serializer = async ( + entryPoint, + preModules, + graph, + bundleOptions + ) => + asSerializerResult( + bundleToString(baseJSBundle(entryPoint, preModules, graph, bundleOptions)) + ); + + // 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)) { + // 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 + ); + } + + const serialize = customSerializer ?? defaultSerializer; + return serialize(entryPoint, preModules, graph, bundleOptions); } - mainEntryPointModules = new Set( - await getAllFiles(preModules, graph, options) + 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. 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); + } + + // 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 }) ); - return baseSerializer(entryPoint, preModules, graph, options); }; }; diff --git a/packages/bundler-metro/src/withRnHarness.ts b/packages/bundler-metro/src/withRnHarness.ts index 7ecb458..439814e 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,12 +116,17 @@ export const withRnHarness = ( getHarnessCacheStores(); } - if (harnessConfig.unstable__skipAlreadyIncludedModules) { + if (resolveSkipAlreadyIncludedModules(harnessConfig)) { ( 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; diff --git a/packages/config/eslint.config.mjs b/packages/config/eslint.config.mjs index c334bc0..97a5fc1 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 0000000..7941c69 --- /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/src/index.ts b/packages/config/src/index.ts index 97e4a50..edaffdd 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 9ad63a6..f0bb43d 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; +}; diff --git a/packages/config/vite.config.ts b/packages/config/vite.config.ts new file mode 100644 index 0000000..90e679c --- /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, + }, + }, +}));