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/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/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/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 7b1691ae2c..ad8af6207f 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) { @@ -716,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), @@ -1213,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, 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..ce8c444284 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,16 @@ import H from '../../constants'; 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, + }, +})); + describe.each([['win32'], ['posix']])('TreeFS on %s', platform => { // Convenience function to write paths with posix separators but convert them // to system separators @@ -273,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([ @@ -380,6 +479,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 +1211,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 () => { @@ -1044,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)); @@ -1064,6 +1307,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', () => { 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,