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
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
robhogan
removed this pull request from stack #2042
October 11, 2026 14:41
robhogan
added this pull request to stack #2057
October 11, 2026 14:41
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
marked this pull request as ready for review
October 12, 2026 19:09
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.
Builds on #2041 (
VirtualSource) and #2056 (thevirtual: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.Motivation
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 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.tsximportsApp.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
expoVirtualModulesmap onbundler._depGraph._fileSystem,getSha1returning the\0-prefixed id, and a wrappedBundler.transformFilethat reads from the map. It serves\0polyfill:*,\0node:*,\0shim:*and\0weak:*.Each of those becomes a
virtualModulereturn fromresolveRequest, and the transform cache follows the content: a changedEXPO_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 fixedvirtualPathunder 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
ResolutionContextAPIs as usual and produces thesourceand 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:inlineusesApp.inline-<hash>.tsx.Whether an edit is a modification or a new module is up to the resolver's choice of path.
metro:inlinehashes 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
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 otherwise the nullability would transfer toResolutionContext.originModulePath, which would be a breaking change. We don't needdata: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:
ModuleResolutionturns avirtualModuleresolution into aBundlerResolutionwhosefilePathis the namespaced path, carrying the source as aBufferwith its SHA-1. AnullvirtualPathfails an invariant here.DependencyGraphmemoises 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.buildSubgraphputs the source on the dependency edge as a{type: 'buffer', source, sha1}VirtualSource, the shape Graph: Carry require.context inputs as a VirtualSource #2041 gaverequire.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-dependencytryso it can't be swallowed.getTransformFnhands the buffer totransformFilein place of a file read, as it does a rendered context template, so the cache key follows the content.Graph.traverseDependenciesrevisits 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-resolverdocuments and exports theVirtualResolutiontype.Changelog: [Feature] Resolver: custom resolvers may return
{type: 'virtualModule', virtualPath, source}to provide a module's source directlyTest plan
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, aBuffersource with the same identity as the same text, and thenullinvariant.buildSubgraph-test: the transformer and the resolver see the bare virtual path.virtual-module-test(integration): bundles from a customresolveRequestand runs the bundle. Covers a relative import from a virtual module, two importers in one directory getting separate modules,require.resolveWeak, a.tsvirtual 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.