DEV Community

Rei
Rei

Posted on

Importmap or jsbundling? I use both

Starting from Rails 7, Importmap has become the default mechanism for handling JavaScript loading. It can fully utilize HTTP/2's parallel download and caching mechanisms, avoiding the need to download all code every time a change is made by bundling everything into one large package.

For js dependencies, Importmap provides a pin feature, for example, by running:

./bin/importmap pin local-time
Enter fullscreen mode Exit fullscreen mode

Importmap will download the local-time js file from the CDN and place it in the vendor/javascript directory, automatically adding the configuration to config/importmap.rb. Then you can import it in your js file:

import LocalTime from "local-time"
LocalTime.start()
Enter fullscreen mode Exit fullscreen mode

However, some js libraries assume developers will use bundling tools and do not package the source code into a complete bundle but instead split it into many files. In this case, using importmap pin will encounter problems. For example, with Lit, if you execute:

bin/importmap pin lit
Enter fullscreen mode Exit fullscreen mode

You will see the output:

Pinning "lit" to vendor/javascript/lit.js via download from https://ga.jspm.io/npm:lit@3.2.0/index.js
Pinning "@lit/reactive-element" to vendor/javascript/@lit/reactive-element.js via download from https://ga.jspm.io/npm:@lit/reactive-element@2.0.4/reactive-element.js
Pinning "lit-element/lit-element.js" to vendor/javascript/lit-element/lit-element.js.js via download from https://ga.jspm.io/npm:lit-element@4.1.0/lit-element.js
Pinning "lit-html" to vendor/javascript/lit-html.js via download from https://ga.jspm.io/npm:lit-html@3.2.0/lit-html.js
Pinning "lit-html/is-server.js" to vendor/javascript/lit-html/is-server.js.js via download from https://ga.jspm.io/npm:lit-html@3.2.0/is-server.js
Enter fullscreen mode Exit fullscreen mode

You can see that Lit references many sub-packages. The problem is that even after downloading so many packages, the import is still incomplete. If you import { LitElement } from "lit" in your js code, you will get an error in the browser:

GET http://localhost:3000/assets/css-tag.js net::ERR_ABORTED 404 (Not Found)
Enter fullscreen mode Exit fullscreen mode

This is because the @lit/reactive-element package has many optional modules that were not downloaded. But if you download all optional modules, the importmap configuration will become very bloated. There is a PR in progress (#235), but it's hard to say if it will solve the problem because the issue lies in the library authors not considering the need for unbundled imports.

So why not change the approach: first use jsbundling to bundle the dependencies, and then use importmap to import them. Here's how to achieve it.

Implementation

Assuming you have already created a project using Rails and are using importmap by default:

rails new myapp
Enter fullscreen mode Exit fullscreen mode

The code is tested on Rails 8.0.0.beta1 but should work for Rails 7+.

Next, install jsbundling:

./bin/bundle add jsbundling-rails
./bin/rails javascript:install:esbuild
Enter fullscreen mode Exit fullscreen mode

At this point, you will see js compilation errors because the default configurations of jsbundling and importmap conflict. Next, we will fix the conflicts.

Remove this line from app/views/layouts/application.html.erb:

  <%= javascript_include_tag "application", "data-turbo-track": "reload", type: "module" %>
Enter fullscreen mode Exit fullscreen mode

Modify package.json to:

  "scripts": {
    "build": "esbuild app/assets/javascripts/*.* --bundle --sourcemap --format=esm --outdir=app/assets/builds --public-path=/assets"
  }
Enter fullscreen mode Exit fullscreen mode

Note that the path is changed to app/assets/javascripts/*.*, which is the directory where js files that need to be compiled by esbuild will be placed in the future.

Add the following to config/application.rb:

config.assets.excluded_paths << Rails.root.join("app/assets/javascripts")
Enter fullscreen mode Exit fullscreen mode

Create the folder app/javascript/src/ and add the file app/assets/javascripts/lit.js with the content:

export * from 'lit';
Enter fullscreen mode Exit fullscreen mode

Install the lit package via yarn:

yarn add lit
Enter fullscreen mode Exit fullscreen mode

Add the following configuration to config/importmap.rb:

pin "lit", to: "lit.js"
Enter fullscreen mode Exit fullscreen mode

Now start the development process with ./bin/dev, and you will see esbuild compile lit to app/assets/builds/lit.js. Open the browser and view the page source; the importmap content has increased:

  "imports": {
    ...
    "lit": "/assets/lit-9c62c803.js",
    ...
  }
Enter fullscreen mode Exit fullscreen mode

The entire workflow is: esbuild compiles the source code in app/assets/javascripts to app/assets/build, the content of app/assets/build is processed by the assets pipeline, and the import name and file name mapping is added in config/importmap.rb, so the module can be imported by the application's js code.

Now, you can import { LitElement } from "lit" in your js.

Summary

This article uses a combination of esbuild and importmap to solve the problem of importmap being unable to handle complex dependencies. Although this breaks the expectation of nobuild, it still takes advantage of fine-grained caching. Until importmap is widely compatible with js packages, you can use this method to handle complex dependencies.

Top comments (0)