diff --git a/packages/metro-file-map/package.json b/packages/metro-file-map/package.json index a9e31a1a15..76885be966 100644 --- a/packages/metro-file-map/package.json +++ b/packages/metro-file-map/package.json @@ -25,7 +25,6 @@ "graceful-fs": "^4.2.4", "invariant": "^2.2.4", "jest-worker": "^29.7.0", - "micromatch": "^4.0.4", "nullthrows": "^1.1.1" }, "devDependencies": { diff --git a/packages/metro-file-map/src/Watcher.js b/packages/metro-file-map/src/Watcher.js index 96e83f02f2..6893503ae6 100644 --- a/packages/metro-file-map/src/Watcher.js +++ b/packages/metro-file-map/src/Watcher.js @@ -221,15 +221,14 @@ export class Watcher extends EventEmitter { const createWatcherBackend = (root: Path): Promise => { const watcherOptions: WatcherBackendOptions = { - dot: true, - globs: [ + fileFilter: { + extensions: new Set(extensions), // Ensure we always include package.json files, which are crucial for - /// module resolution. - '**/package.json', + // module resolution. + fileNames: new Set(['package.json']), // Ensure we always watch any health check files - '**/' + this.#options.healthCheckFilePrefix + '*', - ...extensions.map(extension => '**/*.' + extension), - ], + fileNamePrefixes: [this.#options.healthCheckFilePrefix], + }, ignored: ignorePatternForWatch, watchmanDeferStates: this.#options.watchmanDeferStates, }; diff --git a/packages/metro-file-map/src/flow-types.js b/packages/metro-file-map/src/flow-types.js index bfb42e02da..28121b7f0d 100644 --- a/packages/metro-file-map/src/flow-types.js +++ b/packages/metro-file-map/src/flow-types.js @@ -418,8 +418,6 @@ export interface FileSystem { }): Iterable; } -export type Glob = string; - export type JsonData = | string | number @@ -617,11 +615,20 @@ export type WatcherBackendChangeEvent = export type WatcherBackendOptions = Readonly<{ ignored: ?RegExp, - globs: ReadonlyArray, - dot: boolean, + fileFilter: ?WatcherFileFilter, ... }>; +/** + * Regular files a watcher reports changes to: those matching any of the + * extensions (without a leading dot), basenames, or basename prefixes. + */ +export type WatcherFileFilter = Readonly<{ + extensions: ReadonlySet, + fileNames: ReadonlySet, + fileNamePrefixes: ReadonlyArray, +}>; + export type WatchmanClockSpec = string | Readonly<{scm: Readonly<{'mergebase-with': string}>}>; export type WatchmanClocks = Map; diff --git a/packages/metro-file-map/src/watchers/AbstractWatcher.js b/packages/metro-file-map/src/watchers/AbstractWatcher.js index 5566515a26..9360dcf0b2 100644 --- a/packages/metro-file-map/src/watchers/AbstractWatcher.js +++ b/packages/metro-file-map/src/watchers/AbstractWatcher.js @@ -12,6 +12,7 @@ import type { WatcherBackend, WatcherBackendChangeEvent, WatcherBackendOptions, + WatcherFileFilter, } from '../flow-types'; import {posixPathMatchesPattern} from './common'; @@ -26,17 +27,15 @@ export type Listeners = Readonly<{ export class AbstractWatcher implements WatcherBackend { readonly root: string; readonly ignored: ?RegExp; - readonly globs: ReadonlyArray; - readonly dot: boolean; + readonly fileFilter: ?WatcherFileFilter; readonly doIgnore: (path: string) => boolean; #emitter: EventEmitter = new EventEmitter(); constructor(dir: string, opts: WatcherBackendOptions) { - const {ignored, globs, dot} = opts; - this.dot = dot || false; + const {ignored, fileFilter} = opts; this.ignored = ignored; - this.globs = globs; + this.fileFilter = fileFilter; this.doIgnore = ignored ? (filePath: string) => posixPathMatchesPattern(ignored, filePath) : () => false; diff --git a/packages/metro-file-map/src/watchers/FallbackWatcher.js b/packages/metro-file-map/src/watchers/FallbackWatcher.js index 53956a83fa..8ade418653 100644 --- a/packages/metro-file-map/src/watchers/FallbackWatcher.js +++ b/packages/metro-file-map/src/watchers/FallbackWatcher.js @@ -107,7 +107,7 @@ export default class FallbackWatcher extends AbstractWatcher { if ( this.doIgnore(relativePath) || (type === 'f' && - !common.includedByGlob('f', this.globs, this.dot, relativePath)) + !common.includedByFilter('f', this.fileFilter, relativePath)) ) { return false; } @@ -298,7 +298,7 @@ export default class FallbackWatcher extends AbstractWatcher { if ( this.doIgnore(relativePath) || - !common.includedByGlob('d', this.globs, this.dot, relativePath) + !common.includedByFilter('d', this.fileFilter, relativePath) ) { return; } diff --git a/packages/metro-file-map/src/watchers/NativeWatcher.js b/packages/metro-file-map/src/watchers/NativeWatcher.js index 72dc3e8fc7..34dbb4b73a 100644 --- a/packages/metro-file-map/src/watchers/NativeWatcher.js +++ b/packages/metro-file-map/src/watchers/NativeWatcher.js @@ -8,11 +8,11 @@ * @format */ -import type {WatcherBackendChangeEvent} from '../flow-types'; +import type {WatcherBackendChangeEvent, WatcherFileFilter} from '../flow-types'; import type {FSWatcher} from 'node:fs'; import {AbstractWatcher} from './AbstractWatcher'; -import {includedByGlob, typeFromStat} from './common'; +import {includedByFilter, typeFromStat} from './common'; import debugModule from 'debug'; import {promises as fsPromises, watch} from 'node:fs'; import {platform} from 'node:os'; @@ -60,8 +60,7 @@ export default class NativeWatcher extends AbstractWatcher { dir: string, opts: Readonly<{ ignored: ?RegExp, - globs: ReadonlyArray, - dot: boolean, + fileFilter: ?WatcherFileFilter, ... }>, ) { @@ -154,7 +153,7 @@ export default class NativeWatcher extends AbstractWatcher { return null; } - if (!includedByGlob(type, this.globs, this.dot, relativePath)) { + if (!includedByFilter(type, this.fileFilter, relativePath)) { return null; } diff --git a/packages/metro-file-map/src/watchers/WatchmanWatcher.js b/packages/metro-file-map/src/watchers/WatchmanWatcher.js index 79d36edf60..705d7c9064 100644 --- a/packages/metro-file-map/src/watchers/WatchmanWatcher.js +++ b/packages/metro-file-map/src/watchers/WatchmanWatcher.js @@ -143,18 +143,6 @@ export default class WatchmanWatcher extends AbstractWatcher { relative_root: watchProjectInfo.relativePath, }; - // Make sure we honor the dot option if even we're not using globs. - if (self.globs.length === 0 && !self.dot) { - options.expression = [ - 'match', - '**', - 'wholename', - { - includedotfiles: false, - }, - ]; - } - self.#client.command( ['subscribe', getWatchRoot(), self.subscriptionName, options], onSubscribe, @@ -269,7 +257,7 @@ export default class WatchmanWatcher extends AbstractWatcher { if ( this.doIgnore(relativePath) || - !common.includedByGlob(type, this.globs, this.dot, relativePath) + !common.includedByFilter(type, this.fileFilter, relativePath) ) { return; } diff --git a/packages/metro-file-map/src/watchers/__tests__/FallbackWatcher-test.js b/packages/metro-file-map/src/watchers/__tests__/FallbackWatcher-test.js index 5139a5fbbf..b54e410546 100644 --- a/packages/metro-file-map/src/watchers/__tests__/FallbackWatcher-test.js +++ b/packages/metro-file-map/src/watchers/__tests__/FallbackWatcher-test.js @@ -85,8 +85,7 @@ describe('FallbackWatcher', () => { }); watcher = new FallbackWatcher(watchRoot, { - dot: true, - globs: [], + fileFilter: null, ignored: null, watchmanDeferStates: [], }); diff --git a/packages/metro-file-map/src/watchers/__tests__/NativeWatcher-test.js b/packages/metro-file-map/src/watchers/__tests__/NativeWatcher-test.js index f2ef843d12..9e24b30eef 100644 --- a/packages/metro-file-map/src/watchers/__tests__/NativeWatcher-test.js +++ b/packages/metro-file-map/src/watchers/__tests__/NativeWatcher-test.js @@ -78,7 +78,10 @@ describe('NativeWatcher', () => { return stat.promise; }); - watcher = new NativeWatcher(ROOT, {dot: true, globs: [], ignored: null}); + watcher = new NativeWatcher(ROOT, { + fileFilter: null, + ignored: null, + }); events = []; watcher.onFileEvent(event => { events.push(event); diff --git a/packages/metro-file-map/src/watchers/__tests__/WatchmanWatcher-test.js b/packages/metro-file-map/src/watchers/__tests__/WatchmanWatcher-test.js index d40c14ecab..9676205dad 100644 --- a/packages/metro-file-map/src/watchers/__tests__/WatchmanWatcher-test.js +++ b/packages/metro-file-map/src/watchers/__tests__/WatchmanWatcher-test.js @@ -49,9 +49,12 @@ jest.mock('fb-watchman', () => ({ describe('WatchmanWatcher', () => { test('initializes with watch-project, clock, subscribe', () => { const watchmanWatcher = new WatchmanWatcher(p('/project/subdir/js'), { - dot: true, ignored: null, - globs: ['**/*.js'], + fileFilter: { + extensions: new Set(['js']), + fileNames: new Set(), + fileNamePrefixes: [], + }, watchmanDeferStates: ['busy'], }); let isSettled = false; @@ -104,9 +107,12 @@ describe('WatchmanWatcher', () => { let watchmanWatcher: WatchmanWatcher; beforeEach(async () => { watchmanWatcher = new WatchmanWatcher(p('/project/subdir/js'), { - dot: true, ignored: null, - globs: ['**/*.js'], + fileFilter: { + extensions: new Set(['js']), + fileNames: new Set(), + fileNamePrefixes: [], + }, watchmanDeferStates: ['busy'], }); const startPromise = watchmanWatcher.startWatching(); @@ -160,9 +166,12 @@ describe('WatchmanWatcher', () => { beforeEach(async () => { watchmanWatcher = new WatchmanWatcher(p('/project/subdir/js'), { - dot: true, ignored: null, - globs: ['**/*.js'], + fileFilter: { + extensions: new Set(['js']), + fileNames: new Set(), + fileNamePrefixes: [], + }, watchmanDeferStates: ['busy'], }); startPromise = watchmanWatcher.startWatching(); diff --git a/packages/metro-file-map/src/watchers/__tests__/common-test.js b/packages/metro-file-map/src/watchers/__tests__/common-test.js new file mode 100644 index 0000000000..c0d2f97c88 --- /dev/null +++ b/packages/metro-file-map/src/watchers/__tests__/common-test.js @@ -0,0 +1,50 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict-local + * @format + * @oncall react_native + */ + +import type {WatcherFileFilter} from '../../flow-types'; + +import {includedByFilter} from '../common'; +import {join} from 'node:path'; + +describe('includedByFilter', () => { + const fileFilter: WatcherFileFilter = { + extensions: new Set(['js', 'json']), + fileNames: new Set(['package.json', 'BUCK']), + fileNamePrefixes: ['.metro-health-check'], + }; + + test.each([ + [join('src', 'index.js'), true], + [join('src', 'index.test.js'), true], + [join('src', 'data.json'), true], + ['BUCK', true], + [join('node_modules', 'foo', 'BUCK'), true], + [join('src', '.metro-health-check-abc123'), true], + [join('src', 'index.ts'), false], + [join('src', 'js'), false], + [join('src', 'BUCK.v2'), false], + [join('src', 'x-.metro-health-check'), false], + [join('.hidden', 'index.js'), true], + ])('regular file %s -> %s', (relativePath, expected) => { + expect(includedByFilter('f', fileFilter, relativePath)).toBe(expected); + }); + + test.each([['d'], ['l'], [null]])( + 'type %s is not checked against the filter', + type => { + expect(includedByFilter(type, fileFilter, 'foo.ts')).toBe(true); + }, + ); + + test('a null filter includes every regular file', () => { + expect(includedByFilter('f', null, join('src', 'foo.ts'))).toBe(true); + }); +}); diff --git a/packages/metro-file-map/src/watchers/__tests__/integration-test.js b/packages/metro-file-map/src/watchers/__tests__/integration-test.js index 2ca6794d3a..c89eefd755 100644 --- a/packages/metro-file-map/src/watchers/__tests__/integration-test.js +++ b/packages/metro-file-map/src/watchers/__tests__/integration-test.js @@ -69,8 +69,11 @@ describe.each(Object.keys(WATCHERS))( await new Promise(resolve => setTimeout(resolve, 100)); const opts: WatcherOptions = { - dot: true, - globs: ['**/package.json', '**/*.js', '**/cookie-*'], + fileFilter: { + extensions: new Set(['js']), + fileNames: new Set(['package.json']), + fileNamePrefixes: ['cookie-'], + }, // We need to ignore `.watchmanconfig` to keep these tests stable. // Even though we write it before initialising watchers, OS-level // delays/debouncing(?) can mean the write is *sometimes* reported by diff --git a/packages/metro-file-map/src/watchers/common.js b/packages/metro-file-map/src/watchers/common.js index 04826e7d5b..2b46475f31 100644 --- a/packages/metro-file-map/src/watchers/common.js +++ b/packages/metro-file-map/src/watchers/common.js @@ -14,11 +14,9 @@ * https://github.com/amasad/sane/blob/64ff3a870c42e84f744086884bf55a4f9c22d376/src/common.js */ -import type {ChangeEventMetadata} from '../flow-types'; +import type {ChangeEventMetadata, WatcherFileFilter} from '../flow-types'; import type {Stats} from 'node:fs'; -// $FlowFixMe[untyped-import] - Write libdefs for `micromatch` -import micromatch from 'micromatch'; import path from 'node:path'; /** @@ -30,8 +28,7 @@ export const RECRAWL_EVENT = 'recrawl'; export const ALL_EVENT = 'all'; export type WatcherOptions = Readonly<{ - globs: ReadonlyArray, - dot: boolean, + fileFilter: ?WatcherFileFilter, ignored: ?RegExp, watchmanDeferStates: ReadonlyArray, watchman?: unknown, @@ -39,20 +36,24 @@ export type WatcherOptions = Readonly<{ }>; /** - * Checks a file relative path against the globs array. + * Whether a watcher should report a change at the given relative path. Only + * regular files are checked against `fileFilter`, and a null filter includes + * every file. */ -export function includedByGlob( +export function includedByFilter( type: ?('f' | 'l' | 'd'), - globs: ReadonlyArray, - dot: boolean, + fileFilter: ?WatcherFileFilter, relativePath: string, ): boolean { - // For non-regular files or if there are no glob matchers, just respect the - // `dot` option to filter dotfiles if dot === false. - if (globs.length === 0 || type !== 'f') { - return dot || micromatch.some(relativePath, '**/*'); + if (fileFilter == null || type !== 'f') { + return true; } - return micromatch.some(relativePath, globs, {dot}); + const basename = path.basename(relativePath); + return ( + fileFilter.extensions.has(path.extname(basename).slice(1)) || + fileFilter.fileNames.has(basename) || + fileFilter.fileNamePrefixes.some(prefix => basename.startsWith(prefix)) + ); } /**