Skip to content

Resolver: Collect file system observations per resolution under unstable_incrementalResolution - #1949

Draft
robhogan wants to merge 5 commits into
robhogan/remove-closest-package-memofrom
robhogan/collect-resolution-observations
Draft

robhogan wants to merge 5 commits into
robhogan/remove-closest-package-memofrom
robhogan/collect-resolution-observations

Conversation

@robhogan

@robhogan robhogan commented Sep 18, 2026 •

Copy link
Copy Markdown
Collaborator

The first consumer of the Observations API from #1944. With resolver.unstable_incrementalResolution on, each resolution records everything it looks up in the file map, and the record is returned on the BundlerResolution as unstable_observations. Invalidating from it is the next step, so resolution results are unaffected, and with the flag off nothing is allocated.

ModuleResolver already builds a resolution context per call, so with the flag on it binds the file system capabilities in that context to the resolution's record:

try {
const result = Resolver.resolve(
createDefaultContext(
{
allowHaste,
assetExts,
customResolverOptions: resolverOptions.customResolverOptions ?? {},
dev: resolverOptions.dev,
disableHierarchicalLookup,
doesFileExist,
extraNodeModules,
fileSystemLookup,
getPackage,
getPackageForModule,
isESMImport: dependency.data.isESMImport,
mainFields,
nodeModulesPaths,
originModulePath,
preferNativePlatform,
resolveAsset,
resolveHasteModule: (name: string) =>
this._options.getHasteModulePath(name, platform),
resolveHastePackage: (name: string) =>
this._options.getHastePackagePath(name, platform),

A custom resolveRequest therefore records whatever it looks up through the context without knowing about any of this, and DependencyGraph's whole-resolution cache carries the record along with the cached resolution.

Still to follow: package.json contents, which are read outside the file map (#1950 and #1951), Haste lookups (#1952), and failed resolutions.

With the flag on this costs ~16% of resolution time (~2µs per resolution) and 3.5x the retained heap (5.3MB -> 18.8MB on our benchmark app). Nearly all of that is extension and platform candidates that missed (a.ios.js, a.native.js, ...), which is what to go after before this is worth turning on, eg by recording the directory once. With the flag off, everything is within noise.

Benchmark

AI-driven. Every resolveDependency call of one iOS dev build replayed through the whole-resolution cache, 7 interleaved rounds. Resolutions are identical with the flag on and off.

scenario #1948 this PR, flag off this PR, 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)

Changelog: Internal

Test plan

New tests assert the exact sets recorded for a relative import, a package walk, a symlink, asset variants and the empty module, and that nothing is present by default.

Benchmark app: the synthetic RN 0.87 app described in #1945 (2.8k-module bundle, 34k-file file map).

@robhogan
robhogan added this pull request to stack #1947 September 18, 2026 18:54
@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/collect-resolution-observations branch from b2faf45 to 4e24402 Compare September 18, 2026 19:01
@robhogan
robhogan force-pushed the robhogan/collect-resolution-observations branch from 4e24402 to 04896ea Compare September 25, 2026 18:04
robhogan added a commit that referenced this pull request Sep 25, 2026
Stacked on #1949.

## Summary
#1949 records what a resolution looks up in the file map. It doesn't record Haste lookups, which aren't paths - the resolver asks the Haste map for a module or package by name, for every bare specifier when `allowHaste` is set. A module of that name appearing later changes the result, so the name needs observing whether or not it was found.

This adds `haste` to `ResolutionObservations`, the set of Haste names a resolution looked up. They're recorded in the two Haste closures that `ModuleResolver` already creates per resolution, so nothing else changes and the resolution context is untouched. It's the bare name that's recorded, not the platform - `HastePlugin` falls back from the platform to `native` and then to the generic module of that name, so a change to any of those bindings can change the answer. Haste packages share the bucket, since they share the namespace.

`metro-file-map` doesn't change. Its `Observations` is an interface, and Metro's record is a wider type that satisfies it, which is why that type wasn't exported.

Two things keep this cheap:
 - Nothing is observed when no file can be given a Haste name - no `hasteImplModulePath` and `enableGlobalPackages: false`, which is Metro's default. The Haste map is empty then and always will be, so every lookup is a guaranteed miss, and recording one per bare specifier would be paying to observe something that can't change. `createModuleResolver` works this out from config, by the same rule `HastePlugin`'s filter uses.
 - The set is created on the first name recorded, and `haste` is `null` until then. Most resolutions are relative imports that never consult Haste. Allocating a set for each when Haste is enabled cost ~0.9MB on the benchmark app for ~0.2MB of entries.

Nothing reads these yet. Invalidating from them needs `HastePlugin` to report which names changed binding in a batch - it's the only thing that knows, since a removed file's name is gone from the map by the time anyone else sees the change event. That's to follow, alongside invalidation generally.

The empty module is resolved with `allowHaste: false`, so it observes no names and there's nothing to merge for it.

## Benchmark
Resolution time in isolation, flag on in both columns, through the whole-resolution cache (see the test plan). No measurable cost either way - everything is within about half a percent, inside the run-to-run spread.

| scenario | #1949 | this diff | #1949, Haste enabled | this diff, Haste enabled |
|---|---|---|---|---|
| first build (ms) | 112.4 | 111.8 | 111.2 | 110.6 |
| rebuild, nothing changed (ms) | 7.75 | 7.78 | 7.81 | 7.79 |
| after a source edit, the edited module's dependencies (µs) | 177 | 168 | 170 | 172 |
| after a `package.json` change, re-resolving everything (ms) | 93.2 | 93.2 | 93.6 | 94.1 |
| heap retained after resolving (MB) | 18.8 | 18.8 | | 19.1 |

With Haste enabled, 27% of distinct resolutions look up a name (1.9k of 6.9k), one name each, over 72 distinct names. With it inert, no resolution carries a set.

## 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`: nothing is observed when no file can be given a Haste name, a name is recorded when it's found and when it isn't, a deep import records only its package name, a relative import records nothing, and a global package name is recorded. The existing exact-set expectations gain `haste: null`.

**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. "Haste enabled" is the same app with `enableGlobalPackages: true`, which gives its first-party packages Haste names. 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
robhogan force-pushed the robhogan/collect-resolution-observations branch 2 times, most recently from 97d1a02 to 476f5ab Compare September 29, 2026 14:36
## Summary
`redirectModulePath` asks `context.getPackageForModule` for the package containing a module path, then applies that package's `mainFields` redirections (the "browser" spec) to the module's package-relative path. This splits the second half out as `redirectPackageSubpath`, for callers that already know the containing package. `redirectModulePath` delegates to it for relative and absolute paths and behaves exactly as before; bare specifiers are unchanged.

#1945 uses it to look up the package of a directory's files once and apply it to every extension candidate, instead of calling `redirectModulePath` per candidate.

## Test plan
```
yarn jest packages/metro-resolver packages/metro/src/DeltaBundler/__tests__/resolver-test.js
yarn flow check
```

Changelog: Internal
@robhogan
robhogan removed this pull request from stack #1947 October 9, 2026 15:33
@robhogan
robhogan force-pushed the robhogan/collect-resolution-observations branch from 476f5ab to 526109e Compare October 9, 2026 15:34
@robhogan
robhogan added this pull request to stack #2045 October 9, 2026 15:34
…andidates

Stacked on #2044.

## Summary
`resolveFile` tries each candidate extension in turn, and for each one `resolveSourceFileForExt` calls `redirectModulePath`, which asks `context.getPackageForModule` for the package scope of the candidate path. Every candidate in a directory belongs to the same package, so that's the same question asked once per extension - and because Metro's `PackageCache` memoises by module path, each distinct candidate string misses the first time it's seen, mostly for files that don't exist.

This asks once, up front, for the package of the files in the directory, and shares it with every candidate via the source file context, applying each candidate's `mainFields` redirections with `redirectPackageSubpath` from the PR beneath.

The package of the files in a directory isn't always the package of the directory itself - a `node_modules` directory belongs to whatever contains it while the files in it belong to none, and a symlink can change which directory along the way is named `node_modules`. So `getCandidatePackageScope` asks about a stand-in file in the directory, `.metro-package-scope`, which gets the same answer any real candidate there would. If that name happens to exist as a package root, the answer is about that package instead, which shows in its package-relative path, so it tries another name, and throws if `getPackageForModule` keeps answering about something else.

`package-scope-test.js` pins the sequence of `getPackageForModule` calls the resolver makes for the common specifier shapes, so that the call count is a tested property rather than incidental. Two `resolver-test.js` cases pin the package scope of files reached through a symlink to or from a `node_modules` directory, where the directory and the files in it disagree.

This also removes the `@deprecated` tag from `getPackageForModule` - the resolver depends on it, and its doc now says what it returns for symlinked and missing paths.

## Benchmark
This cuts package scope lookups 3.7x, and takes 28% off first-pass resolution time, measured in isolation on our benchmark app (described in the test plan). 7 interleaved rounds. Resolver output is byte-identical.

| metric | #1944 | this diff |
|---|---|---|
| `getPackageForModule` calls per pass | 64k | **17k (3.7x fewer)** |
| first pass after startup (ms) | 119.6 | **85.7 (−28.3%)** |
| warm pass (ms) | 55.2 | **53.5 (−3.1%)** |
| after a source change (ms) | 63.0 | **60.9 (−3.4%)** |
| after a `package.json` change (ms) | 68.4 | **64.0 (−6.4%)** |

The first pass is where this lands, because that's where every distinct candidate path is a `PackageCache` miss and a walk up the tree.

## Test plan
```
yarn jest packages/metro-resolver packages/metro-file-map packages/metro/src/node-haste packages/metro/src/DeltaBundler/__tests__/resolver-test.js
yarn flow check
yarn verify-api-snapshots
```

**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.

Changelog: Internal
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.
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
```
…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/collect-resolution-observations branch from 526109e to 8226d83 Compare October 9, 2026 15:42

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