From 4ab7642e6f6eaafea92d060fb2ff42cbc4d9fc36 Mon Sep 17 00:00:00 2001 From: gogulapatipavani212 <302255938+gogulapatipavani212@users.noreply.github.com> Date: Mon, 5 Oct 2026 15:13:18 -0500 Subject: [PATCH] Backport paired micromatch removal and extension matching fixes to 0.83.x Backports a77924694dd4e36c0a3e595956e322033a6c2c57 and 3865c007c592c4ab7357f95fa6ae9e6ac735a7d1 while retaining 0.83.x watcher event behavior and regenerating TypeScript declarations. Co-authored-by: Rob Hogan <2590098+robhogan@users.noreply.github.com> --- packages/metro-file-map/package.json | 1 - packages/metro-file-map/src/Watcher.js | 13 ++--- packages/metro-file-map/src/flow-types.js | 15 +++-- .../src/watchers/AbstractWatcher.js | 9 ++- .../src/watchers/FallbackWatcher.js | 5 +- .../src/watchers/NativeWatcher.js | 8 +-- .../src/watchers/WatchmanWatcher.js | 14 +---- .../__tests__/WatchmanWatcher-test.js | 21 +++++-- .../src/watchers/__tests__/common-test.js | 56 +++++++++++++++++++ .../watchers/__tests__/integration-test.js | 20 +++++-- .../metro-file-map/src/watchers/common.js | 48 +++++++++++----- packages/metro-file-map/types/flow-types.d.ts | 15 +++-- .../types/watchers/AbstractWatcher.d.ts | 6 +- .../types/watchers/NativeWatcher.d.ts | 7 ++- .../metro-file-map/types/watchers/common.d.ts | 19 ++++--- 15 files changed, 177 insertions(+), 80 deletions(-) create mode 100644 packages/metro-file-map/src/watchers/__tests__/common-test.js diff --git a/packages/metro-file-map/package.json b/packages/metro-file-map/package.json index aa08221232..01abe63dad 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", "walker": "^1.0.7" }, diff --git a/packages/metro-file-map/src/Watcher.js b/packages/metro-file-map/src/Watcher.js index 99e10618a1..4590f9921b 100644 --- a/packages/metro-file-map/src/Watcher.js +++ b/packages/metro-file-map/src/Watcher.js @@ -205,15 +205,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 44d05dea4d..49121f439a 100644 --- a/packages/metro-file-map/src/flow-types.js +++ b/packages/metro-file-map/src/flow-types.js @@ -365,8 +365,6 @@ export interface FileSystem { }): Iterable; } -export type Glob = string; - export type JsonData = | string | number @@ -546,11 +544,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}>}>; diff --git a/packages/metro-file-map/src/watchers/AbstractWatcher.js b/packages/metro-file-map/src/watchers/AbstractWatcher.js index 094cafd38d..afaeced7b4 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 { +root: string; +ignored: ?RegExp; - +globs: ReadonlyArray; - +dot: boolean; + +included: ?WatcherIncludedFiles; +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 a9b2d4b8d9..9fecf55a37 100644 --- a/packages/metro-file-map/src/watchers/FallbackWatcher.js +++ b/packages/metro-file-map/src/watchers/FallbackWatcher.js @@ -97,8 +97,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; } @@ -292,7 +291,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 3c11a384fe..7f7007bf4f 100644 --- a/packages/metro-file-map/src/watchers/NativeWatcher.js +++ b/packages/metro-file-map/src/watchers/NativeWatcher.js @@ -8,10 +8,11 @@ * @format */ +import type {WatcherIncludedFiles} from '../flow-types'; import type {FSWatcher} from 'fs'; import {AbstractWatcher} from './AbstractWatcher'; -import {includedByGlob, typeFromStat} from './common'; +import {isIncluded, typeFromStat} from './common'; import {promises as fsPromises, watch} from 'fs'; import {platform} from 'os'; import * as path from 'path'; @@ -54,8 +55,7 @@ export default class NativeWatcher extends AbstractWatcher { dir: string, opts: Readonly<{ ignored: ?RegExp, - globs: ReadonlyArray, - dot: boolean, + included: ?WatcherIncludedFiles, ... }>, ) { @@ -122,7 +122,7 @@ export default class NativeWatcher extends AbstractWatcher { return; } - if (!includedByGlob(type, this.globs, this.dot, relativePath)) { + if (!isIncluded(type, this.included, relativePath)) { return; } diff --git a/packages/metro-file-map/src/watchers/WatchmanWatcher.js b/packages/metro-file-map/src/watchers/WatchmanWatcher.js index fddaca522c..33e141d8ae 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__/WatchmanWatcher-test.js b/packages/metro-file-map/src/watchers/__tests__/WatchmanWatcher-test.js index 8bcd3a1d13..d261468044 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..4c099e0232 --- /dev/null +++ b/packages/metro-file-map/src/watchers/__tests__/common-test.js @@ -0,0 +1,56 @@ +/** + * 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 'path'; + +describe('isIncluded', () => { + const included: WatcherIncludedFiles = { + extensions: new Set(['js', 'json', 'env', 'local', 'd.ts']), + 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], + ['.env', true], + ['.env.local', true], + [join('src', '.js'), true], + [join('src', 'types.d.ts'), true], + ['.envrc', false], + [join('src', 'index.js.map'), false], + ])('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 798b6a4bf3..8abbdfc658 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', 'env', 'local', 'd.ts']), + 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 @@ -122,9 +125,16 @@ describe.each(Object.keys(WATCHERS))( await rm(watchRoot, {recursive: true}); }); - maybeTest('detects a new, changed, deleted file', async () => { - const testFile = join(appRoot, 'test.js'); - const relativePath = join('app', 'test.js'); + maybeTest.each([ + 'test.js', + '.env', + '.env.local', + 'types.d.ts', + 'package.json', + '.hidden.js', + ])('detects a new, changed, deleted %s file', async filename => { + const testFile = join(appRoot, filename); + const relativePath = join('app', filename); expect( await eventHelpers.nextEvent(() => writeFile(testFile, 'hello world')), ).toStrictEqual({ diff --git a/packages/metro-file-map/src/watchers/common.js b/packages/metro-file-map/src/watchers/common.js index 16f4c705bb..06415e7630 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 'fs'; -// $FlowFixMe[untyped-import] - Write libdefs for `micromatch` -import micromatch from 'micromatch'; import path from '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,43 @@ 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. + * + * A file matches an extension when its basename ends with `.` followed by + * that extension, so `.env` matches `env` and `foo.d.ts` matches `d.ts`. */ -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; + } + const basename = path.basename(relativePath); + return ( + hasIncludedExtension(included.extensions, basename) || + included.basenames.has(basename) || + included.basenamePrefixes.some(prefix => basename.startsWith(prefix)) + ); +} + +function hasIncludedExtension( + extensions: ReadonlySet, + basename: string, +): boolean { + for ( + let i = basename.indexOf('.'); + i !== -1; + i = basename.indexOf('.', i + 1) + ) { + if (extensions.has(basename.slice(i + 1))) { + return true; + } } - return micromatch.some(relativePath, globs, {dot}); + return false; } /** diff --git a/packages/metro-file-map/types/flow-types.d.ts b/packages/metro-file-map/types/flow-types.d.ts index 16ca47efd9..c294fff519 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 @@ -313,7 +313,6 @@ export interface FileSystem { rootDir?: Path | null; }): Iterable; } -export type Glob = string; export type JsonData = | string | number @@ -437,8 +436,16 @@ export type WatcherBackendChangeEvent = }>; export type WatcherBackendOptions = Readonly<{ ignored: null | undefined | RegExp; - globs: ReadonlyArray; - dot: boolean; + included: null | undefined | 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 diff --git a/packages/metro-file-map/types/watchers/AbstractWatcher.d.ts b/packages/metro-file-map/types/watchers/AbstractWatcher.d.ts index d5c6f47462..0d09c1c75f 100644 --- a/packages/metro-file-map/types/watchers/AbstractWatcher.d.ts +++ b/packages/metro-file-map/types/watchers/AbstractWatcher.d.ts @@ -5,7 +5,7 @@ * LICENSE file in the root directory of this source tree. * * @noformat - * @generated SignedSource<> + * @generated SignedSource<<73dcfda27ad8d16345f50153d8a0035b>> * * This file was translated from Flow by scripts/generateTypeScriptDefinitions.js * Original file: packages/metro-file-map/src/watchers/AbstractWatcher.js @@ -18,6 +18,7 @@ import type { WatcherBackend, WatcherBackendChangeEvent, WatcherBackendOptions, + WatcherIncludedFiles, } from '../flow-types'; export type Listeners = Readonly<{ @@ -27,8 +28,7 @@ export type Listeners = Readonly<{ export declare class AbstractWatcher implements WatcherBackend { readonly root: string; readonly ignored: null | undefined | RegExp; - readonly globs: ReadonlyArray; - readonly dot: boolean; + readonly included: null | undefined | WatcherIncludedFiles; readonly doIgnore: (path: string) => boolean; constructor(dir: string, opts: WatcherBackendOptions); onFileEvent(listener: (event: WatcherBackendChangeEvent) => void): () => void; diff --git a/packages/metro-file-map/types/watchers/NativeWatcher.d.ts b/packages/metro-file-map/types/watchers/NativeWatcher.d.ts index b118598f7e..917a70ee52 100644 --- a/packages/metro-file-map/types/watchers/NativeWatcher.d.ts +++ b/packages/metro-file-map/types/watchers/NativeWatcher.d.ts @@ -5,7 +5,7 @@ * LICENSE file in the root directory of this source tree. * * @noformat - * @generated SignedSource<> + * @generated SignedSource<> * * This file was translated from Flow by scripts/generateTypeScriptDefinitions.js * Original file: packages/metro-file-map/src/watchers/NativeWatcher.js @@ -14,6 +14,8 @@ * yarn run build-ts-defs (OSS) */ +import type {WatcherIncludedFiles} from '../flow-types'; + import {AbstractWatcher} from './AbstractWatcher'; /** * NativeWatcher uses Node's native fs.watch API with recursive: true. @@ -41,8 +43,7 @@ declare class NativeWatcher extends AbstractWatcher { dir: string, opts: Readonly<{ ignored: null | undefined | RegExp; - globs: ReadonlyArray; - dot: boolean; + included: null | undefined | WatcherIncludedFiles; }>, ); startWatching(): Promise; diff --git a/packages/metro-file-map/types/watchers/common.d.ts b/packages/metro-file-map/types/watchers/common.d.ts index a9612932ba..3cf9161208 100644 --- a/packages/metro-file-map/types/watchers/common.d.ts +++ b/packages/metro-file-map/types/watchers/common.d.ts @@ -6,7 +6,7 @@ * * @noformat * @oncall react_native - * @generated SignedSource<> + * @generated SignedSource<<67a04b1dc8137da401da7c42a82c6d28>> * * This file was translated from Flow by scripts/generateTypeScriptDefinitions.js * Original file: packages/metro-file-map/src/watchers/common.js @@ -20,7 +20,7 @@ * 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 'fs'; /** * Constants @@ -34,20 +34,23 @@ export declare type RECRAWL_EVENT = typeof RECRAWL_EVENT; export declare const ALL_EVENT: 'all'; export declare type ALL_EVENT = typeof ALL_EVENT; export type WatcherOptions = Readonly<{ - globs: ReadonlyArray; - dot: boolean; + included: null | undefined | WatcherIncludedFiles; ignored: null | undefined | RegExp; watchmanDeferStates: ReadonlyArray; watchman?: unknown; watchmanPath?: string; }>; /** - * 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. + * + * A file matches an extension when its basename ends with `.` followed by + * that extension, so `.env` matches `env` and `foo.d.ts` matches `d.ts`. */ -export declare function includedByGlob( +export declare function isIncluded( type: null | undefined | ('f' | 'l' | 'd'), - globs: ReadonlyArray, - dot: boolean, + included: null | undefined | WatcherIncludedFiles, relativePath: string, ): boolean; /**