Skip to content

metro-file-map: Track only directories and symlinks in FallbackWatcher - #2107

Draft
robhogan wants to merge 1 commit into
pr2106from
pr2107
Draft

robhogan wants to merge 1 commit into
pr2106from
pr2107

Conversation

@robhogan

@robhogan robhogan commented Oct 25, 2026 •

Copy link
Copy Markdown
Collaborator

FallbackWatcher keeps an entry for every file it has seen:

readonly #dirRegistry: {
[directory: string]: {[file: string]: true, __proto__: null},
__proto__: null,
} = Object.create(null);

It only needs them to decide which vanished paths to report, and to report a deleted directory's files one by one. A file's deletion is wanted if its name passes the same include checks as its other events, and the file map already removes everything under a directory it's told is deleted, which is how NativeWatcher reports one. So this keeps entries only for directories (including ones it couldn't watch) and symlinks, which can't be judged by name.

Benchmark

This takes another 22-28% off watch-mode startup on Linux and Windows (Linux: 969ms -> 707ms), 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,896ms 969ms 707ms -62.7% (-63.2% to -62.4%) -27.1% (-27.6% to -26.5%)
Windows: Watch-mode start, warm cache 2,554ms 926ms 721ms -71.8% (-71.9% to -71.6%) -22.1% (-22.3% to -21.8%)
Linux: Watch-mode start, cold cache 1,882ms 967ms 699ms -62.9% (-63.3% to -62.6%) -27.7% (-28.9% to -27.3%)
Windows: Watch-mode start, cold cache 2,547ms 912ms 709ms -72.2% (-72.4% to -72.0%) -22.3% (-22.9% to -21.9%)
Linux: Heap after a watch-mode start 31.8MB 31.4MB 28.3MB -11.0% (-11.0% to -11.0%) -9.8% (-9.8% to -9.8%)
Windows: Heap after a watch-mode start 31.9MB 31.5MB 28.4MB -11.0% (-11.0% to -11.0%) -9.8% (-9.8% to -9.8%)
Linux: RSS after a watch-mode start 197.6MB 180.3MB 175.2MB -11.3% (-11.9% to -10.9%) -2.8% (-3.4% to -2.4%)
Windows: RSS after a watch-mode start 206.1MB 187.0MB 182.9MB -11.2% (-11.7% to -10.9%) -2.2% (-2.5% to -2.0%)

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.

@robhogan
robhogan added this pull request to stack #2109 October 25, 2026 15:22
@meta-cla meta-cla Bot added the CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. label Oct 25, 2026
robhogan added a commit that referenced this pull request Oct 25, 2026
Without Watchman, `Watcher` starts its backends before the crawl and buffers their events, so that a change made during the crawl isn't missed:

https://github.com/react/metro/blob/56efab302200568c919ac1b3c28dacf8d84fc692/packages/metro-file-map/src/Watcher.js#L118-L127

For `FallbackWatcher` that means walking every root to watch each directory, and then the node crawler walks the same tree again. This lets the crawl do both. Just before the crawler lists a directory it passes it to the watcher, which watches it - the order the watcher's own walk uses, so a change still can't slip between the two. Directories the crawl skips as ignored are passed on too, and the watcher walks those itself unless it ignores them as well (eg `node_modules` without `retainAllFiles`).

The hand-off is an internal second parameter of the built-in node crawler, so there's no API change. A `crawlerFactory` crawler, a scoped recrawl and a root added with `addRoot` keep the watcher's own walk. The longer-term shape for this is a backend that owns both crawling and watching, which this would move into.

## Benchmark

This takes another 25% off watch-mode startup on Linux and Windows. With the rest of this stack, watch-mode startup without Watchman goes from 1,894ms to 508ms (-73%) on Linux, and from 2,580ms to 539ms (-79%) on Windows. On macOS, which uses `NativeWatcher`, PRs 2-5 measure the same as the first PR (124ms).

