Repository navigation
Conversation
robhogan
added this pull request to stack #2038
October 7, 2026 16:00
robhogan
force-pushed
the
pr2036
branch
7 times, most recently
from
October 8, 2026 12:46
fe9864a to
30082b7
Compare
`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
removed this pull request from stack #2038
October 8, 2026 12:55
robhogan
added this pull request to stack #2042
October 8, 2026 12:56
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
ResolutionContextAPIs as usual and produces thesourceand (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
ResolutionContextAPIs likedoesFileExist. 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
doesFileExiston 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.virtualPathThis 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 toResolutionContext.originModulePath, which would be a breaking change. We don't needdata: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>.jsfrom inside the visitor, aresolveRequestmaps it back, and a patch makesgetOrComputeSha1return 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
expoVirtualModulesmap onbundler._depGraph._fileSystem,getSha1returning the\0-prefixed id, and a wrappedBundler.transformFilethat 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
virtualModulereturn fromresolveRequest, and invalidation comes for free: the environment-variables polyfill's hash changes when anEXPO_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 fixedoriginModulePathunder the project root gives. Anullorigin (no base at all, asdata:has) is in the type but rejected for now, since resolving such a module's imports needs aResolutionContextwithout 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