From 7915a12507efb1f3d8cb8afb7600d011141bbc2a Mon Sep 17 00:00:00 2001 From: Marco Wang Date: Tue, 14 Apr 2026 20:35:24 -0700 Subject: [PATCH 01/11] Update prettier-plugin-hermes-parser in fbsource to 0.35.0 Summary: X-link: https://github.com/facebook/react-native/pull/56444 Bump prettier-plugin-hermes-parser to 0.35.0. Changelog: [internal] Reviewed By: SamChou19815 Differential Revision: D100850992 fbshipit-source-id: 3bb9386c007e036262b3d4fc92438e3913a12baa --- package.json | 2 +- yarn.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index 67a9846271..c42247cdc1 100644 --- a/package.json +++ b/package.json @@ -42,7 +42,7 @@ "metro-babel-register": "*", "micromatch": "^4.0.4", "prettier": "3.6.2", - "prettier-plugin-hermes-parser": "0.34.1", + "prettier-plugin-hermes-parser": "0.35.0", "progress": "^2.0.0", "signedsource": "^2.0.0", "tinyglobby": "^0.2.15", diff --git a/yarn.lock b/yarn.lock index 9a1432f989..d335870aa4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4812,10 +4812,10 @@ prelude-ls@^1.2.1: resolved "https://registry.yarnpkg.com/prelude-ls/-/prelude-ls-1.2.1.tgz#debc6489d7a6e6b0e7611888cec880337d316396" integrity sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g== -prettier-plugin-hermes-parser@0.34.1: - version "0.34.1" - resolved "https://registry.yarnpkg.com/prettier-plugin-hermes-parser/-/prettier-plugin-hermes-parser-0.34.1.tgz#75fc7abe0435ab45ee4431b1b1ce1a7baba6903c" - integrity sha512-cdA3tlvvFZkr8CuzaRJ28EVl7ep2zbfxKBBiS1t1w2Kud+Gsv/aQeU2a6rmMBnMJn510xPrIy0aZ9AG0uQHcRQ== +prettier-plugin-hermes-parser@0.35.0: + version "0.35.0" + resolved "https://registry.yarnpkg.com/prettier-plugin-hermes-parser/-/prettier-plugin-hermes-parser-0.35.0.tgz#f037f76b50669aa9fd9dcbe78ceec7b891239646" + integrity sha512-+qAxEwdNI6sWw/g/+OxbUp5Tt5WaiuufQpDyE95M13S+P+IO8UDmErSBUwN+QvxakcT0bGTd8sfSLJNZrfV4eg== prettier@3.6.2: version "3.6.2" From 3e2d8e738605ad04a58b521e445f971259838345 Mon Sep 17 00:00:00 2001 From: Rob Hogan Date: Thu, 16 Apr 2026 08:21:46 -0700 Subject: [PATCH 02/11] Remove unused `DependencyAnalysisPlugin.#rootDir` Summary: This was copied over from another plugin but is completely unreferenced and isn't relevant to dependency extraction. Changelog: [Internal] Reviewed By: huntie Differential Revision: D98728740 fbshipit-source-id: c737e932007d6a1b1a2e36877d0e60cbec442c33 --- .../metro-file-map/src/__tests__/index-test.js | 1 - .../src/plugins/DependencyPlugin.js | 3 --- .../__tests__/DependencyPlugin-test.js | 16 ---------------- .../types/plugins/DependencyPlugin.d.ts | 3 +-- .../node-haste/DependencyGraph/createFileMap.js | 1 - 5 files changed, 1 insertion(+), 23 deletions(-) diff --git a/packages/metro-file-map/src/__tests__/index-test.js b/packages/metro-file-map/src/__tests__/index-test.js index 48da613f06..04a87e58b2 100644 --- a/packages/metro-file-map/src/__tests__/index-test.js +++ b/packages/metro-file-map/src/__tests__/index-test.js @@ -397,7 +397,6 @@ describe('FileMap', () => { const dependencyPlugin = new DependencyPlugin({ dependencyExtractor: dependencyOverrides.dependencyExtractor ?? null, computeDependencies: true, - rootDir: defaultConfig.rootDir, }); const hasteMap = new (require('../plugins/HastePlugin').default)({ ...defaultHasteConfig, diff --git a/packages/metro-file-map/src/plugins/DependencyPlugin.js b/packages/metro-file-map/src/plugins/DependencyPlugin.js index 7ad2b57e22..54ee9c97b3 100644 --- a/packages/metro-file-map/src/plugins/DependencyPlugin.js +++ b/packages/metro-file-map/src/plugins/DependencyPlugin.js @@ -21,7 +21,6 @@ export type DependencyPluginOptions = Readonly<{ dependencyExtractor: ?string, /** Whether to compute dependencies (performance optimization) */ computeDependencies: boolean, - rootDir: Path, }>; export default class DependencyPlugin @@ -32,12 +31,10 @@ export default class DependencyPlugin #dependencyExtractor: ?string; #computeDependencies: boolean; #getDependencies: Path => ?ReadonlyArray; - #rootDir: Path; constructor(options: DependencyPluginOptions) { this.#dependencyExtractor = options.dependencyExtractor; this.#computeDependencies = options.computeDependencies; - this.#rootDir = options.rootDir; } async initialize( diff --git a/packages/metro-file-map/src/plugins/dependencies/__tests__/DependencyPlugin-test.js b/packages/metro-file-map/src/plugins/dependencies/__tests__/DependencyPlugin-test.js index bf00973127..0414994161 100644 --- a/packages/metro-file-map/src/plugins/dependencies/__tests__/DependencyPlugin-test.js +++ b/packages/metro-file-map/src/plugins/dependencies/__tests__/DependencyPlugin-test.js @@ -28,7 +28,6 @@ describe('DependencyPlugin', () => { plugin = new DependencyPlugin({ dependencyExtractor: null, computeDependencies: true, - rootDir: '/project', }); expect(plugin.name).toBe('dependencies'); @@ -42,7 +41,6 @@ describe('DependencyPlugin', () => { plugin = new DependencyPlugin({ dependencyExtractor: extractorPath, computeDependencies: true, - rootDir: '/project', }); expect(plugin.name).toBe('dependencies'); @@ -52,7 +50,6 @@ describe('DependencyPlugin', () => { plugin = new DependencyPlugin({ dependencyExtractor: null, computeDependencies: false, - rootDir: '/project', }); expect(plugin.name).toBe('dependencies'); @@ -64,7 +61,6 @@ describe('DependencyPlugin', () => { plugin = new DependencyPlugin({ dependencyExtractor: null, computeDependencies: true, - rootDir: '/project', }); expect(plugin.getCacheKey()).toBe('default-dependency-extractor'); @@ -80,7 +76,6 @@ describe('DependencyPlugin', () => { const plugin1 = new DependencyPlugin({ dependencyExtractor: extractorPath, computeDependencies: true, - rootDir: '/project', }); const cacheKey1 = plugin1.getCacheKey(); @@ -89,7 +84,6 @@ describe('DependencyPlugin', () => { const plugin2 = new DependencyPlugin({ dependencyExtractor: extractorPath, computeDependencies: true, - rootDir: '/project', }); const cacheKey2 = plugin2.getCacheKey(); @@ -104,7 +98,6 @@ describe('DependencyPlugin', () => { plugin = new DependencyPlugin({ dependencyExtractor: extractorPath, computeDependencies: true, - rootDir: '/project', }); const cacheKey = plugin.getCacheKey(); @@ -123,7 +116,6 @@ describe('DependencyPlugin', () => { plugin = new DependencyPlugin({ dependencyExtractor: extractorPath, computeDependencies: true, - rootDir: '/project', }); const cacheKey = plugin.getCacheKey(); @@ -143,7 +135,6 @@ describe('DependencyPlugin', () => { plugin = new DependencyPlugin({ dependencyExtractor: extractorPath, computeDependencies: true, - rootDir: '/project', }); const worker = plugin.getWorker(); @@ -158,7 +149,6 @@ describe('DependencyPlugin', () => { plugin = new DependencyPlugin({ dependencyExtractor: null, computeDependencies: true, - rootDir: '/project', }); const worker = plugin.getWorker(); @@ -172,7 +162,6 @@ describe('DependencyPlugin', () => { plugin = new DependencyPlugin({ dependencyExtractor: null, computeDependencies: false, - rootDir: '/project', }); const worker = plugin.getWorker(); @@ -186,7 +175,6 @@ describe('DependencyPlugin', () => { plugin = new DependencyPlugin({ dependencyExtractor: null, computeDependencies: true, - rootDir: '/project', }); const worker = plugin.getWorker(); @@ -203,7 +191,6 @@ describe('DependencyPlugin', () => { plugin = new DependencyPlugin({ dependencyExtractor: null, computeDependencies: true, - rootDir: '/project', }); const worker = plugin.getWorker(); @@ -221,7 +208,6 @@ describe('DependencyPlugin', () => { plugin = new DependencyPlugin({ dependencyExtractor: null, computeDependencies: true, - rootDir: '/project', }); const worker = plugin.getWorker(); @@ -240,7 +226,6 @@ describe('DependencyPlugin', () => { plugin = new DependencyPlugin({ dependencyExtractor: null, computeDependencies: true, - rootDir: '/project', }); }); @@ -321,7 +306,6 @@ describe('DependencyPlugin', () => { plugin = new DependencyPlugin({ dependencyExtractor: null, computeDependencies: true, - rootDir: '/project', }); }); diff --git a/packages/metro-file-map/types/plugins/DependencyPlugin.d.ts b/packages/metro-file-map/types/plugins/DependencyPlugin.d.ts index 0fa9a41778..1a7cc779e1 100644 --- a/packages/metro-file-map/types/plugins/DependencyPlugin.d.ts +++ b/packages/metro-file-map/types/plugins/DependencyPlugin.d.ts @@ -6,7 +6,7 @@ * * @noformat * @oncall react_native - * @generated SignedSource<> + * @generated SignedSource<> * * This file was translated from Flow by scripts/generateTypeScriptDefinitions.js * Original file: packages/metro-file-map/src/plugins/DependencyPlugin.js @@ -27,7 +27,6 @@ export type DependencyPluginOptions = Readonly<{ dependencyExtractor: null | undefined | string; /** Whether to compute dependencies (performance optimization) */ computeDependencies: boolean; - rootDir: Path; }>; declare class DependencyPlugin implements FileMapPlugin | null> diff --git a/packages/metro/src/node-haste/DependencyGraph/createFileMap.js b/packages/metro/src/node-haste/DependencyGraph/createFileMap.js index 7b0db46499..c3c311153f 100644 --- a/packages/metro/src/node-haste/DependencyGraph/createFileMap.js +++ b/packages/metro/src/node-haste/DependencyGraph/createFileMap.js @@ -88,7 +88,6 @@ export default function createFileMap( dependencyPlugin = new DependencyPlugin({ dependencyExtractor: config.resolver.dependencyExtractor, computeDependencies: true, - rootDir: config.projectRoot, }); plugins.push(dependencyPlugin); } From 207230622438077de15888911e8ab4960772760b Mon Sep 17 00:00:00 2001 From: Rob Hogan Date: Thu, 16 Apr 2026 08:21:46 -0700 Subject: [PATCH 03/11] Extract generic FileDataPlugin from DependencyPlugin to use as the basis for other plugins Summary: Refactor `DependencyPlugin` to extend a new reusable `FileDataPlugin` containing most of the unused boilerplate / default implementations. We'll use this for a `package.json` plugin. Changelog: Internal Reviewed By: huntie Differential Revision: D100990729 fbshipit-source-id: a1f5fbee10df4f3cf8506e0b7154523e4466e80d --- .../src/plugins/DependencyPlugin.js | 104 ++++-------------- .../src/plugins/FileDataPlugin.js | 76 +++++++++++++ .../__tests__/DependencyPlugin-test.js | 26 ++--- .../types/plugins/DependencyPlugin.d.ts | 29 +---- .../types/plugins/FileDataPlugin.d.ts | 55 +++++++++ 5 files changed, 169 insertions(+), 121 deletions(-) create mode 100644 packages/metro-file-map/src/plugins/FileDataPlugin.js create mode 100644 packages/metro-file-map/types/plugins/FileDataPlugin.d.ts diff --git a/packages/metro-file-map/src/plugins/DependencyPlugin.js b/packages/metro-file-map/src/plugins/DependencyPlugin.js index 54ee9c97b3..a9f33241cb 100644 --- a/packages/metro-file-map/src/plugins/DependencyPlugin.js +++ b/packages/metro-file-map/src/plugins/DependencyPlugin.js @@ -9,12 +9,10 @@ * @oncall react_native */ -import type { - FileMapPlugin, - FileMapPluginInitOptions, - FileMapPluginWorker, - Path, -} from '../flow-types'; +import type {Path} from '../flow-types'; + +import excludedExtensions from '../workerExclusionList'; +import FileDataPlugin from './FileDataPlugin'; export type DependencyPluginOptions = Readonly<{ /** Path to custom dependency extractor module */ @@ -23,104 +21,46 @@ export type DependencyPluginOptions = Readonly<{ computeDependencies: boolean, }>; -export default class DependencyPlugin - implements FileMapPlugin | null> -{ - +name: 'dependencies' = 'dependencies'; - - #dependencyExtractor: ?string; - #computeDependencies: boolean; - #getDependencies: Path => ?ReadonlyArray; - +export default class DependencyPlugin extends FileDataPlugin | null> { constructor(options: DependencyPluginOptions) { - this.#dependencyExtractor = options.dependencyExtractor; - this.#computeDependencies = options.computeDependencies; - } - - async initialize( - initOptions: FileMapPluginInitOptions | null>, - ): Promise { - const {files} = initOptions; - // Create closure to access dependencies from file metadata plugin data - this.#getDependencies = (mixedPath: Path) => { - const result = files.lookup(mixedPath); - if (result.exists && result.type === 'f') { - // Backwards compatibility: distinguish an extant file that we've not - // run the worker on (probably because it fails the extension filter) - // from a missing file. Non-source files are expected to have empty - // dependencies. - return result.pluginData ?? []; - } - return null; - }; - } - - getSerializableSnapshot(): null { - // Dependencies stored in plugin data, no separate serialization needed - return null; - } + const {dependencyExtractor, computeDependencies} = options; - onChanged(): void { - // No-op: Worker already populated plugin data - // Plugin data is write-only from worker - } - - assertValid(): void { - // No validation needed - } - - getCacheKey(): string { - if (this.#dependencyExtractor != null) { - // Dynamic require to get extractor's cache key + let cacheKey: string; + if (dependencyExtractor != null) { // $FlowFixMe[unsupported-syntax] - dynamic require - const extractor = require(this.#dependencyExtractor); - return JSON.stringify({ - extractorKey: extractor.getCacheKey?.() ?? null, - extractorPath: this.#dependencyExtractor, - }); + const extractor = require(dependencyExtractor); + cacheKey = extractor.getCacheKey?.() ?? dependencyExtractor; + } else { + cacheKey = 'default-dependency-extractor'; } - return 'default-dependency-extractor'; - } - getWorker(): FileMapPluginWorker { - const excludedExtensions = require('../workerExclusionList'); - - return { + super({ + name: 'dependencies', + cacheKey, worker: { modulePath: require.resolve('./dependencies/worker.js'), setupArgs: { - dependencyExtractor: this.#dependencyExtractor ?? null, + dependencyExtractor: dependencyExtractor ?? null, }, }, filter: ({normalPath, isNodeModules}) => { - // Respect computeDependencies flag - if (!this.#computeDependencies) { + if (!computeDependencies) { return false; } - - // Never process node_modules if (isNodeModules) { return false; } - - // Skip excluded extensions const ext = normalPath.substr(normalPath.lastIndexOf('.')); return !excludedExtensions.has(ext); }, - }; + }); } - /** - * Get the list of dependencies for a given file. - * @param mixedPath Absolute or project-relative path to the file - * @returns Array of dependency module names, or null if the file doesn't exist - */ getDependencies(mixedPath: Path): ?ReadonlyArray { - if (this.#getDependencies == null) { - throw new Error( - 'DependencyPlugin has not been initialized before getDependencies', - ); + const result = this.getFileSystem().lookup(mixedPath); + if (result.exists && result.type === 'f') { + return result.pluginData ?? []; } - return this.#getDependencies(mixedPath); + return null; } } diff --git a/packages/metro-file-map/src/plugins/FileDataPlugin.js b/packages/metro-file-map/src/plugins/FileDataPlugin.js new file mode 100644 index 0000000000..ba18a2ae9e --- /dev/null +++ b/packages/metro-file-map/src/plugins/FileDataPlugin.js @@ -0,0 +1,76 @@ +/** + * 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 strict-local + * @format + * @oncall react_native + */ + +import type { + FileMapPlugin, + FileMapPluginInitOptions, + FileMapPluginWorker, + ReadonlyFileSystemChanges, + V8Serializable, +} from '../flow-types'; + +export type FileDataPluginOptions = Readonly<{ + ...FileMapPluginWorker, + name: string, + cacheKey: string, +}>; + +/** + * Base class for FileMap plugins that store per-file data via a worker and + * have no separate serializable state. Provides default no-op implementations + * of lifecycle methods that subclasses can override as needed. + */ +export default class FileDataPlugin< + -PerFileData extends void | V8Serializable = void | V8Serializable, +> implements FileMapPlugin +{ + +name: string; + + #worker: FileMapPluginWorker; + #cacheKey: string; + #files: ?FileMapPluginInitOptions['files']; + + constructor({name, worker, filter, cacheKey}: FileDataPluginOptions) { + this.name = name; + this.#worker = {worker, filter}; + this.#cacheKey = cacheKey; + } + + async initialize( + initOptions: FileMapPluginInitOptions, + ): Promise { + this.#files = initOptions.files; + } + + getFileSystem(): FileMapPluginInitOptions['files'] { + const files = this.#files; + if (files == null) { + throw new Error(`${this.name} plugin has not been initialized`); + } + return files; + } + + onChanged(_changes: ReadonlyFileSystemChanges): void {} + + assertValid(): void {} + + getSerializableSnapshot(): null { + return null; + } + + getCacheKey(): string { + return this.#cacheKey; + } + + getWorker(): FileMapPluginWorker { + return this.#worker; + } +} diff --git a/packages/metro-file-map/src/plugins/dependencies/__tests__/DependencyPlugin-test.js b/packages/metro-file-map/src/plugins/dependencies/__tests__/DependencyPlugin-test.js index 0414994161..2fc1646379 100644 --- a/packages/metro-file-map/src/plugins/dependencies/__tests__/DependencyPlugin-test.js +++ b/packages/metro-file-map/src/plugins/dependencies/__tests__/DependencyPlugin-test.js @@ -34,10 +34,7 @@ describe('DependencyPlugin', () => { }); test('creates plugin with custom dependency extractor', () => { - const extractorPath = path.join( - __dirname, - '../../__tests__/dependencyExtractor.js', - ); + const extractorPath = path.join(__dirname, 'mockDependencyExtractor.js'); plugin = new DependencyPlugin({ dependencyExtractor: extractorPath, computeDependencies: true, @@ -93,15 +90,18 @@ describe('DependencyPlugin', () => { expect(cacheKey2).toContain('bar'); }); - test('cache key includes extractor path', () => { + test('cache key uses extractor getCacheKey result', () => { const extractorPath = path.join(__dirname, 'mockDependencyExtractor.js'); + // $FlowFixMe[untyped-import] + const dependencyExtractor = require('./mockDependencyExtractor'); + dependencyExtractor.setCacheKey('test-key'); + plugin = new DependencyPlugin({ dependencyExtractor: extractorPath, computeDependencies: true, }); - const cacheKey = plugin.getCacheKey(); - expect(cacheKey).toContain(JSON.stringify(extractorPath)); + expect(plugin.getCacheKey()).toBe('test-key'); }); test('handles extractor without getCacheKey method', () => { @@ -119,7 +119,8 @@ describe('DependencyPlugin', () => { }); const cacheKey = plugin.getCacheKey(); - expect(cacheKey).toContain('null'); // Should include null for extractorKey + // Falls back to extractor path when getCacheKey is not available + expect(cacheKey).toBe(extractorPath); // Restore getCacheKey dependencyExtractor.getCacheKey = originalGetCacheKey; @@ -128,10 +129,7 @@ describe('DependencyPlugin', () => { describe('getWorker', () => { test('returns worker configuration with dependency extractor', () => { - const extractorPath = path.join( - __dirname, - '../../__tests__/dependencyExtractor.js', - ); + const extractorPath = path.join(__dirname, 'mockDependencyExtractor.js'); plugin = new DependencyPlugin({ dependencyExtractor: extractorPath, computeDependencies: true, @@ -232,9 +230,7 @@ describe('DependencyPlugin', () => { test('throws error if getDependencies called before initialize', () => { expect(() => { plugin.getDependencies('src/index.js'); - }).toThrow( - 'DependencyPlugin has not been initialized before getDependencies', - ); + }).toThrow('dependencies plugin has not been initialized'); }); test('returns null for non-existent file', async () => { diff --git a/packages/metro-file-map/types/plugins/DependencyPlugin.d.ts b/packages/metro-file-map/types/plugins/DependencyPlugin.d.ts index 1a7cc779e1..584e2f8b08 100644 --- a/packages/metro-file-map/types/plugins/DependencyPlugin.d.ts +++ b/packages/metro-file-map/types/plugins/DependencyPlugin.d.ts @@ -6,7 +6,7 @@ * * @noformat * @oncall react_native - * @generated SignedSource<> + * @generated SignedSource<<344b340710d6da24bcb609058e7ce8d6>> * * This file was translated from Flow by scripts/generateTypeScriptDefinitions.js * Original file: packages/metro-file-map/src/plugins/DependencyPlugin.js @@ -15,12 +15,9 @@ * yarn run build-ts-defs (OSS) */ -import type { - FileMapPlugin, - FileMapPluginInitOptions, - FileMapPluginWorker, - Path, -} from '../flow-types'; +import type {Path} from '../flow-types'; + +import FileDataPlugin from './FileDataPlugin'; export type DependencyPluginOptions = Readonly<{ /** Path to custom dependency extractor module */ @@ -28,24 +25,8 @@ export type DependencyPluginOptions = Readonly<{ /** Whether to compute dependencies (performance optimization) */ computeDependencies: boolean; }>; -declare class DependencyPlugin - implements FileMapPlugin | null> -{ - readonly name: 'dependencies'; +declare class DependencyPlugin extends FileDataPlugin | null> { constructor(options: DependencyPluginOptions); - initialize( - initOptions: FileMapPluginInitOptions | null>, - ): Promise; - getSerializableSnapshot(): null; - onChanged(): void; - assertValid(): void; - getCacheKey(): string; - getWorker(): FileMapPluginWorker; - /** - * Get the list of dependencies for a given file. - * @param mixedPath Absolute or project-relative path to the file - * @returns Array of dependency module names, or null if the file doesn't exist - */ getDependencies(mixedPath: Path): null | undefined | ReadonlyArray; } export default DependencyPlugin; diff --git a/packages/metro-file-map/types/plugins/FileDataPlugin.d.ts b/packages/metro-file-map/types/plugins/FileDataPlugin.d.ts new file mode 100644 index 0000000000..fb6d639fda --- /dev/null +++ b/packages/metro-file-map/types/plugins/FileDataPlugin.d.ts @@ -0,0 +1,55 @@ +/** + * 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. + * + * @noformat + * @oncall react_native + * @generated SignedSource<<8833f226ec9fa3a4c96370862ca4d59f>> + * + * This file was translated from Flow by scripts/generateTypeScriptDefinitions.js + * Original file: packages/metro-file-map/src/plugins/FileDataPlugin.js + * To regenerate, run: + * js1 build metro-ts-defs (internal) OR + * yarn run build-ts-defs (OSS) + */ + +import type { + FileMapPlugin, + FileMapPluginInitOptions, + FileMapPluginWorker, + ReadonlyFileSystemChanges, + V8Serializable, +} from '../flow-types'; + +export type FileDataPluginOptions = Readonly< + Omit & { + name: string; + cacheKey: string; + } +>; +/** + * Base class for FileMap plugins that store per-file data via a worker and + * have no separate serializable state. Provides default no-op implementations + * of lifecycle methods that subclasses can override as needed. + */ +declare class FileDataPlugin< + PerFileData extends void | V8Serializable = void | V8Serializable, +> implements FileMapPlugin +{ + readonly name: string; + constructor($$PARAM_0$$: FileDataPluginOptions); + initialize( + initOptions: FileMapPluginInitOptions, + ): Promise; + getFileSystem(): FileMapPluginInitOptions['files']; + onChanged( + _changes: ReadonlyFileSystemChanges, + ): void; + assertValid(): void; + getSerializableSnapshot(): null; + getCacheKey(): string; + getWorker(): FileMapPluginWorker; +} +export default FileDataPlugin; From a8386ef90ce0d1ff09cfd2abd72c43b051771073 Mon Sep 17 00:00:00 2001 From: Stian Jensen Date: Fri, 17 Apr 2026 08:18:11 -0700 Subject: [PATCH 04/11] Replace chalk with Node core util.styleText (#1690) Summary: All supported Node versions in `engines` ship `util.styleText` with the array-format API, removing the need for the chalk dependency. Pull Request resolved: https://github.com/facebook/metro/pull/1690 Reviewed By: huntie Differential Revision: D101224668 Pulled By: robhogan fbshipit-source-id: 02f873fb624aab54f3b237b157024287bddaf080 --- packages/metro/package.json | 1 - packages/metro/src/index.flow.js | 4 +- packages/metro/src/lib/TerminalReporter.js | 83 ++++++++++++------- .../src/lib/__tests__/logToConsole-test.js | 47 +++++++---- packages/metro/src/lib/logToConsole.js | 14 ++-- packages/metro/src/lib/reporting.js | 21 +++-- 6 files changed, 105 insertions(+), 65 deletions(-) diff --git a/packages/metro/package.json b/packages/metro/package.json index 853338fedd..fa06ec82c4 100644 --- a/packages/metro/package.json +++ b/packages/metro/package.json @@ -27,7 +27,6 @@ "@babel/traverse": "^7.29.0", "@babel/types": "^7.29.0", "accepts": "^2.0.0", - "chalk": "^4.0.0", "ci-info": "^2.0.0", "connect": "^3.6.5", "debug": "^4.4.0", diff --git a/packages/metro/src/index.flow.js b/packages/metro/src/index.flow.js index d4f718b42f..66bbe9d90e 100644 --- a/packages/metro/src/index.flow.js +++ b/packages/metro/src/index.flow.js @@ -41,7 +41,6 @@ import JsonReporter from './lib/JsonReporter'; import TerminalReporter from './lib/TerminalReporter'; import MetroServer from './Server'; import * as outputBundle from './shared/output/bundle'; -import chalk from 'chalk'; import fs from 'fs'; import http from 'http'; import https from 'https'; @@ -54,6 +53,7 @@ import { import {Terminal} from 'metro-core'; import net from 'net'; import nullthrows from 'nullthrows'; +import util from 'util'; const DEFAULTS = MetroServer.DEFAULT_BUNDLE_OPTIONS; @@ -296,7 +296,7 @@ export const runServer = async ( if (secure != null || secureCert != null || secureKey != null) { // eslint-disable-next-line no-console console.warn( - chalk.inverse.yellow.bold(' DEPRECATED '), + util.styleText(['inverse', 'yellow', 'bold'], ' DEPRECATED '), 'The `secure`, `secureCert`, and `secureKey` options are now deprecated. ' + 'Please use the `secureServerOptions` object instead to pass options to ' + "Metro's https development server, or `config.server.tls` in Metro's configuration", diff --git a/packages/metro/src/lib/TerminalReporter.js b/packages/metro/src/lib/TerminalReporter.js index 11d7016030..a16e0d61f2 100644 --- a/packages/metro/src/lib/TerminalReporter.js +++ b/packages/metro/src/lib/TerminalReporter.js @@ -12,15 +12,22 @@ import type {BundleDetails, ReportableEvent} from './reporting'; import type {Terminal} from 'metro-core'; import type {HealthCheckResult, WatcherStatus} from 'metro-file-map'; +import type {BackgroundColors, ForegroundColors, Modifiers} from 'util'; import {calculateBundleProgressRatio} from './bundleProgressUtils'; import logToConsole from './logToConsole'; import * as reporting from './reporting'; -import chalk from 'chalk'; // $FlowFixMe[untyped-import] lodash.throttle import throttle from 'lodash.throttle'; import {AmbiguousModuleResolutionError} from 'metro-core'; import path from 'path'; +import util from 'util'; + +type StyleFormat = ReadonlyArray< + ForegroundColors | BackgroundColors | Modifiers, +>; +const style = (format: StyleFormat, text: string): string => + util.styleText(format, text); type BundleProgress = { bundleDetails: BundleDetails, @@ -121,28 +128,26 @@ export default class TerminalReporter { ): string { const localPath = path.relative('.', entryFile); const filledBar = Math.floor(ratio * MAX_PROGRESS_BAR_CHAR_WIDTH); - const bundleTypeColor = - phase === 'done' - ? chalk.green - : phase === 'failed' - ? chalk.red - : chalk.yellow; + const bundleTypeColor: StyleFormat = + phase === 'done' ? ['green'] : phase === 'failed' ? ['red'] : ['yellow']; const progress = phase === 'in_progress' - ? chalk.green.bgGreen(DARK_BLOCK_CHAR.repeat(filledBar)) + - chalk.bgWhite.white( + ? style(['green', 'bgGreen'], DARK_BLOCK_CHAR.repeat(filledBar)) + + style( + ['bgWhite', 'white'], LIGHT_BLOCK_CHAR.repeat(MAX_PROGRESS_BAR_CHAR_WIDTH - filledBar), ) + - chalk.bold(` ${Math.floor(100 * ratio)}% `) + - chalk.dim(`(${transformedFileCount}/${totalFileCount})`) + style(['bold'], ` ${Math.floor(100 * ratio)}% `) + + style(['dim'], `(${transformedFileCount}/${totalFileCount})`) : ''; return ( - bundleTypeColor.inverse.bold( + style( + [...bundleTypeColor, 'inverse', 'bold'], ` ${isPrefetch === true ? 'PREBUNDLE' : bundleType.toUpperCase()} `, ) + - chalk.reset.dim(` ${path.dirname(localPath)}/`) + - chalk.bold(path.basename(localPath)) + + style(['reset', 'dim'], ` ${path.dirname(localPath)}/`) + + style(['bold'], path.basename(localPath)) + ' ' + progress ); @@ -191,31 +196,37 @@ export default class TerminalReporter { '', ]; - const color = hasReducedPerformance ? chalk.red : chalk.blue; - this.terminal.log(color(logo.join('\n'))); + const color: StyleFormat = hasReducedPerformance ? ['red'] : ['blue']; + this.terminal.log(style(color, logo.join('\n'))); } _logInitializingFailed(port: number, error: SnippetError): void { if (error.code === 'EADDRINUSE') { this.terminal.log( - chalk.bgRed.bold(' ERROR '), - chalk.red("Metro can't listen on port", chalk.bold(String(port))), + style(['bgRed', 'bold'], ' ERROR '), + style( + ['red'], + `Metro can't listen on port ${style(['bold'], String(port))}`, + ), ); this.terminal.log( 'Most likely another process is already using this port', ); this.terminal.log('Run the following command to find out which process:'); - this.terminal.log('\n ', chalk.bold('lsof -i :' + port), '\n'); + this.terminal.log('\n ', style(['bold'], 'lsof -i :' + port), '\n'); this.terminal.log('Then, you can either shut down the other process:'); - this.terminal.log('\n ', chalk.bold('kill -9 '), '\n'); + this.terminal.log('\n ', style(['bold'], 'kill -9 '), '\n'); this.terminal.log('or run Metro on different port.'); } else { - this.terminal.log(chalk.bgRed.bold(' ERROR '), chalk.red(error.message)); + this.terminal.log( + style(['bgRed', 'bold'], ' ERROR '), + style(['red'], error.message), + ); const errorAttributes = JSON.stringify(error); if (errorAttributes !== '{}') { - this.terminal.log(chalk.red(errorAttributes)); + this.terminal.log(style(['red'], errorAttributes)); } - this.terminal.log(chalk.red(error.stack)); + this.terminal.log(style(['red'], String(error.stack))); } } @@ -271,20 +282,26 @@ export default class TerminalReporter { logFn(this.terminal, String(format), ...args); break; case 'dep_graph_loading': - const color = event.hasReducedPerformance ? chalk.red : chalk.blue; + const color: StyleFormat = event.hasReducedPerformance + ? ['red'] + : ['blue']; // eslint-disable-next-line import/no-commonjs // $FlowFixMe[untyped-import] package.json const version = 'v' + require('../../package.json').version; this.terminal.log( - color.bold( - ' '.repeat(19 - version.length / 2), - 'Welcome to Metro ' + chalk.white(version) + '\n', - ) + chalk.dim(' Fast - Scalable - Integrated\n\n'), + style( + [...color, 'bold'], + ' '.repeat(19 - version.length / 2) + + ' Welcome to Metro ' + + style(['white'], version) + + '\n', + ) + style(['dim'], ' Fast - Scalable - Integrated\n\n'), ); if (event.hasReducedPerformance) { this.terminal.log( - chalk.red( + style( + ['red'], 'Metro is operating with reduced performance.\n' + 'Please fix the problem above and restart Metro.\n\n', ), @@ -459,7 +476,7 @@ export default class TerminalReporter { // Only report success after a prior failure. if (this._prevHealthCheckResult) { this.terminal.log( - chalk.green(`Watcher ${watcherName} is now healthy.`), + style(['green'], `Watcher ${watcherName} is now healthy.`), ); } break; @@ -499,7 +516,8 @@ export default class TerminalReporter { break; case 'watchman_slow_command': this.terminal.log( - chalk.dim( + style( + ['dim'], `Waiting for Watchman \`${status.command}\` (${Math.round( status.timeElapsed / 1000, )}s)...`, @@ -508,7 +526,8 @@ export default class TerminalReporter { break; case 'watchman_slow_command_complete': this.terminal.log( - chalk.green( + style( + ['green'], `Watchman \`${status.command}\` finished after ${( status.timeElapsed / 1000 ).toFixed(1)}s.`, diff --git a/packages/metro/src/lib/__tests__/logToConsole-test.js b/packages/metro/src/lib/__tests__/logToConsole-test.js index c410c52627..ae22839516 100644 --- a/packages/metro/src/lib/__tests__/logToConsole-test.js +++ b/packages/metro/src/lib/__tests__/logToConsole-test.js @@ -12,17 +12,6 @@ 'use strict'; -jest.mock('chalk', () => { - const bold = _ => _; - return { - inverse: { - red: {bold}, - white: {bold}, - yellow: {bold}, - }, - }; -}); - let log; beforeEach(() => { @@ -38,16 +27,32 @@ test('invoke native console methods', () => { log(console, 'warn', 'Kiwi'); jest.runAllTimers(); - expect(console.log).toHaveBeenNthCalledWith(1, ' LOG ', 'Banana'); - expect(console.log).toHaveBeenNthCalledWith(2, ' WARN ', 'Apple'); - expect(console.log).toHaveBeenNthCalledWith(3, ' WARN ', 'Kiwi'); + expect(console.log).toHaveBeenNthCalledWith( + 1, + expect.stringContaining(' LOG '), + 'Banana', + ); + expect(console.log).toHaveBeenNthCalledWith( + 2, + expect.stringContaining(' WARN '), + 'Apple', + ); + expect(console.log).toHaveBeenNthCalledWith( + 3, + expect.stringContaining(' WARN '), + 'Kiwi', + ); }); test('removes excess whitespace', () => { log(console, 'log', 'Banana\n '); jest.runAllTimers(); - expect(console.log).toHaveBeenNthCalledWith(1, ' LOG ', 'Banana'); + expect(console.log).toHaveBeenNthCalledWith( + 1, + expect.stringContaining(' LOG '), + 'Banana', + ); }); test('ignore `groupCollapsed` calls', () => { @@ -63,14 +68,17 @@ test('warn if `groupCollapsed` and `groupEnd` are not balanced', () => { jest.runAllTimers(); expect(console.log).toHaveBeenCalledWith( - ' WARN ', + expect.stringContaining(' WARN '), 'Expected `console.groupEnd` to be called after `console.groupCollapsed`.', ); // Ensure that the console resets the state and will accept new logs log(console, 'warn', 'Apple'); jest.runAllTimers(); - expect(console.log).toHaveBeenCalledWith(' WARN ', 'Apple'); + expect(console.log).toHaveBeenCalledWith( + expect.stringContaining(' WARN '), + 'Apple', + ); }); test('can deal with nested `group` and `groupCollapsed` calls', () => { @@ -112,5 +120,8 @@ test('can deal with nested `group` and `groupCollapsed` calls', () => { jest.runAllTimers(); expect(console.log).toHaveBeenCalledTimes(2); - expect(console.log).toHaveBeenCalledWith(' LOG ', 'Banana'); + expect(console.log).toHaveBeenCalledWith( + expect.stringContaining(' LOG '), + 'Banana', + ); }); diff --git a/packages/metro/src/lib/logToConsole.js b/packages/metro/src/lib/logToConsole.js index b327e9a879..6cb83915e1 100644 --- a/packages/metro/src/lib/logToConsole.js +++ b/packages/metro/src/lib/logToConsole.js @@ -11,8 +11,8 @@ /* eslint-disable no-console */ import type {Terminal} from 'metro-core'; +import type {BackgroundColors, ForegroundColors, Modifiers} from 'util'; -import chalk from 'chalk'; import util from 'util'; const groupStack = []; @@ -21,12 +21,12 @@ let collapsedGuardTimer; export default (terminal: Terminal, level: string, ...data: Array) => { // $FlowFixMe[invalid-computed-prop] const logFunction = console[level] && level !== 'trace' ? level : 'log'; - const color = + const color: ReadonlyArray = level === 'error' - ? chalk.inverse.red + ? ['inverse', 'red'] : level === 'warn' - ? chalk.inverse.yellow - : chalk.inverse.white; + ? ['inverse', 'yellow'] + : ['inverse', 'white']; if (level === 'group') { groupStack.push(level); @@ -37,7 +37,7 @@ export default (terminal: Terminal, level: string, ...data: Array) => { collapsedGuardTimer = setTimeout(() => { if (groupStack.includes('groupCollapsed')) { terminal.log( - chalk.inverse.yellow.bold(' WARN '), + util.styleText(['inverse', 'yellow', 'bold'], ' WARN '), 'Expected `console.groupEnd` to be called after `console.groupCollapsed`.', ); groupStack.length = 0; @@ -60,7 +60,7 @@ export default (terminal: Terminal, level: string, ...data: Array) => { } terminal.log( - color.bold(` ${logFunction.toUpperCase()} `) + + util.styleText([...color, 'bold'], ` ${logFunction.toUpperCase()} `) + ''.padEnd(groupStack.length * 2, ' '), // `util.format` actually accepts any arguments. // If the first argument is a string, it tries to format it. diff --git a/packages/metro/src/lib/reporting.js b/packages/metro/src/lib/reporting.js index a5fb8f27c5..e7818aaf3b 100644 --- a/packages/metro/src/lib/reporting.js +++ b/packages/metro/src/lib/reporting.js @@ -14,9 +14,12 @@ import type {HealthCheckResult, WatcherStatus} from 'metro-file-map'; import type {CustomResolverOptions} from 'metro-resolver'; import type {CustomTransformOptions} from 'metro-transform-worker'; -import chalk from 'chalk'; +import tty from 'tty'; import util from 'util'; +const supportsColor = (): boolean => + process.stdout instanceof tty.WriteStream && process.stdout.hasColors(); + export type BundleDetails = { bundleType: string, customResolverOptions: CustomResolverOptions, @@ -190,7 +193,11 @@ export function logWarning( ...args: Array ): void { const str = util.format(format, ...args); - terminal.log('%s %s', chalk.yellow.inverse.bold(' WARN '), str); + terminal.log( + '%s %s', + util.styleText(['yellow', 'inverse', 'bold'], ' WARN '), + str, + ); } /** @@ -203,13 +210,13 @@ export function logError( ): void { terminal.log( '%s %s', - chalk.red.inverse.bold(' ERROR '), + util.styleText(['red', 'inverse', 'bold'], ' ERROR '), // Syntax errors may have colors applied for displaying code frames // in various places outside of where Metro is currently running. // If the current terminal does not support color, we'll strip the colors // here. util.format( - chalk.supportsColor ? format : util.stripVTControlCharacters(format), + supportsColor() ? format : util.stripVTControlCharacters(format), ...args, ), ); @@ -224,7 +231,11 @@ export function logInfo( ...args: Array ): void { const str = util.format(format, ...args); - terminal.log('%s %s', chalk.cyan.inverse.bold(' INFO '), str); + terminal.log( + '%s %s', + util.styleText(['cyan', 'inverse', 'bold'], ' INFO '), + str, + ); } /** From 1935b14e30dee0dd5ee6d6972cf52699c1c82033 Mon Sep 17 00:00:00 2001 From: Phil Pluckthun Date: Sat, 18 Apr 2026 10:17:11 -0700 Subject: [PATCH 05/11] Performance: Interleave resolution attempts with building node_modules candidate paths (#1680) Summary: The current implementation in `resolve.js` seems slightly optimised for readability over performance, but is a hot-path that is highly impactful for bundling performance. This is slightly amplified depending on the depth of folders. This branch aims to: - eliminate redundant `fileSystem.lookup` and instead resolve immediately and return the earliest result - avoid redundant array allocations and instead iterate target paths directly - delay building a full array of target `nodeModulesPaths` when a `FailedToResolveNameError` is constructed While this interleaves the actual resolution, avoiding redundant work and allocations makes up for this. A very primitive (LLM-authored) benchmark can be found here, which shows an up to 10% benefit or neutral results for resolution: https://github.com/kitten/metro/commit/a36d55825cb0d492d9ea5b7ac5f42b0b8e7b0a77 Changelog: [Performance] Refactor performance sensitive metro-resolver Node module resolution hot path Pull Request resolved: https://github.com/facebook/metro/pull/1680 Test Plan: - CI tests should pass unchanged Reviewed By: huntie Differential Revision: D100149182 Pulled By: robhogan fbshipit-source-id: a0c4533a1111cacc8363549cdffea705149bb78f --- .../src/PackageExportsResolve.js | 4 +- packages/metro-resolver/src/PackageResolve.js | 4 +- packages/metro-resolver/src/resolve.js | 213 +++++++++++++----- packages/metro-resolver/src/utils/paths.js | 22 ++ .../metro-resolver/src/utils/toPosixPath.js | 29 --- packages/metro-resolver/types/resolve.d.ts | 4 +- .../utils/{toPosixPath.d.ts => paths.d.ts} | 14 +- 7 files changed, 185 insertions(+), 105 deletions(-) create mode 100644 packages/metro-resolver/src/utils/paths.js delete mode 100644 packages/metro-resolver/src/utils/toPosixPath.js rename packages/metro-resolver/types/utils/{toPosixPath.d.ts => paths.d.ts} (50%) diff --git a/packages/metro-resolver/src/PackageExportsResolve.js b/packages/metro-resolver/src/PackageExportsResolve.js index 89a661d5ba..f34479fbc3 100644 --- a/packages/metro-resolver/src/PackageExportsResolve.js +++ b/packages/metro-resolver/src/PackageExportsResolve.js @@ -24,7 +24,7 @@ import resolveAsset from './resolveAsset'; import isAssetFile from './utils/isAssetFile'; import {isSubpathDefinedInExportsLike} from './utils/isSubpathDefinedInExportsLike'; import {matchSubpathFromExportsLike} from './utils/matchSubpathFromExportsLike'; -import toPosixPath from './utils/toPosixPath'; +import {systemToPosixPath} from './utils/paths'; import path from 'path'; /** @@ -134,7 +134,7 @@ export function resolvePackageTargetFromExports( * "exports" field lookup. */ function getExportsSubpath(packageSubpath: string): string { - return packageSubpath === '' ? '.' : './' + toPosixPath(packageSubpath); + return packageSubpath === '' ? '.' : './' + systemToPosixPath(packageSubpath); } /** diff --git a/packages/metro-resolver/src/PackageResolve.js b/packages/metro-resolver/src/PackageResolve.js index 3601e54be6..f12c17a8da 100644 --- a/packages/metro-resolver/src/PackageResolve.js +++ b/packages/metro-resolver/src/PackageResolve.js @@ -11,7 +11,7 @@ import type {PackageInfo, PackageJson, ResolutionContext} from './types'; -import toPosixPath from './utils/toPosixPath'; +import {systemToPosixPath} from './utils/paths'; import path from 'path'; /** @@ -123,7 +123,7 @@ export function redirectModulePath( redirectedPath = matchSubpathFromMainFields( // Use prefixed POSIX path for lookup in package.json - './' + toPosixPath(packageRelativeModulePath), + './' + systemToPosixPath(packageRelativeModulePath), containingPackage.packageJson, mainFields, ); diff --git a/packages/metro-resolver/src/resolve.js b/packages/metro-resolver/src/resolve.js index 10884b520a..ebde8adf27 100644 --- a/packages/metro-resolver/src/resolve.js +++ b/packages/metro-resolver/src/resolve.js @@ -29,6 +29,7 @@ import {resolvePackageTargetFromImports} from './PackageImportsResolve'; import {getPackageEntryPoint, redirectModulePath} from './PackageResolve'; import resolveAsset from './resolveAsset'; import isAssetFile from './utils/isAssetFile'; +import {posixToSystemPath} from './utils/paths'; import path from 'path'; type ParsedBareSpecifier = Readonly<{ @@ -42,7 +43,7 @@ type ParsedBareSpecifier = Readonly<{ export default function resolve( context: ResolutionContext, - moduleName: string, + specifier: string, platform: string | null, ): Resolution { const resolveRequest = context.resolveRequest; @@ -53,29 +54,29 @@ export default function resolve( ) { return resolveRequest( Object.freeze({...context, resolveRequest: resolve}), - moduleName, + specifier, platform, ); } - if (isRelativeImport(moduleName) || path.isAbsolute(moduleName)) { - const result = resolveModulePath(context, moduleName, platform); + if (isRelativeImport(specifier) || path.isAbsolute(specifier)) { + const result = resolveModulePath(context, specifier, platform); if (result.type === 'failed') { throw new FailedToResolvePathError(result.candidates); } return result.resolution; - } else if (isSubpathImport(moduleName)) { + } else if (isSubpathImport(specifier)) { const pkg = context.getPackageForModule(context.originModulePath); const importsField = pkg?.packageJson.imports; if (pkg == null) { throw new PackageImportNotResolvedError({ - importSpecifier: moduleName, + importSpecifier: specifier, reason: `Could not find a package.json file relative to module ${context.originModulePath}`, }); } else if (importsField == null) { throw new PackageImportNotResolvedError({ - importSpecifier: moduleName, + importSpecifier: specifier, reason: `Missing field "imports" in package.json. Check package.json at: ${pkg.rootPath}`, }); } else { @@ -83,7 +84,7 @@ export default function resolve( const packageImportsResult = resolvePackageTargetFromImports( context, pkg.rootPath, - moduleName, + specifier, importsField, platform, ); @@ -109,27 +110,28 @@ export default function resolve( } } - const realModuleName = redirectModulePath(context, moduleName); + const redirectedSpecifier = redirectModulePath(context, specifier); // exclude - if (realModuleName === false) { + if (redirectedSpecifier === false) { return {type: 'empty'}; } const {originModulePath} = context; const isDirectImport = - isRelativeImport(realModuleName) || path.isAbsolute(realModuleName); + isRelativeImport(redirectedSpecifier) || + path.isAbsolute(redirectedSpecifier); if (isDirectImport) { - // derive absolute path /.../node_modules/originModuleDir/realModuleName + // derive absolute path /.../node_modules/originModuleDir/redirectedSpecifier const fromModuleParentIdx = originModulePath.lastIndexOf('node_modules' + path.sep) + 13; const originModuleDir = originModulePath.slice( 0, originModulePath.indexOf(path.sep, fromModuleParentIdx), ); - const absPath = path.join(originModuleDir, realModuleName); + const absPath = path.join(originModuleDir, redirectedSpecifier); const result = resolveModulePath(context, absPath, platform); if (result.type === 'failed') { throw new FailedToResolvePathError(result.candidates); @@ -138,12 +140,12 @@ export default function resolve( } /** - * At this point, realModuleName is not a "direct" (absolute or relative) + * At this point, redirectedSpecifier is not a "direct" (absolute or relative) * import, so it's a bare specifier - for our purposes either Haste name * or a package specifier. */ - const parsedSpecifier = parseBareSpecifier(realModuleName); + const parsedSpecifier = parseBareSpecifier(redirectedSpecifier); if (context.allowHaste) { if (parsedSpecifier.isSinglePart) { @@ -161,75 +163,162 @@ export default function resolve( } /** - * realModuleName is now a package specifier. + * redirectedSpecifier is now a package specifier. */ const {disableHierarchicalLookup} = context; - const nodeModulesPaths = []; - let next = path.dirname(originModulePath); - if (!disableHierarchicalLookup) { + const visited: {[string]: ?true, __proto__: null} = Object.create(null); + let next = path.dirname(originModulePath); let candidate; do { candidate = next; const nodeModulesPath = candidate.endsWith(path.sep) ? candidate + 'node_modules' : candidate + path.sep + 'node_modules'; - nodeModulesPaths.push(nodeModulesPath); + + const resolution = resolveFromNodeModulesPath( + context, + parsedSpecifier, + platform, + nodeModulesPath, + ); + if (resolution != null) { + return resolution; + } + + visited[nodeModulesPath] = true; next = path.dirname(candidate); } while (candidate !== next); - } - - // Fall back to `nodeModulesPaths` after hierarchical lookup, similar to $NODE_PATH - nodeModulesPaths.push(...context.nodeModulesPaths); - const extraPaths = []; + // Fall back to `nodeModulesPaths` after hierarchical lookup, similar to $NODE_PATH + // This is done separately from the else branch below to save an allocation and check `visited` + for (let i = 0; i < context.nodeModulesPaths.length; i++) { + // Skip already checked paths, since this could contain duplicates that we already checked + if (visited[context.nodeModulesPaths[i]]) { + continue; + } + const resolution = resolveFromNodeModulesPath( + context, + parsedSpecifier, + platform, + context.nodeModulesPaths[i], + ); + if (resolution != null) { + return resolution; + } + } + } else { + // Only visit `nodeModulesPaths` when hierarchical lookup is disabled + for (let i = 0; i < context.nodeModulesPaths.length; i++) { + const resolution = resolveFromNodeModulesPath( + context, + parsedSpecifier, + platform, + context.nodeModulesPaths[i], + ); + if (resolution != null) { + return resolution; + } + } + } const {extraNodeModules} = context; + let extraNodeModulePath: string | void; if (extraNodeModules && extraNodeModules[parsedSpecifier.packageName]) { const newPackageName = extraNodeModules[parsedSpecifier.packageName]; - extraPaths.push(path.join(newPackageName, parsedSpecifier.posixSubpath)); - } - - const allDirPaths = nodeModulesPaths - .map(nodeModulePath => { - let lookupResult = null; - // Insight: The module can only exist if there is a `node_modules` at - // this path. Redirections cannot succeed, because we will never look - // beyond a node_modules path segment for finding the closest - // package.json. Moreover, if the specifier contains a '/' separator, - // the first part *must* be a real directory, because it is the - // shallowest path that can possibly contain a redirecting package.json. - const mustBeDirectory = - parsedSpecifier.posixSubpath !== '.' || - parsedSpecifier.packageName.length > parsedSpecifier.firstPart.length - ? nodeModulePath + path.sep + parsedSpecifier.firstPart - : nodeModulePath; - lookupResult = context.fileSystemLookup(mustBeDirectory); - if (!lookupResult.exists || lookupResult.type !== 'd') { - return null; - } - return path.join(nodeModulePath, realModuleName); - }) - .filter(Boolean) - .concat(extraPaths); - for (let i = 0; i < allDirPaths.length; ++i) { - const candidate = redirectModulePath(context, allDirPaths[i]); - - if (candidate === false) { - return {type: 'empty'}; + extraNodeModulePath = path.join( + newPackageName, + parsedSpecifier.posixSubpath, + ); + const resolution = resolveModuleFromTargetPath( + context, + platform, + extraNodeModulePath, + ); + if (resolution != null) { + return resolution; } + } - // candidate should be absolute here - we assume that redirectModulePath - // always returns an absolute path when given an absolute path. - const result = resolvePackage(context, candidate, platform); - if (result.type === 'resolved') { - return result.resolution; - } + throw buildFailedToResolveNameError( + context, + extraNodeModulePath != null ? [extraNodeModulePath] : [], + ); +} + +function resolveFromNodeModulesPath( + context: ResolutionContext, + parsedSpecifier: ParsedBareSpecifier, + platform: string | null, + nodeModulesPath: string, +): Resolution | null { + // Insight: The module can only exist if there is a `node_modules` at + // this path. Redirections cannot succeed, because we will never look + // beyond a node_modules path segment for finding the closest + // package.json. Moreover, if the specifier contains a '/' separator, + // the first part *must* be a real directory, because it is the + // shallowest path that can possibly contain a redirecting package.json. + const mustBeDirectory = + parsedSpecifier.posixSubpath !== '.' || + parsedSpecifier.packageName.length > parsedSpecifier.firstPart.length + ? nodeModulesPath + path.sep + parsedSpecifier.firstPart + : nodeModulesPath; + const lookupResult = context.fileSystemLookup(mustBeDirectory); + if (!lookupResult.exists || lookupResult.type !== 'd') { + return null; } + return resolveModuleFromTargetPath( + context, + platform, + nodeModulesPath + + path.sep + + posixToSystemPath(parsedSpecifier.normalizedSpecifier), + ); +} + +function resolveModuleFromTargetPath( + context: ResolutionContext, + platform: string | null, + targetPath: string, +): Resolution | null { + const candidate = redirectModulePath(context, targetPath); + if (candidate === false) { + return {type: 'empty'}; + } + + // candidate should be absolute here - we assume that redirectModulePath + // always returns an absolute path when given an absolute path. + const result = resolvePackage(context, candidate, platform); + if (result.type === 'resolved') { + return result.resolution; + } + + return null; +} - throw new FailedToResolveNameError(nodeModulesPaths, extraPaths); +function buildFailedToResolveNameError( + context: ResolutionContext, + extraPaths: ReadonlyArray, +): FailedToResolveNameError { + const nodeModulesPaths: string[] = []; + + if (!context.disableHierarchicalLookup) { + let next = path.dirname(context.originModulePath); + let candidate; + do { + candidate = next; + const nodeModulesPath = candidate.endsWith(path.sep) + ? candidate + 'node_modules' + : candidate + path.sep + 'node_modules'; + nodeModulesPaths.push(nodeModulesPath); + next = path.dirname(candidate); + } while (candidate !== next); + } + + nodeModulesPaths.push(...context.nodeModulesPaths); + return new FailedToResolveNameError(nodeModulesPaths, extraPaths); } function parseBareSpecifier(specifier: string): ParsedBareSpecifier { diff --git a/packages/metro-resolver/src/utils/paths.js b/packages/metro-resolver/src/utils/paths.js new file mode 100644 index 0000000000..2fd087729d --- /dev/null +++ b/packages/metro-resolver/src/utils/paths.js @@ -0,0 +1,22 @@ +/** + * 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 strict + * @format + * @oncall react_native + */ + +import path from 'path'; + +export const systemToPosixPath: (relativeSystemPath: string) => string = + path.sep === '/' + ? inputPath => inputPath + : inputPath => inputPath.replaceAll('\\', '/'); + +export const posixToSystemPath: (relativePosixPath: string) => string = + path.sep === '/' + ? inputPath => inputPath + : inputPath => inputPath.replaceAll('/', '\\'); diff --git a/packages/metro-resolver/src/utils/toPosixPath.js b/packages/metro-resolver/src/utils/toPosixPath.js deleted file mode 100644 index 8b714ea81e..0000000000 --- a/packages/metro-resolver/src/utils/toPosixPath.js +++ /dev/null @@ -1,29 +0,0 @@ -/** - * 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 strict - * @format - * @oncall react_native - */ - -import path from 'path'; - -const MATCH_NON_POSIX_PATH_SEPS = new RegExp('\\' + path.win32.sep, 'g'); - -/** - * Replace path separators in the passed string to coerce to a POSIX path. This - * is a no-op on POSIX systems. - */ -export default function toPosixPath(relativePathOrSpecifier: string): string { - if (path.sep === path.posix.sep) { - return relativePathOrSpecifier; - } - - return relativePathOrSpecifier.replace( - MATCH_NON_POSIX_PATH_SEPS, - path.posix.sep, - ); -} diff --git a/packages/metro-resolver/types/resolve.d.ts b/packages/metro-resolver/types/resolve.d.ts index fc1775ca1b..5903b07d71 100644 --- a/packages/metro-resolver/types/resolve.d.ts +++ b/packages/metro-resolver/types/resolve.d.ts @@ -6,7 +6,7 @@ * * @noformat * @oncall react_native - * @generated SignedSource<<56f6e00225ee5ece6142bb2b9e4c608d>> + * @generated SignedSource<> * * This file was translated from Flow by scripts/generateTypeScriptDefinitions.js * Original file: packages/metro-resolver/src/resolve.js @@ -19,7 +19,7 @@ import type {Resolution, ResolutionContext} from './types'; declare function resolve( context: ResolutionContext, - moduleName: string, + specifier: string, platform: string | null, ): Resolution; export default resolve; diff --git a/packages/metro-resolver/types/utils/toPosixPath.d.ts b/packages/metro-resolver/types/utils/paths.d.ts similarity index 50% rename from packages/metro-resolver/types/utils/toPosixPath.d.ts rename to packages/metro-resolver/types/utils/paths.d.ts index c787f3163a..ada6b3daa9 100644 --- a/packages/metro-resolver/types/utils/toPosixPath.d.ts +++ b/packages/metro-resolver/types/utils/paths.d.ts @@ -6,18 +6,16 @@ * * @noformat * @oncall react_native - * @generated SignedSource<<091df9100cc8f841af449036a548f6aa>> + * @generated SignedSource<<5fff5c32149db3606cb58437bfe37a8b>> * * This file was translated from Flow by scripts/generateTypeScriptDefinitions.js - * Original file: packages/metro-resolver/src/utils/toPosixPath.js + * Original file: packages/metro-resolver/src/utils/paths.js * To regenerate, run: * js1 build metro-ts-defs (internal) OR * yarn run build-ts-defs (OSS) */ -/** - * Replace path separators in the passed string to coerce to a POSIX path. This - * is a no-op on POSIX systems. - */ -declare function toPosixPath(relativePathOrSpecifier: string): string; -export default toPosixPath; +export declare const systemToPosixPath: (relativeSystemPath: string) => string; +export declare type systemToPosixPath = typeof systemToPosixPath; +export declare const posixToSystemPath: (relativePosixPath: string) => string; +export declare type posixToSystemPath = typeof posixToSystemPath; From cbd08befb55011541712080cf3183097b10d1312 Mon Sep 17 00:00:00 2001 From: Mike Vitousek Date: Tue, 21 Apr 2026 15:37:45 -0700 Subject: [PATCH 06/11] Deploy 0.310.0 to xplat Summary: X-link: https://github.com/facebook/react-native/pull/56543 [changelog](https://github.com/facebook/flow/blob/main/Changelog.md) Changelog: [Internal] Reviewed By: panagosg7 Differential Revision: D101742377 fbshipit-source-id: 57321dad493b7d17677cf9741d320e42f5ab182d --- .flowconfig | 2 +- package.json | 2 +- yarn.lock | 8 ++++---- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.flowconfig b/.flowconfig index cc67a8382a..7ae2cc6638 100644 --- a/.flowconfig +++ b/.flowconfig @@ -38,4 +38,4 @@ untyped-import untyped-type-import [version] -^0.309.0 +^0.310.0 diff --git a/package.json b/package.json index c42247cdc1..64ef1b15c7 100644 --- a/package.json +++ b/package.json @@ -30,7 +30,7 @@ "eslint-plugin-react": "^7.37.5", "eslint-plugin-relay": "^1.8.3", "flow-api-translator": "0.35.0", - "flow-bin": "^0.309.0", + "flow-bin": "^0.310.0", "hermes-eslint": "0.35.0", "invariant": "^2.2.4", "istanbul-api": "3.0.0", diff --git a/yarn.lock b/yarn.lock index d335870aa4..7f7c538e6f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3066,10 +3066,10 @@ flow-api-translator@0.35.0: hermes-transform "0.35.0" typescript "5.3.2" -flow-bin@^0.309.0: - version "0.309.0" - resolved "https://registry.yarnpkg.com/flow-bin/-/flow-bin-0.309.0.tgz#c983003377c80a558e1c4afbf47b6c38cfbca075" - integrity sha512-/RH68gcCY8OHzcdSVTUCw+fhDSEYmNHoovfK0EcbB4rs1Xbc5HhxhHTvr7U+h55De4bDRlE52ghH23MRP625cQ== +flow-bin@^0.310.0: + version "0.310.0" + resolved "https://registry.yarnpkg.com/flow-bin/-/flow-bin-0.310.0.tgz#d1c06486694af8cf92e3087dc2a18d80bdbb3a09" + integrity sha512-Yt2umR1JT3soz2y7gUfayaW66SnYnmtPM4FR2R4NRLuF4ymGTmN+auzahe9YY5ZGW793nGXKWxkWLBFnd94yEQ== flow-enums-runtime@^0.0.6: version "0.0.6" From 6b2e984375ee3d104472f0f37bac248037634348 Mon Sep 17 00:00:00 2001 From: Alex Hunt Date: Thu, 23 Apr 2026 07:30:07 -0700 Subject: [PATCH 07/11] Resolve `[metro-watchFolders]` URL prefix in bundle and entry point paths (#1695) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Summary: Pull Request resolved: https://github.com/facebook/metro/pull/1695 Extends Metro `Server.js` to handle `[metro-watchFolders]/N/...` prefixed entry file paths in `.bundle` and `.map` requests. This convention is already used for source file serving (powering React Native DevTools), and this change extends it to bundle resolution. **Implementation** Adds `_resolveWatchFolderPrefix()`, which parses `[metro-watchFolders]/N/relative/path` URLs and resolves them against the corresponding `watchFolders[N]` entry from the Metro config. Also handles `[metro-project]/...` as a prefix for the project root. This method is called from two sites: - `_resolveRelativePath()` — used for resolving module paths in bundle/map requests - `_getEntryPointAbsolutePath()` — used for resolving the entry file to an absolute path **Motivation** The primary use case is environments where the entry point resolves to a path outside the Metro server root (e.g. via a symlink to a different filesystem mount). In these cases, `path.relative(serverRoot, entryPath)` produces a broken `../../...` path. A client (such as Expo CLI) can instead construct a `[metro-watchFolders]/N/...` URL referencing the watchFolder that contains the entry file, allowing Metro to resolve it correctly. We have an open PR in Expo CLI that aims to use this configuration path: https://github.com/expo/expo/pull/45010. Changelog: [Internal] Reviewed By: robhogan Differential Revision: D102004228 fbshipit-source-id: 617d68af43846168dcabcecfa16f60d6b4bf6771 --- packages/metro/src/Server.js | 50 +++++++++-- .../metro/src/Server/__tests__/Server-test.js | 83 +++++++++++++++++++ packages/metro/types/Server.d.ts | 5 +- 3 files changed, 132 insertions(+), 6 deletions(-) diff --git a/packages/metro/src/Server.js b/packages/metro/src/Server.js index e2b030b499..c21f619851 100644 --- a/packages/metro/src/Server.js +++ b/packages/metro/src/Server.js @@ -1622,6 +1622,33 @@ export default class Server { ); } + _resolveWatchFolderPrefix( + filePath: string, + ): {rootDir: string, filePath: string} | null { + const watchFolderMatch = filePath.match( + /^\.\/\[metro-watchFolders\]\/(\d+)\/(.*)/, + ); + if (watchFolderMatch != null) { + const index = parseInt(watchFolderMatch[1], 10); + const watchFolder = this._config.watchFolders[index]; + if (watchFolder != null) { + return { + rootDir: path.resolve(watchFolder), + filePath: + '.' + path.sep + watchFolderMatch[2].split('/').join(path.sep), + }; + } + } + const projectMatch = filePath.match(/^\.\/\[metro-project\]\/(.*)/); + if (projectMatch != null) { + return { + rootDir: path.resolve(this._config.projectRoot), + filePath: '.' + path.sep + projectMatch[1].split('/').join(path.sep), + }; + } + return null; + } + async _resolveRelativePath( filePath: string, { @@ -1639,13 +1666,22 @@ export default class Server { transformOptions.platform, resolverOptions, ); + const resolved = this._resolveWatchFolderPrefix(filePath); const rootDir = - relativeTo === 'server' - ? this._getServerRootDir() - : this._config.projectRoot; + resolved != null + ? resolved.rootDir + : relativeTo === 'server' + ? this._getServerRootDir() + : this._config.projectRoot; + const resolvedFilePath = resolved != null ? resolved.filePath : filePath; return resolutionFn(`${rootDir}/.`, { - name: filePath, - data: {key: filePath, locs: [], asyncType: null, isESMImport: false}, + name: resolvedFilePath, + data: { + key: resolvedFilePath, + locs: [], + asyncType: null, + isESMImport: false, + }, }).filePath; } @@ -1706,6 +1742,10 @@ export default class Server { } _getEntryPointAbsolutePath(entryFile: string): string { + const resolved = this._resolveWatchFolderPrefix(entryFile); + if (resolved != null) { + return path.resolve(resolved.rootDir, resolved.filePath); + } return path.resolve(this._getServerRootDir(), entryFile); } diff --git a/packages/metro/src/Server/__tests__/Server-test.js b/packages/metro/src/Server/__tests__/Server-test.js index 31eb5e7fe0..47d7e6f954 100644 --- a/packages/metro/src/Server/__tests__/Server-test.js +++ b/packages/metro/src/Server/__tests__/Server-test.js @@ -1436,4 +1436,87 @@ describe('processRequest', () => { }, ); }); + + describe('watchFolder prefix resolution', () => { + let watchFolderServer: $FlowFixMe; + + beforeEach(() => { + watchFolderServer = new Server( + mergeConfig(getDefaultValues('/'), { + projectRoot: '/project', + watchFolders: ['/project', '/external/packages'], + resolver: {blockList: []}, + cacheVersion: '', + serializer: { + getRunModuleStatement: moduleId => + `require(${JSON.stringify(moduleId)});`, + polyfillModuleNames: [], + getModulesRunBeforeMainModule: () => ['InitializeCore'], + }, + reporter: require('../../lib/reporting').nullReporter, + } as InputConfigT), + ); + }); + + test('resolves [metro-watchFolders]/N/ prefix against the Nth watch folder', () => { + expect( + watchFolderServer._resolveWatchFolderPrefix( + './[metro-watchFolders]/1/expo-router/entry', + ), + ).toEqual({ + rootDir: '/external/packages', + filePath: './expo-router/entry', + }); + }); + + test('resolves [metro-watchFolders]/0/ prefix against the first watch folder', () => { + expect( + watchFolderServer._resolveWatchFolderPrefix( + './[metro-watchFolders]/0/app/index', + ), + ).toEqual({ + rootDir: '/project', + filePath: './app/index', + }); + }); + + test('resolves [metro-project]/ prefix against projectRoot', () => { + expect( + watchFolderServer._resolveWatchFolderPrefix( + './[metro-project]/src/App', + ), + ).toEqual({ + rootDir: '/project', + filePath: './src/App', + }); + }); + + test('returns null for paths without a recognized prefix', () => { + expect( + watchFolderServer._resolveWatchFolderPrefix('./mybundle'), + ).toBeNull(); + }); + + test('returns null for out-of-bounds watchFolder index', () => { + expect( + watchFolderServer._resolveWatchFolderPrefix( + './[metro-watchFolders]/99/mybundle', + ), + ).toBeNull(); + }); + + test('_getEntryPointAbsolutePath resolves prefixed entry against the corresponding watch folder', () => { + expect( + watchFolderServer._getEntryPointAbsolutePath( + './[metro-watchFolders]/1/expo-router/entry', + ), + ).toBe('/external/packages/expo-router/entry'); + }); + + test('_getEntryPointAbsolutePath resolves non-prefixed entry against server root', () => { + expect(watchFolderServer._getEntryPointAbsolutePath('./mybundle')).toBe( + '/project/mybundle', + ); + }); + }); }); diff --git a/packages/metro/types/Server.d.ts b/packages/metro/types/Server.d.ts index dc787733a5..0f94dbd03d 100644 --- a/packages/metro/types/Server.d.ts +++ b/packages/metro/types/Server.d.ts @@ -6,7 +6,7 @@ * * @noformat * @oncall react_native - * @generated SignedSource<<161e77301d04ce6cc254f1dbf15ef06b>> + * @generated SignedSource<<03b526801403adb05b3b0f6c25b25ed5>> * * This file was translated from Flow by scripts/generateTypeScriptDefinitions.js * Original file: packages/metro/src/Server.js @@ -225,6 +225,9 @@ declare class Server { _explodedSourceMapForBundleOptions( bundleOptions: BundleOptions, ): Promise; + _resolveWatchFolderPrefix( + filePath: string, + ): {rootDir: string; filePath: string} | null; _resolveRelativePath( filePath: string, $$PARAM_1$$: Readonly<{ From b5a4cd454dc499596ddacbdfeb6ee7eb71e995ac Mon Sep 17 00:00:00 2001 From: Rob Hogan Date: Thu, 23 Apr 2026 08:34:59 -0700 Subject: [PATCH 08/11] Treat dynamic imports with rejection handlers as optional dependencies (#1697) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Summary: Pull Request resolved: https://github.com/facebook/metro/pull/1697 Fixes: https://github.com/facebook/metro/issues/1681 Extends the optional-dependency heuristic in `collectDependencies` to recognise dynamic imports that attach a rejection handler — either `.catch(handler)` or `.then(_, handler)` — anywhere in an unbroken promise chain rooted at the `import()` call. Today, `transformer.allowOptionalDependencies` only treats `require()` / `await import()` as optional when wrapped in a `try` block. Library code that relies on a chained `catch`, e.g. ```js import('node:diagnostics_channel') .then(dc => { ... }) .catch(() => { ... }); ``` (seen in `lru-cache` for instance: https://unpkg.com/lru-cache@11.3.5/dist/esm/diagnostics-channel.js ) still fails the build with an unresolvable-module error, even though the developer has clearly opted into a runtime fallback. This makes a pragmatic extension to `collectDependencies` to walk up the chain from the `import()` call and treat the dependency as optional if any chained call provides a rejection handler. The walk stops as soon as the chain is broken, keeping the heuristic local to the import. Changelog: ``` - **[Fix]**: Treat `import().catch()`, etc. as optional under `resolver.allowOptionalDependencies` ``` Reviewed By: huntie Differential Revision: D102145525 fbshipit-source-id: 4241ba24eb234e7aa42bbaa133b992477ef2327c --- .../__tests__/collectDependencies-test.js | 85 +++++++++++++++++++ .../ModuleGraph/worker/collectDependencies.js | 67 +++++++++++++++ 2 files changed, 152 insertions(+) diff --git a/packages/metro/src/ModuleGraph/worker/__tests__/collectDependencies-test.js b/packages/metro/src/ModuleGraph/worker/__tests__/collectDependencies-test.js index a04439a5b3..a710122190 100644 --- a/packages/metro/src/ModuleGraph/worker/__tests__/collectDependencies-test.js +++ b/packages/metro/src/ModuleGraph/worker/__tests__/collectDependencies-test.js @@ -1605,6 +1605,91 @@ describe('optional dependencies', () => { {name: 'foo', data: expect.not.objectContaining({isOptional: true})}, ]); }); + + describe('dynamic import with rejection handler', () => { + test('import().catch(handler) is optional', () => { + const ast = astFromCode(` + import('optional-async-a').catch(() => {}); + `); + const {dependencies} = collectDependencies(ast, opts); + validateDependencies(dependencies, 2); + }); + + test('import().then(handler, onReject) is optional', () => { + const ast = astFromCode(` + import('optional-async-a').then(() => {}, () => {}); + `); + const {dependencies} = collectDependencies(ast, opts); + validateDependencies(dependencies, 2); + }); + + test('import().then(...).then(...).catch(handler) is optional', () => { + const ast = astFromCode(` + import('optional-async-a') + .then(x => x) + .then(x => x) + .catch(() => {}); + `); + const {dependencies} = collectDependencies(ast, opts); + validateDependencies(dependencies, 2); + }); + + test('await import().catch(handler) is optional', () => { + const ast = astFromCode(` + async function f() { + await import('optional-async-a').catch(() => {}); + } + `); + const {dependencies} = collectDependencies(ast, opts); + validateDependencies(dependencies, 2); + }); + + test('try { await import() } catch {} is optional', () => { + const ast = astFromCode(` + async function f() { + try { + await import('optional-async-a'); + } catch (e) {} + } + `); + const {dependencies} = collectDependencies(ast, opts); + validateDependencies(dependencies, 2); + }); + + test('import().then(handler) without onReject is not optional', () => { + const ast = astFromCode(` + import('not-optional-async-a').then(() => {}); + `); + const {dependencies} = collectDependencies(ast, opts); + validateDependencies(dependencies, 2); + }); + + test('import().catch() with no handler argument is not optional', () => { + const ast = astFromCode(` + import('not-optional-async-a').catch(); + `); + const {dependencies} = collectDependencies(ast, opts); + validateDependencies(dependencies, 2); + }); + + test('import().then(handler, null) is not optional (null/undefined onReject)', () => { + const ast = astFromCode(` + import('not-optional-async-a').then(() => {}, null); + import('not-optional-async-b').then(() => {}, undefined); + `); + const {dependencies} = collectDependencies(ast, opts); + validateDependencies(dependencies, 3); + }); + + test('import() detached from chain is not optional', () => { + const ast = astFromCode(` + const p = import('not-optional-async-a'); + p.catch(() => {}); + `); + const {dependencies} = collectDependencies(ast, opts); + validateDependencies(dependencies, 2); + }); + }); }); test('uses the dependency transformer specified in the options to transform the dependency calls', () => { diff --git a/packages/metro/src/ModuleGraph/worker/collectDependencies.js b/packages/metro/src/ModuleGraph/worker/collectDependencies.js index 2cf744949f..8fa68302c6 100644 --- a/packages/metro/src/ModuleGraph/worker/collectDependencies.js +++ b/packages/metro/src/ModuleGraph/worker/collectDependencies.js @@ -630,6 +630,15 @@ function isOptionalDependency( return false; } + // Treat dynamic imports as optional when a rejection handler is attached + // close to the import call, e.g. + // import('x').catch(handler) + // import('x').then(handler, onReject) + // import('x').then(...).catch(handler) + if (isInPromiseChainWithRejectionHandler(path)) { + return true; + } + // Valid statement stack for single-level try-block: expressionStatement -> blockStatement -> tryStatement let sCount = 0; let p: ?(NodePath<> | NodePath) = path; @@ -652,6 +661,64 @@ function isOptionalDependency( return false; } +// Walk up a chain of `.then(...)` / `.catch(...)` member calls starting from +// `path` (typically an `import()` CallExpression) and return true if any +// chained call provides a rejection handler — either `.catch(handler)` or +// `.then(_, handler)`. The chain must be unbroken: as soon as the parent is +// not a member call applied to the previous expression, we stop. This keeps +// the heuristic local to the import, matching the behaviour of the +// try/catch heuristic above. +function isInPromiseChainWithRejectionHandler(path: NodePath<>): boolean { + let current: NodePath<> = path; + while (current.parentPath != null) { + const member = current.parentPath; + if ( + member.node.type !== 'MemberExpression' || + member.node.object !== current.node || + member.node.computed || + member.node.property.type !== 'Identifier' || + member.parentPath == null + ) { + return false; + } + const call = member.parentPath; + if ( + call.node.type !== 'CallExpression' || + call.node.callee !== member.node + ) { + return false; + } + const propertyName = member.node.property.name; + const args = call.node.arguments; + if ( + propertyName === 'catch' && + args.length >= 1 && + isNonNullishCallbackArg(args[0]) + ) { + return true; + } + if ( + propertyName === 'then' && + args.length >= 2 && + isNonNullishCallbackArg(args[1]) + ) { + return true; + } + current = call; + } + return false; +} + +function isNonNullishCallbackArg(arg: BabelNode): boolean { + if (arg.type === 'NullLiteral') { + return false; + } + if (arg.type === 'Identifier' && arg.name === 'undefined') { + return false; + } + return true; +} + function getModuleNameFromCallArgs(path: NodePath): ?string { const args = path.get('arguments'); if (!Array.isArray(args) || args.length !== 1) { From c82d76dfd08184ff327f2c735b9fac2fd7dd55f7 Mon Sep 17 00:00:00 2001 From: George Zahariev Date: Thu, 23 Apr 2026 21:29:21 -0700 Subject: [PATCH 09/11] Deploy 0.311.0 to xplat Summary: X-link: https://github.com/facebook/react-native/pull/56589 [changelog](https://github.com/facebook/flow/blob/main/Changelog.md) Changelog: [Internal] Reviewed By: bherila, marcoww6, christophpurrer Differential Revision: D102240969 fbshipit-source-id: 09d0a17cbe135eb9e6bb8e06ea86ee67ab32a73a --- .flowconfig | 2 +- package.json | 2 +- yarn.lock | 8 ++++---- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.flowconfig b/.flowconfig index 7ae2cc6638..33eeb5ccf9 100644 --- a/.flowconfig +++ b/.flowconfig @@ -38,4 +38,4 @@ untyped-import untyped-type-import [version] -^0.310.0 +^0.311.0 diff --git a/package.json b/package.json index 64ef1b15c7..82ed3d3920 100644 --- a/package.json +++ b/package.json @@ -30,7 +30,7 @@ "eslint-plugin-react": "^7.37.5", "eslint-plugin-relay": "^1.8.3", "flow-api-translator": "0.35.0", - "flow-bin": "^0.310.0", + "flow-bin": "^0.311.0", "hermes-eslint": "0.35.0", "invariant": "^2.2.4", "istanbul-api": "3.0.0", diff --git a/yarn.lock b/yarn.lock index 7f7c538e6f..f7470bde95 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3066,10 +3066,10 @@ flow-api-translator@0.35.0: hermes-transform "0.35.0" typescript "5.3.2" -flow-bin@^0.310.0: - version "0.310.0" - resolved "https://registry.yarnpkg.com/flow-bin/-/flow-bin-0.310.0.tgz#d1c06486694af8cf92e3087dc2a18d80bdbb3a09" - integrity sha512-Yt2umR1JT3soz2y7gUfayaW66SnYnmtPM4FR2R4NRLuF4ymGTmN+auzahe9YY5ZGW793nGXKWxkWLBFnd94yEQ== +flow-bin@^0.311.0: + version "0.311.0" + resolved "https://registry.yarnpkg.com/flow-bin/-/flow-bin-0.311.0.tgz#a1cbef22d1cb9e05b58ffdbad1c73ed03f4122e2" + integrity sha512-4lXxjhPdmkeizju3F0HDCMYGkoL7hiq0W9bAW4pQpQTi56op+QZrVyMENjbCGZc+KlFBLwWkur+EkyfPTsa6xw== flow-enums-runtime@^0.0.6: version "0.0.6" From 8fa4166b2e6e794cd13845a803d7d2a0c6762ed4 Mon Sep 17 00:00:00 2001 From: Sandeep Kudterkar Date: Fri, 24 Apr 2026 04:51:33 -0700 Subject: [PATCH 10/11] Upgrade lodash/lodash-es to 4.18.1 (CVE-2026-4800) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Summary: Upgrade transitive dependency lodash from 4.17.21/4.17.23 to 4.18.1 and lodash-es from 4.17.21 to 4.18.1 to remediate CVE-2026-4800 (Improper Control of Generation of Code / Code Injection). Updated lodash/lodash-es entries in 3 yarn.lock files: - xplat/js/tools/react-fox/yarn.lock (lodash 4.17.21 → 4.18.1) - xplat/js/tools/react-fox/apps/playground/yarn.lock (lodash 4.17.23 → 4.18.1) - xplat/js/tools/metro/website/yarn.lock (lodash-es 4.17.21 → 4.18.1) No package.json changes needed. Reviewed By: Bellardia Differential Revision: D102241929 fbshipit-source-id: b9a4d3ff16b2e74ea115d7954e6eebc3c0514b34 --- website/yarn.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/website/yarn.lock b/website/yarn.lock index 62239397e8..b81cd63697 100644 --- a/website/yarn.lock +++ b/website/yarn.lock @@ -6548,9 +6548,9 @@ locate-path@^7.1.0: p-locate "^6.0.0" lodash-es@^4.17.21: - version "4.17.21" - resolved "https://registry.yarnpkg.com/lodash-es/-/lodash-es-4.17.21.tgz#43e626c46e6591b7750beb2b50117390c609e3ee" - integrity sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw== + version "4.18.1" + resolved "https://registry.yarnpkg.com/lodash-es/-/lodash-es-4.18.1.tgz#b962eeb80d9d983a900bf342961fb7418ca10b1d" + integrity sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A== lodash.debounce@^4.0.8: version "4.0.8" From 932d127c977d893eab87a6c8022001511ad065ba Mon Sep 17 00:00:00 2001 From: Phil Pluckthun Date: Sun, 26 Apr 2026 03:14:51 -0700 Subject: [PATCH 11/11] fix(metro): Fix regression to allow scale assets to be resolved again for single asset requests (#1694) Summary: Resolves https://github.com/facebook/metro/issues/1667 The prior change checks the existence of the file path too soon, and ignores that scales may be resolved later in this function. > [!NOTE] > Unrelated to this change, the last change feels a bit ad-hoc and incomplete. We should replace the `fs.promises.readdir` call with the file map. That's out of scope for this PR though. Changelog: [Fix] Fix regression to allow single asset request to resolve scaled assets Pull Request resolved: https://github.com/facebook/metro/pull/1694 Test Plan: - Unit tests added Reviewed By: huntie Differential Revision: D102142213 Pulled By: robhogan fbshipit-source-id: 778f142fb4d1d07ce11d8b4661b8bcc87b6b5150 --- packages/metro/src/Assets.js | 33 +++++---- packages/metro/src/__tests__/Assets-test.js | 75 +++++++++++++++++++++ 2 files changed, 95 insertions(+), 13 deletions(-) diff --git a/packages/metro/src/Assets.js b/packages/metro/src/Assets.js index b2759ad59b..b81e3f67d9 100644 --- a/packages/metro/src/Assets.js +++ b/packages/metro/src/Assets.js @@ -299,29 +299,36 @@ export async function getAsset( } // NOTE: If fileExistsInFileMap is not provided, we fall back to pathBelongsToRoots for backward compatibility, as getAsset is part of the public API. - if (fileExistsInFileMap != null) { - if (!fileExistsInFileMap(absolutePath)) { - throw new Error( - `'${relativePath}' could not be found, because it is not within the projectRoot or watchFolders, or it is blocked via the resolver.blockList config`, - ); - } - } else { - if (!pathBelongsToRoots(absolutePath, [projectRoot, ...watchFolders])) { - throw new Error( - `'${relativePath}' could not be found, because it cannot be found in the project root or any watch folder`, - ); - } + if ( + fileExistsInFileMap == null && + !pathBelongsToRoots(absolutePath, [projectRoot, ...watchFolders]) + ) { + throw new Error( + `'${relativePath}' could not be found, because it cannot be found in the project root or any watch folder`, + ); } const record = await getAbsoluteAssetRecord(absolutePath, platform ?? null); for (let i = 0; i < record.scales.length; i++) { if (record.scales[i] >= assetData.resolution) { + if ( + fileExistsInFileMap != null && + !fileExistsInFileMap(record.files[i]) + ) { + continue; + } return fs.promises.readFile(record.files[i]); } } - return fs.promises.readFile(record.files[record.files.length - 1]); + const lastFile = record.files[record.files.length - 1]; + if (fileExistsInFileMap != null && !fileExistsInFileMap(lastFile)) { + throw new Error( + `'${relativePath}' could not be found, because it is not within the projectRoot or watchFolders, or it is blocked via the resolver.blockList config`, + ); + } + return fs.promises.readFile(lastFile); } function pathBelongsToRoots( diff --git a/packages/metro/src/__tests__/Assets-test.js b/packages/metro/src/__tests__/Assets-test.js index 804438ba60..f010240f24 100644 --- a/packages/metro/src/__tests__/Assets-test.js +++ b/packages/metro/src/__tests__/Assets-test.js @@ -166,6 +166,81 @@ describe('getAsset', () => { getAssetStr('imgs/b.png', '/root', [], null, ['png'], () => false), ).rejects.toBeInstanceOf(Error); }); + + test('should serve scale variant when only scale variants exist and fileExistsInFileMap is provided', async () => { + writeImages({ + 'b@2x.png': 'b2 image', + 'b@3x.png': 'b3 image', + }); + + expect( + await getAssetStr( + 'imgs/b@2x.png', + '/root', + [], + null, + ['png'], + () => true, + ), + ).toBe('b2 image'); + }); + + test('should throw when fileExistsInFileMap rejects the resolved scale variant', async () => { + writeImages({ + 'b@2x.png': 'b2 image', + 'b@3x.png': 'b3 image', + }); + + await expect( + getAssetStr('imgs/b@2x.png', '/root', [], null, ['png'], () => false), + ).rejects.toBeInstanceOf(Error); + }); + + test('should check fileExistsInFileMap against the resolved file, not the base path', async () => { + writeImages({ + 'b@2x.png': 'b2 image', + 'b@3x.png': 'b3 image', + }); + + const checkedPaths = []; + const result = await getAssetStr( + 'imgs/b@2x.png', + '/root', + [], + null, + ['png'], + filePath => { + checkedPaths.push(filePath); + return true; + }, + ); + + expect(result).toBe('b2 image'); + expect(checkedPaths).toEqual(['/root/imgs/b@2x.png']); + }); + + test('should check fileExistsInFileMap for the fallback (highest scale) file', async () => { + writeImages({ + 'b@1x.png': 'b1 image', + 'b@2x.png': 'b2 image', + }); + + const checkedPaths = []; + const result = await getAssetStr( + 'imgs/b@3x.png', + '/root', + [], + null, + ['png'], + filePath => { + checkedPaths.push(filePath); + return true; + }, + ); + + expect(result).toBe('b2 image'); + expect(checkedPaths).toEqual(['/root/imgs/b@2x.png']); + }); }); describe('getAssetData', () => {