Metro: Versioning and resolution of "builtin modules" #1582
motiz88
started this conversation in
Bundle Working Group
Replies: 0 comments
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
-
I'd like to share a problem that has come up in a couple of contexts, and my initial thoughts about a possible solution.
At a high level, the problem is that the transformer, resolver, package manager (e.g. npm / yarn) and runtime environment (e.g. React Native) are coupled together by a set of assumptions about versioning and resolution that don't always hold.
Motivating example
When we run the Babel plugin that transpiles
class
syntax on the following file:it will produce something like the following code:
This makes intuitive sense. But those
require("@babel/runtime/...")
calls are evaluated in the context ofMyProject/src/components/Foo.js
. Let's look at that fact more closely.Things that can go wrong
Under the default resolution algorithm, what will typically happen is that we'll traverse the directory tree upwards from
MyProject/src/components
, look for directories namednode_modules/@babel/runtime
along the way, and eventually find a match. But this process has a number of failure modes baked into it:@babel/runtime
than we intended - e.g. one that doesn't have thecreateClass
andclassCallCheck
helpers, or has them under different paths (thus causing build errors), or has them with different semantics (thus causing runtime errors, or worse - silently inconsistent behaviour).@babel/runtime
in multiple parts of our project and inadvertently bloat the bundle.@babel/runtime
altogether and thus fail to build.@babel/runtime
via a symlink to a location that Metro isn't watching for the current project, and fail to build that way.@babel/runtime
in some arbitrary way, either globally or based on the provided context.@babel/runtime
they use in order to produce optimal bundles; but if they accidentally set the wrong version inenableBabelRuntime
compared to what they actually have installed, things can break (see 1).In other words, what we have here is a leaky abstraction; Metro should be able to guarantee correctness and Just Work™️, but in practice this responsibility falls to the user. At minimum, we kind of implicitly expect users not to do any of the things that don't work.
Note that this problem is far from unique to Babel helpers. Similar failure modes arise whenever the transformer generates code that assumes anything about how modules are resolved - something that the transformer fundamentally, architecturally, can't predict. Other examples include:
metro-runtime
(in particular therequire()
implementation) to that assumed by the Metro transformer and serializer.Partial solution: Built-in modules
Instead of relying on the user's project to be set up a certain way, let's think of certain packages as being built into Metro. After all, Metro's transformer ships with specific, known, versions of the compile time bits of Babel, Fast Refresh, etc; can we make it so this fully determines the versions of their runtime counterparts?
My idea here is inspired by Node.js's
node:
imports. We can expose a fixed set of "built-in" packages undermetro:
URIs, e.g.:metro:metro-runtime
metro:@babel/runtime
metro:react-refresh
.Semantically, importing a built-in package would bypass
node_modules
resolution and instead always resolve to a version managed and provided directly by Metro. Using this, we can teach the Babel transformer to emitrequire('metro:@babel/runtime/...')
calls, change React Native to require Fast Refresh asrequire('metro:react-refresh/runtime')
, etc.Implementation details
The physical location of each package would be determined by using
require.resolve
from within one of the core Metro packages. Metro'spackage.json
would declare the dependencies on the builtins so users don't have to (and in fact can't interfere with the builtins, even if they do install their own conflicting versions).To make this work reliably, we might need to transparently add the physicals locations of these packages (and any of their own dependencies) to the active project's file map, so they can be referenced even if they happen to be outside of the current
projectRoot
or its configuredwatchFolders
.TBD whether we can/should invoke the custom resolver on
metro:
imports.More general solution: Transformer-provided modules
Including a closed set of built-in modules with Metro would work for the specific use cases I listed above - but does it scale to custom transformers, custom Babel plugins, etc? Ultimately any sufficiently complex transformer can run into this problem.
Maybe we can extend the Metro transformer API to let custom transformers register their own custom "built-ins" with the same general contract as
metro:
imports: the resolution is not up to the user's project but up to the toolchain that is building the user's project.Ideally, this API would be expressive enough that we can do things like:
metro:
packages on top of itmetro-react-native-babel-transformer
is in use.Beta Was this translation helpful? Give feedback.
All reactions