From 882cdfce9a736cfcfaf751fbd4e49d55fa5c5da4 Mon Sep 17 00:00:00 2001 From: Vitali Zaidman Date: Wed, 19 Aug 2026 01:51:23 -0700 Subject: [PATCH] Add DependencyGraph.unstable_fromStaticFileMap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Summary: Builds a `DependencyGraph` from a known file listing — no filesystem crawl, no `FileProcessor` and no cache persistence — for consumers that already know their file set and hold parsed `package.json` contents in memory: ```js const graph = await DependencyGraph.unstable_fromStaticFileMap(config, {files: [{path, hasteId}], readPackageJson}); ``` `hasteId` is registered as-is, so any extension or `node_modules` filtering is the caller's policy. Three further options cover what a virtual, non-terminal consumer needs: `readPackageJson` feeds `PackageCache` from memory, `unstable_dirExistsFromFileSystem` resolves `dirExists` against the supplied listing rather than `fs.lstatSync`, and `unstable_rawResolutionErrors` lets `DuplicateHasteCandidatesError` and `InvalidPackageError` propagate instead of being wrapped in `AmbiguousModuleResolutionError` / `PackageResolutionError`. Startup progress reporting is skipped on this path: `dep_graph_loading` makes `TerminalReporter` print Metro's banner, which corrupts consumers that speak a protocol over stdout. Relatedly, `createFileMap` now requires `ci-info` lazily, as it probes ~40 environment variables on import and some sandboxed consumers disallow that. Changelog: Internal (`DependencyMap` as class is not exported) Differential Revision: D116161131 --- .../metro/src/node-haste/DependencyGraph.js | 114 +++++++++-- .../DependencyGraph/createFileMap.js | 11 +- .../__tests__/DependencyGraph-static-test.js | 177 ++++++++++++++++++ 3 files changed, 288 insertions(+), 14 deletions(-) create mode 100644 packages/metro/src/node-haste/__tests__/DependencyGraph-static-test.js diff --git a/packages/metro/src/node-haste/DependencyGraph.js b/packages/metro/src/node-haste/DependencyGraph.js index d645af4aad..8e2824bc00 100644 --- a/packages/metro/src/node-haste/DependencyGraph.js +++ b/packages/metro/src/node-haste/DependencyGraph.js @@ -22,9 +22,11 @@ import type { FileSystem, HasteMap, HealthCheckResult, + StaticFile, WatcherStatus, default as MetroFileMap, } from 'metro-file-map'; +import type {PackageJson} from 'metro-resolver/private/types'; import createFileMap from './DependencyGraph/createFileMap'; import createModuleResolver from './DependencyGraph/createModuleResolver'; @@ -35,7 +37,11 @@ import { PackageResolutionError, } from 'metro-core'; import canonicalize from 'metro-core/private/canonicalize'; -import {DuplicateHasteCandidatesError} from 'metro-file-map'; +import { + DuplicateHasteCandidatesError, + NoopCacheManager, + createStaticCrawler, +} from 'metro-file-map'; import {InvalidPackageError} from 'metro-resolver'; import EventEmitter from 'node:events'; import path from 'node:path'; @@ -45,6 +51,46 @@ const {createActionStartEntry, createActionEndEntry, log} = Logger; const NULL_PLATFORM = Symbol(); +function toStaticFiles( + files: Iterable>, +): ReadonlyArray { + const staticFiles: Array = []; + for (const file of files) { + staticFiles.push({ + path: file.path, + pluginData: file.hasteId != null ? {haste: file.hasteId} : null, + }); + } + return staticFiles; +} + +/** + * Options for building a graph from a known file listing rather than by + * crawling the filesystem. Unstable: the shape is still settling. + */ +export type StaticFileMapOptions = Readonly<{ + /** + * Every file in the graph. Paths are absolute, or relative to + * `config.projectRoot`, using system separators. + * + * `hasteId` is registered as-is - unlike a crawl, it is not filtered by + * extension or by `node_modules`. Callers must apply their own policy. + */ + files: Iterable>, + + /** + * Supplies parsed `package.json` contents in place of reading them. + */ + readPackageJson?: (absolutePackageJsonPath: string) => PackageJson, + + /** + * Let `DuplicateHasteCandidatesError` and `InvalidPackageError` propagate + * from `resolveDependency` instead of being wrapped in + * `AmbiguousModuleResolutionError` / `PackageResolutionError`. + */ + unstable_rawResolutionErrors?: boolean, +}>; + function getOrCreateMap( map: Map>, field: string, @@ -83,28 +129,63 @@ export default class DependencyGraph extends EventEmitter { >, >; _initializedPromise: Promise; + #staticOptions: ?StaticFileMapOptions; + + /** + * Build a graph from a known set of files, without crawling or reading the + * filesystem. Resolves once the graph is ready to use. + */ + static async unstable_fromStaticFileMap( + config: ConfigT, + options: StaticFileMapOptions, + ): Promise { + const graph = new DependencyGraph(config, { + unstable_staticFileMap: options, + watch: false, + }); + await graph.ready(); + return graph; + } constructor( config: ConfigT, options?: { readonly hasReducedPerformance?: boolean, readonly watch?: boolean, + readonly unstable_staticFileMap?: ?StaticFileMapOptions, }, ) { super(); this._config = config; - const {hasReducedPerformance, watch} = options ?? {}; - const initializingMetroLogEntry = log( - createActionStartEntry('Initializing Metro'), - ); - - config.reporter.update({ - type: 'dep_graph_loading', - hasReducedPerformance: !!hasReducedPerformance, - }); + const {hasReducedPerformance, watch, unstable_staticFileMap} = + options ?? {}; + this.#staticOptions = unstable_staticFileMap; + + // Startup progress reporting describes crawling a filesystem and starting a + // server. Neither applies when the graph is built from a supplied listing, + // and consumers there may not have a terminal to report to. + const isStatic = unstable_staticFileMap != null; + const initializingMetroLogEntry = isStatic + ? null + : log(createActionStartEntry('Initializing Metro')); + + if (!isStatic) { + config.reporter.update({ + type: 'dep_graph_loading', + hasReducedPerformance: !!hasReducedPerformance, + }); + } const {fileMap, hasteMap, dependencyPlugin} = createFileMap(config, { + // A supplied listing is already in memory - there is nothing to warm up. + cacheManagerFactory: isStatic ? () => new NoopCacheManager() : null, + crawlerFactory: + unstable_staticFileMap != null + ? createStaticCrawler({ + files: toStaticFiles(unstable_staticFileMap.files), + }) + : null, throwOnModuleCollision: false, watch, }); @@ -117,8 +198,10 @@ export default class DependencyGraph extends EventEmitter { this._haste.on('status', status => this._onWatcherStatus(status)); this._initializedPromise = fileMap.build().then(({fileSystem}) => { - log(createActionEndEntry(initializingMetroLogEntry)); - config.reporter.update({type: 'dep_graph_loaded'}); + if (initializingMetroLogEntry != null) { + log(createActionEndEntry(initializingMetroLogEntry)); + config.reporter.update({type: 'dep_graph_loaded'}); + } this._fileSystem = fileSystem; this._hasteMap = hasteMap; @@ -132,6 +215,7 @@ export default class DependencyGraph extends EventEmitter { this.#packageCache = new PackageCache({ getClosestPackage: absoluteModulePath => this._getClosestPackage(absoluteModulePath), + readPackageJson: this.#staticOptions?.readPackageJson, }); this._createModuleResolver(); }); @@ -165,9 +249,10 @@ export default class DependencyGraph extends EventEmitter { } _createModuleResolver() { + const fileSystem = this._fileSystem; this._moduleResolver = createModuleResolver({ config: this._config, - fileSystem: this._fileSystem, + fileSystem, hasteMap: this._hasteMap, packageCache: this.#packageCache, }); @@ -295,6 +380,9 @@ export default class DependencyGraph extends EventEmitter { resolverOptions, ); } catch (error) { + if (this.#staticOptions?.unstable_rawResolutionErrors === true) { + throw error; + } if (error instanceof DuplicateHasteCandidatesError) { throw new AmbiguousModuleResolutionError(originModulePath, error); } diff --git a/packages/metro/src/node-haste/DependencyGraph/createFileMap.js b/packages/metro/src/node-haste/DependencyGraph/createFileMap.js index 9a3729307a..3310f92391 100644 --- a/packages/metro/src/node-haste/DependencyGraph/createFileMap.js +++ b/packages/metro/src/node-haste/DependencyGraph/createFileMap.js @@ -10,7 +10,12 @@ */ import type {ConfigT} from 'metro-config'; -import type {HasteMap, InputFileMapPlugin} from 'metro-file-map'; +import type { + CacheManagerFactory, + CrawlerFactory, + HasteMap, + InputFileMapPlugin, +} from 'metro-file-map'; import MetroFileMap, { DependencyPlugin, @@ -54,6 +59,8 @@ export default function createFileMap( watch?: boolean, throwOnModuleCollision?: boolean, cacheFilePrefix?: string, + cacheManagerFactory?: ?CacheManagerFactory, + crawlerFactory?: ?CrawlerFactory, }>, ): { fileMap: MetroFileMap, @@ -97,6 +104,7 @@ export default function createFileMap( const fileMap = new MetroFileMap({ cacheManagerFactory: + options?.cacheManagerFactory ?? config?.unstable_fileMapCacheManagerFactory ?? (factoryParams => new DiskCacheManager(factoryParams, { @@ -105,6 +113,7 @@ export default function createFileMap( cacheFilePrefix: options?.cacheFilePrefix, autoSave, })), + crawlerFactory: options?.crawlerFactory, perfLoggerFactory: config.unstable_perfLoggerFactory, computeSha1: !config.watcher.unstable_lazySha1, enableSymlinks: true, diff --git a/packages/metro/src/node-haste/__tests__/DependencyGraph-static-test.js b/packages/metro/src/node-haste/__tests__/DependencyGraph-static-test.js new file mode 100644 index 0000000000..e838ebc4d3 --- /dev/null +++ b/packages/metro/src/node-haste/__tests__/DependencyGraph-static-test.js @@ -0,0 +1,177 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + * @format + * @oncall react_native + */ + +import type {ConfigT, InputConfigT} from 'metro-config'; +import type {CustomResolutionContext, Resolution} from 'metro-resolver'; +import type {PackageJson} from 'metro-resolver/private/types'; + +import DependencyGraph from '../DependencyGraph'; +import {getDefaultConfig, mergeConfig} from 'metro-config'; +import { + AmbiguousModuleResolutionError, + PackageResolutionError, +} from 'metro-core'; +import {InvalidPackageError} from 'metro-resolver'; +import * as path from 'node:path'; + +// The Watcher is still constructed (it hosts the injected crawler), but neither +// built-in crawler may run, and nothing may be watched. +jest.mock('metro-file-map/private/crawlers/watchman/index', () => () => { + throw new Error('watchmanCrawl must not be called for a static file map'); +}); +jest.mock('metro-file-map/private/crawlers/node/index', () => () => { + throw new Error('nodeCrawl must not be called for a static file map'); +}); + +const rootDir = path.join(path.sep, 'project'); +const p = (...parts: Array) => path.join(rootDir, ...parts); + +const dep = (name: string) => ({ + name, + data: {asyncType: null, isESMImport: false, key: name, locs: []}, +}); + +async function makeConfig(overrides?: InputConfigT): Promise { + return mergeConfig(await getDefaultConfig(rootDir), { + projectRoot: rootDir, + ...overrides, + }); +} + +describe('DependencyGraph.unstable_fromStaticFileMap', () => { + test('resolves relative, node_modules and Haste imports without touching disk', async () => { + const packageJsons: {[string]: PackageJson} = { + [p('node_modules', 'dep', 'package.json')]: {main: './lib/dep.js'}, + }; + const graph = await DependencyGraph.unstable_fromStaticFileMap( + await makeConfig(), + { + files: [ + {path: p('src', 'index.js')}, + {path: p('src', 'sibling.js')}, + {path: p('src', 'Hasty.js'), hasteId: 'Hasty'}, + {path: p('node_modules', 'dep', 'package.json')}, + {path: p('node_modules', 'dep', 'lib', 'dep.js')}, + ], + readPackageJson: filePath => packageJsons[filePath], + }, + ); + + expect( + graph.resolveDependency(p('src', 'index.js'), dep('./sibling'), null, { + dev: false, + }), + ).toEqual({type: 'sourceFile', filePath: p('src', 'sibling.js')}); + + expect( + graph.resolveDependency(p('src', 'index.js'), dep('Hasty'), null, { + dev: false, + }), + ).toEqual({type: 'sourceFile', filePath: p('src', 'Hasty.js')}); + + expect( + graph.resolveDependency(p('src', 'index.js'), dep('dep'), null, { + dev: false, + }), + ).toEqual({ + type: 'sourceFile', + filePath: p('node_modules', 'dep', 'lib', 'dep.js'), + }); + + await graph.end(); + }); + + test('wraps resolution errors by default', async () => { + const graph = await DependencyGraph.unstable_fromStaticFileMap( + await makeConfig(), + { + files: [ + {path: p('src', 'index.js')}, + {path: p('src', 'a', 'Dup.js'), hasteId: 'Dup'}, + {path: p('src', 'b', 'Dup.js'), hasteId: 'Dup'}, + {path: p('src', 'broken', 'package.json')}, + ], + readPackageJson: () => ({main: './nope.js'}), + }, + ); + + expect(() => + graph.resolveDependency(p('src', 'index.js'), dep('Dup'), null, { + dev: false, + }), + ).toThrow(AmbiguousModuleResolutionError); + + expect(() => + graph.resolveDependency(p('src', 'index.js'), dep('./broken'), null, { + dev: false, + }), + ).toThrow(PackageResolutionError); + + await graph.end(); + }); + + test('unstable_rawResolutionErrors leaves errors unwrapped', async () => { + const {DuplicateHasteCandidatesError} = require('metro-file-map'); + const graph = await DependencyGraph.unstable_fromStaticFileMap( + await makeConfig(), + { + files: [ + {path: p('src', 'index.js')}, + {path: p('src', 'a', 'Dup.js'), hasteId: 'Dup'}, + {path: p('src', 'b', 'Dup.js'), hasteId: 'Dup'}, + {path: p('src', 'broken', 'package.json')}, + ], + readPackageJson: () => ({main: './nope.js'}), + unstable_rawResolutionErrors: true, + }, + ); + + expect(() => + graph.resolveDependency(p('src', 'index.js'), dep('Dup'), null, { + dev: false, + }), + ).toThrow(DuplicateHasteCandidatesError); + + expect(() => + graph.resolveDependency(p('src', 'index.js'), dep('./broken'), null, { + dev: false, + }), + ).toThrow(InvalidPackageError); + + await graph.end(); + }); + + test('caches resolutions per resolverOptions', async () => { + const resolveRequest = jest.fn< + [CustomResolutionContext, string, string | null], + Resolution, + >(() => ({type: 'sourceFile', filePath: p('src', 'sibling.js')})); + const graph = await DependencyGraph.unstable_fromStaticFileMap( + await makeConfig({resolver: {resolveRequest}}), + { + files: [{path: p('src', 'index.js')}, {path: p('src', 'sibling.js')}], + }, + ); + + const opts = {customResolverOptions: {a: '1'}, dev: false}; + graph.resolveDependency(p('src', 'index.js'), dep('x'), null, opts); + graph.resolveDependency(p('src', 'index.js'), dep('x'), null, opts); + expect(resolveRequest).toHaveBeenCalledTimes(1); + + graph.resolveDependency(p('src', 'index.js'), dep('x'), null, { + customResolverOptions: {a: '2'}, + dev: false, + }); + expect(resolveRequest).toHaveBeenCalledTimes(2); + + await graph.end(); + }); +});