Skip to content

Resolver: Implement VirtualResolution for resolver-provided modules - #2036

Open
robhogan wants to merge 1 commit into
pr2056from
pr2036
Open

robhogan wants to merge 1 commit into
pr2056from
pr2036

Conversation

@robhogan

@robhogan robhogan commented Oct 7, 2026 •

Copy link
Copy Markdown
Collaborator

Builds on #2041 (VirtualSource) and #2056 (the virtual: namespace). Nothing in Metro produces a virtual module until #2037.

Summary

This implements VirtualResolution: a resolver may return {type: 'virtualModule', virtualPath: ?string, source: string | Buffer}, and Metro bundles the source as a module.

// metro.config.js
const {version} = require('./package.json');

module.exports = {
  resolver: {
    resolveRequest: (context, moduleName, platform) => {
      if (moduleName === 'virtual:build-info') {
        return {
          type: 'virtualModule',
          // One module, shared by every importer
          virtualPath: path.join(__dirname, 'build-info.js'),
          source: `export const version = ${JSON.stringify(version)};`,
        };
      }
      return context.resolveRequest(context, moduleName, platform);
    },
  },
};

Motivation

Worklets

Bundle mode needs each worklet to be a module. Today the Babel plugin writes react-native-worklets/.worklets/<hash>.js from inside the visitor, a resolveRequest maps it back, and a patch makes getOrComputeSha1 return a random SHA-1 for the directory because the file map never saw the file.

No worklet is ever a transform cache hit, and a cache hit on the parent serves a stale generated module. In addition, a race exists where files created at transform time often aren't registered in the file map by the time the resolver looks at the dependencies of the origin module, so the docs recommend patching Metro, falling back to retrying loading several times, to get a successful build.