|  | `main` | #2107 | This PR | vs `main` | vs #2107 |
|---|---|---|---|---|---|
| Linux: Watch-mode start, warm cache | 1,894ms | 685ms | 508ms | -73.2% (-73.4% to -72.9%) | -25.9% (-26.4% to -25.4%) |
| Windows: Watch-mode start, warm cache | 2,580ms | 721ms | 539ms | -79.1% (-79.3% to -78.3%) | -25.2% (-27.3% to -22.3%) |
| Linux: Watch-mode start, cold cache | 1,876ms | 652ms | 472ms | -74.8% (-75.2% to -74.5%) | -27.6% (-28.5% to -26.7%) |
| Windows: Watch-mode start, cold cache | 2,567ms | 695ms | 516ms | -79.9% (-80.2% to -79.8%) | -25.7% (-28.2% to -24.9%) |
| Linux: Heap after a watch-mode start | 31.8MB | 28.3MB | 28.3MB | -11.1% (-11.1% to -11.0%) | -0.0% (-0.0% to -0.0%) |
| Windows: Heap after a watch-mode start | 31.9MB | 28.4MB | 28.4MB | -11.1% (-11.1% to -11.1%) | -0.0% (-0.0% to -0.0%) |
| Linux: RSS after a watch-mode start | 197.4MB | 176.3MB | 170.9MB | -13.4% (-15.0% to -12.0%) | -3.1% (-4.6% to -1.6%) |
| Windows: RSS after a watch-mode start | 205.7MB | 182.6MB | 184.6MB | -10.3% (-10.7% to -9.4%) | +1.1% (+0.9% to +1.4%) |

## Changelog

```
 - **[Performance]**: metro-file-map: Set up file watching during the crawl instead of after it, on Linux and Windows without Watchman
```

## Test plan

New `Watcher-test.js` cases: the crawl's directories reach a `FallbackWatcher`, and a recrawl or a `crawlerFactory` crawler leaves it to walk. New `watch-integration-test.js` case for a directory the crawl skips but the watcher doesn't. Benchmark as in #2104.
robhogan added a commit that referenced this pull request Oct 25, 2026
Without Watchman, `Watcher` starts its backends before the crawl, so that nothing changed during the crawl is missed:

https://github.com/react/metro/blob/56efab302200568c919ac1b3c28dacf8d84fc692/packages/metro-file-map/src/Watcher.js#L118-L127

For `FallbackWatcher` that means walking the tree once to watch it and again to crawl it. This lets the crawl do both: the crawler hands each directory to the watcher just before listing it, the order the watcher's own walk uses. Directories the crawl skips as ignored are handed over too, for the watcher to walk unless it ignores them as well (eg `node_modules` without `retainAllFiles`).

This is internal to the built-in node crawler. A `crawlerFactory` crawler, a recrawl and a root added with `addRoot` keep the watcher's own walk.

## Benchmark

This takes another 28-30% off watch-mode startup on Linux and Windows. With the rest of this stack, watch-mode startup without Watchman goes from 1,896ms to 510ms (-73%) on Linux, and from 2,554ms to 522ms (-80%) on Windows. On macOS, which uses `NativeWatcher`, PRs 2-5 measure within noise of #2104.

|  | `main` | #2107 | This PR | vs `main` | vs #2107 |
|---|---|---|---|---|---|
| Linux: Watch-mode start, warm cache | 1,896ms | 707ms | 510ms | -73.1% (-73.4% to -72.8%) | -27.9% (-28.6% to -27.2%) |
| Windows: Watch-mode start, warm cache | 2,554ms | 721ms | 522ms | -79.6% (-79.7% to -79.4%) | -27.7% (-28.0% to -27.2%) |
| Linux: Watch-mode start, cold cache | 1,882ms | 699ms | 491ms | -73.9% (-74.5% to -73.5%) | -29.7% (-31.1% to -28.5%) |
| Windows: Watch-mode start, cold cache | 2,547ms | 709ms | 507ms | -80.1% (-80.2% to -80.0%) | -28.5% (-28.9% to -27.9%) |
| Linux: Heap after a watch-mode start | 31.8MB | 28.3MB | 28.3MB | -11.1% (-11.1% to -11.1%) | -0.1% (-0.1% to -0.1%) |
| Windows: Heap after a watch-mode start | 31.9MB | 28.4MB | 28.4MB | -11.1% (-11.1% to -11.1%) | -0.1% (-0.1% to -0.1%) |
| Linux: RSS after a watch-mode start | 197.6MB | 175.2MB | 174.3MB | -11.8% (-12.3% to -11.3%) | -0.5% (-0.9% to +0.0%) |
| Windows: RSS after a watch-mode start | 206.1MB | 182.9MB | 183.9MB | -10.7% (-11.3% to -10.3%) | +0.6% (+0.4% to +0.8%) |

