Rails 7 brings Import Maps into the limelight
Tags: rails, importmaps, javascript
Published: 2021-12-20 08:01:35

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](https://github.com/WICG/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](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Modules) being one of them.

## The current state of ES Modules in the browser

The [browsers that support ES Modules](https://caniuse.com/?search=JavaScript%20modules) via the `<script>` tag do so in 3 ways:

- Using relative paths (relative to the current file):
  ```js
  import foo, { bar } from "../../foobar.js";
  ```
- Or using absolute paths (relative to the Webroot):
  ```js
  import foo, { bar } from "/baz/foobar.js";
  ```
- Or using URLs:
  ```js
  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:

```js
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"`:

```html
<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](https://github.com/WICG/import-maps).

### Prefixes

Instead of specifying an exact thing to match, we can specify a folder prefix (ending with a forward slash):

```json
{
  "imports": {
    "foobar/": "/baz/foobar/"
  }
}
```

which allows us to reference the files inside the `/baz/foobar` folder via the prefix:

```js
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:

```js
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:

```json
{
  "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:

```json
{
  "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:

```json
{
  "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"`:

```json
{
  "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_:

```rb
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"`:

```json
{
  "imports": {
    "foobar": "/assets/baz/foobar-i0f472.js"
  }
}
```

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

```rb
pin "foobar"
```

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

```json
{
  "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):

```rb
pin_all_from "app/javascript/foobar", under: "foobar"
```

saving us from having to write `pin` statements for every file:

```json
{
  "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`:

```json
{
  "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:

```rb
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:

```json
{
  "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:

```rb
pin_all_from "app/javascript/foobar/barfoo", under: "foobar/barfoo"
```

which maps the entire tree inside of the nested folder `barfoo/` present inside `foobar/`:

```json
{
  "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:

```rb
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/`:

```json
{
  "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:

```erb
<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:

```html
<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

- [WICG / import-maps](https://github.com/WICG/import-maps)
- [Import maps - HTTP 203](https://youtu.be/yOcgGSCrn-c)
- [rails / importmap-rails](https://github.com/rails/importmap-rails)
- [Alpha preview: Modern JavaScript in Rails 7 without Webpack](https://youtu.be/PtxZvFnL2i0)