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..d3a522c4b5 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: [ + included: { + extensions: new Set(extensions), // Ensure we always include package.json files, which are crucial for - /// module resolution. - '**/package.json', + // module resolution. + basenames: new Set(['package.json']), // Ensure we always watch any health check files - '**/' + this.#options.healthCheckFilePrefix + '*', - ...extensions.map(extension => '**/*.' + extension), - ], + basenamePrefixes: [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..e0bab892e0 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, + included: ?WatcherIncludedFiles, ... }>; +/** + * Regular files a watcher reports changes to: those matching any of the + * extensions (without a leading dot), basenames, or basename prefixes. + */ +export type WatcherIncludedFiles = Readonly<{ + extensions: ReadonlySet, + basenames: ReadonlySet, + basenamePrefixes: 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..26fce73d8b 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, + WatcherIncludedFiles, } 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 included: ?WatcherIncludedFiles; 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, included} = opts; this.ignored = ignored; - this.globs = globs; + this.included = included; 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..cff41b844a 100644 --- a/packages/metro-file-map/src/watchers/FallbackWatcher.js +++ b/packages/metro-file-map/src/watchers/FallbackWatcher.js @@ -106,8 +106,7 @@ export default class FallbackWatcher extends AbstractWatcher { const relativePath = path.relative(this.root, filepath); if ( this.doIgnore(relativePath) || - (type === 'f' && - !common.includedByGlob('f', this.globs, this.dot, relativePath)) + (type === 'f' && !common.isIncluded('f', this.included, relativePath)) ) { return false; } @@ -298,7 +297,7 @@ export default class FallbackWatcher extends AbstractWatcher { if ( this.doIgnore(relativePath) || - !common.includedByGlob('d', this.globs, this.dot, relativePath) + !common.isIncluded('d', this.included, relativePath) ) { return; } diff --git a/packages/metro-file-map/src/watchers/NativeWatcher.js b/packages/metro-file-map/src/watchers/NativeWatcher.js index 72dc3e8fc7..f2203794ab 100644 --- a/packages/metro-file-map/src/watchers/NativeWatcher.js +++ b/packages/metro-file-map/src/watchers/NativeWatcher.js @@ -8,11 +8,14 @@ * @format */ -import type {WatcherBackendChangeEvent} from '../flow-types'; +import type { + WatcherBackendChangeEvent, + WatcherIncludedFiles, +} from '../flow-types'; import type {FSWatcher} from 'node:fs'; import {AbstractWatcher} from './AbstractWatcher'; -import {includedByGlob, typeFromStat} from './common'; +import {isIncluded, typeFromStat} from './common'; import debugModule from 'debug'; import {promises as fsPromises, watch} from 'node:fs'; import {platform} from 'node:os'; @@ -60,8 +63,7 @@ export default class NativeWatcher extends AbstractWatcher { dir: string, opts: Readonly<{ ignored: ?RegExp, - globs: ReadonlyArray, - dot: boolean, + included: ?WatcherIncludedFiles, ... }>, ) { @@ -154,7 +156,7 @@ export default class NativeWatcher extends AbstractWatcher { return null; } - if (!includedByGlob(type, this.globs, this.dot, relativePath)) { + if (!isIncluded(type, this.included, 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..c01914309e 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.isIncluded(type, this.included, 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..06b83423e2 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: [], + included: 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..b4e936c9d1 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, { + included: 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..40e7fb1540 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'], + included: { + extensions: new Set(['js']), + basenames: new Set(), + basenamePrefixes: [], + }, 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'], + included: { + extensions: new Set(['js']), + basenames: new Set(), + basenamePrefixes: [], + }, 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'], + included: { + extensions: new Set(['js']), + basenames: new Set(), + basenamePrefixes: [], + }, 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..fb3d8c4ee4 --- /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 {WatcherIncludedFiles} from '../../flow-types'; + +import {isIncluded} from '../common'; +import {join} from 'node:path'; + +describe('isIncluded', () => { + const included: WatcherIncludedFiles = { + extensions: new Set(['js', 'json']), + basenames: new Set(['package.json', 'BUCK']), + basenamePrefixes: ['.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(isIncluded('f', included, relativePath)).toBe(expected); + }); + + test.each([['d'], ['l'], [null]])( + 'type %s is not checked against included files', + type => { + expect(isIncluded(type, included, 'foo.ts')).toBe(true); + }, + ); + + test('null included files include every regular file', () => { + expect(isIncluded('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..a0696f17b6 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-*'], + included: { + extensions: new Set(['js']), + basenames: new Set(['package.json']), + basenamePrefixes: ['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..5c326b799e 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, WatcherIncludedFiles} 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, + included: ?WatcherIncludedFiles, 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 `included`, and every file is included + * when it is null. */ -export function includedByGlob( +export function isIncluded( type: ?('f' | 'l' | 'd'), - globs: ReadonlyArray, - dot: boolean, + included: ?WatcherIncludedFiles, 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 (included == null || type !== 'f') { + return true; } - return micromatch.some(relativePath, globs, {dot}); + const basename = path.basename(relativePath); + return ( + included.extensions.has(path.extname(basename).slice(1)) || + included.basenames.has(basename) || + included.basenamePrefixes.some(prefix => basename.startsWith(prefix)) + ); } /**