## Changelog

```
 - **[Performance]**: metro-file-map: Set up file watching during the crawl instead of after it, on Linux and Windows without Watchman
```

## Test plan

New unit tests, and a `watch-integration-test.js` case for a directory the crawl skips but the watcher doesn't. Benchmark as in #2104.
robhogan added a commit that referenced this pull request Oct 26, 2026
Without Watchman, `Watcher` starts its backends before the crawl, so that nothing changed during the crawl is missed:

https://github.com/react/metro/blob/56efab302200568c919ac1b3c28dacf8d84fc692/packages/metro-file-map/src/Watcher.js#L118-L127

For `FallbackWatcher` that means walking the tree once to watch it and again to crawl it. This lets the crawl do both: the crawler hands each directory to the watcher just before listing it, the order the watcher's own walk uses. Directories the crawl skips as ignored are handed over too, for the watcher to walk unless it ignores them as well (eg `node_modules` without `retainAllFiles`).

This is internal to the built-in node crawler. A `crawlerFactory` crawler, a recrawl and a root added with `addRoot` keep the watcher's own walk.

## Benchmark

This takes another 28-30% off watch-mode startup on Linux and Windows. With the rest of this stack, watch-mode startup without Watchman goes from 1,896ms to 510ms (-73%) on Linux, and from 2,554ms to 522ms (-80%) on Windows. On macOS, which uses `NativeWatcher`, PRs 2-5 measure within noise of #2104.

|  | `main` | #2107 | This PR | vs `main` | vs #2107 |
|---|---|---|---|---|---|
| Linux: Watch-mode start, warm cache | 1,896ms | 707ms | 510ms | -73.1% (-73.4% to -72.8%) | -27.9% (-28.6% to -27.2%) |
| Windows: Watch-mode start, warm cache | 2,554ms | 721ms | 522ms | -79.6% (-79.7% to -79.4%) | -27.7% (-28.0% to -27.2%) |
| Linux: Watch-mode start, cold cache | 1,882ms | 699ms | 491ms | -73.9% (-74.5% to -73.5%) | -29.7% (-31.1% to -28.5%) |
| Windows: Watch-mode start, cold cache | 2,547ms | 709ms | 507ms | -80.1% (-80.2% to -80.0%) | -28.5% (-28.9% to -27.9%) |
| Linux: Heap after a watch-mode start | 31.8MB | 28.3MB | 28.3MB | -11.1% (-11.1% to -11.1%) | -0.1% (-0.1% to -0.1%) |
| Windows: Heap after a watch-mode start | 31.9MB | 28.4MB | 28.4MB | -11.1% (-11.1% to -11.1%) | -0.1% (-0.1% to -0.1%) |
| Linux: RSS after a watch-mode start | 197.6MB | 175.2MB | 174.3MB | -11.8% (-12.3% to -11.3%) | -0.5% (-0.9% to +0.0%) |
| Windows: RSS after a watch-mode start | 206.1MB | 182.9MB | 183.9MB | -10.7% (-11.3% to -10.3%) | +0.6% (+0.4% to +0.8%) |

## Changelog

```
 - **[Performance]**: metro-file-map: Set up file watching during the crawl instead of after it, on Linux and Windows without Watchman
```

## Test plan

