Rails 7 is bringing a paradigm shift to the JavaScript ecosystem. One of the reasons we love Rails is because the devs are not afraid to do big changes to challenge the status quo. Import Maps is not something new that Rails 7 came up with. But it's something that needs a push to escape the mess, that is the current JavaScript ecosystem.
We all want to write next-generation JavaScript. And doing so forces us to learn and use various build tools. Many browsers have already started supporting various new features of the ECMAScript Specification. ES Modules being one of them.
The current state of ES Modules in the browser
The browsers that support ES Modules via the <script>
tag do so in 3 ways:
- Using relative paths (relative to the current file):
import foo, { bar } from "../../foobar.js";
- Or using absolute paths (relative to the Webroot):
import foo, { bar } from "/baz/foobar.js";
- Or using URLs:
import foo, { bar } from "https://example.com/baz/foobar.js";
As we can see, this is different from how imports work in Node. In Node, we can just specify the name of the NPM package:
import foo, { bar } from "foobar";
and Node knows how to pick up the package from the node_modules
folder. To get the same result of referring to modules via a bare module specifier in a browser, we need Import Maps.
How do Import Maps work?
Import Maps as the name suggest, are "mappings" for "imports". They allow us to import stuff using a bare module specifier. The mapping information is presented to the browser via a <script>
tag with type="importmap"
:
<script type="importmap">
{
"imports": {
"foobar": "/baz/foobar.js"
}
}
</script>
Is there anything else that Import Maps can do?
Yes. Below are some of the features of Import Maps, but it's not limited to these. For a full list of features, read the official spec.
Prefixes
Instead of specifying an exact thing to match, we can specify a folder prefix (ending with a forward slash):
{
"imports": {
"foobar/": "/baz/foobar/"
}
}
which allows us to reference the files inside the /baz/foobar
folder via the prefix:
import foo from "foobar/foo.js";
import bar from "foobar/bar.js";
Fingerprinting
File fingerprinting allows the browser to invalidate files based on their name:
import foo, { bar } "/baz/foobar-46d0g2.js";
But, having a fingerprinted import creates two problems for us:
- We need to have a build system that takes care of changing the fingerprint when the file
/baz/foobar.js
changes - And, the fingerprint of the file depending on
foobar.js
needs to be updated as well. That means the browser now has to download both files, even though only the code insidefoobar.js
changed. This can go out of hand if more files depend onfoobar.js
.
Using Import Maps, we can remap the fingerprinted file to a non-fingerprinted one:
{
"imports": {
"/foobar.js": "/foobar-8ebg59.js"
}
}
which now allows us to only update the Import Map, and the browser bears no extra cost.
Fallbacks
Import Maps allow us to specify more than one mappings:
{
"imports": {
"foobar": [
"https://example.com/baz/foobar.js",
"/baz/foobar.js"
]
}
}
which will instruct the browser to just download /baz/foobar.js
from our server in case it cannot contact https://example.com
for any reason (such as domain blocking etc.).
Scoping
Let's say we have a dependency problem where a package expects a different version of another package compared to what we've specified in the Import Map:
{
"imports": {
"foobar": "/baz/foobar-v2.js",
"barfoo": "/baz/barfoo.js"
}
}
In the above scenario, /baz/barfoo.js
depends on /baz/foobar-v1.js
instead of /baz/foobar-v2.js
as we've specified. To resolve this dilemma, we can add another sibling key to the "imports"
key called "scopes"
:
{
"imports": {
"...": "..."
},
"scopes": {
"/baz/barfoo.js": {
"foobar": "/baz/foobar-v1.js"
}
}
}
which instructs the browser that inside the file /baz/barfoo.js
, "foobar"
should resolve to "/baz/foobar-v1.js"
instead.
How do Rails come into the picture?
Writing this Import Map by hand might be a tedious process. Rails provide a configuration file (config/importmap.rb
) via which you can generate the Import Map quite easily.
Inside config/importmap.rb
, we have access to two methods:
pin(name, to: nil, preload: false)
pin_all_from(dir, under: nil, to: nil, preload: false)
pin
makes it easier to map a file (specified via the :to
option) and map it to a bare module specifier:
pin "foobar", to: "/baz/foobar.js"
which makes the bare module specifier "foobar"
map to the Asset Pipeline transformed file equivalent of "/baz/foobar.js"
:
{
"imports": {
"foobar": "/assets/baz/foobar-i0f472.js"
}
}
Without the :to
option (which refers to a file in the Asset Pipeline):
pin "foobar"
pin
will infer the file name (ending with .js
) from the first argument itself:
{
"imports": {
"foobar": "/assets/foobar-mt22u90.js"
}
}
The beauty of this approach is that Import Map integrates nicely with the Rails' asset pipeline without having a complicated build process.
pin_all_from
is slightly different, allowing us to map an entire tree of files under a folder (specified using the :under
option):
pin_all_from "app/javascript/foobar", under: "foobar"
saving us from having to write pin
statements for every file:
{
"imports": {
"foobar/foo": "/assets/foobar/foo-v8th63e.js",
"foobar/bar": "/assets/foobar/bar-wi93v01.js"
}
}
provided, we have the files foo.js
and bar.js
inside the app/javascript/foobar
folder. Additionally, if there's an index.js
file alongside foo.js
and bar.js
, then it will map to the value directly specified with :under
:
{
"imports": {
"foobar/foo": "/assets/foobar/foo-e113b5.js",
"foobar/bar": "/assets/foobar/bar-5b3d33.js",
"foobar": "/assets/foobar/index-f70189.js"
}
}
We can even map the files inside a folder under a completely different name, but the caveat is that the :to
option should be provided:
pin_all_from "app/javascript/foobar", under: "barfoo", to: "foobar"
which helps Rails figure out the folder inside public/assets
under which the processed files from app/javascript/foobar
will be placed:
{
"imports": {
"barfoo/foo": "/assets/foobar/foo-e113b5.js",
"barfoo/bar": "/assets/foobar/bar-5b3d33.js",
"barfoo": "/assets/foobar/index-f70189.js"
}
}
We can even pin all the files inside nested folders:
pin_all_from "app/javascript/foobar/barfoo", under: "foobar/barfoo"
which maps the entire tree inside of the nested folder barfoo/
present inside foobar/
:
{
"imports": {
"foobar/barfoo/bar": "/assets/foobar/barfoo/bar-e07c61.js",
"foobar/barfoo/baz": "/assets/foobar/barfoo/baz-7079be.js",
"foobar/barfoo": "/assets/foobar/barfoo/index-83fecf.js"
}
}
Or, if we want to pin the nested folder under a different name:
pin_all_from "app/javascript/foobar/barfoo", under: "barfoo/foobar", to: "foobar/barfoo"
which again maps the entire tree inside the nested folder barfoo/
present inside foobar/
:
{
"imports": {
"barfoo/foobar/bar": "/assets/foobar/barfoo/bar-07689a.js",
"barfoo/foobar/baz": "/assets/foobar/barfoo/baz-486f9d.js",
"barfoo/foobar": "/assets/foobar/barfoo/index-e9a30c.js"
}
}
but under a different bare module specifier.
Just calling the pin
or pin_all_from
methods inside config/importmap.rb
is not enough. We need to call the javascript_importmap_tags
view helper method inside the <head>
tag in our views:
<head>
<%= javascript_importmap_tags %>
</head>
which will actually insert the generated Import Map for the browser to refer to.
Both pin
and pin_all_from
accepts an optional argument called :preload
, which when set to true
will add a <link>
tag with rel="modulepreload"
before the placement of the actual Import Map:
<head>
<link rel="modulepreload" href="/assets/baz/foobar.js">
<script type="importmap">
{
"imports": {
"...": "..."
}
}
</script>
</head>
This makes the browser use its idle time to download files (having ES modules) before they are imported by other modules.
Disclaimer
At the time of writing this blog, Rails 7 is still not fully released. So a lot of the public APIs with respect to Import Maps might change. So keep an eye out for those changes.