Repository navigation
metro-file-map: Check hierarchicalLookup candidates from their own nodes - #1946
Merged
Merged
Conversation
robhogan
force-pushed
the
robhogan/hierarchical-lookup-basename
branch
from
September 18, 2026 17:33
09ea28a to
51d99a7
Compare
robhogan
force-pushed
the
robhogan/hierarchical-lookup-basename
branch
from
September 18, 2026 17:46
51d99a7 to
f00ad10
Compare
robhogan
force-pushed
the
robhogan/hierarchical-lookup-basename
branch
from
September 18, 2026 18:11
f00ad10 to
0929d38
Compare
robhogan
added this pull request to stack #1947
September 18, 2026 18:12
robhogan
force-pushed
the
robhogan/hierarchical-lookup-basename
branch
from
September 18, 2026 18:20
0929d38 to
0d622a5
Compare
robhogan
added a commit
that referenced
this pull request
Sep 18, 2026
Stacked on #1946. ## Summary `PackageCache` remembers the closest `package.json` for every module path it's asked about, with a reverse index from each `package.json` to the module paths under it and a set of paths known to have no package, both of which exist only to invalidate the first. `DependencyGraph` calls `invalidate` for every changed file to keep it honest. With the previous two PRs that memo no longer pays for itself. #1945 cut closest-package lookups 3.7x (64k -> 17k per build on our mid-size RN benchmark app*), and #1946 made each one a walk of single map lookups, 2.1x faster (870ns -> 406ns). This removes the memo and both indices, and looks up the closest package on every call. The parsed contents of each `package.json` are still cached per path, and `invalidate` now just drops those. The other reason to do this is incremental resolution (#1944). Any cache that sits between the resolver and the file system has to store the observations that produced each entry and replay them on a hit, or the second resolution to hit the cache can never be invalidated. That's a fair amount of machinery to add to a cache that's worth 3% of first-build resolution time. This only makes sense on top of the stack - at #1944, removing the memo costs 14% on a first pass, and nearly doubles a pass that re-resolves everything. \* Described in the test plan. ## Benchmark Replaying every `DependencyGraph.resolveDependency` call made by one iOS dev build of the benchmark app. This goes through `DependencyGraph`'s whole-resolution cache, which is cleared only by real file change events, so each row is something that actually happens in a session. 7 interleaved rounds, medians of 15 iterations. Resolver output is byte-identical. | scenario | #1946 | this diff | |---|---|---| | first build (ms) | 92.9 | 95.6 (+2.9%) | | rebuild, nothing changed (ms) | 7.58 | 7.61 | | after a source edit, re-resolving the edited module's 8 dependencies (µs) | 155 | 155 | | after a `package.json` change, re-resolving everything (ms) | 73.6 | 80.4 (+9.3%) | | heap retained after resolving (MB) | 3.0 | **1.9 (−37%)** | The tradeoffs are: - ~3% of resolution time on a first build (3ms here). - ~9% (7ms here) when a `package.json` or symlink change resets the whole graph, which is the only time everything is re-resolved against a warm `PackageCache`. Incremental resolution aims to remove those full resets. - Nothing on an ordinary edit. Metro re-resolves only the edited module's dependencies, and the difference there is within noise. ## Test plan ``` yarn jest packages/metro-file-map packages/metro-resolver packages/metro/src/node-haste packages/metro/src/DeltaBundler packages/metro/src/integration_tests yarn flow check yarn verify-api-snapshots ``` `PackageCache-test.js` is restructured around the new contract: the closest package is looked up on every call, a change of closest package is reflected with no invalidation, each `package.json` is parsed once however many modules share it, and invalidating one re-reads it. Tests of the reverse index and the no-package set go with those structures. **Benchmark app** - a synthetic mid-size RN 0.87 app: 23 common dependencies (Reanimated, React Navigation, TanStack Query, lodash, etc.) and ~1,000 generated first-party modules, 60% of them in a workspace package outside `projectRoot` with its own `node_modules`. The iOS dev bundle is 2.8k modules, from a file map of 34k files. Timings replay the `resolveDependency` calls a real build of it makes against a fresh `DependencyGraph` per process, in interleaved rounds, with source and `package.json` edits made on disk and picked up by the watcher. Changelog: Internal
robhogan
force-pushed
the
robhogan/hierarchical-lookup-basename
branch
from
September 18, 2026 18:27
0d622a5 to
31f8c66
Compare
robhogan
added a commit
that referenced
this pull request
Sep 18, 2026
Stacked on #1946. ## Summary `PackageCache` remembers the closest `package.json` for every module path it's asked about, with a reverse index from each `package.json` to the module paths under it and a set of paths known to have no package, both of which exist only to invalidate the first. `DependencyGraph` calls `invalidate` for every changed file to keep it honest. With the previous two PRs that memo no longer pays for itself. #1945 cut closest-package lookups 3.7x (64k -> 17k per build on our mid-size RN benchmark app*), and #1946 made each one a walk of single map lookups, 2.1x faster (870ns -> 406ns). This removes the memo and both indices, and looks up the closest package on every call. The parsed contents of each `package.json` are still cached per path, and `invalidate` now just drops those. The other reason to do this is incremental resolution (#1944). Any cache that sits between the resolver and the file system has to store the observations that produced each entry and replay them on a hit, or the second resolution to hit the cache can never be invalidated. That's a fair amount of machinery to add to a cache that's worth 3% of first-build resolution time, which is itself a small part of a build. This only makes sense on top of the stack - at #1944, removing the memo costs 14% on a first pass, and nearly doubles a pass that re-resolves everything. \* Described in the test plan. ## Benchmark Resolution time in isolation, replaying every `DependencyGraph.resolveDependency` call made by one iOS dev build of the benchmark app. Nothing is transformed or serialised, so these are percentages of resolution, not of a build. This goes through `DependencyGraph`'s whole-resolution cache, which is cleared only by real file change events, so each row is something that actually happens in a session. 7 interleaved rounds, medians of 15 iterations. Resolver output is byte-identical. | scenario | #1946 | this diff | |---|---|---| | first build (ms) | 92.9 | 95.6 (+2.9%) | | rebuild, nothing changed (ms) | 7.58 | 7.61 | | after a source edit, re-resolving the edited module's 8 dependencies (µs) | 155 | 155 | | after a `package.json` change, re-resolving everything (ms) | 73.6 | 80.4 (+9.3%) | | heap retained after resolving (MB) | 3.0 | **1.9 (−37%)** | The tradeoffs are: - ~3% of resolution time on a first build (3ms here). A real build does far more than resolve - the same build takes around 4s end to end with a warm transform cache - so that's under 0.1% of it. - ~9% (7ms here) when a `package.json` or symlink change resets the whole graph, which is the only time everything is re-resolved against a warm `PackageCache`. Incremental resolution aims to remove those full resets. - Nothing on an ordinary edit. Metro re-resolves only the edited module's dependencies, and the difference there is within noise. ## Test plan ``` yarn jest packages/metro-file-map packages/metro-resolver packages/metro/src/node-haste packages/metro/src/DeltaBundler packages/metro/src/integration_tests yarn flow check yarn verify-api-snapshots ``` `PackageCache-test.js` is restructured around the new contract: the closest package is looked up on every call, a change of closest package is reflected with no invalidation, each `package.json` is parsed once however many modules share it, and invalidating one re-reads it. Tests of the reverse index and the no-package set go with those structures. **Benchmark app** - a synthetic mid-size RN 0.87 app: 23 common dependencies (Reanimated, React Navigation, TanStack Query, lodash, etc.) and ~1,000 generated first-party modules, 60% of them in a workspace package outside `projectRoot` with its own `node_modules`. The iOS dev bundle is 2.8k modules, from a file map of 34k files. Timings replay the `resolveDependency` calls a real build of it makes against a fresh `DependencyGraph` per process, in interleaved rounds, with source and `package.json` edits made on disk and picked up by the watcher. These timings isolate resolution - no transformation, serialisation or file reads are included. A real build of this app takes around 4s even with a warm transform cache, so resolution is about 2% of it. Changelog: Internal
robhogan
added a commit
that referenced
this pull request
Sep 18, 2026
Stacked on #1946. ## Summary `PackageCache` remembers the closest `package.json` for every module path it's asked about, with a reverse index from each `package.json` to the module paths under it and a set of paths known to have no package, both of which exist only to invalidate the first. `DependencyGraph` calls `invalidate` for every changed file to keep it honest. With the previous two PRs that memo no longer pays for itself. #1945 cut closest-package lookups 3.7x (64k -> 17k per build on our mid-size RN benchmark app*), and #1946 made each one a walk of single map lookups, 2.1x faster (870ns -> 406ns). This removes the memo and both indices, and looks up the closest package on every call. The parsed contents of each `package.json` are still cached per path, and `invalidate` now just drops those. The other reason to do this is incremental resolution (#1944). Any cache that sits between the resolver and the file system has to store the observations that produced each entry and replay them on a hit, or the second resolution to hit the cache can never be invalidated. That's a fair amount of machinery to add to a cache that's worth 3% of first-build resolution time, which is itself a small part of a build. This only makes sense on top of the stack - at #1944, removing the memo costs 14% on a first pass, and nearly doubles a pass that re-resolves everything. \* Described in the test plan. ## Bugs fixed This also fixes two invalidation bugs, both present on main, where the watcher picks up a change on disk but resolution keeps its old answer until Metro restarts: - A nearer `package.json` is added, e.g. creating a nested package in a monorepo. The memo is invalidated through a reverse index from each `package.json` to the modules that resolved to it, and a new `package.json` has no entries, so the modules beneath it keep pointing at the farther one. (It's masked when there was previously no enclosing package at all, which is special-cased.) - A directory symlink is retargeted into another package. Entries are keyed by the path as it was asked for, through the link, and only the link's own path is invalidated. Both changes reset the graph, so everything is re-resolved - against the stale memo, which is probably why they've gone unnoticed. Reproduced with a real `DependencyGraph` and watcher on main and on #1946, using a `browser` redirect in the affected `package.json` to show which one was consulted. With no memo there's nothing to go stale - every lookup walks the live file map. ## Benchmark Resolution time in isolation, replaying every `DependencyGraph.resolveDependency` call made by one iOS dev build of the benchmark app. Nothing is transformed or serialised, so these are percentages of resolution, not of a build. This goes through `DependencyGraph`'s whole-resolution cache, which is cleared only by real file change events, so each row is something that actually happens in a session. 7 interleaved rounds, medians of 15 iterations. Resolver output is byte-identical. | scenario | #1946 | this diff | |---|---|---| | first build (ms) | 92.9 | 95.6 (+2.9%) | | rebuild, nothing changed (ms) | 7.58 | 7.61 | | after a source edit, re-resolving the edited module's 8 dependencies (µs) | 155 | 155 | | after a `package.json` change, re-resolving everything (ms) | 73.6 | 80.4 (+9.3%) | | heap retained after resolving (MB) | 3.0 | **1.9 (−37%)** | The tradeoffs are: - ~3% of resolution time on a first build (3ms here). A real build does far more than resolve - the same build takes around 4s end to end with a warm transform cache - so that's under 0.1% of it. - ~9% (7ms here) when a `package.json` or symlink change resets the whole graph, which is the only time everything is re-resolved against a warm `PackageCache`. Incremental resolution aims to remove those full resets. - Nothing on an ordinary edit. Metro re-resolves only the edited module's dependencies, and the difference there is within noise. ## Test plan ``` yarn jest packages/metro-file-map packages/metro-resolver packages/metro/src/node-haste packages/metro/src/DeltaBundler packages/metro/src/integration_tests yarn flow check yarn verify-api-snapshots ``` `PackageCache-test.js` is restructured around the new contract: the closest package is looked up on every call, a change of closest package is reflected with no invalidation, each `package.json` is parsed once however many modules share it, and invalidating one re-reads it. Tests of the reverse index and the no-package set go with those structures. **Benchmark app** - a synthetic mid-size RN 0.87 app: 23 common dependencies (Reanimated, React Navigation, TanStack Query, lodash, etc.) and ~1,000 generated first-party modules, 60% of them in a workspace package outside `projectRoot` with its own `node_modules`. The iOS dev bundle is 2.8k modules, from a file map of 34k files. Timings replay the `resolveDependency` calls a real build of it makes against a fresh `DependencyGraph` per process, in interleaved rounds, with source and `package.json` edits made on disk and picked up by the watcher. These timings isolate resolution - no transformation, serialisation or file reads are included. A real build of this app takes around 4s even with a warm transform cache, so resolution is about 2% of it. Changelog: ``` - **[Fix]**: Resolver: Fix stale resolutions after adding a nearer `package.json` or retargeting a symlinked directory, which previously needed a restart ```
robhogan
added a commit
that referenced
this pull request
Sep 18, 2026
Stacked on #1946. ## Summary `PackageCache` remembers the closest `package.json` for every module path it's asked about, with a reverse index from each `package.json` to the module paths under it and a set of paths known to have no package, both of which exist only to invalidate the first. `DependencyGraph` calls `invalidate` for every changed file to keep it honest. With the previous two PRs that memo no longer pays for itself. #1945 cut closest-package lookups 3.7x (64k -> 17k per build on our mid-size RN benchmark app*), and #1946 made each one a walk of single map lookups, 2.1x faster (870ns -> 406ns). This removes the memo and both indices, and looks up the closest package on every call. The parsed contents of each `package.json` are still cached per path, and `invalidate` now just drops those. The other reason to do this is incremental resolution (#1944). Any cache that sits between the resolver and the file system has to store the observations that produced each entry and replay them on a hit, or the second resolution to hit the cache can never be invalidated. That's a fair amount of machinery to add to a cache that's worth 3% of first-build resolution time, which is itself a small part of a build. This only makes sense on top of the stack - at #1944, removing the memo costs 14% on a first pass, and nearly doubles a pass that re-resolves everything. \* Described in the test plan. ## Bugs fixed This also fixes two invalidation bugs, both present on main, where the watcher picks up a change on disk but resolution keeps its old answer until Metro restarts: - A nearer `package.json` is added, e.g. creating a nested package in a monorepo. The memo is invalidated through a reverse index from each `package.json` to the modules that resolved to it, and a new `package.json` has no entries, so the modules beneath it keep pointing at the farther one. (It's masked when there was previously no enclosing package at all, which is special-cased.) - A directory symlink is retargeted into another package. Entries are keyed by the path as it was asked for, through the link, and only the link's own path is invalidated. Both changes reset the graph, so everything is re-resolved - against the stale memo, which is probably why they've gone unnoticed. Reproduced with a real `DependencyGraph` and watcher on main and on #1946, using a `browser` redirect in the affected `package.json` to show which one was consulted. With no memo there's nothing to go stale - every lookup walks the live file map. ## Benchmark Resolution time in isolation, replaying every `DependencyGraph.resolveDependency` call made by one iOS dev build of the benchmark app. Nothing is transformed or serialised, so these are percentages of resolution, not of a build. This goes through `DependencyGraph`'s whole-resolution cache, which is cleared only by real file change events, so each row is something that actually happens in a session. 7 interleaved rounds, medians of 15 iterations. Resolver output is byte-identical. | scenario | #1946 | this diff | |---|---|---| | first build (ms) | 92.9 | 95.6 (+2.9%) | | rebuild, nothing changed (ms) | 7.58 | 7.61 | | after a source edit, re-resolving the edited module's 8 dependencies (µs) | 155 | 155 | | after a `package.json` change, re-resolving everything (ms) | 73.6 | 80.4 (+9.3%) | | heap retained after resolving (MB) | 3.0 | **1.9 (−37%)** | The tradeoffs are: - ~3% of resolution time on a first build (3ms here). A real build does far more than resolve - the same build takes around 4s end to end with a warm transform cache - so that's under 0.1% of it. - ~9% (7ms here) when a `package.json` or symlink change resets the whole graph, which is the only time everything is re-resolved against a warm `PackageCache`. Incremental resolution aims to remove those full resets. - Nothing on an ordinary edit. Metro re-resolves only the edited module's dependencies, and the difference there is within noise. ## Test plan ``` yarn jest packages/metro-file-map packages/metro-resolver packages/metro/src/node-haste packages/metro/src/DeltaBundler packages/metro/src/integration_tests yarn flow check yarn verify-api-snapshots ``` `PackageCache-test.js` loses its tests of memo invalidation, along with the structures they tested - with no memo, invalidation is a non-issue by construction. What's left covers the one remaining cache: each `package.json` is parsed once however many modules share it, and is re-read after it's invalidated. **Benchmark app** - a synthetic mid-size RN 0.87 app: 23 common dependencies (Reanimated, React Navigation, TanStack Query, lodash, etc.) and ~1,000 generated first-party modules, 60% of them in a workspace package outside `projectRoot` with its own `node_modules`. The iOS dev bundle is 2.8k modules, from a file map of 34k files. Timings replay the `resolveDependency` calls a real build of it makes against a fresh `DependencyGraph` per process, in interleaved rounds, with source and `package.json` edits made on disk and picked up by the watcher. These timings isolate resolution - no transformation, serialisation or file reads are included. A real build of this app takes around 4s even with a warm transform cache, so resolution is about 2% of it. Changelog: ``` - **[Fix]**: Resolver: Fix stale resolutions after adding a nearer `package.json` or retargeting a symlinked directory, which previously needed a restart ```
robhogan
force-pushed
the
robhogan/hierarchical-lookup-basename
branch
from
September 25, 2026 18:04
31f8c66 to
581a322
Compare
robhogan
added a commit
that referenced
this pull request
Sep 25, 2026
Stacked on #1946. ## Summary `PackageCache` remembers the closest `package.json` for every module path it's asked about, with a reverse index from each `package.json` to the module paths under it and a set of paths known to have no package, both of which exist only to invalidate the first. `DependencyGraph` calls `invalidate` for every changed file to keep it honest. With the previous two PRs that memo no longer pays for itself. #1945 cut closest-package lookups 3.7x (64k -> 17k per build on our mid-size RN benchmark app*), and #1946 made each one a walk of single map lookups, 2.1x faster (870ns -> 406ns). This removes the memo and both indices, and looks up the closest package on every call. The parsed contents of each `package.json` are still cached per path, and `invalidate` now just drops those. The other reason to do this is incremental resolution (#1944). Any cache that sits between the resolver and the file system has to store the observations that produced each entry and replay them on a hit, or the second resolution to hit the cache can never be invalidated. That's a fair amount of machinery to add to a cache that's worth 3% of first-build resolution time, which is itself a small part of a build. This only makes sense on top of the stack - at #1944, removing the memo costs 14% on a first pass, and nearly doubles a pass that re-resolves everything. \* Described in the test plan. ## Bugs fixed This also fixes two invalidation bugs, both present on main, where the watcher picks up a change on disk but resolution keeps its old answer until Metro restarts: - A nearer `package.json` is added, e.g. creating a nested package in a monorepo. The memo is invalidated through a reverse index from each `package.json` to the modules that resolved to it, and a new `package.json` has no entries, so the modules beneath it keep pointing at the farther one. (It's masked when there was previously no enclosing package at all, which is special-cased.) - A directory symlink is retargeted into another package. Entries are keyed by the path as it was asked for, through the link, and only the link's own path is invalidated. Both changes reset the graph, so everything is re-resolved - against the stale memo, which is probably why they've gone unnoticed. Reproduced with a real `DependencyGraph` and watcher on main and on #1946, using a `browser` redirect in the affected `package.json` to show which one was consulted. With no memo there's nothing to go stale - every lookup walks the live file map. ## Benchmark Resolution time in isolation, replaying every `DependencyGraph.resolveDependency` call made by one iOS dev build of the benchmark app. Nothing is transformed or serialised, so these are percentages of resolution, not of a build. This goes through `DependencyGraph`'s whole-resolution cache, which is cleared only by real file change events, so each row is something that actually happens in a session. 7 interleaved rounds, medians of 15 iterations. Resolver output is byte-identical. | scenario | #1946 | this diff | |---|---|---| | first build (ms) | 92.9 | 95.6 (+2.9%) | | rebuild, nothing changed (ms) | 7.58 | 7.61 | | after a source edit, re-resolving the edited module's 8 dependencies (µs) | 155 | 155 | | after a `package.json` change, re-resolving everything (ms) | 73.6 | 80.4 (+9.3%) | | heap retained after resolving (MB) | 3.0 | **1.9 (−37%)** | The tradeoffs are: - ~3% of resolution time on a first build (3ms here). A real build does far more than resolve - the same build takes around 4s end to end with a warm transform cache - so that's under 0.1% of it. - ~9% (7ms here) when a `package.json` or symlink change resets the whole graph, which is the only time everything is re-resolved against a warm `PackageCache`. Incremental resolution aims to remove those full resets. - Nothing on an ordinary edit. Metro re-resolves only the edited module's dependencies, and the difference there is within noise. ## Test plan ``` yarn jest packages/metro-file-map packages/metro-resolver packages/metro/src/node-haste packages/metro/src/DeltaBundler packages/metro/src/integration_tests yarn flow check yarn verify-api-snapshots ``` `PackageCache-test.js` loses its tests of memo invalidation, along with the structures they tested - with no memo, invalidation is a non-issue by construction. What's left covers the one remaining cache: each `package.json` is parsed once however many modules share it, and is re-read after it's invalidated. **Benchmark app** - a synthetic mid-size RN 0.87 app: 23 common dependencies (Reanimated, React Navigation, TanStack Query, lodash, etc.) and ~1,000 generated first-party modules, 60% of them in a workspace package outside `projectRoot` with its own `node_modules`. The iOS dev bundle is 2.8k modules, from a file map of 34k files. Timings replay the `resolveDependency` calls a real build of it makes against a fresh `DependencyGraph` per process, in interleaved rounds, with source and `package.json` edits made on disk and picked up by the watcher. These timings isolate resolution - no transformation, serialisation or file reads are included. A real build of this app takes around 4s even with a warm transform cache, so resolution is about 2% of it. Changelog: ``` - **[Fix]**: Resolver: Fix stale resolutions after adding a nearer `package.json` or retargeting a symlinked directory, which previously needed a restart ```
robhogan
force-pushed
the
robhogan/hierarchical-lookup-basename
branch
2 times, most recently
from
September 29, 2026 14:36
f0bbf74 to
b9bf624
Compare
robhogan
removed this pull request from stack #1947
October 9, 2026 15:33
robhogan
force-pushed
the
robhogan/hierarchical-lookup-basename
branch
from
October 9, 2026 15:34
b9bf624 to
a972a80
Compare
robhogan
added a commit
that referenced
this pull request
Oct 14, 2026
Stacked on #1946. ## Summary `PackageCache` remembers the closest `package.json` for every module path it's asked about, with a reverse index from each `package.json` to the module paths under it and a set of paths known to have no package, both of which exist only to invalidate the first. `DependencyGraph` calls `invalidate` for every changed file to keep it honest. With the previous two PRs that memo no longer pays for itself. #1945 cut closest-package lookups 3.7x (64k -> 17k per build on our mid-size RN benchmark app*), and #1946 made each one a walk of single map lookups, 2.1x faster (870ns -> 406ns). This removes the memo and both indices, and looks up the closest package on every call. The parsed contents of each `package.json` are still cached per path, and `invalidate` now just drops those. The other reason to do this is incremental resolution (#1944). Any cache that sits between the resolver and the file system has to store the observations that produced each entry and replay them on a hit, or the second resolution to hit the cache can never be invalidated. That's a fair amount of machinery to add to a cache that's worth 3% of first-build resolution time, which is itself a small part of a build. This only makes sense on top of the stack - at #1944, removing the memo costs 14% on a first pass, and nearly doubles a pass that re-resolves everything. \* Described in the test plan. ## Bugs fixed This also fixes two invalidation bugs, both present on main, where the watcher picks up a change on disk but resolution keeps its old answer until Metro restarts: - A nearer `package.json` is added, e.g. creating a nested package in a monorepo. The memo is invalidated through a reverse index from each `package.json` to the modules that resolved to it, and a new `package.json` has no entries, so the modules beneath it keep pointing at the farther one. (It's masked when there was previously no enclosing package at all, which is special-cased.) - A directory symlink is retargeted into another package. Entries are keyed by the path as it was asked for, through the link, and only the link's own path is invalidated. Both changes reset the graph, so everything is re-resolved - against the stale memo, which is probably why they've gone unnoticed. Reproduced with a real `DependencyGraph` and watcher on main and on #1946, using a `browser` redirect in the affected `package.json` to show which one was consulted. With no memo there's nothing to go stale - every lookup walks the live file map. ## Benchmark Resolution time in isolation, replaying every `DependencyGraph.resolveDependency` call made by one iOS dev build of the benchmark app. Nothing is transformed or serialised, so these are percentages of resolution, not of a build. This goes through `DependencyGraph`'s whole-resolution cache, which is cleared only by real file change events, so each row is something that actually happens in a session. 7 interleaved rounds, medians of 15 iterations. Resolver output is byte-identical. | scenario | #1946 | this diff | |---|---|---| | first build (ms) | 92.9 | 95.6 (+2.9%) | | rebuild, nothing changed (ms) | 7.58 | 7.61 | | after a source edit, re-resolving the edited module's 8 dependencies (µs) | 155 | 155 | | after a `package.json` change, re-resolving everything (ms) | 73.6 | 80.4 (+9.3%) | | heap retained after resolving (MB) | 3.0 | **1.9 (−37%)** | The tradeoffs are: - ~3% of resolution time on a first build (3ms here). A real build does far more than resolve - the same build takes around 4s end to end with a warm transform cache - so that's under 0.1% of it. - ~9% (7ms here) when a `package.json` or symlink change resets the whole graph, which is the only time everything is re-resolved against a warm `PackageCache`. Incremental resolution aims to remove those full resets. - Nothing on an ordinary edit. Metro re-resolves only the edited module's dependencies, and the difference there is within noise. ## Test plan ``` yarn jest packages/metro-file-map packages/metro-resolver packages/metro/src/node-haste packages/metro/src/DeltaBundler packages/metro/src/integration_tests yarn flow check yarn verify-api-snapshots ``` `PackageCache-test.js` loses its tests of memo invalidation, along with the structures they tested - with no memo, invalidation is a non-issue by construction. What's left covers the one remaining cache: each `package.json` is parsed once however many modules share it, and is re-read after it's invalidated. **Benchmark app** - a synthetic mid-size RN 0.87 app: 23 common dependencies (Reanimated, React Navigation, TanStack Query, lodash, etc.) and ~1,000 generated first-party modules, 60% of them in a workspace package outside `projectRoot` with its own `node_modules`. The iOS dev bundle is 2.8k modules, from a file map of 34k files. Timings replay the `resolveDependency` calls a real build of it makes against a fresh `DependencyGraph` per process, in interleaved rounds, with source and `package.json` edits made on disk and picked up by the watcher. These timings isolate resolution - no transformation, serialisation or file reads are included. A real build of this app takes around 4s even with a warm transform cache, so resolution is about 2% of it. Changelog: ``` - **[Fix]**: Resolver: Fix stale resolutions after adding a nearer `package.json` or retargeting a symlinked directory, which previously needed a restart ```
robhogan
force-pushed
the
robhogan/hierarchical-lookup-basename
branch
from
October 19, 2026 07:10
45b4275 to
20e3fb6
Compare
robhogan
force-pushed
the
robhogan/hierarchical-lookup-basename
branch
2 times, most recently
from
October 19, 2026 07:34
9aa3724 to
ded3137
Compare
robhogan
force-pushed
the
robhogan/hierarchical-lookup-basename
branch
from
October 19, 2026 07:48
ded3137 to
ede291d
Compare
robhogan
marked this pull request as ready for review
October 19, 2026 08:03
Base automatically changed from
robhogan/resolve-package-once-per-directory
to
main
October 20, 2026 08:59
Stacked on #1945. `hierarchicalLookup` walks up from a start path asking whether each ancestor contains a given subpath - it's how `DependencyGraph` finds the closest `package.json`. For every candidate ancestor it joined the candidate's path to the subpath and traversed the result from the root again, even though the traversal that collected the ancestors already holds each candidate's node. The loop even built a `start` argument for `#lookupByNormalPath` to resume from, but it was never read. Each candidate is now checked from its own node: - When the subpath is a single path segment, which is the only way Metro calls it, with one `Map.get`. Whether that applies is decided once per call, by checking the subpath for a path separator. - Otherwise, by traversing only the subpath from the candidate. Expo CLI's `createTypescriptResolver` calls it this way, with deep subpaths such as `node_modules/${targetPath}/tsconfig.json`. Some single segments still take the traversal from the candidate: - A symlinked child, because we need to follow it to learn its type and real path. - A segment on an ancestor of the project root. Its node doesn't hold the segment that leads back down towards the root (with a root of `/A/B/C`, the node for `/A/B` has no `C` entry), so a map lookup there could wrongly report a directory as missing. A subpath that re-enters the project root from an ancestor of it, e.g. `B/C/a/package.json` from `/A`, is still traversed from the root. The joined path collapses to `a/package.json`, of which the candidate's path is no longer a prefix. An empty subpath, `.` and `..` also take the traversal, since a map lookup would answer those differently. The API is unchanged, and observations are recorded exactly as before - the existing test table, which asserts the recorded sets, passes unchanged. This also removes the unused `startPathIdx` and `startNode` options of `#lookupByNormalPath`. I also tried the more explicit alternative of having the caller pass either a `basename` or a `subpath`, which avoids the separator check. It measured 387ns per walk against 394ns for the separator check, within noise, and it changes the call shape for every caller, so it isn't worth it. ## Benchmark Each closest-package walk is 2.1x faster and each deep-subpath walk 1.4x faster, which takes 13% off first-pass resolution time against `main` on our benchmark app (described in the test plan). Resolver output is byte-identical. ### Walks Replaying the closest-package walks of one resolver pass directly against `hierarchicalLookup` (4,483 walks on the benchmark app), once for `package.json` and once for a deep subpath (`node_modules/react/package.json`) from the same start paths. Nanoseconds per walk, medians of 9 interleaved rounds. The middle rows are each half of this diff on its own, to show which half does what: | variant | `package.json` | deep subpath | |---|---|---| | `main` | 911 | 1,150 | | traversal from the candidate only | 598 (−34%) | 792 (−31%) | | `Map.get` for a single segment only | 426 (−53%) | 1,161 (+1%) | | this diff | **426 (−53%)** | **813 (−29%)** | The two halves are complementary: the map lookup is the bigger win for a single segment, and only the traversal from the candidate helps a deep subpath. Together they cost nothing on either path - the ~20ns behind "traversal only" on a deep subpath is the separator check. ### Resolver, end to end Resolution in isolation, 7 interleaved rounds. Only the first pass can move - the later passes are served by `PackageCache` and make no `hierarchicalLookup` calls, and are within ±2% of `main`. | metric | `main` | this diff | |---|---|---| | first pass after startup (ms) | 125.3 | **109.2 (−12.9%)** | ## Changelog Internal ## Test plan ``` yarn jest packages/metro-file-map packages/metro-resolver packages/metro/src/node-haste packages/metro/src/DeltaBundler/__tests__/resolver-test.js yarn flow check yarn typecheck-ts yarn verify-api-snapshots ``` New tests cover a directory-type lookup with a single-segment subpath, a deep subpath, and a subpath that re-enters the project root from an ancestor of it - that last one fails if traversal always starts from the candidate. The existing fixture already covers a symlinked `package.json`, a broken link, and a directory named `package.json`. **Benchmarks** - measured with this diff applied directly to `main` (at a95869a) rather than on top of #1945, so that the baseline is `main`. The ports substitute `main`'s `invalidatedBy` for #1944's observations, and give identical results for every walk, including the recorded paths. **Benchmark app** - a synthetic mid-size RN 0.87 app: 23 common dependencies (Reanimated, React Navigation, TanStack Query, lodash, etc.) and ~1,000 generated first-party modules, 60% of them in a workspace package outside `projectRoot` with its own `node_modules`. The iOS dev bundle is 2.8k modules, from a file map of 34k files. Timings replay the resolutions a real build of it makes against a fresh `DependencyGraph` per process, in interleaved rounds. These timings isolate resolution - no transformation, serialisation or file reads are included. A real build of this app takes around 4s even with a warm transform cache, so resolution is about 2% of it.
robhogan
force-pushed
the
robhogan/hierarchical-lookup-basename
branch
from
October 20, 2026 08:59
ede291d to
eb5502c
Compare
robhogan
added a commit
that referenced
this pull request
Oct 20, 2026
Stacked on #1946. ## Summary `PackageCache` remembers the closest `package.json` for every module path it's asked about, with a reverse index from each `package.json` to the module paths under it and a set of paths known to have no package, both of which exist only to invalidate the first. `DependencyGraph` calls `invalidate` for every changed file to keep it honest. With the previous two PRs that memo no longer pays for itself. #1945 cut closest-package lookups 3.7x (64k -> 17k per build on our mid-size RN benchmark app*), and #1946 made each one a walk of single map lookups, 2.1x faster (870ns -> 406ns). This removes the memo and both indices, and looks up the closest package on every call. The parsed contents of each `package.json` are still cached per path, and `invalidate` now just drops those. The other reason to do this is incremental resolution (#1944). Any cache that sits between the resolver and the file system has to store the observations that produced each entry and replay them on a hit, or the second resolution to hit the cache can never be invalidated. That's a fair amount of machinery to add to a cache that's worth 3% of first-build resolution time, which is itself a small part of a build. This only makes sense on top of the stack - at #1944, removing the memo costs 14% on a first pass, and nearly doubles a pass that re-resolves everything. \* Described in the test plan. ## Bugs fixed This also fixes two invalidation bugs, both present on main, where the watcher picks up a change on disk but resolution keeps its old answer until Metro restarts: - A nearer `package.json` is added, e.g. creating a nested package in a monorepo. The memo is invalidated through a reverse index from each `package.json` to the modules that resolved to it, and a new `package.json` has no entries, so the modules beneath it keep pointing at the farther one. (It's masked when there was previously no enclosing package at all, which is special-cased.) - A directory symlink is retargeted into another package. Entries are keyed by the path as it was asked for, through the link, and only the link's own path is invalidated. Both changes reset the graph, so everything is re-resolved - against the stale memo, which is probably why they've gone unnoticed. Reproduced with a real `DependencyGraph` and watcher on main and on #1946, using a `browser` redirect in the affected `package.json` to show which one was consulted. With no memo there's nothing to go stale - every lookup walks the live file map. ## Benchmark Resolution time in isolation, replaying every `DependencyGraph.resolveDependency` call made by one iOS dev build of the benchmark app. Nothing is transformed or serialised, so these are percentages of resolution, not of a build. This goes through `DependencyGraph`'s whole-resolution cache, which is cleared only by real file change events, so each row is something that actually happens in a session. 7 interleaved rounds, medians of 15 iterations. Resolver output is byte-identical. | scenario | #1946 | this diff | |---|---|---| | first build (ms) | 92.9 | 95.6 (+2.9%) | | rebuild, nothing changed (ms) | 7.58 | 7.61 | | after a source edit, re-resolving the edited module's 8 dependencies (µs) | 155 | 155 | | after a `package.json` change, re-resolving everything (ms) | 73.6 | 80.4 (+9.3%) | | heap retained after resolving (MB) | 3.0 | **1.9 (−37%)** | The tradeoffs are: - ~3% of resolution time on a first build (3ms here). A real build does far more than resolve - the same build takes around 4s end to end with a warm transform cache - so that's under 0.1% of it. - ~9% (7ms here) when a `package.json` or symlink change resets the whole graph, which is the only time everything is re-resolved against a warm `PackageCache`. Incremental resolution aims to remove those full resets. - Nothing on an ordinary edit. Metro re-resolves only the edited module's dependencies, and the difference there is within noise. ## Test plan ``` yarn jest packages/metro-file-map packages/metro-resolver packages/metro/src/node-haste packages/metro/src/DeltaBundler packages/metro/src/integration_tests yarn flow check yarn verify-api-snapshots ``` `PackageCache-test.js` loses its tests of memo invalidation, along with the structures they tested - with no memo, invalidation is a non-issue by construction. What's left covers the one remaining cache: each `package.json` is parsed once however many modules share it, and is re-read after it's invalidated. **Benchmark app** - a synthetic mid-size RN 0.87 app: 23 common dependencies (Reanimated, React Navigation, TanStack Query, lodash, etc.) and ~1,000 generated first-party modules, 60% of them in a workspace package outside `projectRoot` with its own `node_modules`. The iOS dev bundle is 2.8k modules, from a file map of 34k files. Timings replay the `resolveDependency` calls a real build of it makes against a fresh `DependencyGraph` per process, in interleaved rounds, with source and `package.json` edits made on disk and picked up by the watcher. These timings isolate resolution - no transformation, serialisation or file reads are included. A real build of this app takes around 4s even with a warm transform cache, so resolution is about 2% of it. Changelog: ``` - **[Fix]**: Resolver: Fix stale resolutions after adding a nearer `package.json` or retargeting a symlinked directory, which previously needed a restart ```
Contributor
|
Reviewing |
vzaidman
reviewed
Oct 20, 2026
| start?: { | ||
| // Resume traversal from a known directory node, whose normal path is | ||
| // the prefix of the requested path ending at pathIdx - 1. | ||
| start?: ?{ |
Contributor
There was a problem hiding this comment.
I wish it was:
start?: ?{
node: DirectoryNode,
nodeResumeCharOffset: number,
nodeLevelsAboveRoot: ?number,
}
the current start members are a bit cryptic at first.
but obviously this is better for consistency.
Collaborator
Author
There was a problem hiding this comment.
Yeah, sorry, I get that - TreeFS is obviously highly optimised and pretty complicated so some of these internal APIs are somewhat obscure and were difficult to name (and maybe their usage has expanded beyond their original design in some cases).
As you say they're named here after the pre-existing labels so I don't want to change them all in this PR, but might be worth a follow up audit.
Contributor
There was a problem hiding this comment.
@robhogan could you follow up adding this particular change to the stack (if you think these names are better)?
vzaidman
reviewed
Oct 20, 2026
robhogan
added a commit
that referenced
this pull request
Oct 20, 2026
Stacked on #1946. ## Summary `PackageCache` remembers the closest `package.json` for every module path it's asked about, with a reverse index from each `package.json` to the module paths under it and a set of paths known to have no package, both of which exist only to invalidate the first. `DependencyGraph` calls `invalidate` for every changed file to keep it honest. With the previous two PRs that memo no longer pays for itself. #1945 cut closest-package lookups 3.7x (64k -> 17k per build on our mid-size RN benchmark app*), and #1946 made each one a walk of single map lookups, 2.1x faster (870ns -> 406ns). This removes the memo and both indices, and looks up the closest package on every call. The parsed contents of each `package.json` are still cached per path, and `invalidate` now just drops those. The other reason to do this is incremental resolution (#1944). Any cache that sits between the resolver and the file system has to store the observations that produced each entry and replay them on a hit, or the second resolution to hit the cache can never be invalidated. That's a fair amount of machinery to add to a cache that's worth 3% of first-build resolution time, which is itself a small part of a build. This only makes sense on top of the stack - at #1944, removing the memo costs 14% on a first pass, and nearly doubles a pass that re-resolves everything. \* Described in the test plan. ## Bugs fixed This also fixes two invalidation bugs, both present on main, where the watcher picks up a change on disk but resolution keeps its old answer until Metro restarts: - A nearer `package.json` is added, e.g. creating a nested package in a monorepo. The memo is invalidated through a reverse index from each `package.json` to the modules that resolved to it, and a new `package.json` has no entries, so the modules beneath it keep pointing at the farther one. (It's masked when there was previously no enclosing package at all, which is special-cased.) - A directory symlink is retargeted into another package. Entries are keyed by the path as it was asked for, through the link, and only the link's own path is invalidated. Both changes reset the graph, so everything is re-resolved - against the stale memo, which is probably why they've gone unnoticed. Reproduced with a real `DependencyGraph` and watcher on main and on #1946, using a `browser` redirect in the affected `package.json` to show which one was consulted. With no memo there's nothing to go stale - every lookup walks the live file map. ## Benchmark Resolution time in isolation, replaying every `DependencyGraph.resolveDependency` call made by one iOS dev build of the benchmark app. Nothing is transformed or serialised, so these are percentages of resolution, not of a build. This goes through `DependencyGraph`'s whole-resolution cache, which is cleared only by real file change events, so each row is something that actually happens in a session. 7 interleaved rounds, medians of 15 iterations. Resolver output is byte-identical. | scenario | #1946 | this diff | |---|---|---| | first build (ms) | 92.9 | 95.6 (+2.9%) | | rebuild, nothing changed (ms) | 7.58 | 7.61 | | after a source edit, re-resolving the edited module's 8 dependencies (µs) | 155 | 155 | | after a `package.json` change, re-resolving everything (ms) | 73.6 | 80.4 (+9.3%) | | heap retained after resolving (MB) | 3.0 | **1.9 (−37%)** | The tradeoffs are: - ~3% of resolution time on a first build (3ms here). A real build does far more than resolve - the same build takes around 4s end to end with a warm transform cache - so that's under 0.1% of it. - ~9% (7ms here) when a `package.json` or symlink change resets the whole graph, which is the only time everything is re-resolved against a warm `PackageCache`. Incremental resolution aims to remove those full resets. - Nothing on an ordinary edit. Metro re-resolves only the edited module's dependencies, and the difference there is within noise. ## Test plan ``` yarn jest packages/metro-file-map packages/metro-resolver packages/metro/src/node-haste packages/metro/src/DeltaBundler packages/metro/src/integration_tests yarn flow check yarn verify-api-snapshots ``` `PackageCache-test.js` loses its tests of memo invalidation, along with the structures they tested - with no memo, invalidation is a non-issue by construction. What's left covers the one remaining cache: each `package.json` is parsed once however many modules share it, and is re-read after it's invalidated. **Benchmark app** - a synthetic mid-size RN 0.87 app: 23 common dependencies (Reanimated, React Navigation, TanStack Query, lodash, etc.) and ~1,000 generated first-party modules, 60% of them in a workspace package outside `projectRoot` with its own `node_modules`. The iOS dev bundle is 2.8k modules, from a file map of 34k files. Timings replay the `resolveDependency` calls a real build of it makes against a fresh `DependencyGraph` per process, in interleaved rounds, with source and `package.json` edits made on disk and picked up by the watcher. These timings isolate resolution - no transformation, serialisation or file reads are included. A real build of this app takes around 4s even with a warm transform cache, so resolution is about 2% of it. Changelog: ``` - **[Fix]**: Resolver: Fix stale resolutions after adding a nearer `package.json` or retargeting a symlinked directory, which previously needed a restart ```
robhogan
added a commit
that referenced
this pull request
Oct 21, 2026
Stacked on #1946. ## Summary `PackageCache` remembers the closest `package.json` for every module path it's asked about, with a reverse index from each `package.json` to the module paths under it and a set of paths known to have no package, both of which exist only to invalidate the first. `DependencyGraph` calls `invalidate` for every changed file to keep it honest. With the previous two PRs that memo no longer pays for itself. #1945 cut closest-package lookups 3.7x (64k -> 17k per build on our mid-size RN benchmark app*), and #1946 made each one a walk of single map lookups, 2.1x faster (870ns -> 406ns). This removes the memo and both indices, and looks up the closest package on every call. The parsed contents of each `package.json` are still cached per path, and `invalidate` now just drops those. The other reason to do this is incremental resolution (#1944). Any cache that sits between the resolver and the file system has to store the observations that produced each entry and replay them on a hit, or the second resolution to hit the cache can never be invalidated. That's a fair amount of machinery to add to a cache that's worth 3% of first-build resolution time, which is itself a small part of a build. This only makes sense on top of the stack - at #1944, removing the memo costs 14% on a first pass, and nearly doubles a pass that re-resolves everything. \* Described in the test plan. ## Bugs fixed This also fixes two invalidation bugs, both present on main, where the watcher picks up a change on disk but resolution keeps its old answer until Metro restarts: - A nearer `package.json` is added, e.g. creating a nested package in a monorepo. The memo is invalidated through a reverse index from each `package.json` to the modules that resolved to it, and a new `package.json` has no entries, so the modules beneath it keep pointing at the farther one. (It's masked when there was previously no enclosing package at all, which is special-cased.) - A directory symlink is retargeted into another package. Entries are keyed by the path as it was asked for, through the link, and only the link's own path is invalidated. Both changes reset the graph, so everything is re-resolved - against the stale memo, which is probably why they've gone unnoticed. Reproduced with a real `DependencyGraph` and watcher on main and on #1946, using a `browser` redirect in the affected `package.json` to show which one was consulted. With no memo there's nothing to go stale - every lookup walks the live file map. ## Benchmark Resolution time in isolation, replaying every `DependencyGraph.resolveDependency` call made by one iOS dev build of the benchmark app. Nothing is transformed or serialised, so these are percentages of resolution, not of a build. This goes through `DependencyGraph`'s whole-resolution cache, which is cleared only by real file change events, so each row is something that actually happens in a session. 7 interleaved rounds, medians of 15 iterations. Resolver output is byte-identical. | scenario | #1946 | this diff | |---|---|---| | first build (ms) | 92.9 | 95.6 (+2.9%) | | rebuild, nothing changed (ms) | 7.58 | 7.61 | | after a source edit, re-resolving the edited module's 8 dependencies (µs) | 155 | 155 | | after a `package.json` change, re-resolving everything (ms) | 73.6 | 80.4 (+9.3%) | | heap retained after resolving (MB) | 3.0 | **1.9 (−37%)** | The tradeoffs are: - ~3% of resolution time on a first build (3ms here). A real build does far more than resolve - the same build takes around 4s end to end with a warm transform cache - so that's under 0.1% of it. - ~9% (7ms here) when a `package.json` or symlink change resets the whole graph, which is the only time everything is re-resolved against a warm `PackageCache`. Incremental resolution aims to remove those full resets. - Nothing on an ordinary edit. Metro re-resolves only the edited module's dependencies, and the difference there is within noise. ## Test plan ``` yarn jest packages/metro-file-map packages/metro-resolver packages/metro/src/node-haste packages/metro/src/DeltaBundler packages/metro/src/integration_tests yarn flow check yarn verify-api-snapshots ``` `PackageCache-test.js` loses its tests of memo invalidation, along with the structures they tested - with no memo, invalidation is a non-issue by construction. What's left covers the one remaining cache: each `package.json` is parsed once however many modules share it, and is re-read after it's invalidated. **Benchmark app** - a synthetic mid-size RN 0.87 app: 23 common dependencies (Reanimated, React Navigation, TanStack Query, lodash, etc.) and ~1,000 generated first-party modules, 60% of them in a workspace package outside `projectRoot` with its own `node_modules`. The iOS dev bundle is 2.8k modules, from a file map of 34k files. Timings replay the `resolveDependency` calls a real build of it makes against a fresh `DependencyGraph` per process, in interleaved rounds, with source and `package.json` edits made on disk and picked up by the watcher. These timings isolate resolution - no transformation, serialisation or file reads are included. A real build of this app takes around 4s even with a warm transform cache, so resolution is about 2% of it. Changelog: ``` - **[Fix]**: Resolver: Fix stale resolutions after adding a nearer `package.json` or retargeting a symlinked directory, which previously needed a restart ```
robhogan
added a commit
that referenced
this pull request
Oct 21, 2026
Summary: Publish Metro 0.87.2 from `main`. Changelog: [Internal] Test plan: ``` node scripts/updateVersion.js 0.87.2 sl diff --stat ``` Only the 17 `packages/*/package.json` manifests change, and every `0.87.1` reference is replaced. Draft release notes (from the 95 commits in v0.87.1...main - internal-only changes omitted): ``` - **[Feature]**: Add a built-in `metro:runtime/*` specifier, resolvable from any importer, eg for `transformer.asyncRequireModulePath` (#2083 by @robhogan) - **[Feature]**: When `transformer.enableBabelRuntime` is `true`, pass Metro's own `@babel/runtime` (`metro:babel-runtime`) and its installed version to Babel presets via caller data (#1957 by @robhogan) - **[Fix]**: Resolver: Fix stale resolutions after adding a nearer `package.json` or retargeting a symlinked directory, which previously needed a restart (#1948 by @robhogan) - **[Fix]**: Resolver: Packages and directories named with an asset extension (eg `lodash.zip`) resolve as modules rather than failing as assets (#2055 by @robhogan) - **[Fix]**: Resolver: Fall through to parent conditions when a nested `"exports"` or `"imports"` condition has no matching target (#2074 by @maximilliangrand) - **[Fix]**: Resolver: An exact `"exports"` or `"imports"` subpath with a `null` or unmatched target no longer resolves through a less specific subpath pattern (#1987 by @kwy404) - **[Fix]**: Use indexed watch folder paths (`[metro-watchFolders]/N/`) for assets outside `projectRoot` (#1892 by @robhogan) - **[Fix]**: Fix `/[metro-project]/` and `/[metro-watchFolders]/` bundle serving on Windows (#2015 by @motiz88) - **[Fix]**: Prevent HMR crash on `[metro-watchFolders]` or `[metro-project]` URLs (#2017 by @motiz88) - **[Fix]**: Remove erroneous `[metro-project]` and `[metro-watchFolders]` resolution of entry points from the API and CLI (#2030 by @motiz88) - **[Fix]**: Fix backslashes in async bundle paths on Windows (`includeAsyncPaths`) (#1938 by @robhogan) - **[Fix]**: Fix stale modules on the client when a file is deleted and recreated during a build, or changes during a build that fails (#2007 by @YevheniiKotyrlo) - **[Fix]**: Fix files created or modified while Metro starts up being missed until restart (#2054 by @cpruijsen) - **[Fix]**: Fix watch mode losing files when a directory replaces a file or symlink, eg when unlinking a package (#2009 by @robhogan) - **[Fix]**: Fix the fallback (Linux/Windows) watcher hanging on stop, and missing changes in a replaced directory, after a watch error (#1967 by @robhogan) - **[Fix]**: Fix the fallback watcher missing changes when a directory changes faster than Windows can report (#1967 by @robhogan) - **[Fix]**: Close the watch handles of a deleted directory and every directory under it in `FallbackWatcher`, which kept the process busy on Windows (#2004 by @YevheniiKotyrlo) - **[Fix]**: `metro-file-map`: Fix removing a non-active duplicate mock changing which mock is active (#1756 by @durvesh1992) - **[Fix]**: Propagate transformer initialisation errors from `Bundler` (#1854 by @Boulea7) - **[Fix]**: Release transform workers and file watchers when Metro fails to initialise (#1963 by @robhogan) - **[Fix]**: Ignore hyphenated and namespaced SVG attributes (eg `stroke-width`) when reading SVG asset dimensions (#1972 by @GabrielDumbrava) - **[Fix]**: Fix generated positions when iterating the mappings of indexed source maps, eg in `composeSourceMaps` (#1976 by @kwy404) - **[Fix]**: `composeSourceMaps` keeps every mapping at a generated column that the last map maps more than once (#1981 by @robhogan) - **[Fix]**: Source maps no longer lose lines after a gap of more than about two million unmapped generated lines (#1983 by @robhogan) - **[Fix]**: `metro-symbolicate` no longer fails on stack frames with no source position, such as `[native code]:0:0` in crash reporter output (#1891 by @robhogan) - **[Fix]**: Fix `metro-symbolicate` parsing of stack frames with Windows absolute paths (#1939 by @robhogan) - **[Performance]**: `metro-source-map`: Faster `Consumer` decoding and position lookups, and `composeSourceMaps` encodes its output directly - 3.2x faster composition of a bundle map with its Hermes map, using 15% less memory (#1978, #1979, #1980, #1983 by @robhogan) - **[Performance]**: Resolver: Ask for the package of a directory once for all extension candidates, and check `hierarchicalLookup` candidates from their own file map nodes. First-pass resolution is 35% faster (172ms -> 113ms on a mid-size RN app), net of a ~3% first-build cost from dropping `PackageCache`'s closest-package memo, which also cuts the resolver's retained heap by 37% (#1945, #1946, #1948 by @robhogan) - **[Performance]**: 33% faster non-Watchman file system crawl on startup (#1677 by @kitten) - **[Deprecated]**: `server.enhanceMiddleware` logs a deprecation warning when set to anything other than its default. Use `server.unstable_middleware` or `server.unstable_priorityMiddleware` (#2031 by @vzaidman) - **[Deprecated]**: `runServer`'s `unstable_extraMiddleware` option. Use `unstable_priorityMiddleware`, which runs the same middleware in the same place (#2031 by @vzaidman) - **[Deprecated]**: String values of `transformer.enableBabelRuntime` (use `true`) (#1957 by @robhogan) - **[Types]**: Add TypeScript definitions for `metro-runtime` modules, and generate (rather than hand-maintain) types for `metro-file-map` workers (#1996 by @robhogan) > NOTE: Experimental features are not covered by semver and can change at any time. - **[Experimental]**: Add `server.unstable_middleware` and `server.unstable_priorityMiddleware` config options, and `runServer` options of the same names, to add middleware after and before Metro's own. `mergeConfig` combines them from both configs: a later config's `unstable_middleware` runs last and its `unstable_priorityMiddleware` runs first (#2031 by @vzaidman) - **[Experimental]**: Add `server.unstable_onServerCreated` config option and `runServer` option of the same name, called with Metro's server instance before the HTTP server listens (#2031 by @vzaidman) - **[Experimental]**: Fix mismatched asset paths in (release) builds under `unstable_serverRoot`, make them always relative to `projectRoot` (#1917 by @robhogan) **Full Changelog**: v0.87.1...v0.87.2 ```
robhogan
added a commit
that referenced
this pull request
Oct 21, 2026
Summary: Publish Metro 0.87.2 from `main`. Changelog: [Internal] Test plan: ``` node scripts/updateVersion.js 0.87.2 sl diff --stat ``` Only the 17 `packages/*/package.json` manifests change, and every `0.87.1` reference is replaced. Draft release notes (from the 95 commits in v0.87.1...main - internal-only changes omitted): ``` - **[Feature]**: Add a built-in `metro:runtime/*` specifier, resolvable from any importer, eg for `transformer.asyncRequireModulePath` (#2083 by @robhogan) - **[Feature]**: When `transformer.enableBabelRuntime` is `true`, pass Metro's own `@babel/runtime` (`metro:babel-runtime`) and its installed version to Babel presets via caller data (#1957 by @robhogan) - **[Fix]**: Resolver: Fix stale resolutions after adding a nearer `package.json` or retargeting a symlinked directory, which previously needed a restart (#1948 by @robhogan) - **[Fix]**: Resolver: Packages and directories named with an asset extension (eg `lodash.zip`) resolve as modules rather than failing as assets (#2055 by @robhogan) - **[Fix]**: Resolver: Fall through to parent conditions when a nested `"exports"` or `"imports"` condition has no matching target (#2074 by @maximilliangrand) - **[Fix]**: Resolver: An exact `"exports"` or `"imports"` subpath with a `null` or unmatched target no longer resolves through a less specific subpath pattern (#1987 by @kwy404) - **[Fix]**: Use indexed watch folder paths (`[metro-watchFolders]/N/`) for assets outside `projectRoot` (#1892 by @robhogan) - **[Fix]**: Fix `/[metro-project]/` and `/[metro-watchFolders]/` bundle serving on Windows (#2015 by @motiz88) - **[Fix]**: Prevent HMR crash on `[metro-watchFolders]` or `[metro-project]` URLs (#2017 by @motiz88) - **[Fix]**: Remove erroneous `[metro-project]` and `[metro-watchFolders]` resolution of entry points from the API and CLI (#2030 by @motiz88) - **[Fix]**: Fix backslashes in async bundle paths on Windows (`includeAsyncPaths`) (#1938 by @robhogan) - **[Fix]**: Fix stale modules on the client when a file is deleted and recreated during a build, or changes during a build that fails (#2007 by @YevheniiKotyrlo) - **[Fix]**: Fix files created or modified while Metro starts up being missed until restart (#2054 by @cpruijsen) - **[Fix]**: Fix watch mode losing files when a directory replaces a file or symlink, eg when unlinking a package (#2009 by @robhogan) - **[Fix]**: Fix the fallback (Linux/Windows) watcher hanging on stop, and missing changes in a replaced directory, after a watch error (#1967 by @robhogan) - **[Fix]**: Fix the fallback watcher missing changes when a directory changes faster than Windows can report (#1967 by @robhogan) - **[Fix]**: Close the watch handles of a deleted directory and every directory under it in `FallbackWatcher`, which kept the process busy on Windows (#2004 by @YevheniiKotyrlo) - **[Fix]**: `metro-file-map`: Fix removing a non-active duplicate mock changing which mock is active (#1756 by @durvesh1992) - **[Fix]**: Propagate transformer initialisation errors from `Bundler` (#1854 by @Boulea7) - **[Fix]**: Release transform workers and file watchers when Metro fails to initialise (#1963 by @robhogan) - **[Fix]**: Ignore hyphenated and namespaced SVG attributes (eg `stroke-width`) when reading SVG asset dimensions (#1972 by @GabrielDumbrava) - **[Fix]**: Fix generated positions when iterating the mappings of indexed source maps, eg in `composeSourceMaps` (#1976 by @kwy404) - **[Fix]**: `composeSourceMaps` keeps every mapping at a generated column that the last map maps more than once (#1981 by @robhogan) - **[Fix]**: Source maps no longer lose lines after a gap of more than about two million unmapped generated lines (#1983 by @robhogan) - **[Fix]**: `metro-symbolicate` no longer fails on stack frames with no source position, such as `[native code]:0:0` in crash reporter output (#1891 by @robhogan) - **[Fix]**: Fix `metro-symbolicate` parsing of stack frames with Windows absolute paths (#1939 by @robhogan) - **[Performance]**: `metro-source-map`: Faster `Consumer` decoding and position lookups, and `composeSourceMaps` encodes its output directly - ~3.2x faster composition of a bundle map with its Hermes map, using ~15% less memory (#1978, #1979, #1980, #1983 by @robhogan) - **[Performance]**: Resolver: Ask for the package of a directory once for all extension candidates, and check `hierarchicalLookup` candidates from their own file map nodes. First-pass resolution is ~35% faster, net of a ~3% first-build cost from dropping `PackageCache`'s closest-package memo, which also cuts the resolver's retained heap by ~37% (#1945, #1946, #1948 by @robhogan) - **[Performance]**: ~33% faster non-Watchman file system crawl on startup (#1677 by @kitten) - **[Deprecated]**: `server.enhanceMiddleware` logs a deprecation warning when set to anything other than its default. Use `server.unstable_middleware` or `server.unstable_priorityMiddleware` (#2031 by @vzaidman) - **[Deprecated]**: `runServer`'s `unstable_extraMiddleware` option. Use `unstable_priorityMiddleware`, which runs the same middleware in the same place (#2031 by @vzaidman) - **[Deprecated]**: String values of `transformer.enableBabelRuntime` (use `true`) (#1957 by @robhogan) - **[Types]**: Add TypeScript definitions for `metro-runtime` modules, and generate (rather than hand-maintain) types for `metro-file-map` workers (#1996 by @robhogan) > NOTE: Experimental features are not covered by semver and can change at any time. - **[Experimental]**: Add `server.unstable_middleware` and `server.unstable_priorityMiddleware` config options, and `runServer` options of the same names, to add middleware after and before Metro's own. `mergeConfig` combines them from both configs: a later config's `unstable_middleware` runs last and its `unstable_priorityMiddleware` runs first (#2031 by @vzaidman) - **[Experimental]**: Add `server.unstable_onServerCreated` config option and `runServer` option of the same name, called with Metro's server instance before the HTTP server listens (#2031 by @vzaidman) - **[Experimental]**: Fix mismatched asset paths in (release) builds under `unstable_serverRoot`, make them always relative to `projectRoot` (#1917 by @robhogan) **Full Changelog**: v0.87.1...v0.87.2 ```
robhogan
added a commit
that referenced
this pull request
Oct 23, 2026
This fixes three regressions since 0.87.1, all in paths that reach a file or directory other than the way the file map stores it. ## Symlinked roots on a warm start Since #2064, `build()` drops cached files outside the configured roots on a cache hit, so that files from an earlier instance's `addRoot` don't survive. Watchman lists a root's files under its real path, though, so every file of a root given through a symlink (eg a watch folder like `node_modules/my-linked-lib`) is outside it. They're dropped on every warm start and not re-crawled, so imports into that package fail until `--reset-cache`. This also treats each root's real path as a root, at the cost of one `realpath` per root on a cache hit. A custom crawler's file system need not be the disk's, so its roots are taken as given. ## `hierarchicalLookup` through a symlink to an ancestor of the root Once the walk reaches an ancestor of the root, it took that ancestor's path by slicing `..` segments off the start path, which is wrong when the start path reaches it through a symlink instead (eg `app/monorepo -> ..`). That was a harmless miss until #1946 began resuming from the ancestor's node, since when the closest `package.json` of `app/monorepo/shared` is reported at `app/mo/package.json` and resolution fails with `ENOENT`. This uses the ancestor's canonical path, and the start path's real path relative to it. ## Unnormalised roots in the Node crawler Since #1677 the Node crawler builds paths by appending names to the root as given, so a root with a trailing separator (`path.join(__dirname, 'packages/')`) produced keys like `packages//a.js`, and one with `..` segments reached `ignore` unnormalised, defeating `blockList` patterns built from resolved paths. Roots are now resolved first. Changelog: Internal ## Test plan New unit tests for each, which fail without this change. With real Watchman on macOS, a watch folder linked from `node_modules` keeps all 3 of its files on a warm start, against 1 on `main` and 3 on 0.87.1. A differential fuzz of `hierarchicalLookup` (50k lookups in random trees with symlinks) against the code before #1946 and a simple real-path model: wherever this differs from the code before #1946, it matches the model. Those cases are the regression and lookups that previously threw `Failed to collapse`. The Node crawler on a real tree gives `sub/a.js` instead of `sub//a.js`, and now excludes a blocked `../shared/dist`.
robhogan
added a commit
that referenced
this pull request
Oct 23, 2026
Fixes three regressions since 0.87.1: - #2064 drops cached files outside the roots on a warm start. Watchman lists a symlinked root's files under its real path, so they're all dropped, and imports into it fail until `--reset-cache`: ```js watchFolders: [path.resolve(__dirname, 'node_modules/my-linked-lib')] ``` Real paths of roots now count too (built-in crawlers only). - #1946 broke `hierarchicalLookup` through a symlink in a root to its parent: ``` app/monorepo -> .. (watchFolders: ['/repo']) import './monorepo/shared' -> ENOENT: /repo/app/mo/package.json ``` - #1677 stopped the Node crawler normalising roots: ``` roots: ['/proj/sub/'] -> 'sub//a.js' roots: ['/proj/other/../shared'] -> blockList for '/shared/dist' ignored ``` Changelog: Internal Test plan: - New unit tests for each, failing without this change. - Real Watchman, watch folder linked from `node_modules`: 3/3 files on a warm start (`main`: 1/3). - `hierarchicalLookup` fuzz, 50k lookups in random symlinked trees: every difference from before #1946 matches a real-path model.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Context
hierarchicalLookupwalks up from a start path asking whether each ancestor contains a subpath. It's how we find the closestpackage.jsonduring resolution, and so it's an important hot path.This change
Previously, for each ancestor
hierarchicalLookupre-traversed from the root, even though it already holds the ancestor's node:metro/packages/metro-file-map/src/lib/TreeFS.js
Lines 856 to 880 in 6dbe979
This was an oversight in my initial implementation - I intended to use the
startparams but apparently I forgot to complete that optimisation and never used that argument.This completes it - each ancestor is checked from its own node that we retained from the initial walk. That's a single
Map.getfor a one-segment subpath (eg `package.json), which is the only way Metro calls it, or a traversal of just the subpath otherwise (Expo CLI's TypeScript resolver passes deep subpaths).Complex cases: symlinks, ancestors of the project root,
.and..still take the slow-path full traversal.The API is unchanged, and observations are recorded exactly as before.
Each closest-package walk is 2.1x faster (911ns -> 426ns), which takes 11% off first-pass resolution time (87ms -> 78ms on our benchmark app). Later passes don't call
hierarchicalLookupand are unchanged. Resolver output is identical.Benchmark
AI-driven. Closest-package walks of one resolver pass replayed directly against
hierarchicalLookup(4,483 walks), once forpackage.jsonand once for a deep subpath. Nanoseconds per walk, medians of 9 interleaved rounds. The middle rows are each half of this PR on its own:package.jsonmainMap.getfor a single segment onlyResolution end to end, 7 interleaved rounds. Only the first pass can move, since later passes make no
hierarchicalLookupcalls:mainChangelog: Internal
Test plan
New tests cover a single-segment subpath, a deep subpath, and a subpath that re-enters the project root from an ancestor. Walk timings were measured with this change applied directly to
main, so the baseline ismain.Benchmark app: the synthetic RN 0.87 app described in #1945 (2.8k-module bundle, 34k-file file map).