With virtual modules (as the worklets team have requested before - eg #1605), the worklets plugin emits a specifier carrying the module's source (metro:inline;base64,…, next in #2037) and the resolver answers with a virtual module beside the importing file (App.tsx imports App.inline-<hash>.tsx). Imports inside the worklet resolve as the importer's would, and the cache behaves as it should.

Expo virtual modules

Expo CLI already does something like this by patching Metro: an expoVirtualModules map on bundler._depGraph._fileSystem, getSha1 returning the \0-prefixed id, and a wrapped Bundler.transformFile that reads from the map. It serves \0polyfill:*, \0node:*, \0shim:* and \0weak:*.

Each of those becomes a virtualModule return from resolveRequest, and the transform cache follows the content: a changed EXPO_PUBLIC_* value is a modification of the environment-variables polyfill rather than a stale cache hit, where today its id is constant. Expo still has to trigger re-resolution when the value changes, since nothing on disk does. The difference from worklets is identity - a shim wants one module shared by every importer, which a fixed virtualPath under the project root gives.

Design

What is a virtual module?

A virtual module is a source module like any other - it's a node in the module graph, it may have dependencies, it may have multiple dependants. The difference is it does not exist on disk, so its content and path must come from somewhere else.

In this design, a virtual module's source and virtual path are decided by Metro's resolver, which takes the resolution specifier, origin module path, and the ResolutionContext APIs as usual and produces the source and a virtual path, which is the module's identity, as outputs.

Identity

A virtual path names exactly one module, like a file path. A change in a virtual module's source is a modification of that module rather than a new one, so module ids, HMR updates and DevTools source URLs stay stable across edits. The transform cache key still follows the content, so nothing goes stale.

That puts uniqueness on the resolver: it must give the same source for the same virtual path, and Metro throws if two resolutions in one traversal disagree. A resolver deriving several modules from one file should name them beside it - metro:inline uses App.inline-<hash>.tsx.

Whether an edit is a modification or a new module is up to the resolver's choice of path. metro:inline hashes the source into it, so an edited worklet is a new module, while a fixed path like Expo's keeps one module across changes.

In the graph, a virtual module's path is virtual:<virtualPath>, so it can never collide with a real file, including one it sits beside. The transformer and resolvers see the bare virtual path, so extension checks and relative imports work as they would for a file there. #2056 adds the namespace, and how it appears in development module names and URLs.

Change detection

Metro doesn't watch anything for a virtual module. Its source arrives with a resolution, so it can only change when one of its importers is revisited and its dependencies re-resolved, typically after an edit to the importer. The same source again is a no-op. Different source re-transforms the module and commits it as modified, like an edited file.

So a resolver whose output depends on anything other than the importer, like Expo's environment variables, has to trigger that re-resolution itself.

Visibility in the file map

Virtual modules are not represented in Metro's file map, and therefore not visible through the ResolutionContext APIs like doesFileExist. That's deliberate, and consistent with other bundlers (which follows from their use of real IO for resolution).

An observable consequence of that is that importing a virtual module by its virtual path as if it were a real module will fail, and require.context() won't include it. IMO, that's expected.

If they were represented, one resolution could perform a synchronous doesFileExist on a virtual path, and the result would be sensitive to whether another resolution that creates it has already run. Since traversal order is not deterministic that would make builds non-deterministic.

Nullability of VirtualResolution.virtualPath

This is nullable so that in future a module with no origin/base can be declared - this would be needed for a spec-compliant implementation of a data: URL. It's rejected with an invariant for now, because otherwise the nullability would transfer to ResolutionContext.originModulePath, which would be a breaking change. We don't need data: yet anyway, so we can leave this for later, but my hunch is that all such path-less virtual modules could be content-defined, rather than needing another form of ID.

How it works

In the order the source flows:

  1. Resolution - ModuleResolution turns a virtualModule resolution into a BundlerResolution whose filePath is the namespaced path, carrying the source as a Buffer with its SHA-1. A null virtualPath fails an invariant here.
  2. Memoisation - DependencyGraph memoises resolutions per origin directory, but a virtual path may depend on the importing file, so virtual resolutions are memoised per origin file. The per-directory memo is checked first, so other resolutions cost no extra lookup.
  3. The edge - buildSubgraph puts the source on the dependency edge as a {type: 'buffer', source, sha1} VirtualSource, the shape Graph: Carry require.context inputs as a VirtualSource #2041 gave require.context. It transforms the module, and resolves its dependencies, as the bare virtual path. Two edges giving one virtual path different content is an error, raised outside the optional-dependency try so it can't be swallowed.
  4. Transform - getTransformFn hands the buffer to transformFile in place of a file read, as it does a rendered context template, so the cache key follows the content.
  5. Change detection - Graph.traverseDependencies revisits an existing virtual module when its edge brings a different SHA-1, commits it as modified, and writes the new source back for later comparisons.

metro-resolver documents and exports the VirtualResolution type.

Changelog: [Feature] Resolver: custom resolvers may return {type: 'virtualModule', virtualPath, source} to provide a module's source directly

Test plan

yarn flow
yarn jest packages/metro/src/DeltaBundler/__tests__/{Graph,buildSubgraph,resolver}-test.js packages/metro/src/integration_tests/__tests__/virtual-module-test.js

New tests:

  • Graph-test: a virtual module is added once, under its namespaced path. New source at the same path is a modification of the same module, the same source is not, and the new source sticks for later traversals. Two importers disagreeing is an error, even on an optional dependency.
  • resolver-test: resolution to the namespaced path, a Buffer source with the same identity as the same text, and the null invariant.
  • buildSubgraph-test: the transformer and the resolver see the bare virtual path.
  • virtual-module-test (integration): bundles from a custom resolveRequest and runs the bundle. Covers a relative import from a virtual module, two importers in one directory getting separate modules, require.resolveWeak, a .ts virtual path transformed as TypeScript, dev module names, and a production build.

These pass on Windows 11 too, at the top of the stack.

Verified e2e against this stack with the app at https://github.com/robhogan/metro-x-worklets, and validated with @tjzel.

@robhogan
robhogan added this pull request to stack #2038 October 7, 2026 16:00
@meta-cla meta-cla Bot added the CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. label Oct 7, 2026
@robhogan
robhogan force-pushed the pr2036 branch 7 times, most recently from fe9864a to 30082b7 Compare October 8, 2026 12:46
@robhogan
robhogan removed this pull request from stack #2038 October 8, 2026 12:55
@robhogan
robhogan changed the base branch from main to pr2041 October 8, 2026 12:56
@robhogan
robhogan added this pull request to stack #2042 October 8, 2026 12:56
@robhogan
robhogan removed this pull request from stack #2042 October 11, 2026 14:41
@robhogan
robhogan changed the base branch from pr2041 to pr2056 October 11, 2026 14:41
@robhogan
robhogan added this pull request to stack #2057 October 11, 2026 14:41
@robhogan robhogan changed the title Implement VirtualResolution for resolver-provided modules Resolver: Implement VirtualResolution for resolver-provided modules Oct 11, 2026
robhogan added a commit that referenced this pull request Oct 12, 2026
Modules with no file on disk need a graph path that can't be mistaken for a real one. This reserves `virtual:<absolute path>` for them (#2036 lets resolvers produce such modules), and updates the places where a module path leaves Metro to handle it.

A module path becomes user-visible in development module names, HMR source URLs, server-URL source paths and async bundle paths. Each derives a name from the path with `path` functions, which give machine-dependent results for a path that isn't absolute. They now go through one set of helpers in `lib/virtualModule`: dev names use `virtual:<relative path>`, and URLs get a leading `[metro-virtual]` segment, following `[metro-watchFolders]`. `getAllFiles` leaves virtual modules out, since they have no file.

Nothing produces a `virtual:` path until #2036, so existing bundles are unchanged.

Changelog: Internal

Test plan: New unit tests for the helpers, plus HMR, dev name, async path and `getAllFiles` cases for a virtual module. In particular, a virtual module at the same path as a real file gets distinct HMR URLs.
`VirtualResolution` has been reserved in `metro-resolver` and thrown on by `ModuleResolution` since the scheme resolver work. This implements it: a resolver may return `{type: 'virtualModule', virtualPath, source}`, with the source as text or a `Buffer`, and Metro bundles it as a module.

A virtual path names exactly one module, as a file path does. It need not exist on disk, and it should not be the path of a real file. Imports inside the module resolve from its directory, and the transformer sees it as the module's filename, so extension checks and path-keyed transform configuration behave as for a file there. A change in source at the same virtual path is a modification of the same module, so module ids, HMR updates and DevTools source URLs stay stable across edits.

In the graph, a virtual module's path is `virtual:<virtualPath>`, in the namespace added by "Add the virtual: module path namespace", so it can never collide with a real file, including one it is anchored beside. The display and URL forms of that path come from the same commit.

The transform cache key is content-addressed through the buffer, and the graph re-transforms a virtual module when the SHA-1 its edge supplies differs from the one it holds. Two edges resolving the same virtual path to different content in one traversal is an error, since a resolver must produce the same source for the same virtual path.

The source travels with the edge that produced it, as a new `buffer` arm of the `VirtualSource` that `require.context` modules already use. `buildSubgraph` fills it from the resolution, `Graph` drops it when the module is released, and `getTransformFn` passes the source to `transformFile` as the file buffer, the way it passes a rendered context template.

A virtual path may depend on the importing file, not just its directory, so `DependencyGraph` memoises virtual resolutions per origin file rather than per origin directory.

`VirtualResolution.virtualPath` is nullable so that a module with no base can be declared, as a `data:` URL has, but Metro rejects `null` with an invariant for now: resolving such a module's dependencies needs a `ResolutionContext` without an `originModulePath`, which is a breaking change to the resolver contract.

Nothing in Metro produces a virtual module yet. The integration test does so from a custom `resolveRequest`.

Changelog: [Feature] Resolver: custom resolvers may return `{type: 'virtualModule', virtualPath, source}` to provide a module's source directly
robhogan added a commit that referenced this pull request Oct 12, 2026
Modules with no file on disk need a graph path that can't be mistaken for a real one. This reserves `virtual:<absolute path>` for them (#2036 lets resolvers produce such modules), and updates the places where a module path leaves Metro to handle it.

A module path becomes user-visible in development module names, HMR source URLs, server-URL source paths and async bundle paths. Each derives a name from the path with `path` functions, which give machine-dependent results for a path that isn't absolute. They now derive it from a virtual module's virtual path, using the helpers in `lib/modulePath`. Dev names use `virtual:<relative path>`, and URLs are the ones a file at the virtual path would have, plus a `virtual=true` search parameter. `getAllFiles` leaves virtual modules out, since they have no file.

The server can't serve a virtual module by URL yet, and the path in its URL may be a real file's. So bundle and map requests with the parameter fail, and source requests with it are passed on rather than served from disk.

Nothing produces a `virtual:` path until #2036, so existing bundles are unchanged.

Changelog: Internal

Test plan: New unit tests for the helpers, plus HMR, dev name, async path, `getAllFiles` and url-server source map cases for a virtual module, and for the server's handling of virtual module URLs. In particular, a virtual module at the same path as a real file gets distinct HMR URLs and source paths, and a request for its source isn't served from that file.
@robhogan
robhogan marked this pull request as ready for review October 12, 2026 19:09

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