I dislike Webpack. I even more strongly dislike Webpack when integrated with Rails. My prior experience with it has always been one of those tools that when it works silently in the background doing what you expect it’s fine, but when it doesn’t do what you expect, well, there goes the rest of your day debugging it and endlessly tinkering with obscure configuration files. Then again, JavaScript in general has never been my forte, but all the more reason for a desire to not have JavaScript tooling continually get in the way of focusing on my actual application.
Regardless, when I started work on my recent project, Pirep, circa two years ago I read about a new gem installed by default with Rails 7 applications: importmap-rails. It described itself as such in its README:
You can build modern JavaScript applications using JavaScript libraries made for ES modules (ESM) without the need for transpiling or bundling. This frees you from needing Webpack, Yarn, npm, or any other part of the JavaScript toolchain. All you need is the asset pipeline that’s already included in Rails.
All of this was absolute music to my ears. I instantly adopted it into my application and forwent any type of JavaScript build system. And it worked wonderfully. There was one rough edge, however: third party dependency management.
Third Party Dependencies with importmap-rails
By default, the documented method for adding a third-party JavaScript dependency to your Rails application with importmap-rails
is to use one of two options:
- Serving the JavaScript modules from a third-party CDN
- Downloading a local copy of the JavaScript modules to be included in your repository and served through your application
In my opinion, serving anything necessary for the functioning of your website through a third-party CDN that you have no control over is simply asking for trouble so I don’t bother to consider that here.
For option two there’s a significant drawback: The packages are downloaded from JSPM and for pure JavaScript libraries this works great. But for any packages that also have associated CSS or images with them, well, you’re on your own to get those resources into your application.
Maybe I’m missing something, but this trait alone significantly reduces the usability of this entire gem if I have to source the non-JS assets of a package myself through other means. The core concept of using import maps is solid, but the method of dependency management completely misses the mark here. Looking backwards though, there’s already a decent tool that does handle this: Yarn. I’m not in love with Yarn by any means, but at least it downloads all of the assets needed for the functioning of a particular JavaScript package.
This got me thinking, what if I could use Yarn to download my dependencies and then use the import maps gem to get those assets into the asset pipeline for Rails to use. Well, good news because doing that is the whole point of this post.
Using Yarn with importmap-rails
The downloading functionality of importmap-rails
simply downloads the files for a specified JS package to your vendor/assets
directory. From there, your importmap.rb
and manifest.js
files will read the files in that directory for inclusion into your Rails application.
So here’s the trick: it doesn’t matter what places the files in that directory. It could be the importmap-rails
download functionality or it could be another dependency management tool like Yarn.
Yarn, of course, will put files into node_modules
so if we want to download our dependencies with Yarn and then have import maps read them without manually copying anything or making a mess of our config files we can just create symlinks from vendor/assets
to node_modules
. Yup, that’s basically the whole point to this post: a fairly dumb, albeit effective, solution to an annoying problem.
Example Package
Let’s walk through then how this would work with a JavaScript package we want to include in our Rails app. For Pirep, I heavily used Mapbox-gl so I’ll use that as an example here.
To start, we add it to our package.json
file as you would with any other JS package when using Yarn:
1
2
3
4
5
{
"dependencies": {
"mapbox-gl": "^2.10.0"
}
}
Then running yarn install
will fetch the files and put them under node_modules
.
Since Mapbox-gl has a compiled JS file we only need to symlink it into vendor/assets
. But this approach can also work with multiple JS files using import
statements. My vendor/assets/javascripts
directory looks as such:
1
2
3
4
$ ls -la vendor/assets/javascripts
drwxr-xr-x shane shane 4.0 KB Sun Feb 26 23:25:57 2023 .
drwxr-xr-x shane shane 4.0 KB Sun Feb 26 23:25:57 2023 ..
lrwxrwxrwx shane shane 49 B Sun Feb 26 23:25:57 2023 mapbox-gl.js > ../../../node_modules/mapbox-gl/dist/mapbox-gl.js
And similarly for Mapbox’s CSS:
1
2
3
4
$ ls -la vendor/assets/stylesheets
drwxr-xr-x shane shane 4.0 KB Sun Feb 26 23:25:57 2023 .
drwxr-xr-x shane shane 4.0 KB Sun Feb 26 23:25:57 2023 ..
lrwxrwxrwx shane shane 50 B Sun Feb 26 23:25:57 2023 mapbox-gl.css > ../../../node_modules/mapbox-gl/dist/mapbox-gl.css
Then we can add the package to our config/importmap.rb
file to tell it that references to mapbox-gl
should be resolved to mapbox-gl.js
:
1
2
pin 'application', preload: true
pin 'mapbox-gl', to: 'mapbox-gl.js'
This works because vendor/assets/javascripts
is a default search path for Rails’ asset pipeline.
Finally, in the app/assets/config/manifest.js
file we tell it to include all JS files under vendor/assets/javascripts
in the import map that is sent to the browser:
1
2
//= link_tree ../javascripts .js
//= link_tree ../../../vendor/assets/javascripts .js
This assumes you are using app/assets
for your JavaScript files. This is a relative path so adjust it as needed if you have a different location such as app/javascript
.
For the CSS, the good ‘ole Sprockets pipeline continues to work well here. In my app/assets/stylesheets/application.scss
file I have the following import:
1
@import 'mapbox-gl';
This pulls in Mapbox’s CSS file from the vendor/assets/stylesheets
directory that we symlinked before. vendor/assets/stylesheets
is a default search path for the asset pipeline so there’s no additional configuration needed.
And that’s it! From here we can use Mapbox-gl in our own JS files with a simple import 'mapbox-gl';
statement. When the browser loads the page it will have an import map defined:
1
2
3
4
5
6
<script type="importmap" data-turbo-track="reload" nonce="P6km21BKVZz6fWe52Z0eae9iEd1Du2jBG+UW5UVKdQ4=">{
"imports": {
"application": "https://cdn.pirep.io/assets/application-c6ab36beca07f0adacc25acb300bd176b60316e3c3b436d3ef30f07818b9a4e6.js",
"mapbox-gl": "https://cdn.pirep.io/assets/mapbox-gl-945cb90660c81cfd8dc80d59a1ae0b69e43748888e5e63bcf16643a05a24315f.js",
}
}</script>
This will tell the browser which JS modules to load which allows us to natively use import
statements without the need for fumbling around with any build systems like Webpack.
For anyone worried about browser compatibility, import maps are currently widely supported in recent browsers. At the time of this writing the only major browser to not have support is Safari (althought support exists in the technology preview version so it’s coming shortly there too). The importmap-rails
gem has a built-in polyfill for unsupported browsers though so there’s little need to worry about lack of support here.
Where it won’t work
It’s not all rainbows and sunshine, unfortunately. There are some JavaScript packages that don’t play nicely with import maps just yet.
For example, in Pirep I use Sentry for error reporting. They have a JavaScript library for collecting frontend errors. Unlike Mapbox-gl, however, its NPM package does not have a single built JS file to symlink into the vendor directory. That’s not necessarily a problem though since the whole point of an import map is that it can pull in multiple JavaScript modules directly from the browser. The issue I found with the Sentry library in particular is that some of those import paths are not compatible with loading from a browser. I outlined all of my different attempts at making it work in an issue I opened asking for import map support.
In the end, I ended up pulling the built Sentry file from their CDN and copied it directly into my vendor/assets/javascripts
directory then included a separate, standalone <script>
tag for it in the <head>
of my templates. This was disappointing, but this was also the sole library that I had to do this with so it was only a mild annoyance than a common occurrence.
The other area where import maps won’t do much for you is if you’re using any form of JavaScript that won’t run in the browser. For example, if you make use of TypeScript. The idea is that you’re running JavaScript in the browser as it is written without any intermediary. If you have any compilation step this won’t work for you.
Overall, in my experience so far using importmap-rails
has all been a huge simplification of my asset management. Having a Rails application with a clean and understandable asset pipeline takes me back to the pure Sprockets days which I’ve honestly sorely missed. That’s not to say this is ready for everyone to use or for complex legacy applications, but there’s finally a light leading us out of the Webpack darkness that we’ve all been stuck in for the past decade.