From 540f5cc28b8fbaa385038042cda73fdfe93c71a8 Mon Sep 17 00:00:00 2001 From: Rob Hogan Date: Fri, 25 Sep 2026 19:03:16 +0100 Subject: [PATCH] Resolver: Observe Haste names under unstable_incrementalResolution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stacked on https://github.com/react/metro/pull/1949. ## Summary https://github.com/react/metro/pull/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 --- .../DeltaBundler/__tests__/resolver-test.js | 109 ++++++++++++++++++ packages/metro/src/DeltaBundler/types.js | 13 ++- .../DependencyGraph/ModuleResolution.js | 38 ++++-- .../DependencyGraph/createModuleResolver.js | 5 + 4 files changed, 154 insertions(+), 11 deletions(-) diff --git a/packages/metro/src/DeltaBundler/__tests__/resolver-test.js b/packages/metro/src/DeltaBundler/__tests__/resolver-test.js index 7c3086a13b..b80819a005 100644 --- a/packages/metro/src/DeltaBundler/__tests__/resolver-test.js +++ b/packages/metro/src/DeltaBundler/__tests__/resolver-test.js @@ -2933,6 +2933,7 @@ function dep(name: string): TransformResultDependency { '.metro-package-scope', ), content: new Set(), + haste: null, }, }); }); @@ -2979,6 +2980,7 @@ function dep(name: string): TransformResultDependency { 'node_modules/pkg/main.js', ), content: new Set(), + haste: null, }, }); }); @@ -3002,6 +3004,7 @@ function dep(name: string): TransformResultDependency { ), // Retargeting the link changes what every path through it means content: canonical('link'), + haste: null, }, }); }); @@ -3027,6 +3030,7 @@ function dep(name: string): TransformResultDependency { 'package.json', ), content: new Set(), + haste: null, }); }); @@ -3067,6 +3071,7 @@ function dep(name: string): TransformResultDependency { 'package.json', ), content: new Set(), + haste: null, }, }); }); @@ -3079,6 +3084,110 @@ function dep(name: string): TransformResultDependency { expect(second).toBe(first); expect(second.unstable_observations?.existence.size).toBeGreaterThan(0); }); + describe('Haste names', () => { + const withHaste: InputConfigT = { + resolver: { + hasteImplModulePath: path.join( + __dirname, + '../__fixtures__/hasteImpl.js', + ), + unstable_incrementalResolution: true, + }, + }; + + test('are not observed when no file can be given one', async () => { + setMockFileSystem({ + 'index.js': '', + node_modules: {pkg: {'index.js': ''}}, + }); + resolver = await createResolver(incremental, 'ios'); + // A bare specifier is looked up in the Haste map, which is empty and + // will stay that way + expect( + resolver.resolve(p('/root/index.js'), dep('pkg')) + .unstable_observations?.haste, + ).toBe(null); + }); + + test('records a name that is found', async () => { + setMockFileSystem({ + 'index.js': '', + 'hasteModule.js': '@providesModule hasteModule', + }); + resolver = await createResolver(withHaste, 'ios'); + const resolution = resolver.resolve( + p('/root/index.js'), + dep('hasteModule'), + ); + expect(resolution.filePath).toBe(p('/root/hasteModule.js')); + expect(resolution.unstable_observations?.haste).toEqual( + new Set(['hasteModule']), + ); + }); + + test('records a name that is not found, since it may appear later', async () => { + setMockFileSystem({ + 'index.js': '', + node_modules: {pkg: {'index.js': ''}}, + }); + resolver = await createResolver(withHaste, 'ios'); + const resolution = resolver.resolve(p('/root/index.js'), dep('pkg')); + expect(resolution.filePath).toBe( + p('/root/node_modules/pkg/index.js'), + ); + expect(resolution.unstable_observations?.haste).toEqual( + new Set(['pkg']), + ); + }); + + test('records only the package name of a deep import', async () => { + setMockFileSystem({ + 'index.js': '', + node_modules: {pkg: {lib: {'thing.js': ''}}}, + }); + resolver = await createResolver(withHaste, 'ios'); + expect( + resolver.resolve(p('/root/index.js'), dep('pkg/lib/thing')) + .unstable_observations?.haste, + ).toEqual(new Set(['pkg'])); + }); + + test('records nothing for a relative import, which does not consult Haste', async () => { + setMockFileSystem({'index.js': '', 'a.js': ''}); + resolver = await createResolver(withHaste, 'ios'); + expect( + resolver.resolve(p('/root/index.js'), dep('./a')) + .unstable_observations?.haste, + ).toBe(null); + }); + + test('records a global package name', async () => { + setMockFileSystem({ + 'index.js': '', + aPackage: { + 'package.json': JSON.stringify({name: 'aPackage'}), + 'index.js': '', + }, + }); + resolver = await createResolver( + { + resolver: { + enableGlobalPackages: true, + unstable_incrementalResolution: true, + }, + }, + 'ios', + ); + const resolution = resolver.resolve( + p('/root/index.js'), + dep('aPackage'), + ); + expect(resolution.filePath).toBe(p('/root/aPackage/index.js')); + expect(resolution.unstable_observations?.haste).toEqual( + new Set(['aPackage']), + ); + }); + }); }); describe('schemeResolvers', () => { diff --git a/packages/metro/src/DeltaBundler/types.js b/packages/metro/src/DeltaBundler/types.js index d114a6dc21..a55b875ef3 100644 --- a/packages/metro/src/DeltaBundler/types.js +++ b/packages/metro/src/DeltaBundler/types.js @@ -151,12 +151,19 @@ export type AllowOptionalDependenciesWithOptions = { export type AllowOptionalDependencies = boolean | AllowOptionalDependenciesWithOptions; -// What a resolution observed of the file system: canonical paths whose -// addition or removal can change its result, and those whose modification -// can too. Satisfies `metro-file-map`'s `Observations`, which populates it. +// What a resolution observed. `existence` and `content` are canonical paths +// whose addition or removal can change its result, and those whose +// modification can too. They satisfy `metro-file-map`'s `Observations`, which +// populates them. +// +// `haste` is every Haste module or package name the resolution looked up, +// whether or not it was found, since one appearing later changes the result. +// It is null if there were none, which is always the case when no file can be +// given a Haste name - nothing is paid to observe what cannot change. export type ResolutionObservations = Readonly<{ existence: Set, content: Set, + haste: ?Set, }>; export type BundlerResolution = Readonly<{ diff --git a/packages/metro/src/node-haste/DependencyGraph/ModuleResolution.js b/packages/metro/src/node-haste/DependencyGraph/ModuleResolution.js index 305cf3dd86..4688b30000 100644 --- a/packages/metro/src/node-haste/DependencyGraph/ModuleResolution.js +++ b/packages/metro/src/node-haste/DependencyGraph/ModuleResolution.js @@ -48,6 +48,9 @@ type Options = Readonly<{ ) => ReturnType, getHasteModulePath: (name: string, platform: ?string) => ?string, getHastePackagePath: (name: string, platform: ?string) => ?string, + // Whether any file can be given a Haste name. If not, Haste lookups always + // miss, and are not worth observing. + isHasteEnabled: boolean, mainFields: ReadonlyArray, getPackage: (packageJsonPath: string) => ?PackageJson, getPackageForModule: ( @@ -77,8 +80,8 @@ type Options = Readonly<{ // Every record is created here, with the same properties in the same order, // so that reading them stays monomorphic on the lookup hot path. -function createObservations(): ResolutionObservations { - return {existence: new Set(), content: new Set()}; +function createObservations(): {...ResolutionObservations} { + return {existence: new Set(), content: new Set(), haste: null}; } export class ModuleResolver { @@ -149,9 +152,21 @@ export class ModuleResolver { // The capabilities given to the resolver are bound to it, so that the // resolution context keeps its shape and a custom resolver records what // it looks up without having to know about it. - const observations: ?ResolutionObservations = unstable_incrementalResolution + const observations = unstable_incrementalResolution ? createObservations() : null; + // Most resolutions never consult Haste, so the set is created on demand. + const observeHasteName = + observations != null && this._options.isHasteEnabled + ? (name: string) => { + let names = observations.haste; + if (names == null) { + names = new Set(); + observations.haste = names; + } + names.add(name); + } + : null; try { const result = Resolver.resolve( @@ -187,10 +202,17 @@ export class ModuleResolver { ? resolveAsset : (dirPath, assetName, extension) => resolveAsset(dirPath, assetName, extension, observations), - resolveHasteModule: (name: string) => - this._options.getHasteModulePath(name, platform), - resolveHastePackage: (name: string) => - this._options.getHastePackagePath(name, platform), + // A name is recorded whether or not it is found, and for any + // platform, since `HastePlugin` falls back from the platform to + // `native` and then to the generic module of that name. + resolveHasteModule: (name: string) => { + observeHasteName?.(name); + return this._options.getHasteModulePath(name, platform); + }, + resolveHastePackage: (name: string) => { + observeHasteName?.(name); + return this._options.getHastePackagePath(name, platform); + }, resolveRequest, schemeResolvers, sourceExts, @@ -303,7 +325,7 @@ export class ModuleResolver { } // The empty module is resolved once and cached, so a resolution that // lands on it depends on what that resolution observed as well as on - // what led here. + // what led here. It is resolved without Haste, so observes no names. const emptyObservations = emptyModule.unstable_observations; if (emptyObservations != null) { for (const canonicalPath of emptyObservations.existence) { diff --git a/packages/metro/src/node-haste/DependencyGraph/createModuleResolver.js b/packages/metro/src/node-haste/DependencyGraph/createModuleResolver.js index 85efa30ab4..82cd63e3bd 100644 --- a/packages/metro/src/node-haste/DependencyGraph/createModuleResolver.js +++ b/packages/metro/src/node-haste/DependencyGraph/createModuleResolver.js @@ -65,6 +65,11 @@ export default function createModuleResolver({ emptyModulePath: config.resolver.emptyModulePath, extraNodeModules: config.resolver.extraNodeModules, fileSystemLookup, + // As `HastePlugin` decides which files to give a name: packages only if + // global packages are enabled, and other files only with a `hasteImpl`. + isHasteEnabled: + config.resolver.enableGlobalPackages || + config.resolver.hasteImplModulePath != null, getHasteModulePath: (name, platform) => hasteMap.getModule(name, platform, true), getHastePackagePath: (name, platform) =>