From 05dfff872942dfe1fb3e1871a68f30ece7ddcec8 Mon Sep 17 00:00:00 2001 From: Rob Hogan <2590098+robhogan@users.noreply.github.com> Date: Wed, 11 Nov 2026 18:37:20 +0000 Subject: [PATCH] metro-file-map: Track only directories and symlinks in FallbackWatcher `FallbackWatcher` keeps an entry for every file it has seen: https://github.com/react/metro/blob/56efab302200568c919ac1b3c28dacf8d84fc692/packages/metro-file-map/src/watchers/FallbackWatcher.js#L53-L56 It only reads them when something is deleted. Deleting `src/a.js` is reported because there's an entry for it, and deleting `src` reports `src/a.js`, `src/b.js` and so on from the entries, then `src` itself. Neither needs an entry per file: - A file's name says whether it was reported: `src/a.js` passes the include checks, `src/README.md` doesn't. - Told that `src` is deleted, the file map removes everything under it (that's how `NativeWatcher` reports it), so one `delete` is enough. So this keeps entries only for directories (including ones it couldn't watch) and symlinks. Those are reported whatever their name, so once `src/components` is gone, only an entry says whether it was a directory or a file the include checks left out. ## Benchmark This takes another 22-30% off watch-mode startup on Linux and Windows (Linux: 969ms -> 695ms), and 3MB (10%) off the heap after startup on this app. | | `main` | #2106 | This PR | vs `main` | vs #2106 | |---|---|---|---|---|---| | Linux: Watch-mode start, warm cache | 1,887ms | 969ms | 695ms | -63.2% (-63.6% to -62.8%) | -28.2% (-29.2% to -27.6%) | | Windows: Watch-mode start, warm cache | 2,577ms | 935ms | 727ms | -71.8% (-71.9% to -71.7%) | -22.2% (-22.4% to -21.9%) | | Linux: Watch-mode start, cold cache | 1,885ms | 931ms | 652ms | -65.4% (-65.6% to -65.1%) | -30.0% (-30.9% to -29.2%) | | Windows: Watch-mode start, cold cache | 2,562ms | 917ms | 701ms | -72.7% (-72.8% to -72.5%) | -23.6% (-23.8% to -23.1%) | | Linux: Heap after a watch-mode start | 31.7MB | 31.2MB | 28.1MB | -11.2% (-11.2% to -11.2%) | -9.9% (-9.9% to -9.9%) | | Windows: Heap after a watch-mode start | 31.7MB | 31.3MB | 28.2MB | -11.3% (-11.3% to -11.3%) | -9.9% (-9.9% to -9.9%) | ## Changelog ``` - **[Performance]**: metro-file-map: Start the file watcher faster, and with less memory, on Linux and Windows without Watchman ``` ## Test plan New unit tests, and a `watch-integration-test.js` that drives FileMap in watch mode over a real directory, deleting directories and symlinks and moving directories out (with `FallbackWatcher`, or `NativeWatcher` on macOS). Benchmark as in #2104. --- .../src/__tests__/watch-integration-test.js | 175 +++++++++++++++++ .../src/watchers/FallbackWatcher.js | 181 +++++++----------- .../__tests__/FallbackWatcher-test.js | 123 ++++++++++++ .../src/watchers/__tests__/helpers.js | 20 +- .../watchers/__tests__/integration-test.js | 43 +++-- 5 files changed, 414 insertions(+), 128 deletions(-) create mode 100644 packages/metro-file-map/src/__tests__/watch-integration-test.js diff --git a/packages/metro-file-map/src/__tests__/watch-integration-test.js b/packages/metro-file-map/src/__tests__/watch-integration-test.js new file mode 100644 index 0000000000..fd030f0bdb --- /dev/null +++ b/packages/metro-file-map/src/__tests__/watch-integration-test.js @@ -0,0 +1,175 @@ +/** + * 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 {ChangeEvent, FileSystem} from '../flow-types'; + +import FileMap from '../index'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +// A FileMap in watch mode, with the watcher backend it uses on this platform +// (FallbackWatcher, or NativeWatcher on macOS), against a real file system. +// Changes are made after the initial crawl and checked through the file system +// the build returned. + +jest.useRealTimers(); +jest.setTimeout(30000); + +let rootDir: string; +let fileMap: FileMap; +let fileSystem: FileSystem; +let removedFiles: Set; + +const abs = (normalPath: string) => path.join(rootDir, normalPath); + +function write(normalPath: string, content: string = '') { + fs.mkdirSync(path.dirname(abs(normalPath)), {recursive: true}); + fs.writeFileSync(abs(normalPath), content); +} + +async function until(label: string, check: () => boolean): Promise { + const deadline = Date.now() + 10000; + while (!check()) { + if (Date.now() > deadline) { + throw new Error(`Timed out waiting for: ${label}`); + } + await new Promise(resolve => setTimeout(resolve, 25)); + } +} + +const exists = (normalPath: string) => fileSystem.exists(abs(normalPath)); + +function createFileMap({retainAllFiles}: {retainAllFiles: boolean}) { + return new FileMap({ + cacheManagerFactory: () => ({ + read: async () => null, + write: async () => {}, + end: async () => {}, + }), + enableSymlinks: true, + extensions: ['js'], + healthCheck: { + enabled: false, + interval: 10000, + timeout: 1000, + filePrefix: '.metro-file-map-health-check', + }, + ignorePattern: /ignored-dir/, + maxWorkers: 1, + retainAllFiles, + rootDir, + roots: [rootDir], + useWatchman: false, + watch: true, + }); +} + +describe('FileMap in watch mode', () => { + beforeEach(async () => { + rootDir = fs.realpathSync( + fs.mkdtempSync(path.join(os.tmpdir(), 'metro-file-map-watch-')), + ); + write('src/deep/a/b/c/one.js'); + write('src/two.js'); + write('pkg/real/index.js'); + write('ignored-dir/x.js'); + for (let i = 0; i < 20; i++) { + write(`many/d${i}/f.js`); + } + fs.mkdirSync(abs('node_modules')); + fs.symlinkSync( + path.join('..', 'pkg', 'real'), + abs('node_modules/linked'), + 'junction', + ); + + removedFiles = new Set(); + fileMap = createFileMap({retainAllFiles: true}); + ({fileSystem} = await fileMap.build()); + fileMap.on('change', ({changes}: ChangeEvent) => { + for (const [canonicalPath] of changes.removedFiles) { + removedFiles.add(canonicalPath); + } + }); + }); + + afterEach(async () => { + await fileMap.end(); + fs.rmSync(rootDir, {recursive: true, force: true}); + }); + + test('reports a new file in a directory that existed at startup', async () => { + write('src/deep/a/b/c/new.js'); + await until('new.js added', () => exists('src/deep/a/b/c/new.js')); + }); + + test('watches every directory that existed at startup', async () => { + for (let i = 0; i < 20; i++) { + write(`many/d${i}/g.js`); + } + await until('all added', () => + Array.from({length: 20}, (_, i) => exists(`many/d${i}/g.js`)).every( + Boolean, + ), + ); + }); + + test('removes the files under a deleted directory', async () => { + fs.rmSync(abs('src/deep'), {recursive: true}); + await until('one.js removed', () => !exists('src/deep/a/b/c/one.js')); + expect(removedFiles).toContain( + path.join('src', 'deep', 'a', 'b', 'c', 'one.js'), + ); + expect(exists('src/two.js')).toBe(true); + }); + + test('removes the files under a directory moved out of the root', async () => { + const outside = fs.mkdtempSync( + path.join(os.tmpdir(), 'metro-file-map-out-'), + ); + try { + fs.renameSync(abs('many/d7'), path.join(outside, 'd7')); + await until('f.js removed', () => !exists('many/d7/f.js')); + expect(exists('many/d8/f.js')).toBe(true); + } finally { + fs.rmSync(outside, {recursive: true, force: true}); + } + }); + + test('reports files in a new directory, and files added to it later', async () => { + write('fresh/sub/new.js'); + await until('new.js added', () => exists('fresh/sub/new.js')); + write('fresh/sub/later.js'); + await until('later.js added', () => exists('fresh/sub/later.js')); + }); + + test('removes a deleted symlink', async () => { + expect(fileSystem.linkStats(abs('node_modules/linked'))).toMatchObject({ + fileType: 'l', + }); + // A junction on Windows, which is removed as a directory + (process.platform === 'win32' ? fs.rmdirSync : fs.unlinkSync)( + abs('node_modules/linked'), + ); + await until( + 'link removed', + () => fileSystem.linkStats(abs('node_modules/linked')) == null, + ); + }); + + test('ignores changes in an ignored directory', async () => { + write('ignored-dir/y.js'); + write('src/marker.js'); + await until('marker.js added', () => exists('src/marker.js')); + expect(exists('ignored-dir/y.js')).toBe(false); + }); +}); diff --git a/packages/metro-file-map/src/watchers/FallbackWatcher.js b/packages/metro-file-map/src/watchers/FallbackWatcher.js index f0a97106c8..25ed255283 100644 --- a/packages/metro-file-map/src/watchers/FallbackWatcher.js +++ b/packages/metro-file-map/src/watchers/FallbackWatcher.js @@ -50,10 +50,11 @@ const CRAWL_CONCURRENCY = 32; export default class FallbackWatcher extends AbstractWatcher { readonly #changeTimers: Map> = new Map(); - readonly #dirRegistry: { - [directory: string]: {[file: string]: true, __proto__: null}, - __proto__: null, - } = Object.create(null); + // Directories and symlinks we have registered. Regular files aren't + // tracked: whether a deletion is wanted follows from the file's name, and the + // file map removes a directory's files when told the directory is deleted. + readonly #knownDirs: Set = new Set(); + readonly #knownSymlinks: Set = new Set(); readonly #watched: {[key: string]: FSWatcher, __proto__: null} = Object.create(null); @@ -65,14 +66,19 @@ export default class FallbackWatcher extends AbstractWatcher { async startWatching(): Promise { this.#watchdir(this.root); - await recReaddir( - this.root, - dir => { - this.#watchdir(dir); - }, - filename => { - this.#register(filename, 'f'); + await this.#watchTree(this.root); + } + + /** + * Watch every directory under `dir`, and register its symlinks. + */ + #watchTree(dir: string): Promise { + return recReaddir( + dir, + subdir => { + this.#watchdir(subdir); }, + () => {}, symlink => { this.#register(symlink, 'l'); }, @@ -82,26 +88,11 @@ export default class FallbackWatcher extends AbstractWatcher { } /** - * Register files that matches our globs to know what to type of event to - * emit in the future. - * - * Registry looks like the following: - * - * dirRegister => Map { - * dirpath => Map { - * filename => true - * } - * } - * - * Return false if ignored or already registered. + * Whether events for a path are wanted, i.e. it is neither ignored nor (for + * a regular file) excluded. A directory or symlink is also recorded as known, + * and false is returned if it already was. */ #register(filepath: string, type: ChangeEventMetadata['type']): boolean { - const dir = path.dirname(filepath); - const filename = path.basename(filepath); - if (this.#dirRegistry[dir] && this.#dirRegistry[dir][filename]) { - return false; - } - const relativePath = path.relative(this.root, filepath); if ( this.doIgnore(relativePath) || @@ -109,61 +100,32 @@ export default class FallbackWatcher extends AbstractWatcher { ) { return false; } - - if (!this.#dirRegistry[dir]) { - this.#dirRegistry[dir] = Object.create(null); + if (type !== 'f') { + const known = type === 'd' ? this.#knownDirs : this.#knownSymlinks; + if (known.has(filepath)) { + return false; + } + known.add(filepath); } - - this.#dirRegistry[dir][filename] = true; - return true; } /** - * Removes a file from the registry. + * Forget a path, and if it was a directory, everything beneath it. */ - #unregister(filepath: string) { - const dir = path.dirname(filepath); - if (this.#dirRegistry[dir]) { - const filename = path.basename(filepath); - delete this.#dirRegistry[dir][filename]; + #unregister(filepath: string): void { + this.#knownSymlinks.delete(filepath); + if (!this.#knownDirs.has(filepath)) { + return; } - } - - /** - * Removes a dir from the registry, returning all files that were registered - * under it (recursively). - */ - #unregisterDir(dirpath: string): Array { - const removedFiles: Array = []; - - // Find and remove all entries under this directory - for (const registeredDir of Object.keys(this.#dirRegistry)) { - if ( - registeredDir === dirpath || - registeredDir.startsWith(dirpath + path.sep) - ) { - // Collect all files in this directory - for (const filename of Object.keys(this.#dirRegistry[registeredDir])) { - removedFiles.push(path.join(registeredDir, filename)); + const prefix = filepath + path.sep; + for (const known of [this.#knownDirs, this.#knownSymlinks]) { + for (const entry of known) { + if (entry === filepath || entry.startsWith(prefix)) { + known.delete(entry); } - delete this.#dirRegistry[registeredDir]; } } - - return removedFiles; - } - - /** - * Checks if a file or directory exists in the registry. - */ - #registered(fullpath: string): boolean { - const dir = path.dirname(fullpath); - return !!( - this.#dirRegistry[fullpath] || - (this.#dirRegistry[dir] && - this.#dirRegistry[dir][path.basename(fullpath)]) - ); } /** @@ -182,6 +144,11 @@ export default class FallbackWatcher extends AbstractWatcher { if (this.#watched[dir]) { return false; } + if (this.root !== dir) { + // Known even if it can't be watched, so that its deletion, reported by + // its parent, still removes what's under it. + this.#register(dir, 'd'); + } let watcher: FSWatcher; try { watcher = fs.watch(dir, {persistent: true}, (event, filename) => { @@ -217,9 +184,6 @@ export default class FallbackWatcher extends AbstractWatcher { this.#checkedEmitError(error); }); - if (this.root !== dir) { - this.#register(dir, 'd'); - } return true; }; @@ -300,20 +264,7 @@ export default class FallbackWatcher extends AbstractWatcher { */ async #processUnnamedChange(dir: string) { // Watch and register anything new, so that later changes are reported. - await recReaddir( - dir, - subdir => { - this.#watchdir(subdir); - }, - filename => { - this.#register(filename, 'f'); - }, - symlink => { - this.#register(symlink, 'l'); - }, - this.#checkedEmitError, - this.ignored, - ); + await this.#watchTree(dir); // Then have the file map reconcile everything under `dir`. this.#emitEvent({ event: RECRAWL_EVENT, @@ -328,7 +279,13 @@ export default class FallbackWatcher extends AbstractWatcher { const fullPath = path.join(dir, file); const relativePath = path.join(path.relative(this.root, dir), file); - const registered = this.#registered(fullPath); + const isKnownDir = this.#knownDirs.has(fullPath); + // Whether something here may have been reported to the file map. + const mayBeReported = + isKnownDir || + this.#knownSymlinks.has(fullPath) || + (!this.doIgnore(relativePath) && + common.isIncluded('f', this.included, relativePath)); try { const stat = await fsPromises.lstat(fullPath); @@ -353,10 +310,10 @@ export default class FallbackWatcher extends AbstractWatcher { this.#checkedEmitError, this.ignored, ); - // A directory we already knew about has been replaced, so entries we - // registered under it may since have changed or been removed. Have the - // file map reconcile it. - if (registered) { + // What was reported here before has been replaced by a directory, or + // entries under a directory we knew may have changed or been removed. + // Have the file map reconcile it. + if (mayBeReported) { this.#emitEvent({event: RECRAWL_EVENT, relativePath}); } } else { @@ -369,12 +326,11 @@ export default class FallbackWatcher extends AbstractWatcher { size: stat.size, type, }; - if (registered) { + // Register it before checking, so that a new symlink is recorded. + // Report it if new, or if it may already be in the file map, e.g. a + // symlink retargeted in place. + if (this.#register(fullPath, type) || mayBeReported) { this.#emitEvent({event: TOUCH_EVENT, relativePath, metadata}); - } else { - if (this.#register(fullPath, type)) { - this.#emitEvent({event: TOUCH_EVENT, relativePath, metadata}); - } } } } catch (error) { @@ -382,17 +338,18 @@ export default class FallbackWatcher extends AbstractWatcher { this.emitError(error); return; } - this.#unregister(fullPath); - // When a directory is deleted, emit delete events for all files we - // knew about under that directory - const removedFiles = this.#unregisterDir(fullPath); - for (const removedFile of removedFiles) { - this.#emitEvent({ - event: DELETE_EVENT, - relativePath: path.relative(this.root, removedFile), - }); + if ( + dir !== this.root && + !this.#knownDirs.has(dir) && + this.#watched[dir] == null + ) { + // `dir` has since been reported deleted itself, which removes + // everything under it. + return; } - if (registered) { + this.#unregister(fullPath); + // For a directory, the file map removes everything under it too. + if (mayBeReported) { this.#emitEvent({event: DELETE_EVENT, relativePath}); } // Close every handle under a deleted directory. Left open, a handle stops @@ -400,7 +357,7 @@ export default class FallbackWatcher extends AbstractWatcher { // also follows the directory if it was moved (as npm moves a package // aside to update it), so it goes on watching the old copy. macOS // watches by path, so there an open handle is merely redundant. - if (removedFiles.length > 0 || this.#watched[fullPath]) { + if (isKnownDir || this.#watched[fullPath]) { await this.#stopWatchingTree(fullPath); } } 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 3d734b176b..94763d87d7 100644 --- a/packages/metro-file-map/src/watchers/__tests__/FallbackWatcher-test.js +++ b/packages/metro-file-map/src/watchers/__tests__/FallbackWatcher-test.js @@ -50,6 +50,19 @@ describe('FallbackWatcher', () => { expect(indexOfCall('watch', dir)).toBeLessThan(indexOfCall('readdir', dir)); }; + // As Metro does, include only some files, so that a directory or symlink + // isn't included by name. + const createJsWatcher = () => + new FallbackWatcher(watchRoot, { + included: { + extensions: new Set(['js']), + basenames: new Set(), + basenamePrefixes: [], + }, + ignored: null, + watchmanDeferStates: [], + }); + beforeEach(async () => { watchRoot = await createTempWatchRoot('Fallback', false); calls = []; @@ -224,6 +237,116 @@ describe('FallbackWatcher', () => { }, ); + test('reports the deletion of a directory it cannot watch', async () => { + await mkdir(join(watchRoot, 'a', 'b'), {recursive: true}); + await writeFile(join(watchRoot, 'a', 'b', 'file.js'), ''); + watchFailure = {code: 'ENOSPC', path: join(watchRoot, 'a')}; + watcher = createJsWatcher(); + watcher.onError(() => {}); + const events: Array = []; + watcher?.onFileEvent(event => { + events.push(`${event.event}:${event.relativePath}`); + }); + await watcher?.startWatching(); + // Wait until the root's watch is reporting changes + await writeFile(join(watchRoot, 'probe.js'), ''); + await waitFor(() => events.includes('touch:probe.js')); + + await rm(join(watchRoot, 'a'), {recursive: true}); + + // The file map removes everything under it + await waitFor(() => events.includes('delete:a')); + expect(events).toContain('delete:a'); + }); + + // As npm replaces a linked package with a copy. The file map holds the + // symlink, so it must reconcile the path. + test('requests a recrawl of a symlink replaced by a directory', async () => { + const link = join(watchRoot, 'link'); + await mkdir(join(watchRoot, 'target')); + await fs.promises.symlink(join(watchRoot, 'target'), link, 'junction'); + watcher = createJsWatcher(); + await watcher?.startWatching(); + const events: Array = []; + watcher?.onFileEvent(event => { + events.push(event); + }); + // Report the change only once the swap is complete + mutedDirs.add(watchRoot); + // A junction on Windows, which is removed as a directory + await ( + process.platform === 'win32' ? fs.promises.rmdir : fs.promises.unlink + )(link); + await mkdir(link); + await writeFile(join(link, 'file.js'), ''); + listeners.get(watchRoot)?.('rename', 'link'); + + await waitFor(() => events.some(event => event.event === 'recrawl')); + expect(events).toContainEqual({ + event: 'recrawl', + relativePath: 'link', + root: watchRoot, + }); + }); + + // As `ln -sfn` does. The file map holds the symlink, so it must be told of + // the new target. + test('reports a symlink retargeted in place', async () => { + const link = join(watchRoot, 'link'); + await mkdir(join(watchRoot, 'a')); + await mkdir(join(watchRoot, 'b')); + await fs.promises.symlink(join(watchRoot, 'a'), link, 'junction'); + watcher = createJsWatcher(); + await watcher?.startWatching(); + const events: Array = []; + watcher?.onFileEvent(event => { + events.push(event); + }); + // Report the change only once the swap is complete + mutedDirs.add(watchRoot); + // A junction on Windows, which is removed as a directory + await ( + process.platform === 'win32' ? fs.promises.rmdir : fs.promises.unlink + )(link); + await fs.promises.symlink(join(watchRoot, 'b'), link, 'junction'); + listeners.get(watchRoot)?.('rename', 'link'); + + await waitFor(() => events.some(event => event.relativePath === 'link')); + expect(events).toContainEqual({ + event: 'touch', + relativePath: 'link', + root: watchRoot, + metadata: expect.objectContaining({type: 'l'}), + }); + }); + + // The file map removes everything under a directory reported deleted, so a + // deletion reported under it afterwards is redundant. + test('reports nothing under a directory once it is reported deleted', async () => { + await mkdir(join(watchRoot, 'a')); + await writeFile(join(watchRoot, 'a', 'file.js'), ''); + watcher = createJsWatcher(); + await watcher?.startWatching(); + const events: Array = []; + watcher?.onFileEvent(event => { + events.push(`${event.event}:${event.relativePath}`); + }); + // Report the deletions in this order: the directory, then its file + mutedDirs.add(watchRoot); + mutedDirs.add(join(watchRoot, 'a')); + await rm(join(watchRoot, 'a'), {recursive: true}); + listeners.get(watchRoot)?.('rename', 'a'); + await waitFor(() => events.includes('delete:a')); + listeners.get(join(watchRoot, 'a'))?.('rename', 'file.js'); + // Reported after anything from the file's deletion would be + mutedDirs.delete(watchRoot); + await writeFile(join(watchRoot, 'probe.js'), ''); + await waitFor(() => events.includes('touch:probe.js')); + + expect(events).toContain('delete:a'); + expect(events).not.toContain(`delete:${join('a', 'file.js')}`); + }); + describe('after a directory watcher errors', () => { let erroredWatcher: ErroredFSWatcher; diff --git a/packages/metro-file-map/src/watchers/__tests__/helpers.js b/packages/metro-file-map/src/watchers/__tests__/helpers.js index 5a0e76b88a..53ed712ba1 100644 --- a/packages/metro-file-map/src/watchers/__tests__/helpers.js +++ b/packages/metro-file-map/src/watchers/__tests__/helpers.js @@ -69,7 +69,11 @@ export type EventHelpers = { allEvents: ( afterFn: () => Promise, events: ReadonlyArray<[string, 'touch' | 'delete' | 'recrawl']>, - opts?: {rejectUnexpected: boolean}, + opts?: { + rejectUnexpected: boolean, + // Events that may arrive, but needn't + optionalEvents?: ReadonlyArray<[string, 'touch' | 'delete' | 'recrawl']>, + }, ) => Promise, }; @@ -179,13 +183,20 @@ export const startWatching = async ( rejectUnexpected: false, }), - allEvents: (afterFn, expectedEvents, {rejectUnexpected = true} = {}) => + allEvents: ( + afterFn, + expectedEvents, + {rejectUnexpected = true, optionalEvents = []} = {}, + ) => Promise.all([ new Promise((resolve, reject) => { const tupleToKey = (tuple: ReadonlyArray) => tuple.join('\0'); const allEventKeys = new Set( expectedEvents.map(tuple => tupleToKey(tuple)), ); + const optionalEventKeys = new Set( + optionalEvents.map(tuple => tupleToKey(tuple)), + ); const unsubscribe: () => void = watcherInstance.onFileEvent( change => { if (change.relativePath === '') { @@ -203,7 +214,10 @@ export const startWatching = async ( unsubscribe(); resolve(); } - } else if (rejectUnexpected) { + } else if ( + rejectUnexpected && + !optionalEventKeys.has(receivedKey) + ) { unsubscribe(); reject( new Error( 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 a0696f17b6..8f9374268e 100644 --- a/packages/metro-file-map/src/watchers/__tests__/integration-test.js +++ b/packages/metro-file-map/src/watchers/__tests__/integration-test.js @@ -247,14 +247,22 @@ describe.each(Object.keys(WATCHERS))( join(watchRoot, 'existing', 'to-move-out'), join(outsideDir, 'moved-out'), ), - watcherName === 'Native' - ? // NativeWatcher only emits an event for the directory, not contents - [[join('existing', 'to-move-out'), 'delete']] - : [ + watcherName === 'Watchman' + ? [ [join('existing', 'to-move-out'), 'delete'], [join('existing', 'to-move-out', 'file.js'), 'delete'], - ], - {rejectUnexpected: true}, + ] + : // The file map removes the directory's contents + [[join('existing', 'to-move-out'), 'delete']], + { + rejectUnexpected: true, + // FallbackWatcher may report what's under the directory before + // the directory itself + optionalEvents: + watcherName === 'Fallback' + ? [[join('existing', 'to-move-out', 'file.js'), 'delete']] + : [], + }, ); }, ); @@ -343,17 +351,26 @@ describe.each(Object.keys(WATCHERS))( {rejectUnexpected: true}, ); + const deletedUnder: ReadonlyArray< + [string, 'touch' | 'delete' | 'recrawl'], + > = [ + [join('app', 'subdir', 'subdir2'), 'delete'], + [join('app', 'subdir', 'deep.js'), 'delete'], + [join('app', 'subdir', 'subdir2', 'deeper.js'), 'delete'], + ]; await eventHelpers.allEvents( async () => { await rm(join(appRoot, 'subdir'), {recursive: true}); }, - [ - [join('app', 'subdir'), 'delete'], - [join('app', 'subdir', 'subdir2'), 'delete'], - [join('app', 'subdir', 'deep.js'), 'delete'], - [join('app', 'subdir', 'subdir2', 'deeper.js'), 'delete'], - ], - {rejectUnexpected: true}, + watcherName === 'Fallback' + ? [[join('app', 'subdir'), 'delete']] + : [[join('app', 'subdir'), 'delete'], ...deletedUnder], + { + rejectUnexpected: true, + // FallbackWatcher reports what's under the directory only until it + // reports the directory - the file map removes it either way + optionalEvents: watcherName === 'Fallback' ? deletedUnder : [], + }, ); }, );