Mario Alberto Cháve

Blog personal desarrollo de software

desarrollo

Nobuild with Rails and Importmap

The latest versions of Ruby on Rails have focused on simplicity across different aspects of the framework, accompanied by the promise to return to the “one-man framework” (where a single developer can effectively build and maintain an entire application).

Importmap Rails library is based on the principle that modern web browsers have caught up with the ECMAScript specification and can interpret ES Modules (ESM). As a web standard, Importmap allows you to control how JavaScript modules are resolved in the browser and manage dependencies and versions without the need to transpile or bundle the code sent to the browser.

How the Importmap Web Standard Works

It all starts with a script tag of type importmap defined in your application’s main layout or web page. Inside this tag, a JSON object defines aliases and their corresponding paths to the source code.

<script type="importmap">
  {
    "imports": {
      "application": "/assets/application.js",
      "local-time": "https://cdn.jsdelivr.net/npm/[email protected]/app/assets/javascripts/local-time.es2017-esm.min.js",
      "utils": "/assets/utils.js"
    }
  }
</script>

In the same map, you can mix library paths pointing to a CDN or using local resources. To use libraries from this map, reference the alias name.

<!-- Below the importmap script -->
<script type="module">import "application"</script>

And in your application.js, import needed dependencies:

// application.js

import LocalTime from "local-time";
LocalTime.start();

import "utils";

Importmap support is present in browsers Chrome 89+, Safari 16.4+, Firefox 108+, and Edge 89+. For older browsers, include a polyfill:

<script
  async
  src="https://ga.jspm.io/npm:[email protected]/dist/es-module-shims.js"
></script>

How Importmap Works in Ruby on Rails

Importmap functionality in Ruby on Rails follows the same standard described above and offers an easy way to create maps and version files. Using a web application named heroImage as an example (source code available on Github), let’s explore the implementation.

heroImage website

When you create a new Rails 8 application, the importmap-rails gem is added and installed by default. A file config/importmap.rb is created where you can pin the JavaScript code needed in your application.

pin "application"

pin "@hotwired/turbo-rails", to: "turbo.min.js"
pin "@hotwired/stimulus", to: "stimulus.min.js"
pin "@hotwired/stimulus-loading", to: "stimulus-loading.js"

pin_all_from "app/javascript/controllers", under: "controllers", preload: false

The pin keyword takes up to three arguments. The first one is required, as it is the alias of the JavaScript code. pin "application" is a shortcut for file application.js with alias application:

pin "application", to: "application.js"

When alias and file names differ, use the keyword to::

pin "@hotwired/turbo-rails", to: "turbo.min.js"

The pin_all_from keyword helps reference multiple files at once. The first argument is the path where the JavaScript files are located, and the under: argument prefixes the alias for each file. The generated alias uses the under prefix and the file name, like controllers/alert-controller for alert_controller.js file.

To visualize the Importmap JSON file, execute:

bin/importmap json

{
  "imports": {
    "@hotwired/turbo-rails": "//127.0.0.1:4700/assets/turbo.min-fae85750.js",
    "@hotwired/stimulus": "//127.0.0.1:4700/assets/stimulus.min-4b1e420e.js",
    "@hotwired/stimulus-loading": "//127.0.0.1:4700/assets/stimulus-loading-1fc53fe7.js",
    "application": "//127.0.0.1:4700/assets/application-b1902c45.js",
    "controllers/application": "//127.0.0.1:4700/assets/controllers/application-fab0f35b.js",
    "controllers": "//127.0.0.1:4700/assets/controllers/index-c3f5d3c4.js",
    "controllers/alert_controller": "//127.0.0.1:4700/assets/controllers/alert_controller-caf203bf.js",
    "controllers/file_controller": "//127.0.0.1:4700/assets/controllers/file_controller-5da5fdc5.js",
    "controllers/notifications_controller": "//127.0.0.1:4700/assets/controllers/notifications_controller-88e2cc65.js",
    "controllers/service_worker_controller": "//127.0.0.1:4700/assets/controllers/service_worker_controller-ad4a24d3.js",
    "controllers/share_controller": "//127.0.0.1:4700/assets/controllers/share_controller-fe28ed00.js"
  }
}

Rails resolves all JavaScript through the Propshaft gem, which resolves the physical path of the JavaScript code, maps to the /assets web path, and adds the digest to each file for better caching and invalidations.

Propshaft discovers physical paths from the asset’s configuration:

Rails.application.config.assets.paths

Ensure your files exist in any of the registered paths or add your own path to be discovered by Propshaft and Importmap.

Importmap in Rails allows you to specify how the browser should load JavaScript files. There are two options: preload (default) and no preload. Preload tells the browser to download files as soon as possible. Importmap generates a link tag with rel="modulepreload":

<link
  rel="modulepreload"
  href="https://heroimage.co/assets/turbo.min-fae85750.js"
/>
<link
  rel="modulepreload"
  href="https://heroimage.co/assets/stimulus-loading-1fc53fe7.js"
/>
<link
  rel="modulepreload"
  href="https://heroimage.co/assets/stimulus-loading-1fc53fe7.js"
/>
<link
  rel="modulepreload"
  href="https://heroimage.co/assets/application-b1902c45.js"
/>

