Rails 7 brings Import Maps into the limelight

radiantshaw

Nipun Paradkar

Posted on December 20, 2021

Rails 7 brings Import Maps into the limelight

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";
Enter fullscreen mode Exit fullscreen mode
  • Or using absolute paths (relative to the Webroot):
  import foo, { bar } from "/baz/foobar.js";
Enter fullscreen mode Exit fullscreen mode
  • Or using URLs:
  import foo, { bar } from "https://example.com/baz/foobar.js";
Enter fullscreen mode Exit fullscreen mode

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";
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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/"
  }
}
Enter fullscreen mode Exit fullscreen mode

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";
Enter fullscreen mode Exit fullscreen mode

Fingerprinting

File fingerprinting allows the browser to invalidate files based on their name:

import foo, { bar } "/baz/foobar-46d0g2.js";
Enter fullscreen mode Exit fullscreen mode

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"
  }
}
Enter fullscreen mode Exit fullscreen mode

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"
    ]
  }
}
Enter fullscreen mode Exit fullscreen mode

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"
  }
}
Enter fullscreen mode Exit fullscreen mode

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"
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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"
  }
}
Enter fullscreen mode Exit fullscreen mode

Without the :to option (which refers to a file in the Asset Pipeline):

pin "foobar"
Enter fullscreen mode Exit fullscreen mode

pin will infer the file name (ending with .js) from the first argument itself:

{
  "imports": {
    "foobar": "/assets/foobar-mt22u90.js"
  }
}
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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"
  }
}
Enter fullscreen mode Exit fullscreen mode

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"
  }
}
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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"
  }
}
Enter fullscreen mode Exit fullscreen mode

We can even pin all the files inside nested folders:

pin_all_from "app/javascript/foobar/barfoo", under: "foobar/barfoo"
Enter fullscreen mode Exit fullscreen mode

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"
  }
}
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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"
  }
}
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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

💖 💪 🙅 🚩
radiantshaw
Nipun Paradkar

Posted on December 20, 2021

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related