From 0fd5007b942bad3aa00fe0e984de23a052be6b70 Mon Sep 17 00:00:00 2001 From: Phil Pluckthun Date: Sat, 11 Apr 2026 00:12:35 +0100 Subject: [PATCH 1/5] Avoid stat when file is new/unaccessed --- .../metro-file-map/src/crawlers/node/index.js | 43 ++++++++++++------- packages/metro-file-map/src/flow-types.js | 1 + packages/metro-file-map/src/lib/TreeFS.js | 31 ++++++++++++- 3 files changed, 58 insertions(+), 17 deletions(-) diff --git a/packages/metro-file-map/src/crawlers/node/index.js b/packages/metro-file-map/src/crawlers/node/index.js index e7aaabe7a7..1f33ca0b3d 100644 --- a/packages/metro-file-map/src/crawlers/node/index.js +++ b/packages/metro-file-map/src/crawlers/node/index.js @@ -14,6 +14,7 @@ import type { CrawlerOptions, CrawlResult, FileData, + FileSystem, IgnoreMatcher, } from '../../flow-types'; @@ -36,6 +37,7 @@ function find( includeSymlinks: boolean, rootDir: string, console: Console, + previousFileSystem: FileSystem | null, callback: Callback, ): void { const result: FileData = new Map(); @@ -58,7 +60,8 @@ function find( return; } - if (entry.isSymbolicLink() && !includeSymlinks) { + const isSymlink = entry.isSymbolicLink(); + if (isSymlink && !includeSymlinks) { return; } @@ -67,29 +70,38 @@ function find( return; } - activeCalls++; - - fs.lstat(file, (err, stat) => { - activeCalls--; + const ext = path.extname(file).substr(1); + if (!isSymlink && !extensions.includes(ext)) { + return; + } - if (!err && stat) { - const ext = path.extname(file).substr(1); - if (stat.isSymbolicLink() || extensions.includes(ext)) { - result.set(pathUtils.absoluteToNormal(file), [ + const fileNormal = pathUtils.absoluteToNormal(file); + const mtime = previousFileSystem?.getMtimeByNormalPath(fileNormal); + if (mtime == null || mtime === 0) { + // When we're in a cold start or a previous file doesn't exist, we can skip + // the mtime/size lstat now and treat the file as new + result.set(fileNormal, [null, 0, 0, null, isSymlink ? 1 : 0, null]); + } else { + activeCalls++; + fs.lstat(file, (err, stat) => { + activeCalls--; + + if (!err && stat) { + result.set(fileNormal, [ stat.mtime.getTime(), stat.size, 0, null, - stat.isSymbolicLink() ? 1 : 0, + isSymlink ? 1 : 0, null, ]); } - } - if (activeCalls === 0) { - callback(result); - } - }); + if (activeCalls === 0) { + callback(result); + } + }); + } }); } @@ -232,6 +244,7 @@ export default async function nodeCrawl( includeSymlinks, rootDir, console, + previousState.fileSystem, callback, ); } diff --git a/packages/metro-file-map/src/flow-types.js b/packages/metro-file-map/src/flow-types.js index 44d05dea4d..70c8e1a1fb 100644 --- a/packages/metro-file-map/src/flow-types.js +++ b/packages/metro-file-map/src/flow-types.js @@ -298,6 +298,7 @@ export interface FileSystem { removedFiles: Set, }; getSerializableSnapshot(): CacheData['fileSystemData']; + getMtimeByNormalPath(file: Path): ?number; getSha1(file: Path): ?string; getOrComputeSha1(file: Path): Promise; diff --git a/packages/metro-file-map/src/lib/TreeFS.js b/packages/metro-file-map/src/lib/TreeFS.js index 7b1691ae2c..6f6f7d4d45 100644 --- a/packages/metro-file-map/src/lib/TreeFS.js +++ b/packages/metro-file-map/src/lib/TreeFS.js @@ -22,6 +22,7 @@ import type { import H from '../constants'; import {RootPathUtils} from './RootPathUtils'; +import fs from 'fs'; import invariant from 'invariant'; import path from 'path'; @@ -204,12 +205,17 @@ export default class TreeFS implements MutableFileSystem { } if ( newMetadata[H.MTIME] != null && - // TODO: Remove when mtime is null if not populated - newMetadata[H.MTIME] != 0 && + newMetadata[H.MTIME] !== 0 && newMetadata[H.MTIME] === metadata[H.MTIME] ) { // Types and modified time match - not changed. changedFiles.delete(canonicalPath); + } else if ( + (newMetadata[H.MTIME] == null || newMetadata[H.MTIME] === 0) && + (metadata[H.MTIME] == null || metadata[H.MTIME] === 0) + ) { + // If file is still untouched then mark it as unchanged + changedFiles.delete(canonicalPath); } else if ( newMetadata[H.SHA1] != null && newMetadata[H.SHA1] === metadata[H.SHA1] && @@ -230,6 +236,15 @@ export default class TreeFS implements MutableFileSystem { }; } + getMtimeByNormalPath(normalPath: Path): ?number { + const result = this.#lookupByNormalPath(normalPath, { + followLeaf: false, + }); + return result.exists && !isDirectory(result.node) + ? result.node[H.MTIME] + : null; + } + getSha1(mixedPath: Path): ?string { const fileMetadata = this.#getFileData(mixedPath); return (fileMetadata && fileMetadata[H.SHA1]) ?? null; @@ -247,6 +262,18 @@ export default class TreeFS implements MutableFileSystem { } const {canonicalPath, node: fileMetadata} = result; + // Populate mtime and size on demand + if (fileMetadata[H.MTIME] == null || fileMetadata[H.MTIME] === 0) { + fileMetadata[H.SHA1] = null; + const absolutePath = this.#pathUtils.normalToAbsolute(canonicalPath); + try { + const stat = await fs.promises.lstat(absolutePath); + const diskMtime = stat.mtime.getTime(); + fileMetadata[H.MTIME] = diskMtime; + fileMetadata[H.SIZE] = stat.size; + } catch {} + } + // Empty strings const existing = fileMetadata[H.SHA1]; if (existing != null && existing.length > 0) { From 5fae6ef3caca7edd08297d0655f45faf147c28a2 Mon Sep 17 00:00:00 2001 From: Phil Pluckthun Date: Sat, 11 Apr 2026 00:32:37 +0100 Subject: [PATCH 2/5] Add new test cases --- .../crawlers/__tests__/integration-test.js | 73 ++++--- .../src/crawlers/__tests__/node-test.js | 118 ++++++++++- .../src/lib/__tests__/TreeFS-test.js | 194 ++++++++++++++++++ 3 files changed, 344 insertions(+), 41 deletions(-) diff --git a/packages/metro-file-map/src/crawlers/__tests__/integration-test.js b/packages/metro-file-map/src/crawlers/__tests__/integration-test.js index 3f01c0beca..8607c9b1ab 100644 --- a/packages/metro-file-map/src/crawlers/__tests__/integration-test.js +++ b/packages/metro-file-map/src/crawlers/__tests__/integration-test.js @@ -80,44 +80,55 @@ function oneOf(this: $FlowFixMe, actual: unknown, ...expectOneOf: unknown[]) { * https://fburl.com/gdoc/y8dn025u */ expect.extend({oneOf}); -const CASES = [ - [ - true, - new Map([ - ['foo.js', [expect.any(Number), 245, 0, null, 0, null]], - [ - join('directory', 'bar.js'), - [expect.any(Number), 245, 0, null, 0, null], - ], - [ - 'link-to-directory', - [expect.any(Number), 9, 0, null, expect.oneOf(1, 'directory'), null], - ], - [ - 'link-to-foo.js', - [expect.any(Number), 6, 0, null, expect.oneOf(1, 'foo.js'), null], - ], - ]), - ], - [ - false, - new Map([ - [ - join('directory', 'bar.js'), - [expect.any(Number), 245, 0, null, 0, null], - ], - ['foo.js', [expect.any(Number), 245, 0, null, 0, null]], - ]), - ], -]; +function getCases(skipsStat: boolean) { + const fileEntry = skipsStat + ? [null, 0, 0, null, 0, null] + : [expect.any(Number), 245, 0, null, 0, null]; + return [ + [ + true, + new Map([ + ['foo.js', fileEntry], + [join('directory', 'bar.js'), fileEntry], + [ + 'link-to-directory', + skipsStat + ? [null, 0, 0, null, 1, null] + : [ + expect.any(Number), + 9, + 0, + null, + expect.oneOf(1, 'directory'), + null, + ], + ], + [ + 'link-to-foo.js', + skipsStat + ? [null, 0, 0, null, 1, null] + : [expect.any(Number), 6, 0, null, expect.oneOf(1, 'foo.js'), null], + ], + ]), + ], + [ + false, + new Map([ + [join('directory', 'bar.js'), fileEntry], + ['foo.js', fileEntry], + ]), + ], + ]; +} describe.each(Object.keys(CRAWLERS))( 'Crawler integration tests (%s)', crawlerName => { const crawl = CRAWLERS[crawlerName]; const maybeTest = crawl ? test : test.skip; + const skipsStat = crawlerName === 'node-recursive'; - maybeTest.each(CASES)( + maybeTest.each(getCases(skipsStat))( 'Finds the expected files (includeSymlinks: %s)', async (includeSymlinks, expectedChangedFiles) => { invariant(crawl, 'crawl should not be null within maybeTest'); diff --git a/packages/metro-file-map/src/crawlers/__tests__/node-test.js b/packages/metro-file-map/src/crawlers/__tests__/node-test.js index 479617b29b..7100c5f1c5 100644 --- a/packages/metro-file-map/src/crawlers/__tests__/node-test.js +++ b/packages/metro-file-map/src/crawlers/__tests__/node-test.js @@ -272,8 +272,8 @@ describe('node crawler', () => { ); expect(changedFiles).toEqual( createMap({ - 'fruits/directory/strawberry.js': [33, 42, 0, null, 0, null], - 'fruits/tomato.js': [32, 42, 0, null, 0, null], + 'fruits/directory/strawberry.js': [null, 0, 0, null, 0, null], + 'fruits/tomato.js': [null, 0, 0, null, 0, null], }), ); expect(removedFiles).toEqual(new Set()); @@ -297,8 +297,8 @@ describe('node crawler', () => { expect(changedFiles).toEqual( createMap({ - 'fruits/directory/strawberry.js': [33, 42, 0, null, 0, null], - 'fruits/tomato.js': [32, 42, 0, null, 0, null], + 'fruits/directory/strawberry.js': [null, 0, 0, null, 0, null], + 'fruits/tomato.js': [null, 0, 0, null, 0, null], }), ); expect(removedFiles).toEqual(new Set()); @@ -321,8 +321,8 @@ describe('node crawler', () => { expect(childProcess.spawn).toHaveBeenCalledTimes(0); expect(changedFiles).toEqual( createMap({ - 'fruits/directory/strawberry.js': [33, 42, 0, null, 0, null], - 'fruits/tomato.js': [32, 42, 0, null, 0, null], + 'fruits/directory/strawberry.js': [null, 0, 0, null, 0, null], + 'fruits/tomato.js': [null, 0, 0, null, 0, null], }), ); expect(removedFiles).toEqual(new Set()); @@ -386,17 +386,115 @@ describe('node crawler', () => { expect(changedFiles).toEqual( createMap({ - 'fruits/directory/strawberry.js': [33, 42, 0, null, 0, null], - 'fruits/tomato.js': [32, 42, 0, null, 0, null], + 'fruits/directory/strawberry.js': [null, 0, 0, null, 0, null], + 'fruits/tomato.js': [null, 0, 0, null, 0, null], }), ); expect(removedFiles).toEqual(new Set()); - // once for /project/fruits, once for /project/fruits/directory expect(fs.readdir).toHaveBeenCalledTimes(2); - // once for strawberry.js, once for tomato.js + expect(fs.lstat).toHaveBeenCalledTimes(0); + }); + + test('skips lstat for files with no prior mtime', async () => { + nodeCrawl = require('../node').default; + const fs = require('graceful-fs'); + + const files = createMap({ + 'fruits/tomato.js': [null, 0, 0, null, 0, null], + 'fruits/directory/strawberry.js': [null, 0, 0, null, 0, null], + }); + + const {changedFiles, removedFiles} = await nodeCrawl({ + console: global.console, + previousState: {fileSystem: getFS(files)}, + extensions: ['js'], + forceNodeFilesystemAPI: true, + ignore: pearMatcher, + rootDir, + roots: ['/project/fruits'], + }); + + expect(changedFiles).toEqual(new Map()); + expect(removedFiles).toEqual(new Set()); + expect(fs.lstat).toHaveBeenCalledTimes(0); + }); + + test('calls lstat only for files with existing mtime', async () => { + nodeCrawl = require('../node').default; + const fs = require('graceful-fs'); + + const files = createMap({ + 'fruits/tomato.js': [31, 42, 1, null, 0, null], + 'fruits/directory/strawberry.js': [null, 0, 0, null, 0, null], + }); + + const {changedFiles, removedFiles} = await nodeCrawl({ + console: global.console, + previousState: {fileSystem: getFS(files)}, + extensions: ['js'], + forceNodeFilesystemAPI: true, + ignore: pearMatcher, + rootDir, + roots: ['/project/fruits'], + }); + + expect(changedFiles).toEqual( + createMap({ + 'fruits/tomato.js': [32, 42, 0, null, 0, null], + }), + ); + expect(removedFiles).toEqual(new Set()); + expect(fs.lstat).toHaveBeenCalledTimes(1); + }); + + test('excludes unchanged files when lstat mtime matches cache', async () => { + nodeCrawl = require('../node').default; + const fs = require('graceful-fs'); + + const files = createMap({ + 'fruits/tomato.js': [32, 42, 1, null, 0, null], + 'fruits/directory/strawberry.js': [33, 42, 1, null, 0, null], + }); + + const {changedFiles, removedFiles} = await nodeCrawl({ + console: global.console, + previousState: {fileSystem: getFS(files)}, + extensions: ['js'], + forceNodeFilesystemAPI: true, + ignore: pearMatcher, + rootDir, + roots: ['/project/fruits'], + }); + + expect(changedFiles).toEqual(new Map()); + expect(removedFiles).toEqual(new Set()); expect(fs.lstat).toHaveBeenCalledTimes(2); }); + test('marks symlinks correctly when stat is skipped', async () => { + nodeCrawl = require('../node').default; + + const {changedFiles} = await nodeCrawl({ + console: global.console, + previousState: {fileSystem: emptyFS}, + extensions: ['js'], + forceNodeFilesystemAPI: true, + includeSymlinks: true, + ignore: pearMatcher, + rootDir, + roots: ['/project/fruits'], + }); + + expect(changedFiles.get(normalize('fruits/symlink'))).toEqual([ + null, + 0, + 0, + null, + 1, + null, + ]); + }); + test('aborts the crawl on pre-aborted signal', async () => { nodeCrawl = require('../node').default; const err = new Error('aborted for test'); diff --git a/packages/metro-file-map/src/lib/__tests__/TreeFS-test.js b/packages/metro-file-map/src/lib/__tests__/TreeFS-test.js index a65eab4293..c04818f15f 100644 --- a/packages/metro-file-map/src/lib/__tests__/TreeFS-test.js +++ b/packages/metro-file-map/src/lib/__tests__/TreeFS-test.js @@ -22,6 +22,14 @@ import H from '../../constants'; let mockPathModule; jest.mock('path', () => mockPathModule); +const mockLstat = jest.fn(); +jest.mock('fs', () => ({ + ...jest.requireActual<{}>('fs'), + promises: { + lstat: mockLstat, + }, +})); + describe.each([['win32'], ['posix']])('TreeFS on %s', platform => { // Convenience function to write paths with posix separators but convert them // to system separators @@ -380,6 +388,126 @@ describe.each([['win32'], ['posix']])('TreeFS on %s', platform => { expect(withEmpty).toEqual(withUndefined); }); + + test('treats files as unchanged when both old and new mtime are null', () => { + const nullMtimeTfs = new TreeFS({ + rootDir: p('/project'), + files: new Map([ + [p('a.js'), [null, 0, 0, null, 0, null]], + ]), + processFile: () => { + throw new Error('Not implemented'); + }, + }); + + const newFiles: FileData = new Map([ + [p('a.js'), [null, 0, 0, null, 0, null]], + ]); + + expect(nullMtimeTfs.getDifference(newFiles)).toEqual({ + changedFiles: new Map(), + removedFiles: new Set(), + }); + }); + + test('treats files as unchanged when both old and new mtime are 0', () => { + const zeroMtimeTfs = new TreeFS({ + rootDir: p('/project'), + files: new Map([ + [p('a.js'), [0, 0, 0, null, 0, null]], + ]), + processFile: () => { + throw new Error('Not implemented'); + }, + }); + + const newFiles: FileData = new Map([ + [p('a.js'), [0, 0, 0, null, 0, null]], + ]); + + expect(zeroMtimeTfs.getDifference(newFiles)).toEqual({ + changedFiles: new Map(), + removedFiles: new Set(), + }); + }); + + test('treats file as changed when old has mtime but new does not', () => { + const newFiles: FileData = new Map([ + [p('bar.js'), [null, 0, 0, null, 0, null]], + ]); + + const result = tfs.getDifference(newFiles); + expect(result.changedFiles.has(p('bar.js'))).toBe(true); + }); + + test('treats file as changed when new has mtime but old does not', () => { + const nullMtimeTfs = new TreeFS({ + rootDir: p('/project'), + files: new Map([ + [p('a.js'), [null, 0, 0, null, 0, null]], + ]), + processFile: () => { + throw new Error('Not implemented'); + }, + }); + + const newFiles: FileData = new Map([ + [p('a.js'), [500, 10, 0, null, 0, null]], + ]); + + expect(nullMtimeTfs.getDifference(newFiles)).toEqual({ + changedFiles: new Map([ + [p('a.js'), [500, 10, 0, null, 0, null]], + ]), + removedFiles: new Set(), + }); + }); + + test('detects type change even when both mtimes are null', () => { + const symlinkTfs = new TreeFS({ + rootDir: p('/project'), + files: new Map([ + [p('a.js'), [null, 0, 0, null, p('./b.js'), null]], + ]), + processFile: () => { + throw new Error('Not implemented'); + }, + }); + + const newFiles: FileData = new Map([ + [p('a.js'), [null, 0, 0, null, 0, null]], + ]); + + const result = symlinkTfs.getDifference(newFiles); + expect(result.changedFiles.has(p('a.js'))).toBe(true); + }); + }); + + describe('getMtimeByNormalPath', () => { + test('returns mtime for an existing file', () => { + expect(tfs.getMtimeByNormalPath(p('bar.js'))).toBe(234); + }); + + test('returns null for a non-existent file', () => { + expect(tfs.getMtimeByNormalPath(p('nonexistent.js'))).toBeNull(); + }); + + test('returns null for a directory', () => { + expect(tfs.getMtimeByNormalPath(p('foo'))).toBeNull(); + }); + + test('returns null for a file with null mtime', () => { + const nullMtimeTfs = new TreeFS({ + rootDir: p('/project'), + files: new Map([ + [p('a.js'), [null, 0, 0, null, 0, null]], + ]), + processFile: () => { + throw new Error('Not implemented'); + }, + }); + expect(nullMtimeTfs.getMtimeByNormalPath(p('a.js'))).toBeNull(); + }); }); describe('hierarchicalLookup', () => { @@ -992,6 +1120,7 @@ describe.each([['win32'], ['posix']])('TreeFS on %s', platform => { return; }); mockProcessFile.mockClear(); + mockLstat.mockClear(); }); test('returns the precomputed SHA-1 of a file if set', async () => { @@ -1064,6 +1193,71 @@ describe.each([['win32'], ['posix']])('TreeFS on %s', platform => { expect(await tfs.getOrComputeSha1(p('bar.js'))).toEqual({sha1: 'abc123'}); expect(mockProcessFile).toHaveBeenCalledTimes(2); }); + + test('lazily stats file and clears SHA1 when mtime is null', async () => { + tfs = new TreeFS({ + rootDir: p('/project'), + files: new Map([ + [p('unstated.js'), [null, 0, 0, 'stale', 0, null]], + ]), + processFile: mockProcessFile, + }); + + mockLstat.mockResolvedValueOnce({ + mtime: {getTime: () => 999}, + size: 50, + }); + + await tfs.getOrComputeSha1(p('unstated.js')); + + expect(mockLstat).toHaveBeenCalledTimes(1); + expect(mockProcessFile).toHaveBeenCalledTimes(1); + expect(tfs.getMtimeByNormalPath(p('unstated.js'))).toBe(999); + }); + + test('lazily stats file when mtime is 0', async () => { + tfs = new TreeFS({ + rootDir: p('/project'), + files: new Map([ + [p('zero.js'), [0, 0, 0, null, 0, null]], + ]), + processFile: mockProcessFile, + }); + + mockLstat.mockResolvedValueOnce({ + mtime: {getTime: () => 888}, + size: 30, + }); + + await tfs.getOrComputeSha1(p('zero.js')); + + expect(mockLstat).toHaveBeenCalledTimes(1); + expect(mockProcessFile).toHaveBeenCalledTimes(1); + expect(tfs.getMtimeByNormalPath(p('zero.js'))).toBe(888); + }); + + test('does not stat file when mtime is already populated', async () => { + mockLstat.mockClear(); + await tfs.getOrComputeSha1(p('bar.js')); + + expect(mockLstat).not.toHaveBeenCalled(); + }); + + test('handles lstat failure gracefully when mtime is null', async () => { + tfs = new TreeFS({ + rootDir: p('/project'), + files: new Map([ + [p('missing.js'), [null, 0, 0, null, 0, null]], + ]), + processFile: mockProcessFile, + }); + + mockLstat.mockRejectedValueOnce(new Error('ENOENT')); + + const result = await tfs.getOrComputeSha1(p('missing.js')); + expect(result).toEqual({sha1: 'abc123'}); + expect(mockProcessFile).toHaveBeenCalledTimes(1); + }); }); describe('change listener', () => { From 34e30b495b422f2134a57a0cf2ef5bb971c73a7c Mon Sep 17 00:00:00 2001 From: Phil Pluckthun Date: Sat, 11 Apr 2026 00:39:50 +0100 Subject: [PATCH 3/5] Lazily populate cold startup symlinks This is needed because we won't mark unvisited files as changed anymore. This causes them to not be read or processed. We could exclude symlinks from this, but the lazy symlinking makes sense in conjunction with the lazy lstat change. This is a trade-off, we skip the readlinks on startup that are async, and instead run them synchronously. However, they're skipped if the symlink is never accessed, which in workspaces with isolated dependency installations can be beneficial. In most cases, this is unlikely to impact performance as much as eagerly populating symlinks, as the previous code seems to mostly assume a low number of symlinks anyway (which isn't true for isolated installations) --- packages/metro-file-map/src/index.js | 4 ++- packages/metro-file-map/src/lib/TreeFS.js | 36 +++++++++++++++++++---- 2 files changed, 33 insertions(+), 7 deletions(-) diff --git a/packages/metro-file-map/src/index.js b/packages/metro-file-map/src/index.js index 113f85be1d..6e6744733d 100644 --- a/packages/metro-file-map/src/index.js +++ b/packages/metro-file-map/src/index.js @@ -608,7 +608,9 @@ export default class FileMap extends EventEmitter { if (fileData[H.SYMLINK] === 0) { filesToProcess.push([normalFilePath, fileData]); - } else { + } else if (fileData[H.MTIME] != null && fileData[H.MTIME] !== 0) { + // The symlink will only be updated, if it's been accessed before + // If this is a newly crawled entry, it's skipped const maybeReadLink = this.#maybeReadLink(normalFilePath, fileData); if (maybeReadLink) { readLinkPromises.push( diff --git a/packages/metro-file-map/src/lib/TreeFS.js b/packages/metro-file-map/src/lib/TreeFS.js index 6f6f7d4d45..ad8af6207f 100644 --- a/packages/metro-file-map/src/lib/TreeFS.js +++ b/packages/metro-file-map/src/lib/TreeFS.js @@ -743,6 +743,13 @@ export default class TreeFS implements MutableFileSystem { segmentNode, currentPath, ); + if (normalSymlinkTarget == null) { + return { + canonicalMissingPath: currentPath, + exists: false, + missingSegmentName: segmentName, + }; + } if (opts.collectLinkPaths) { opts.collectLinkPaths.add( this.#pathUtils.normalToAbsolute(currentPath), @@ -1240,17 +1247,34 @@ export default class TreeFS implements MutableFileSystem { #resolveSymlinkTargetToNormalPath( symlinkNode: FileMetadata, canonicalPathOfSymlink: Path, - ): NormalizedSymlinkTarget { + ): NormalizedSymlinkTarget | null { const cachedResult = this.#cachedNormalSymlinkTargets.get(symlinkNode); if (cachedResult != null) { return cachedResult; } - const literalSymlinkTarget = symlinkNode[H.SYMLINK]; - invariant( - typeof literalSymlinkTarget === 'string', - 'Expected symlink target to be populated.', - ); + let literalSymlinkTarget: string; + if (symlinkNode[H.SYMLINK] === 1) { + // Symlink target not yet resolved — read it lazily on first traversal + const absoluteSymlink = this.#pathUtils.normalToAbsolute( + canonicalPathOfSymlink, + ); + try { + literalSymlinkTarget = fs.readlinkSync(absoluteSymlink); + symlinkNode[H.SYMLINK] = literalSymlinkTarget; + symlinkNode[H.VISITED] = 1; + } catch { + return null; + } + } else if (symlinkNode[H.SYMLINK] === 0 || symlinkNode[H.SYMLINK] == null) { + // WARN: We shouldn't call this method on non-symlinks. Outside of tests + // this condition shouldn't trigger. It's fine not to resolve a symlink if + // it does trigger however + return null; + } else { + literalSymlinkTarget = symlinkNode[H.SYMLINK]; + } + const absoluteSymlinkTarget = path.resolve( this.#rootDir, canonicalPathOfSymlink, From 6d1b062a5d737251b7b79a7b4561adccee85f188 Mon Sep 17 00:00:00 2001 From: Phil Pluckthun Date: Sat, 11 Apr 2026 00:51:39 +0100 Subject: [PATCH 4/5] Add new test cases #2 --- .../src/__tests__/index-test.js | 57 +++++++++ .../src/lib/__tests__/TreeFS-test.js | 114 ++++++++++++++++++ 2 files changed, 171 insertions(+) diff --git a/packages/metro-file-map/src/__tests__/index-test.js b/packages/metro-file-map/src/__tests__/index-test.js index 48da613f06..d5f09bd389 100644 --- a/packages/metro-file-map/src/__tests__/index-test.js +++ b/packages/metro-file-map/src/__tests__/index-test.js @@ -176,6 +176,19 @@ jest.mock('fs', () => ({ error.code = 'ENOENT'; throw error; }), + readlinkSync: jest.fn(path => { + const entry = mockFs[path]; + if (!entry) { + const error = new Error(`Cannot read path '${path}'.`); + // $FlowFixMe[prop-missing] code + error.code = 'ENOENT'; + throw error; + } + if (typeof entry.link !== 'string') { + throw new Error(`Not a symlink: '${path}'.`); + } + return entry.link; + }), writeFileSync: jest.fn((path, data, options) => { expect(options).toBe(require('v8').serialize ? undefined : 'utf8'); mockFs[path] = data; @@ -749,6 +762,50 @@ describe('FileMap', () => { ); }); + test('defers symlink resolution for entries with null mtime', async () => { + const node = require('../crawlers/node').default; + const fsModule = require('fs'); + + // $FlowFixMe[prop-missing] + // $FlowFixMe[missing-local-annot] + node.mockImplementation(options => { + const changedFiles = createMap({ + [path.join('fruits', 'Strawberry.js')]: [32, 42, 0, null, 0, null], + [path.join('fruits', 'LinkToStrawberry.js')]: [ + null, + 0, + 0, + null, + 1, + null, + ], + }); + return Promise.resolve({changedFiles, removedFiles: new Set()}); + }); + + const {fileSystem} = await buildNewFileMap({ + enableSymlinks: true, + useWatchman: false, + }); + + expect(fsModule.promises.readlink).not.toHaveBeenCalledWith( + expect.stringContaining('LinkToStrawberry'), + ); + + expect( + fileSystem.lookup( + path.join('/', 'project', 'fruits', 'LinkToStrawberry.js'), + ), + ).toMatchObject({ + exists: true, + realPath: path.join('/', 'project', 'fruits', 'Strawberry.js'), + }); + + expect(fsModule.readlinkSync).toHaveBeenCalledWith( + path.join('/', 'project', 'fruits', 'LinkToStrawberry.js'), + ); + }); + test('handles a Haste module moving between builds', async () => { mockFs = object({ [path.join('/', 'project', 'vegetables', 'Melon.js')]: ` diff --git a/packages/metro-file-map/src/lib/__tests__/TreeFS-test.js b/packages/metro-file-map/src/lib/__tests__/TreeFS-test.js index c04818f15f..ce8c444284 100644 --- a/packages/metro-file-map/src/lib/__tests__/TreeFS-test.js +++ b/packages/metro-file-map/src/lib/__tests__/TreeFS-test.js @@ -23,8 +23,10 @@ let mockPathModule; jest.mock('path', () => mockPathModule); const mockLstat = jest.fn(); +const mockReadlinkSync = jest.fn(); jest.mock('fs', () => ({ ...jest.requireActual<{}>('fs'), + readlinkSync: mockReadlinkSync, promises: { lstat: mockLstat, }, @@ -281,6 +283,95 @@ describe.each([['win32'], ['posix']])('TreeFS on %s', platform => { }); }); + describe('lazy symlink resolution', () => { + let lazyTfs: TreeFSType; + + beforeEach(() => { + mockReadlinkSync.mockReset(); + lazyTfs = new TreeFS({ + rootDir: p('/project'), + files: new Map([ + [p('target.js'), [123, 10, 0, null, 0, null]], + [p('unresolved-link'), [0, 0, 0, null, 1, null]], + [p('dir/nested.js'), [123, 10, 0, null, 0, null]], + [p('unresolved-dir-link'), [0, 0, 0, null, 1, null]], + ]), + processFile: () => { + throw new Error('Not implemented'); + }, + }); + }); + + test('resolves unresolved symlink via readlinkSync on lookup', () => { + mockReadlinkSync.mockReturnValue(p('./target.js')); + + expect(lazyTfs.lookup(p('/project/unresolved-link'))).toMatchObject({ + exists: true, + realPath: p('/project/target.js'), + type: 'f', + }); + expect(mockReadlinkSync).toHaveBeenCalledTimes(1); + expect(mockReadlinkSync).toHaveBeenCalledWith( + p('/project/unresolved-link'), + ); + }); + + test('resolves unresolved symlink to a directory', () => { + mockReadlinkSync.mockReturnValue(p('./dir')); + + expect( + lazyTfs.lookup(p('/project/unresolved-dir-link/nested.js')), + ).toMatchObject({ + exists: true, + realPath: p('/project/dir/nested.js'), + type: 'f', + }); + }); + + test('updates metadata after lazy resolution', () => { + mockReadlinkSync.mockReturnValue(p('./target.js')); + + lazyTfs.lookup(p('/project/unresolved-link')); + + const metadata = [ + ...lazyTfs.metadataIterator({ + includeSymlinks: true, + includeNodeModules: true, + }), + ].find(entry => entry.canonicalPath === p('unresolved-link')); + + expect(metadata?.metadata[H.SYMLINK]).toBe(p('./target.js')); + expect(metadata?.metadata[H.VISITED]).toBe(1); + }); + + test('caches resolved symlink and does not re-read', () => { + mockReadlinkSync.mockReturnValue(p('./target.js')); + + lazyTfs.lookup(p('/project/unresolved-link')); + lazyTfs.lookup(p('/project/unresolved-link')); + + expect(mockReadlinkSync).toHaveBeenCalledTimes(1); + }); + + test('returns exists:false for broken unresolved symlink', () => { + mockReadlinkSync.mockImplementation(() => { + throw new Error('ENOENT'); + }); + + expect(lazyTfs.lookup(p('/project/unresolved-link'))).toMatchObject({ + exists: false, + }); + }); + + test('does not call readlinkSync for already-resolved symlinks', () => { + expect(tfs.lookup(p('/project/foo/link-to-bar.js'))).toMatchObject({ + exists: true, + realPath: p('/project/bar.js'), + }); + expect(mockReadlinkSync).not.toHaveBeenCalled(); + }); + }); + describe('getDifference', () => { test('returns changed (inc. new) and removed files in given FileData', () => { const newFiles: FileData = new Map([ @@ -1173,6 +1264,29 @@ describe.each([['win32'], ['posix']])('TreeFS on %s', platform => { ); }); + test('lazily resolves unresolved symlink and computes SHA1', async () => { + tfs = new TreeFS({ + rootDir: p('/project'), + files: new Map([ + [p('target.js'), [123, 10, 0, null, 0, null]], + [p('lazy-link'), [0, 0, 0, null, 1, null]], + ]), + processFile: mockProcessFile, + }); + + mockReadlinkSync.mockReturnValue(p('./target.js')); + + expect(await tfs.getOrComputeSha1(p('lazy-link'))).toEqual({ + sha1: 'abc123', + }); + expect(mockReadlinkSync).toHaveBeenCalledTimes(1); + expect(mockProcessFile).toHaveBeenCalledWith( + p('target.js'), + expect.any(Array), + {computeSha1: true}, + ); + }); + test('clears stored SHA-1 on modification', async () => { let resolve: (sha1: string) => void; const processPromise = new Promise(r => (resolve = r)); From 3c2d74c8a2382caf8f4e4379da9e16de893eeb77 Mon Sep 17 00:00:00 2001 From: Phil Pluckthun Date: Sat, 11 Apr 2026 01:01:39 +0100 Subject: [PATCH 5/5] Rebuild ts-defs --- packages/metro-file-map/types/flow-types.d.ts | 3 ++- packages/metro-file-map/types/lib/TreeFS.d.ts | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/metro-file-map/types/flow-types.d.ts b/packages/metro-file-map/types/flow-types.d.ts index 16ca47efd9..9b5dc2ffc6 100644 --- a/packages/metro-file-map/types/flow-types.d.ts +++ b/packages/metro-file-map/types/flow-types.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/flow-types.js @@ -258,6 +258,7 @@ export interface FileSystem { }>, ): {changedFiles: FileData; removedFiles: Set}; getSerializableSnapshot(): CacheData['fileSystemData']; + getMtimeByNormalPath(file: Path): null | undefined | number; getSha1(file: Path): null | undefined | string; getOrComputeSha1( file: Path, diff --git a/packages/metro-file-map/types/lib/TreeFS.d.ts b/packages/metro-file-map/types/lib/TreeFS.d.ts index bf1293ee38..f6870e07d6 100644 --- a/packages/metro-file-map/types/lib/TreeFS.d.ts +++ b/packages/metro-file-map/types/lib/TreeFS.d.ts @@ -5,7 +5,7 @@ * LICENSE file in the root directory of this source tree. * * @noformat - * @generated SignedSource<<65a3c4140d459a56b8c949e52b32ea1b>> + * @generated SignedSource<<6d6884954a365012937ecfef9e179037>> * * This file was translated from Flow by scripts/generateTypeScriptDefinitions.js * Original file: packages/metro-file-map/src/lib/TreeFS.js @@ -109,6 +109,7 @@ declare class TreeFS implements MutableFileSystem { files: FileData, options?: Readonly<{subpath?: string}>, ): {changedFiles: FileData; removedFiles: Set}; + getMtimeByNormalPath(normalPath: Path): null | undefined | number; getSha1(mixedPath: Path): null | undefined | string; getOrComputeSha1( mixedPath: Path,