diff --git a/packages/metro-file-map/API.md b/packages/metro-file-map/API.md index 095cbd210f..0127987439 100644 --- a/packages/metro-file-map/API.md +++ b/packages/metro-file-map/API.md @@ -67,6 +67,7 @@ export type CrawlerFactoryOptions = Readonly<{ export type CrawlerOptions = { abortSignal?: null | undefined | AbortSignal; + basenames: ReadonlyArray; computeSha1: boolean; console: Console_2; extensions: ReadonlyArray; diff --git a/packages/metro-file-map/src/Watcher.js b/packages/metro-file-map/src/Watcher.js index d3a522c4b5..b7a595a25f 100644 --- a/packages/metro-file-map/src/Watcher.js +++ b/packages/metro-file-map/src/Watcher.js @@ -37,6 +37,10 @@ const debug = debugModule('Metro:Watcher'); const MAX_WAIT_TIME = 240000; +// Files crawled and watched regardless of `extensions`. package.json files are +// crucial for module resolution. +const INCLUDED_BASENAMES: ReadonlyArray = ['package.json']; + type InternalCrawlOptions = Readonly<{ previousState: CrawlerOptions['previousState'], roots: ReadonlyArray, @@ -148,6 +152,7 @@ export class Watcher extends EventEmitter { console: options.console, includeSymlinks: options.enableSymlinks, extensions: options.extensions, + basenames: INCLUDED_BASENAMES, ignore: ignoreForCrawl, onStatus: status => { this.emit('status', status); @@ -223,9 +228,7 @@ export class Watcher extends EventEmitter { const watcherOptions: WatcherBackendOptions = { included: { extensions: new Set(extensions), - // Ensure we always include package.json files, which are crucial for - // module resolution. - basenames: new Set(['package.json']), + basenames: new Set(INCLUDED_BASENAMES), // Ensure we always watch any health check files basenamePrefixes: [this.#options.healthCheckFilePrefix], }, diff --git a/packages/metro-file-map/src/__tests__/index-test.js b/packages/metro-file-map/src/__tests__/index-test.js index 9ac6da3611..4cfce3c91e 100644 --- a/packages/metro-file-map/src/__tests__/index-test.js +++ b/packages/metro-file-map/src/__tests__/index-test.js @@ -2018,38 +2018,6 @@ describe('FileMap', () => { }, ); - fm_it( - 'does not emit changes for regular files with unwatched extensions', - async ({fileMap}) => { - const {fileSystem} = await fileMap.build(); - mockFs[p('/project/fruits/Banana.unwatched')] = ''; - - const e = mockEmitters[p('/project/fruits')]; - e.emitFileEvent({ - event: 'touch', - relativePath: 'Banana.js', - metadata: MOCK_CHANGE_FILE, - }); - e.emitFileEvent({ - event: 'touch', - relativePath: 'Banana.unwatched', - metadata: MOCK_CHANGE_FILE, - }); - const {changes} = await waitForItToChange(fileMap); - const filePath = p('/project/fruits/Banana.js'); - expect(countFileChanges(changes)).toBe(1); - expectChanges(changes, { - modifiedFiles: [ - [ - path.join('fruits', 'Banana.js'), - {isSymlink: false, modifiedTime: 45}, - ], - ], - }); - expect(fileSystem.exists(filePath)).toBe(true); - }, - ); - fm_it( 'does not emit delete events for unknown files', async ({fileMap}) => { diff --git a/packages/metro-file-map/src/crawlers/__fixtures__/directory/package.json b/packages/metro-file-map/src/crawlers/__fixtures__/directory/package.json new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/packages/metro-file-map/src/crawlers/__fixtures__/directory/package.json @@ -0,0 +1 @@ +{} 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 16f8591185..64e65fec14 100644 --- a/packages/metro-file-map/src/crawlers/__tests__/integration-test.js +++ b/packages/metro-file-map/src/crawlers/__tests__/integration-test.js @@ -78,6 +78,7 @@ const CASES = [ new Map([ ['foo.js', [expect.any(Number), 245, 0, null, 0]], [join('directory', 'bar.js'), [expect.any(Number), 245, 0, null, 0]], + [join('directory', 'package.json'), [expect.any(Number), 3, 0, null, 0]], [ 'link-to-directory', [expect.any(Number), 9, 0, null, expect.oneOf(1, 'directory')], @@ -92,6 +93,7 @@ const CASES = [ false, new Map([ [join('directory', 'bar.js'), [expect.any(Number), 245, 0, null, 0]], + [join('directory', 'package.json'), [expect.any(Number), 3, 0, null, 0]], ['foo.js', [expect.any(Number), 245, 0, null, 0]], ]), ], @@ -121,6 +123,7 @@ describe.each(Object.keys(CRAWLERS))( }, includeSymlinks, extensions: ['js'], + basenames: ['package.json'], ignore: path => path.includes('ignored'), roots: [FIXTURES_DIR], rootDir: FIXTURES_DIR, diff --git a/packages/metro-file-map/src/crawlers/__tests__/watchman-test.js b/packages/metro-file-map/src/crawlers/__tests__/watchman-test.js index 1a3e99dff5..f116dcf563 100644 --- a/packages/metro-file-map/src/crawlers/__tests__/watchman-test.js +++ b/packages/metro-file-map/src/crawlers/__tests__/watchman-test.js @@ -135,6 +135,7 @@ describe('watchman watch', () => { fileSystem: getFS(new Map()), }, extensions: ['js', 'json'], + basenames: [], ignore: pearMatcher, rootDir: ROOT_MOCK, roots: ROOTS, @@ -210,6 +211,7 @@ describe('watchman watch', () => { fileSystem: getFS(mockFiles), }, extensions: ['js', 'json'], + basenames: [], ignore: pearMatcher, rootDir: ROOT_MOCK, roots: ROOTS, @@ -278,6 +280,7 @@ describe('watchman watch', () => { fileSystem: getFS(mockFiles), }, extensions: ['js', 'json'], + basenames: [], ignore: pearMatcher, rootDir: ROOT_MOCK, roots: ROOTS, @@ -358,6 +361,7 @@ describe('watchman watch', () => { fileSystem: getFS(mockFiles), }, extensions: ['js', 'json'], + basenames: [], ignore: pearMatcher, rootDir: ROOT_MOCK, roots: ROOTS, @@ -413,6 +417,7 @@ describe('watchman watch', () => { fileSystem: getFS(new Map()), }, extensions: ['js', 'json'], + basenames: [], ignore: pearMatcher, rootDir: ROOT_MOCK, roots: [...ROOTS, ROOT_MOCK], @@ -476,6 +481,7 @@ describe('watchman watch', () => { fileSystem: getFS(new Map()), }, extensions: ['js', 'json'], + basenames: [], rootDir: ROOT_MOCK, roots: [ROOT_MOCK], }); @@ -528,6 +534,7 @@ describe('watchman watch', () => { files: mockFiles, }, extensions: ['js', 'json'], + basenames: [], ignore: pearMatcher, rootDir: ROOT_MOCK, roots: ROOTS, @@ -559,6 +566,7 @@ describe('watchman watch', () => { files: new Map(), }, extensions: ['js', 'json'], + basenames: [], ignore: pearMatcher, rootDir: ROOT_MOCK, roots: ROOTS, @@ -592,6 +600,7 @@ describe('watchman watch', () => { files: new Map(), }, extensions: ['js', 'json'], + basenames: [], ignore: pearMatcher, rootDir: ROOT_MOCK, roots: ROOTS, diff --git a/packages/metro-file-map/src/crawlers/node/index.js b/packages/metro-file-map/src/crawlers/node/index.js index 1958af23d6..9964ccd322 100644 --- a/packages/metro-file-map/src/crawlers/node/index.js +++ b/packages/metro-file-map/src/crawlers/node/index.js @@ -16,11 +16,19 @@ import * as fs from 'graceful-fs'; import * as path from 'node:path'; function find(options: CrawlerOptions): Promise { - const {console, extensions, ignore, includeSymlinks, rootDir, roots} = - options; + const { + basenames, + console, + extensions, + ignore, + includeSymlinks, + rootDir, + roots, + } = options; const result: FileData = new Map(); const pathUtils = new RootPathUtils(rootDir); const exts = new Set(extensions); + const includedBasenames = new Set(basenames); return new Promise(resolve => { let activeCalls = 0; @@ -76,7 +84,11 @@ function find(options: CrawlerOptions): Promise { } const ext = path.extname(name).substr(1); - if (!isSymbolicLink && !exts.has(ext)) { + if ( + !isSymbolicLink && + !exts.has(ext) && + !includedBasenames.has(name) + ) { continue; } diff --git a/packages/metro-file-map/src/crawlers/watchman/__tests__/index-test.js b/packages/metro-file-map/src/crawlers/watchman/__tests__/index-test.js index 230e7f6863..8ad8e8724b 100644 --- a/packages/metro-file-map/src/crawlers/watchman/__tests__/index-test.js +++ b/packages/metro-file-map/src/crawlers/watchman/__tests__/index-test.js @@ -37,6 +37,7 @@ const watchmanPath: string => string = filePath => const DEFAULT_OPTIONS: CrawlerOptions = { abortSignal: null, + basenames: [], computeSha1: true, console: global.console, extensions: ['js'], diff --git a/packages/metro-file-map/src/crawlers/watchman/__tests__/planQuery-test.js b/packages/metro-file-map/src/crawlers/watchman/__tests__/planQuery-test.js index dd382c625f..7b1b1538f8 100644 --- a/packages/metro-file-map/src/crawlers/watchman/__tests__/planQuery-test.js +++ b/packages/metro-file-map/src/crawlers/watchman/__tests__/planQuery-test.js @@ -17,6 +17,7 @@ describe('planQuery with includeSymlinks: false', () => { since: 'clock', directoryFilters: ['/dir1', '/dir2'], extensions: ['js', 'ts'], + basenames: [], includeSha1: true, includeSymlinks: false, }); @@ -38,6 +39,7 @@ describe('planQuery with includeSymlinks: false', () => { since: 'clock', directoryFilters: [], extensions: ['js', 'ts'], + basenames: [], includeSha1: true, includeSymlinks: false, }); @@ -54,6 +56,7 @@ describe('planQuery with includeSymlinks: false', () => { since: null, directoryFilters: ['/dir1', '/dir2'], extensions: ['js', 'ts'], + basenames: [], includeSha1: true, includeSymlinks: false, }); @@ -71,6 +74,7 @@ describe('planQuery with includeSymlinks: false', () => { since: null, directoryFilters: [], extensions: ['js', 'ts'], + basenames: [], includeSha1: true, includeSymlinks: false, }); @@ -87,6 +91,7 @@ describe('planQuery with includeSymlinks: false', () => { since: null, directoryFilters: [], extensions: ['js', 'ts'], + basenames: [], includeSha1: false, includeSymlinks: false, }); @@ -104,6 +109,7 @@ test('does not request type if includeSymlinks == false', () => { since: null, directoryFilters: [], extensions: ['js', 'ts'], + basenames: [], includeSha1: false, includeSymlinks: false, }); @@ -121,6 +127,7 @@ describe('planQuery with includeSymlinks: true', () => { since: 'clock', directoryFilters: ['/dir1', '/dir2'], extensions: ['js', 'ts'], + basenames: [], includeSha1: true, includeSymlinks: true, }); @@ -145,6 +152,7 @@ describe('planQuery with includeSymlinks: true', () => { since: 'clock', directoryFilters: [], extensions: ['js', 'ts'], + basenames: [], includeSha1: true, includeSymlinks: true, }); @@ -165,6 +173,7 @@ describe('planQuery with includeSymlinks: true', () => { since: null, directoryFilters: ['/dir1', '/dir2'], extensions: ['js', 'ts'], + basenames: [], includeSha1: true, includeSymlinks: true, }); @@ -186,6 +195,7 @@ describe('planQuery with includeSymlinks: true', () => { since: null, directoryFilters: [], extensions: ['js', 'ts'], + basenames: [], includeSha1: true, includeSymlinks: true, }); @@ -205,6 +215,7 @@ describe('planQuery with includeSymlinks: true', () => { since: null, directoryFilters: [], extensions: ['js', 'ts'], + basenames: [], includeSha1: false, includeSymlinks: true, }); @@ -219,3 +230,84 @@ describe('planQuery with includeSymlinks: true', () => { }); }); }); + +describe('planQuery with basenames', () => { + test('does not change a query when extensions match every basename', () => { + const options = { + since: null, + directoryFilters: [], + extensions: ['js', 'json'], + includeSha1: false, + includeSymlinks: false, + }; + expect(planQuery({...options, basenames: ['package.json']})).toEqual( + planQuery({...options, basenames: []}), + ); + }); + + test('plans a "suffix" query that also generates basename suffixes', () => { + const {query, queryGenerator} = planQuery({ + since: null, + directoryFilters: [], + extensions: ['js', 'ts'], + basenames: ['package.json'], + includeSha1: false, + includeSymlinks: false, + }); + expect(queryGenerator).toBe('suffix'); + expect(query).toEqual({ + suffix: ['js', 'ts', 'json'], + expression: [ + 'allof', + ['type', 'f'], + ['anyof', ['suffix', ['js', 'ts']], ['name', ['package.json']]], + ], + fields: ['name', 'exists', 'mtime_ms', 'size'], + }); + }); + + test('plans an "all" query for a basename with no extension', () => { + const {query, queryGenerator} = planQuery({ + since: null, + directoryFilters: [], + extensions: ['js', 'ts'], + basenames: ['BUCK'], + includeSha1: false, + includeSymlinks: false, + }); + expect(queryGenerator).toBe('all'); + expect(query).toEqual({ + expression: [ + 'allof', + ['type', 'f'], + ['anyof', ['suffix', ['js', 'ts']], ['name', ['BUCK']]], + ], + fields: ['name', 'exists', 'mtime_ms', 'size'], + }); + }); + + test('includes basenames alongside symlinks', () => { + const {query, queryGenerator} = planQuery({ + since: 'clock', + directoryFilters: [], + extensions: ['js', 'ts'], + basenames: ['package.json'], + includeSha1: false, + includeSymlinks: true, + }); + expect(queryGenerator).toBe('since'); + expect(query).toEqual({ + since: 'clock', + expression: [ + 'anyof', + [ + 'allof', + ['type', 'f'], + ['anyof', ['suffix', ['js', 'ts']], ['name', ['package.json']]], + ], + ['type', 'l'], + ], + fields: ['name', 'exists', 'mtime_ms', 'size', 'type'], + }); + }); +}); diff --git a/packages/metro-file-map/src/crawlers/watchman/index.js b/packages/metro-file-map/src/crawlers/watchman/index.js index 751293c691..9ebac46334 100644 --- a/packages/metro-file-map/src/crawlers/watchman/index.js +++ b/packages/metro-file-map/src/crawlers/watchman/index.js @@ -48,6 +48,7 @@ function makeWatchmanError(error: Error): Error { export default async function watchmanCrawl({ abortSignal, + basenames, computeSha1, extensions, ignore, @@ -204,6 +205,7 @@ export default async function watchmanCrawl({ const {query, queryGenerator} = planQuery({ since, extensions, + basenames, directoryFilters, includeSha1: computeSha1, includeSymlinks, diff --git a/packages/metro-file-map/src/crawlers/watchman/planQuery.d.ts b/packages/metro-file-map/src/crawlers/watchman/planQuery.d.ts index 26bbb423fc..1b314ed9ea 100644 --- a/packages/metro-file-map/src/crawlers/watchman/planQuery.d.ts +++ b/packages/metro-file-map/src/crawlers/watchman/planQuery.d.ts @@ -15,6 +15,7 @@ export declare function planQuery( since: WatchmanQuerySince; directoryFilters: ReadonlyArray; extensions: ReadonlyArray; + basenames: ReadonlyArray; includeSha1: boolean; includeSymlinks: boolean; }>, diff --git a/packages/metro-file-map/src/crawlers/watchman/planQuery.js b/packages/metro-file-map/src/crawlers/watchman/planQuery.js index 8e9f94d7a2..2a658a7d51 100644 --- a/packages/metro-file-map/src/crawlers/watchman/planQuery.js +++ b/packages/metro-file-map/src/crawlers/watchman/planQuery.js @@ -15,16 +15,20 @@ import type { WatchmanQuerySince, } from 'fb-watchman'; +import * as path from 'node:path'; + export function planQuery({ since, directoryFilters, extensions, + basenames, includeSha1, includeSymlinks, }: Readonly<{ since: ?WatchmanQuerySince, directoryFilters: ReadonlyArray, extensions: ReadonlyArray, + basenames: ReadonlyArray, includeSha1: boolean, includeSymlinks: boolean, }>): { @@ -53,14 +57,20 @@ export function planQuery({ fields.push('type'); } + // Basenames that a suffix term on `extensions` doesn't already match. + const extraBasenames = basenames.filter( + basename => !extensions.includes(path.extname(basename).slice(1)), + ); + const extraSuffixes = [ + ...new Set(extraBasenames.map(basename => path.extname(basename).slice(1))), + ]; + const fileTerm: WatchmanExpression = + extraBasenames.length > 0 + ? ['anyof', ['suffix', extensions], ['name', extraBasenames]] + : ['suffix', extensions]; + const allOfTerms: Array = includeSymlinks - ? [ - [ - 'anyof', - ['allof', ['type', 'f'], ['suffix', extensions]], - ['type', 'l'], - ], - ] + ? [['anyof', ['allof', ['type', 'f'], fileTerm], ['type', 'l']]] : [['type', 'f']]; const query: WatchmanQuery = {fields}; @@ -109,10 +119,11 @@ export function planQuery({ query.glob = directoryFilters.map(directory => `${directory}/**`); query.glob_includedotfiles = true; queryGenerator = 'glob'; - } else if (!includeSymlinks) { - // Use the `suffix` generator with no path/extension filtering, as long - // as we don't need (suffixless) directory symlinks. - query.suffix = extensions; + } else if (!includeSymlinks && !extraSuffixes.includes('')) { + // Use the `suffix` generator, as long as we don't need suffixless + // directory symlinks or basenames. It also generates the suffixes of any + // extra basenames, which the file term then narrows to those basenames. + query.suffix = [...extensions, ...extraSuffixes]; queryGenerator = 'suffix'; } else { // Fall back to `all` if we need symlinks and don't have a clock or @@ -121,9 +132,13 @@ export function planQuery({ } // `includeSymlinks` implies we need (suffixless) directory links. In the - // case of the `suffix` generator, a suffix expression would be redundant. - if (!includeSymlinks && queryGenerator !== 'suffix') { - allOfTerms.push(['suffix', extensions]); + // case of the `suffix` generator with no extra basenames, the file term + // would be redundant. + if ( + !includeSymlinks && + (queryGenerator !== 'suffix' || extraBasenames.length > 0) + ) { + allOfTerms.push(fileTerm); } // If we only have one "all of" expression we can use it directly, otherwise diff --git a/packages/metro-file-map/src/flow-types.js b/packages/metro-file-map/src/flow-types.js index e0bab892e0..271baed70f 100644 --- a/packages/metro-file-map/src/flow-types.js +++ b/packages/metro-file-map/src/flow-types.js @@ -110,6 +110,10 @@ export type Console = typeof global.console; export type CrawlerOptions = { abortSignal?: ?AbortSignal, + /** + * Basenames of regular files to include whatever their extension. + */ + basenames: ReadonlyArray, computeSha1: boolean, console: Console, extensions: ReadonlyArray, diff --git a/packages/metro-file-map/src/index.js b/packages/metro-file-map/src/index.js index ce3de70d4d..84eee78c3d 100644 --- a/packages/metro-file-map/src/index.js +++ b/packages/metro-file-map/src/index.js @@ -797,9 +797,6 @@ export default class FileMap extends EventEmitter { return; } - const hasWatchedExtension = (filePath: string) => - this.#options.extensions.some(ext => filePath.endsWith(ext)); - let nextEmit: ?{ events: Array, firstEventTimestamp: number, @@ -892,9 +889,6 @@ export default class FileMap extends EventEmitter { change.metadata && // Ignore all directory events (change.metadata.type === 'd' || - // Ignore regular files with unwatched extensions - (change.metadata.type === 'f' && - !hasWatchedExtension(change.relativePath)) || // Don't emit events relating to symlinks if enableSymlinks: false (!this.#options.enableSymlinks && change.metadata?.type === 'l')) ) {