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 inside foobar.js changed. This can go out of hand if more files depend on foobar.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.

References