From 84922de4506f5a4d5003d88706ff7f56fb7a0449 Mon Sep 17 00:00:00 2001 From: Phil Pluckthun Date: Wed, 8 Apr 2026 00:50:04 +0100 Subject: [PATCH 1/8] Create extensions dictionary --- packages/metro-file-map/src/crawlers/node/index.js | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/packages/metro-file-map/src/crawlers/node/index.js b/packages/metro-file-map/src/crawlers/node/index.js index e7aaabe7a7..af6d2d1209 100644 --- a/packages/metro-file-map/src/crawlers/node/index.js +++ b/packages/metro-file-map/src/crawlers/node/index.js @@ -42,6 +42,14 @@ function find( let activeCalls = 0; const pathUtils = new RootPathUtils(rootDir); + const exts = extensions.reduce( + (acc, ext) => { + acc[ext] = true; + return acc; + }, + {} as {[string]: ?true}, + ); + function search(directory: string): void { activeCalls++; fs.readdir(directory, {withFileTypes: true}, (err, entries) => { @@ -74,7 +82,7 @@ function find( if (!err && stat) { const ext = path.extname(file).substr(1); - if (stat.isSymbolicLink() || extensions.includes(ext)) { + if (stat.isSymbolicLink() || exts[ext]) { result.set(pathUtils.absoluteToNormal(file), [ stat.mtime.getTime(), stat.size, From b7550a04300d273dca8a32a897ff1bc3b377deea Mon Sep 17 00:00:00 2001 From: Phil Pluckthun Date: Wed, 8 Apr 2026 00:54:25 +0100 Subject: [PATCH 2/8] Append crawl child paths replacing repeated `pathUtils.absoluteToNormal` calls --- .../metro-file-map/src/crawlers/node/index.js | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/packages/metro-file-map/src/crawlers/node/index.js b/packages/metro-file-map/src/crawlers/node/index.js index af6d2d1209..49b7257f58 100644 --- a/packages/metro-file-map/src/crawlers/node/index.js +++ b/packages/metro-file-map/src/crawlers/node/index.js @@ -50,7 +50,7 @@ function find( {} as {[string]: ?true}, ); - function search(directory: string): void { + function search(directory: string, dirNormal: string): void { activeCalls++; fs.readdir(directory, {withFileTypes: true}, (err, entries) => { activeCalls--; @@ -60,18 +60,17 @@ function find( ); } else { entries.forEach((entry: fs.Dirent) => { - const file = path.join(directory, entry.name.toString()); + const name = entry.name.toString(); + const file = directory + path.sep + name; - if (ignore(file)) { - return; - } - - if (entry.isSymbolicLink() && !includeSymlinks) { + if (ignore(file) || (!includeSymlinks && entry.isSymbolicLink())) { return; } + const childNormal = + dirNormal === '' ? name : dirNormal + path.sep + name; if (entry.isDirectory()) { - search(file); + search(file, childNormal); return; } @@ -83,7 +82,7 @@ function find( if (!err && stat) { const ext = path.extname(file).substr(1); if (stat.isSymbolicLink() || exts[ext]) { - result.set(pathUtils.absoluteToNormal(file), [ + result.set(childNormal, [ stat.mtime.getTime(), stat.size, 0, @@ -108,7 +107,7 @@ function find( } if (roots.length > 0) { - roots.forEach(search); + roots.forEach(root => search(root, pathUtils.absoluteToNormal(root))); } else { callback(result); } From 452346807f43bf6fc8d65e28017d526c5f118a8d Mon Sep 17 00:00:00 2001 From: Phil Pluckthun Date: Wed, 8 Apr 2026 00:57:03 +0100 Subject: [PATCH 3/8] Tweak formatting/prefer for-of --- .../metro-file-map/src/crawlers/node/index.js | 22 +++++++++++-------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/packages/metro-file-map/src/crawlers/node/index.js b/packages/metro-file-map/src/crawlers/node/index.js index 49b7257f58..963d63fc77 100644 --- a/packages/metro-file-map/src/crawlers/node/index.js +++ b/packages/metro-file-map/src/crawlers/node/index.js @@ -59,19 +59,20 @@ function find( `Error "${err.code ?? err.message}" reading contents of "${directory}", skipping. Add this directory to your ignore list to exclude it.`, ); } else { - entries.forEach((entry: fs.Dirent) => { + for (let idx = 0; idx < entries.length; idx++) { + const entry = entries[idx]; const name = entry.name.toString(); const file = directory + path.sep + name; if (ignore(file) || (!includeSymlinks && entry.isSymbolicLink())) { - return; + continue; } const childNormal = dirNormal === '' ? name : dirNormal + path.sep + name; if (entry.isDirectory()) { search(file, childNormal); - return; + continue; } activeCalls++; @@ -81,13 +82,14 @@ function find( if (!err && stat) { const ext = path.extname(file).substr(1); - if (stat.isSymbolicLink() || exts[ext]) { + const isSymbolicLink = stat.isSymbolicLink(); + if (isSymbolicLink || exts[ext]) { result.set(childNormal, [ stat.mtime.getTime(), stat.size, 0, null, - stat.isSymbolicLink() ? 1 : 0, + isSymbolicLink ? 1 : 0, null, ]); } @@ -97,7 +99,7 @@ function find( callback(result); } }); - }); + } } if (activeCalls === 0) { @@ -107,7 +109,9 @@ function find( } if (roots.length > 0) { - roots.forEach(root => search(root, pathUtils.absoluteToNormal(root))); + for (const root of roots) { + search(root, pathUtils.absoluteToNormal(root)); + } } else { callback(result); } @@ -156,7 +160,7 @@ function findNative( if (!count) { callback(new Map()); } else { - lines.forEach(path => { + for (const path of lines) { fs.lstat(path, (err, stat) => { if (!err && stat) { result.set(pathUtils.absoluteToNormal(path), [ @@ -172,7 +176,7 @@ function findNative( callback(result); } }); - }); + } } }); } From 76b643967d958eff309445f3c34dcbb56a357ca9 Mon Sep 17 00:00:00 2001 From: Phil Pluckthun Date: Wed, 8 Apr 2026 01:01:55 +0100 Subject: [PATCH 4/8] Flatten Promise in `nodeCrawl` (formatting-only) --- .../metro-file-map/src/crawlers/node/index.js | 58 +++++++------------ 1 file changed, 20 insertions(+), 38 deletions(-) diff --git a/packages/metro-file-map/src/crawlers/node/index.js b/packages/metro-file-map/src/crawlers/node/index.js index 963d63fc77..00a3ed5e9d 100644 --- a/packages/metro-file-map/src/crawlers/node/index.js +++ b/packages/metro-file-map/src/crawlers/node/index.js @@ -208,43 +208,25 @@ export default async function nodeCrawl( debug('Using system find: %s', useNativeFind); - return new Promise((resolve, reject) => { - const callback: Callback = fileData => { - const difference = previousState.fileSystem.getDifference(fileData, { - subpath, - }); - - perfLogger?.point('nodeCrawl_end'); - - try { - // TODO: Use AbortSignal.reason directly when Flow supports it - abortSignal?.throwIfAborted(); - } catch (e) { - reject(e); - } - resolve(difference); - }; - - if (useNativeFind) { - findNative( - roots, - extensions, - ignore, - includeSymlinks, - rootDir, - console, - callback, - ); - } else { - find( - roots, - extensions, - ignore, - includeSymlinks, - rootDir, - console, - callback, - ); - } + const crawlFn = useNativeFind ? findNative : find; + const fileData = await new Promise(resolve => { + crawlFn( + roots, + extensions, + ignore, + includeSymlinks, + rootDir, + console, + resolve, + ); + }); + + abortSignal?.throwIfAborted(); + + const difference = previousState.fileSystem.getDifference(fileData, { + subpath, }); + + perfLogger?.point('nodeCrawl_end'); + return difference; } From 5e32080e6681be257101d9f676a6a29dbb6a2a8a Mon Sep 17 00:00:00 2001 From: Phil Pluckthun Date: Thu, 9 Apr 2026 12:36:54 +0100 Subject: [PATCH 5/8] Remove overlapping roots in FileMap A previous implementation kept a `visited` set in the Node crawler. Instead, we can also deduplicate and remove overlapping roots (directories within other roots) in the `FileMap` preemptively for all crawlers to prevent duplicate crawls. --- packages/metro-file-map/src/index.js | 3 +- .../__tests__/removeOverlappingRoots-test.js | 119 ++++++++++++++++++ .../src/lib/removeOverlappingRoots.js | 35 ++++++ 3 files changed, 156 insertions(+), 1 deletion(-) create mode 100644 packages/metro-file-map/src/lib/__tests__/removeOverlappingRoots-test.js create mode 100644 packages/metro-file-map/src/lib/removeOverlappingRoots.js diff --git a/packages/metro-file-map/src/index.js b/packages/metro-file-map/src/index.js index 113f85be1d..387057e865 100644 --- a/packages/metro-file-map/src/index.js +++ b/packages/metro-file-map/src/index.js @@ -49,6 +49,7 @@ import {FileProcessor} from './lib/FileProcessor'; import {FileSystemChangeAggregator} from './lib/FileSystemChangeAggregator'; import normalizePathSeparatorsToPosix from './lib/normalizePathSeparatorsToPosix'; import normalizePathSeparatorsToSystem from './lib/normalizePathSeparatorsToSystem'; +import removeOverlappingRoots from './lib/removeOverlappingRoots'; import {RootPathUtils} from './lib/RootPathUtils'; import TreeFS from './lib/TreeFS'; import {Watcher} from './Watcher'; @@ -323,7 +324,7 @@ export default class FileMap extends EventEmitter { plugins, retainAllFiles: options.retainAllFiles, rootDir: options.rootDir, - roots: Array.from(new Set(options.roots)), + roots: removeOverlappingRoots(options.roots), }; this.#options = { diff --git a/packages/metro-file-map/src/lib/__tests__/removeOverlappingRoots-test.js b/packages/metro-file-map/src/lib/__tests__/removeOverlappingRoots-test.js new file mode 100644 index 0000000000..084dd41e21 --- /dev/null +++ b/packages/metro-file-map/src/lib/__tests__/removeOverlappingRoots-test.js @@ -0,0 +1,119 @@ +/** + * 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. + * + * @format + * @flow strict + * @oncall react_native + */ + +let mockPathModule; +jest.mock('path', () => mockPathModule); + +describe.each([['win32'], ['posix']])( + 'removeOverlappingRoots on %s', + platform => { + // Convenience function to write paths with posix separators but convert them + // to system separators + const p: string => string = filePath => + platform === 'win32' + ? filePath.replace(/\//g, '\\').replace(/^\\/, 'C:\\') + : filePath; + + let removeOverlappingRoots; + + beforeEach(() => { + jest.resetModules(); + mockPathModule = jest.requireActual<{}>('path')[platform]; + removeOverlappingRoots = require('../removeOverlappingRoots').default; + }); + + test('returns empty array for empty input', () => { + expect(removeOverlappingRoots([])).toEqual([]); + }); + + test('returns single root unchanged', () => { + expect(removeOverlappingRoots([p('/a/b')])).toEqual([p('/a/b')]); + }); + + test('sorts roots', () => { + expect(removeOverlappingRoots([p('/b'), p('/a')])).toEqual([ + p('/a'), + p('/b'), + ]); + }); + + test('removes exact duplicates', () => { + expect(removeOverlappingRoots([p('/a'), p('/b'), p('/a')])).toEqual([ + p('/a'), + p('/b'), + ]); + }); + + test('removes a subdirectory of another root', () => { + expect(removeOverlappingRoots([p('/a/b'), p('/a/b/c')])).toEqual([ + p('/a/b'), + ]); + }); + + test('removes deeply nested subdirectories', () => { + expect(removeOverlappingRoots([p('/a'), p('/a/b'), p('/a/b/c')])).toEqual( + [p('/a')], + ); + }); + + test('keeps sibling directories', () => { + expect(removeOverlappingRoots([p('/a/b'), p('/a/c')])).toEqual([ + p('/a/b'), + p('/a/c'), + ]); + }); + + test('does not treat a path-prefix as a parent (e.g. /a/b vs /a/b-foo)', () => { + expect(removeOverlappingRoots([p('/a/b'), p('/a/b-foo')])).toEqual([ + p('/a/b-foo'), + p('/a/b'), + ]); + }); + + test('filters subdirectories even when interleaved with non-children', () => { + expect( + removeOverlappingRoots([p('/a/b/c'), p('/a/b-foo'), p('/a/b')]), + ).toEqual([p('/a/b-foo'), p('/a/b')]); + }); + + test('shorter parent always sorts before longer child', () => { + expect( + removeOverlappingRoots([p('/a/long/nested/path'), p('/a')]), + ).toEqual([p('/a')]); + }); + + test('handles a mix of duplicates, subdirectories, and siblings', () => { + expect( + removeOverlappingRoots([ + p('/project/src'), + p('/project/lib'), + p('/project/src/utils'), + p('/project/src'), + p('/project/lib/internal'), + p('/other'), + ]), + ).toEqual([p('/other'), p('/project/lib'), p('/project/src')]); + }); + + test('resolves paths (normalizes trailing slashes and ..)', () => { + expect(removeOverlappingRoots([p('/a/b/'), p('/a/c/../d')])).toEqual([ + p('/a/b'), + p('/a/d'), + ]); + }); + + test('resolves paths before deduplicating', () => { + expect(removeOverlappingRoots([p('/a/b'), p('/a/b/')])).toEqual([ + p('/a/b'), + ]); + }); + }, +); diff --git a/packages/metro-file-map/src/lib/removeOverlappingRoots.js b/packages/metro-file-map/src/lib/removeOverlappingRoots.js new file mode 100644 index 0000000000..5a6e1b7576 --- /dev/null +++ b/packages/metro-file-map/src/lib/removeOverlappingRoots.js @@ -0,0 +1,35 @@ +/** + * 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 + */ + +import path from 'path'; + +export default function removeOverlappingRoots( + roots: ReadonlyArray, +): ReadonlyArray { + const sorted = roots + .map(r => path.resolve(r)) + .sort((a, b) => { + const aRoot = a + path.sep; + const bRoot = b + path.sep; + return aRoot < bRoot ? -1 : aRoot > bRoot ? 1 : 0; + }); + if (sorted.length === 0) { + return sorted; + } + const result = [sorted[0]]; + for (let i = 1; i < sorted.length; i++) { + const rootPath = sorted[i] + path.sep; + const prevPath = result[result.length - 1] + path.sep; + if (!rootPath.startsWith(prevPath)) { + result.push(sorted[i]); + } + } + return result; +} From b708850758065d7d86a84f0613185a782512ef2c Mon Sep 17 00:00:00 2001 From: Phil Pluckthun Date: Sat, 11 Apr 2026 13:44:04 +0100 Subject: [PATCH 6/8] Fix crawling above root directory --- .../metro-file-map/src/crawlers/node/index.js | 23 +++++++++++++++---- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/packages/metro-file-map/src/crawlers/node/index.js b/packages/metro-file-map/src/crawlers/node/index.js index 00a3ed5e9d..299787cc23 100644 --- a/packages/metro-file-map/src/crawlers/node/index.js +++ b/packages/metro-file-map/src/crawlers/node/index.js @@ -50,7 +50,11 @@ function find( {} as {[string]: ?true}, ); - function search(directory: string, dirNormal: string): void { + function search( + directory: string, + dirNormal: string, + isWithinRoot: boolean, + ): void { activeCalls++; fs.readdir(directory, {withFileTypes: true}, (err, entries) => { activeCalls--; @@ -68,10 +72,17 @@ function find( continue; } - const childNormal = - dirNormal === '' ? name : dirNormal + path.sep + name; + // Deriving a normal path above the root dir requires slicing off an up-fragment + // then checking if the target matches the next segment of the root dir. It's therefore + // easier to fall back to `pathUtils.absoluteToNormal` + const childNormal = !isWithinRoot + ? pathUtils.absoluteToNormal(file) + : dirNormal === '' + ? name + : dirNormal + path.sep + name; + if (entry.isDirectory()) { - search(file, childNormal); + search(file, childNormal, isWithinRoot || childNormal === ''); continue; } @@ -110,7 +121,9 @@ function find( if (roots.length > 0) { for (const root of roots) { - search(root, pathUtils.absoluteToNormal(root)); + const rootNormal = pathUtils.absoluteToNormal(root); + const isWithinRoot = !rootNormal.startsWith('..' + path.sep); + search(root, rootNormal, isWithinRoot); } } else { callback(result); From f85bea09561a00859ce91c91b483e8eabf38043d Mon Sep 17 00:00:00 2001 From: Phil Pluckthun Date: Sat, 11 Apr 2026 13:48:57 +0100 Subject: [PATCH 7/8] Check file type and extname before stating --- .../metro-file-map/src/crawlers/node/index.js | 29 ++++++++++--------- 1 file changed, 15 insertions(+), 14 deletions(-) diff --git a/packages/metro-file-map/src/crawlers/node/index.js b/packages/metro-file-map/src/crawlers/node/index.js index 299787cc23..7c5427403c 100644 --- a/packages/metro-file-map/src/crawlers/node/index.js +++ b/packages/metro-file-map/src/crawlers/node/index.js @@ -68,7 +68,8 @@ function find( const name = entry.name.toString(); const file = directory + path.sep + name; - if (ignore(file) || (!includeSymlinks && entry.isSymbolicLink())) { + const isSymbolicLink = entry.isSymbolicLink(); + if (ignore(file) || (!includeSymlinks && isSymbolicLink)) { continue; } @@ -86,24 +87,24 @@ function find( continue; } - activeCalls++; + const ext = path.extname(file).substr(1); + if (!isSymbolicLink && !exts[ext]) { + continue; + } + activeCalls++; fs.lstat(file, (err, stat) => { activeCalls--; if (!err && stat) { - const ext = path.extname(file).substr(1); - const isSymbolicLink = stat.isSymbolicLink(); - if (isSymbolicLink || exts[ext]) { - result.set(childNormal, [ - stat.mtime.getTime(), - stat.size, - 0, - null, - isSymbolicLink ? 1 : 0, - null, - ]); - } + result.set(childNormal, [ + stat.mtime.getTime(), + stat.size, + 0, + null, + isSymbolicLink ? 1 : 0, + null, + ]); } if (activeCalls === 0) { From c1cea5c6f957002f07758f74844c6d61129d956b Mon Sep 17 00:00:00 2001 From: Phil Pluckthun Date: Sat, 11 Apr 2026 13:49:38 +0100 Subject: [PATCH 8/8] Rebuild ts-defs --- .../types/lib/removeOverlappingRoots.d.ts | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 packages/metro-file-map/types/lib/removeOverlappingRoots.d.ts diff --git a/packages/metro-file-map/types/lib/removeOverlappingRoots.d.ts b/packages/metro-file-map/types/lib/removeOverlappingRoots.d.ts new file mode 100644 index 0000000000..014449a14e --- /dev/null +++ b/packages/metro-file-map/types/lib/removeOverlappingRoots.d.ts @@ -0,0 +1,20 @@ +/** + * 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 + * @generated SignedSource<> + * + * This file was translated from Flow by scripts/generateTypeScriptDefinitions.js + * Original file: packages/metro-file-map/src/lib/removeOverlappingRoots.js + * To regenerate, run: + * js1 build metro-ts-defs (internal) OR + * yarn run build-ts-defs (OSS) + */ + +declare function removeOverlappingRoots( + roots: ReadonlyArray, +): ReadonlyArray; +export default removeOverlappingRoots;