Skip to content

Remove PackageCache's per-module closest package memo - #1948

Draft
robhogan wants to merge 1 commit into
robhogan/hierarchical-lookup-basenamefrom
robhogan/remove-closest-package-memo
Draft

robhogan wants to merge 1 commit into
robhogan/hierarchical-lookup-basenamefrom
robhogan/remove-closest-package-memo

Conversation

@robhogan

@robhogan robhogan commented Sep 18, 2026 •

Copy link
Copy Markdown
Collaborator

Context

PackageCache memoises the closest package.json for every module path, with a reverse index and a no-package set to invalidate it, and DependencyGraph calls invalidate for every changed file:

invalidate(filePath: string) {
this.#packageCache.delete(filePath);
// Clean up any cached result for this module path (including null).
// Derive the package.json path from the cached result to clean up the
// reverse index.
const cachedResult = this.#resultByModulePath.get(filePath);
this.#resultByModulePath.delete(filePath);
this.#modulePathsWithNoPackage.delete(filePath);
if (cachedResult != null) {
const packagePath = cachedResult.rootPath + sep + 'package.json';
const modules = this.#modulePathsByPackagePath.get(packagePath);
if (modules != null) {
modules.delete(filePath);
if (modules.size === 0) {
this.#modulePathsByPackagePath.delete(packagePath);
}
}
}
// If filePath is a package.json, invalidate all module lookups pointing to it
const modulePaths = this.#modulePathsByPackagePath.get(filePath);
if (modulePaths != null) {
for (const modulePath of modulePaths) {
this.#resultByModulePath.delete(modulePath);
}
this.#modulePathsByPackagePath.delete(filePath);
}
// If a package.json was created, modified, or deleted, invalidate all
// null-cached module results, since modules that previously had no
// enclosing package.json may now resolve to this one.
if (filePath.endsWith(sep + 'package.json')) {
for (const modulePath of this.#modulePathsWithNoPackage) {
this.#resultByModulePath.delete(modulePath);
}
this.#modulePathsWithNoPackage.clear();
}

This change

After #1945 and #1946 that memo no longer pays for itself. There are 3.7x fewer lookups and each is ~2x faster. This removes it and looks up the closest package on every call. Parsed package.json contents are still cached per path.

The other reason is incremental resolution (#1944). Any cache between the resolver and the file system has to store the observations behind each entry and replay them on a hit, which is a lot of machinery for a cache worth ~3% of first-build resolution time.

It also fixes two stale-resolution bugs on main that currently need a restart: adding a nearer package.json (eg a nested package in a monorepo), and retargeting a directory symlink into another package. Both reset the graph, so everything is re-resolved against the stale memo, which is probably why nobody noticed.

The cost is ~3% of first-build resolution time (+3ms, under 0.1% of a ~4s build), ~9% when a package.json or symlink change resets the whole graph, and nothing on an ordinary edit. Retained heap after resolving drops 37% (3.0MB -> 1.9MB). Without the two PRs above, removing the memo costs 14% on a first pass.

Benchmark

AI-driven. Every resolveDependency call of one iOS dev build replayed through DependencyGraph's whole-resolution cache, so each row is something that happens in a session. 7 interleaved rounds, medians of 15 iterations.

scenario #1946 this PR
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%)

Changelog

 - **[Fix]**: Resolver: Fix stale resolutions after adding a nearer `package.json` or retargeting a symlinked directory, which previously needed a restart

Test plan

Reproduced both bugs with a real DependencyGraph and watcher on main, using a browser redirect to show which package.json was consulted, and confirmed they're fixed here.

Benchmark app: the synthetic RN 0.87 app described in #1945 (2.8k-module bundle, 34k-file file map). Source and package.json edits are made on disk and picked up by the watcher.

@robhogan
robhogan added this pull request to stack #1947 September 18, 2026 18:13
@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 Sep 18, 2026
@robhogan
robhogan force-pushed the robhogan/remove-closest-package-memo branch from f4ce93d to cf3a8e7 Compare September 18, 2026 18:20
@robhogan
robhogan force-pushed the robhogan/remove-closest-package-memo branch 3 times, most recently from c519006 to 73c7c94 Compare September 18, 2026 18:33
robhogan added a commit that referenced this pull request Sep 18, 2026
…ble_incrementalResolution

Stacked on #1948.

## Summary
The first consumer of the `Observations` API from #1944. When `resolver.unstable_incrementalResolution` is enabled, `ModuleResolver.resolveDependency` creates an observations record at the start of each resolution, and everything that resolution looks up in the file map is recorded into it. The record is returned on the `BundlerResolution` as `unstable_observations`. Nothing reads it yet - invalidating from it is the next step - so there's no behaviour change, and with the flag off nothing is allocated.

The resolution context given to `metro-resolver` doesn't change. `ModuleResolver` already builds a context per resolution, so when the flag is on it binds the file system capabilities to that resolution's record (`fileSystemLookup: p => fileSystemLookup(p, observations)`), and when it's off it passes the same functions as before. That means a custom `resolveRequest` records whatever it looks up through the context, without knowing about any of this. The observations parameter only appears on Metro's internal option types.

What's recorded:
 - `fileSystemLookup`, and `resolveAsset` through it.
 - `getPackageForModule`, down to `hierarchicalLookup`. There's no memo in the way since #1948.
 - `doesFileExist`. `FileSystem.exists` can't record, so when recording this is answered by `lookup`, which agrees with it - true for exactly the paths found to be a file.

Putting the record on the `BundlerResolution` means `DependencyGraph`'s whole-resolution cache replays it for free - every origin given a cached resolution gets its observations too. The one other memo is the empty module, which is resolved once and cached, so a resolution that lands on it returns a copy with the union of what led there and what resolving the empty module observed.

Not included, to follow:
 - The contents of `package.json`. `getPackage` reads with `fs` outside the file map, and Metro has no way to turn an absolute path into a canonical one. The file is recorded in `existence` by `hierarchicalLookup`, so adding or removing one is covered, but editing one isn't.
 - Haste lookups, which have no bucket yet.
 - Failed resolutions. They throw, so there's nowhere to put what they observed.

## Benchmark
Resolution time in isolation, replaying every `DependencyGraph.resolveDependency` call made by one iOS dev build of the benchmark app (described in the test plan), through the whole-resolution cache. Nothing is transformed or serialised, so these are percentages of resolution, not of a build. 7 interleaved rounds. Resolutions are identical with the flag on and off.

| scenario | #1948 | this diff, flag off | this diff, flag on |
|---|---|---|---|
| first build (ms) | 95.9 | 95.8 (−0.1%) | 111.6 (+16.5%) |
| rebuild, nothing changed (ms) | 7.61 | 7.64 (+0.4%) | 7.80 (+2.1%) |
| after a source edit, re-resolving the edited module's dependencies (µs) | 153 | 159 (+4.1%) | 180 (+13.2%) |
| after a `package.json` change, re-resolving everything (ms) | 79.8 | 80.1 (+0.4%) | 93.1 (+16.2%) |
| heap retained after resolving (MB) | | 5.3 | 18.8 (3.5x) |

Flag off is within noise of #1948. (The source edit row is 8 resolutions, so that's under a microsecond each, and the runs overlap.)

Flag on costs about 16% of resolution time wherever everything is resolved, which is ~2µs per resolution, and 3.5x the retained heap. On the benchmark app that's 100k entries across 6.9k distinct resolutions - a median of 14 each, 90th percentile 27 - naming 28k distinct paths. Nearly all of it is `existence`, and most of that is extension and platform candidates that missed (`a.ios.js`, `a.native.js`, ...), which can't collapse because the directory they're in exists. That's the memory to go after before this is worth turning on, e.g. by recording the directory once instead.

## 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 typecheck-ts
yarn verify-api-snapshots
```
New tests in `resolver-test.js` assert the exact sets recorded for a relative import's candidates, each level probed for a package, a symlink (recorded as `content`, with lookups recorded by real path), asset variants, and a resolution that lands on the empty module. They also check that there's no `unstable_observations` by default, and that origins sharing a cached resolution share its observations.

**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. 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
robhogan force-pushed the robhogan/remove-closest-package-memo branch from 73c7c94 to 7d2a2d5 Compare September 25, 2026 18:04
robhogan added a commit that referenced this pull request Sep 25, 2026
…ble_incrementalResolution

Stacked on #1948.

## Summary
The first consumer of the `Observations` API from #1944. When `resolver.unstable_incrementalResolution` is enabled, `ModuleResolver.resolveDependency` creates an observations record at the start of each resolution, and everything that resolution looks up in the file map is recorded into it. The record is returned on the `BundlerResolution` as `unstable_observations`. Nothing reads it yet - invalidating from it is the next step - so there's no behaviour change, and with the flag off nothing is allocated.

The resolution context given to `metro-resolver` doesn't change. `ModuleResolver` already builds a context per resolution, so when the flag is on it binds the file system capabilities to that resolution's record (`fileSystemLookup: p => fileSystemLookup(p, observations)`), and when it's off it passes the same functions as before. That means a custom `resolveRequest` records whatever it looks up through the context, without knowing about any of this. The observations parameter only appears on Metro's internal option types.

What's recorded:
 - `fileSystemLookup`, and `resolveAsset` through it.
 - `getPackageForModule`, down to `hierarchicalLookup`. There's no memo in the way since #1948.
 - `doesFileExist`. `FileSystem.exists` can't record, so when recording this is answered by `lookup`, which agrees with it - true for exactly the paths found to be a file.

Putting the record on the `BundlerResolution` means `DependencyGraph`'s whole-resolution cache replays it for free - every origin given a cached resolution gets its observations too. The one other memo is the empty module, which is resolved once and cached, so a resolution that lands on it returns a copy with the union of what led there and what resolving the empty module observed.

Not included, to follow:
 - The contents of `package.json`. `getPackage` reads with `fs` outside the file map, and Metro has no way to turn an absolute path into a canonical one. The file is recorded in `existence` by `hierarchicalLookup`, so adding or removing one is covered, but editing one isn't.
 - Haste lookups, which have no bucket yet.
 - Failed resolutions. They throw, so there's nowhere to put what they observed.

## Benchmark
Resolution time in isolation, replaying every `DependencyGraph.resolveDependency` call made by one iOS dev build of the benchmark app (described in the test plan), through the whole-resolution cache. Nothing is transformed or serialised, so these are percentages of resolution, not of a build. 7 interleaved rounds. Resolutions are identical with the flag on and off.

| scenario | #1948 | this diff, flag off | this diff, flag on |
|---|---|---|---|
| first build (ms) | 95.9 | 95.8 (−0.1%) | 111.6 (+16.5%) |
| rebuild, nothing changed (ms) | 7.61 | 7.64 (+0.4%) | 7.80 (+2.1%) |
| after a source edit, re-resolving the edited module's dependencies (µs) | 153 | 159 (+4.1%) | 180 (+13.2%) |
| after a `package.json` change, re-resolving everything (ms) | 79.8 | 80.1 (+0.4%) | 93.1 (+16.2%) |
| heap retained after resolving (MB) | | 5.3 | 18.8 (3.5x) |

Flag off is within noise of #1948. (The source edit row is 8 resolutions, so that's under a microsecond each, and the runs overlap.)

Flag on costs about 16% of resolution time wherever everything is resolved, which is ~2µs per resolution, and 3.5x the retained heap. On the benchmark app that's 100k entries across 6.9k distinct resolutions - a median of 14 each, 90th percentile 27 - naming 28k distinct paths. Nearly all of it is `existence`, and most of that is extension and platform candidates that missed (`a.ios.js`, `a.native.js`, ...), which can't collapse because the directory they're in exists. That's the memory to go after before this is worth turning on, e.g. by recording the directory once instead.

## 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 typecheck-ts
yarn verify-api-snapshots
```
New tests in `resolver-test.js` assert the exact sets recorded for a relative import's candidates, each level probed for a package, a symlink (recorded as `content`, with lookups recorded by real path), asset variants, and a resolution that lands on the empty module. They also check that there's no `unstable_observations` by default, and that origins sharing a cached resolution share its observations.

**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. 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
robhogan force-pushed the robhogan/remove-closest-package-memo branch 2 times, most recently from 9d5006a to 534dfb7 Compare September 29, 2026 14:36
@robhogan
robhogan removed this pull request from stack #1947 October 9, 2026 15:33
@robhogan
robhogan force-pushed the robhogan/remove-closest-package-memo branch from 534dfb7 to f4beb51 Compare October 9, 2026 15:34
@robhogan
robhogan added this pull request to stack #2045 October 9, 2026 15:34
@robhogan
robhogan force-pushed the robhogan/remove-closest-package-memo branch from f4beb51 to 378980b Compare October 9, 2026 15:42
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
robhogan force-pushed the robhogan/remove-closest-package-memo branch from 378980b to c59121f Compare October 14, 2026 10:11

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