Skip to content

Implement VirtualResolution for resolver-provided modules - #2036

Draft
robhogan wants to merge 1 commit into
pr2041from
pr2036
Draft

robhogan wants to merge 1 commit into
pr2041from
pr2036

Conversation

@robhogan

@robhogan robhogan commented Oct 7, 2026 •

Copy link
Copy Markdown
Collaborator

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

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 is decided by Metro's resolver - so that takes the resolution specifier, origin module path, and the ResolutionContext APIs as usual and produces the source and (optionally) a virtual module path as outputs.

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 if not nullability would transfer to ResolutionContext.originModulePath, which would be a breaking change. We don't need data: yet anyway, so we can leave that.

Use cases

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 multiple refreshes are recommended 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 anchored at the importing file. Imports inside the worklet resolve as the importer's would, and the cache behaves as it should.

Verified e2e at https://github.com/robhogan/metro-x-worklets and validated with @tjzel.

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

https://github.com/expo/expo/blob/f7d9310f1252266773c020b3ec7b118e8dde402c/packages/%40expo/cli/src/start/server/metro/metroVirtualModules.ts#L82-L100

Each of those becomes a virtualModule return from resolveRequest, and invalidation comes for free: the environment-variables polyfill's hash changes when an EXPO_PUBLIC_* value does, where today its id is constant. The difference from worklets is identity - a shim wants one module per content, not one per importer, which a fixed originModulePath under the project root gives. A null origin (no base at all, as data: has) is in the type but rejected for now, since resolving such a module's imports needs a ResolutionContext without an origin, and that's a breaking change.

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', originModulePath, source} to provide a module's source directly

@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
`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.

`ModuleResolution` derives the module path as `<virtualPath>?virtual=<sha1(specifier)>`, the same shape as `?ctx=`. The virtual path need not exist on disk: it is the path the module notionally lives at, so `path.dirname` is the base for imports inside the module, path-keyed transform configuration sees a path derived from it, and the transform cache key stays portable. A resolver that passes the importing module's own path gets one module per importer, and a fixed path gets one module per distinct specifier.

Identity is independent of content, as it is for a file: a change in what the resolver produces for the same specifier is a modification of the same module, not a new one, so module ids, HMR updates and DevTools source URLs stay stable across edits. 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 module to different content in one traversal is an error, since a resolver must be a function of the virtual path and specifier.

The identity suffix exists only in the graph. A virtual module is transformed as, and resolves its dependencies from, its bare virtual path, so extension-keyed decisions in transformers and Babel presets, relative imports and path-keyed transform configuration all behave as for a file at that path. Source map names and module ids keep the full graph 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 module's identity is derived from the importing file, not its directory, so `DependencyGraph` memoises virtual resolutions per origin file rather than per origin directory. The same specifier in two sibling files is two modules.

`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
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

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