If you set the preload argument to false, the link tag is not generated and the browser downloads the file when needed.

With Rails’ Importmap, you can also pin JavaScript code from a CDN using the to: argument for the URL:

pin "local-time", to: "https://cdn.jsdelivr.net/npm/[email protected]/app/assets/javascripts/local-time.es2017-esm.min.js"

The Importmap includes a CLI to pin or unpin JavaScript code into config/importmap.rb file. It also includes commands to update, audit, and inspect versions:

bin/importmap --help
Commands:
  importmap audit              # Run a security audit
  importmap help [COMMAND]     # Describe available commands or one specific command
  importmap json               # Show the full importmap in json
  importmap outdated           # Check for outdated packages
  importmap packages           # Print out packages with version numbers
  importmap pin [*PACKAGES]    # Pin new packages
  importmap unpin [*PACKAGES]  # Unpin existing packages
  importmap update             # Update outdated package pins

When using the pin command for a JavaScript package, instead of setting the to: argument to the CDN, Importmap resolves package dependencies and downloads the package and dependencies to vendor/javascript, allowing the Rails application to serve those files:

bin/importmap pin local-time
Pinning "local-time" to vendor/javascript/local-time.js via download from https://ga.jspm.io/npm:[email protected]/app/assets/javascripts/local-time.es2017-esm.js
# config/importmap.rb
...
pin "local-time" # @3.0.2

This approach works well when your package has simple dependencies or well-defined dependencies in the JavaScript package. If that’s not the case, it becomes challenging to use with Importmap vendoring the code at vendor/javascript. It might work with the URL and manual dependency addition, or you can tweak the vendored code to make it work.

How to Work with Rails Gems - Engines - and Importmap?

There are two approaches to creating Ruby on Rails gems compatible with Importmap. The first approach allows your gem to provide JavaScript code, which you can choose to pin in the Importmap configuration. This is how the turbo-rails and stimulus-rails gems are implemented.

Place your JavaScript code in the app/assets/javascripts folder of your gem. You may need an additional process that minifies the JavaScript files and generates JavaScript map files. Then, inside the Engine class, define an initializer hook to declare your JavaScript code with Propshaft:

module MyEngine
  class Engine < ::Rails::Engine
    # Additional code
    PRECOMPILE_ASSETS = %w( my_javascript.js my_javascript.min.js my_javascript.min.js.map ).freeze

    initializer "my_engine.assets" do
      if Rails.application.config.respond_to?(:assets)
        Rails.application.config.assets.precompile += PRECOMPILE_ASSETS
      end
    end
  end
end

The second option uses an Importmap configuration file. If your engine has its layout template and the views are isolated from the host application, and the engine doesn’t need to share the JavaScript code with the host application, you can create an Importmap configuration file at config/importmap.rb, set your pins, place your JavaScript code at app/javascript, and configure the engine with an initializer.

Open your engine.rb Ruby file and add the Importmap configuration file and a sweeper:

initializer "my-engine.importmap", after: "importmap" do |app|
  MyEngine.importmap.draw(root.join("config/importmap.rb"))
  MyEngine.importmap.cache_sweeper(watches: root.join("app/javascript"))

  ActiveSupport.on_load(:action_controller_base) do
    before_action { MyEngine.importmap.cache_sweeper.execute_if_updated }
  end
end

Specify the Importmap to use in your engine’s layout template:

<%= javascript_importmap_tags "application", importmap: MyEngine.importmap %>

For sharing JavaScript code with the host application, like Stimulus controllers, create a partial Importmap configuration file and set the engine to merge it with the main one in the host application.

Create an Importmap configuration file at config/importmap.rb and add the JavaScript pins to share with the host application. If you have dependencies for external packages, add those via a generator or installer to the host application:

pin_all_from File.expand_path("../app/assets/javascripts/controllers", __dir__), under: "controllers", preload: false

Open your engine.rb file and add an initializer:

initializer "maquina.importmap", before: "importmap" do |app|
  app.config.importmap.paths << Engine.root.join("config/importmap.rb")
end

What are the Advantages of Using Importmap?

From a Ruby on Rails developer perspective, the main advantage of using Importmap is the freedom from requiring a JavaScript runtime-like node and freedom from the node_modules dependency.

Additionally, you don’t need an additional process in development mode to transpile and minify the JavaScript code. You rely on web standards to serve the code to the browser. Deploying your Rails application behind a reverse proxy offers several benefits. First, if you enable the HTTP/2 protocol, your browser can fetch multiple files with a single HTTP connection, and downloading many small JavaScript files won’t impact performance.

Web tools

Enabling your proxy to use gzip or brotli compression ensures you are sending very small files while maintaining readability when using browser developer tools. If you change one file, you only need to invalidate that specific file, which the browser will download. The browser knows that a file was modified because of the fingerprint that Propshaft adds to all files.

Using a reverse proxy like Thruster along with Puma offloads the assets serving from the Rails application. Thruster can cache assets and serve them when a client requests a file.

When Not to Use Importmap

There are cases where you should avoid using Importmap in a Rails application. If you are building a SPA application with React, Vue, or any other similar tool, there is a high likelihood you are writing your code with TypeScript. In this case, you should stick with the bundling strategy.

Additionally, if you need to support older browsers, bundling with code transpilation is a better option.