New unit tests, and a `watch-integration-test.js` case for a directory the crawl skips but the watcher doesn't. Benchmark as in #2104.
`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 needs them to decide which vanished paths to report, and to report a deleted directory's files one by one. A file's deletion is wanted if its name passes the same include checks as its other events, and the file map already removes everything under a directory it's told is deleted, which is how `NativeWatcher` reports one. So this keeps entries only for directories (including ones it couldn't watch) and symlinks, which can't be judged by name.

## Benchmark

This takes another 22-28% off watch-mode startup on Linux and Windows (Linux: 969ms -> 707ms), 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,896ms | 969ms | 707ms | -62.7% (-63.2% to -62.4%) | -27.1% (-27.6% to -26.5%) |
| Windows: Watch-mode start, warm cache | 2,554ms | 926ms | 721ms | -71.8% (-71.9% to -71.6%) | -22.1% (-22.3% to -21.8%) |
| Linux: Watch-mode start, cold cache | 1,882ms | 967ms | 699ms | -62.9% (-63.3% to -62.6%) | -27.7% (-28.9% to -27.3%) |
| Windows: Watch-mode start, cold cache | 2,547ms | 912ms | 709ms | -72.2% (-72.4% to -72.0%) | -22.3% (-22.9% to -21.9%) |
| Linux: Heap after a watch-mode start | 31.8MB | 31.4MB | 28.3MB | -11.0% (-11.0% to -11.0%) | -9.8% (-9.8% to -9.8%) |
| Windows: Heap after a watch-mode start | 31.9MB | 31.5MB | 28.4MB | -11.0% (-11.0% to -11.0%) | -9.8% (-9.8% to -9.8%) |
| Linux: RSS after a watch-mode start | 197.6MB | 180.3MB | 175.2MB | -11.3% (-11.9% to -10.9%) | -2.8% (-3.4% to -2.4%) |
| Windows: RSS after a watch-mode start | 206.1MB | 187.0MB | 182.9MB | -11.2% (-11.7% to -10.9%) | -2.2% (-2.5% to -2.0%) |

## 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.
robhogan added a commit that referenced this pull request Oct 26, 2026
Without Watchman, `Watcher` starts its backends before the crawl, so that nothing changed during the crawl is missed:

https://github.com/react/metro/blob/56efab302200568c919ac1b3c28dacf8d84fc692/packages/metro-file-map/src/Watcher.js#L118-L127

For `FallbackWatcher` that means walking the tree once to watch it and again to crawl it. This lets the crawl do both: the crawler hands each directory to the watcher just before listing it, the order the watcher's own walk uses. Directories the crawl skips as ignored are handed over too, for the watcher to walk unless it ignores them as well (eg `node_modules` without `retainAllFiles`).

This is internal to the built-in node crawler. A `crawlerFactory` crawler, a recrawl and a root added with `addRoot` keep the watcher's own walk.

## Benchmark

This takes another 28-30% off watch-mode startup on Linux and Windows. With the rest of this stack, watch-mode startup without Watchman goes from 1,896ms to 510ms (-73%) on Linux, and from 2,554ms to 522ms (-80%) on Windows. On macOS, which uses `NativeWatcher`, PRs 2-5 measure within noise of #2104.

|  | `main` | #2107 | This PR | vs `main` | vs #2107 |
|---|---|---|---|---|---|
| Linux: Watch-mode start, warm cache | 1,896ms | 707ms | 510ms | -73.1% (-73.4% to -72.8%) | -27.9% (-28.6% to -27.2%) |
| Windows: Watch-mode start, warm cache | 2,554ms | 721ms | 522ms | -79.6% (-79.7% to -79.4%) | -27.7% (-28.0% to -27.2%) |
| Linux: Watch-mode start, cold cache | 1,882ms | 699ms | 491ms | -73.9% (-74.5% to -73.5%) | -29.7% (-31.1% to -28.5%) |
| Windows: Watch-mode start, cold cache | 2,547ms | 709ms | 507ms | -80.1% (-80.2% to -80.0%) | -28.5% (-28.9% to -27.9%) |
| Linux: Heap after a watch-mode start | 31.8MB | 28.3MB | 28.3MB | -11.1% (-11.1% to -11.1%) | -0.1% (-0.1% to -0.1%) |
| Windows: Heap after a watch-mode start | 31.9MB | 28.4MB | 28.4MB | -11.1% (-11.1% to -11.1%) | -0.1% (-0.1% to -0.1%) |
| Linux: RSS after a watch-mode start | 197.6MB | 175.2MB | 174.3MB | -11.8% (-12.3% to -11.3%) | -0.5% (-0.9% to +0.0%) |
| Windows: RSS after a watch-mode start | 206.1MB | 182.9MB | 183.9MB | -10.7% (-11.3% to -10.3%) | +0.6% (+0.4% to +0.8%) |

## Changelog

```
 - **[Performance]**: metro-file-map: Set up file watching during the crawl instead of after it, on Linux and Windows without Watchman
```

## Test plan

New unit tests, and a `watch-integration-test.js` case for a directory the crawl skips but the watcher doesn't. Benchmark as in #2104.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant