Managing JS and CSS Assets in Rails 7

Managing JS and CSS Assets in Rails 7

Exploring asset management options in Rails

·

12 min read

Image by Pawel Czerwinski

On Ruby on Rails 7 the asset management processes have changed from using Webpacker to use the asset pipeline with Import Maps by default as a way to streamline uses of JavaScript based tools and package managers such as Webpack (or other bundlers) Yarn or npm.

This article aims to explore Import Maps and custom bundling setups on a high level including a quick look over Webpacker so that it can be compared with other approaches, a short example of using Import Maps and a more convoluted example of custom bundling using esbuild with TypeScript and PostCSS.

Hopefully this article can be used as a starting point for someone who uses JavaScript tools to bundle assets but has little knowledge of how this currently works in the context of a Rails app.

Webpacker

This asset management approach was introduced in Rails 6 and is essentially an implementation of Webpack specifically configured to be used with Rails. This is a quick overview of Webpacker so that we can draw a comparison with more recent approaches to asset bundling.

With Webpacker a config/webpacker.yml is used as an interface to define the app’s Webpack configuration and a config/webpack folder was used store files to specify handling of assets in different environments (development, production) or to adapt it to use certain JavaScript libraries which might require additional configuration.

It would also include a package.json which has become common to use in any application that makes use of Node modules.

To install dependencies, yarn install needs to be run but when rails server is ran it would spin up the Rails application and run the Webpack watch task so that the assets are bundled correctly.

One disadvantage could be that the bundling tool is locked to Webpack behind an abstraction configuration layer as it was the default asset management approach picked for version 6.0.0 of Rails.

What I mean by abstraction layer here is that there wouldn’t be a need to configure Webpack and it would just work out of the box but configuration aspects are hidden behind the scenes and changing them required to change a webpacker.yml and not the Webpack config directly. Rails had logic in place to glue all of this together behind the scenes.

Stripping it out or ignoring it in favour of a custom implementation is possible but it’s an extra step and can be more time consuming.

Import Maps

Import Maps is the pattern being shipped with a default Rails 7 application. It makes use of a feature where JavaScript modules that would typically be installed with a package manager, such as Yarn or npm, and in most cases transpiled and bundled into a .js file can be imported directly into the browser and used in your application without an extra build step.

Key aspects on the Import Maps approach

  • It’s more tightly coupled with Rails as it is the way the creator encourages developers to go for and ships with a default Rails app.
  • Can simplify your toolchain since no npm or bundlers are required to make use of JavaScript libraries.
  • Requires less configuration, running a new rails new myapp is enough to get you started.
  • It does not include an option if you prefer an approach of bundling your own styles. For example using SASS or Postcss, although nothing stops you from making use of a hybrid approach and add a build step yourself.
  • Less control of your asset bundling so if you require more complex JavaScript and CSS handling such as using Postcss partials or using a custom way of transpiling JavaScript it might not be the best choice.

Using Import Maps in a website (including a Rails app) will result in the source code look something like this:

<script type="importmap" data-turbo-track="reload">{
  "imports": {
    "application": "/assets/application.js", // A local JS file.
        "another-js-library": "/assets/another-js-library.js, // Another local JS file.
        "local-time": "https://ga.jspm.io/npm:local-time@2.1.0/app/assets/javascripts/local-time.js" // A library being imported via a CDN.
  }
}</script>

The example above shows a description of which modules the page is using as importable files. Others could be added such as React, JQuery or pretty much any other JavaScript library.

Then the modules are imported after the importmap script tag by rendering a few additional module tags (can be one per module at times). In this case the libraries in the importmaps script tag are being used in application.js so only a single module tag is required and this should work for most cases:

<script type="module">import "application"</script>

Rails will generate these tags for you when the <%= javascript_importmap_tags %> is added to a layout, typically application.html.erb and will work out which modules need to be included.

For browsers that do not fully support this feature, the Rails team has created a shim to make it work for now.

What is a shim?

Essentially it’s a program that intercepts the default behaviour of another program or implementation and adds new logic to it, with the aim to make it work better with the application is being used in.

In this case it intercepts the Import Maps feature and adds logic to make sure it works correctly in all the modern browsers as well as making it compatible with the Rails pattern.

Using Import Maps in Rails

To import a package that is typically available in npm run the following command in the terminal. In this case it will install local-time:

./bin/importmap pin local-time

This will add a new line to config/importmap.rb to put the package to use. This file is essentially used for Rails to generate the Import Maps script tag that is placed in the final HTML output:

pin "local-time", to: "https://ga.jspm.io/npm:local-time@2.1.0/app/assets/javascripts/local-time.js"

If you would like to download the package to store it in your application, using the --download flag will pull the module file into vendor/javascript/local-time.js and it would also change the pin statement to reflect the change:

pin "local-time" # @2.1.0

The module can then be used in app/javascript/application.js like a regular import would be:

import LocalTime from "local-time"

In some cases you might want to use a module you’ve been working on and is not hosted with npm. To do this, add the file to assets/javascript in this case I’ve named it home.js:

console.log("Hello Home!")

Then it can be imported to application.js:

// Configure your import map in config/importmap.rb. Read more: https://github.com/rails/importmap-rails
import "@hotwired/turbo-rails"
import "controllers"
import "trix"
import "@rails/actiontext"
// Importing the home.js script here!
import "./home"
import LocalTime from "local-time"
LocalTime.start()

That should be it, the code inside home.js should run without the need to be pinned in importmap.rb.

The importmap.rb file is used to workout which modules will be in the following tag:

<script type="importmap" data-turbo-track="reload">{
  "imports": {
    "application": "/assets/application.js", // A local JS file.
        "another-js-library": "/assets/another-js-library.js, // Another local JS file.
        "local-time": "https://ga.jspm.io/npm:local-time@2.1.0/app/assets/javascripts/local-time.js" // A library being imported via a CDN.
  }
}</script>

It will also render any other necessary tags in order for Import Maps to work. Each tag points to a module used by this app in particular so your output might be different than this snippet:

<link rel="modulepreload" href="/assets/application-97114f95015a6fb5e0cb87c109b1397e96ba9a9d1e7422725a491c2034ce6580.js">
<link rel="modulepreload" href="/assets/turbo.min-305f0d205866ac9fc3667580728220ae0c3b499e5f15df7c4daaeee4d03b5ac1.js">
<link rel="modulepreload" href="/assets/stimulus.min-900648768bd96f3faeba359cf33c1bd01ca424ca4d2d05f36a5d8345112ae93c.js">
<link rel="modulepreload" href="/assets/stimulus-loading-685d40a0b68f785d3cdbab1c0f3575320497462e335c4a63b8de40a355d883c0.js">
<script src="/assets/es-module-shims.min-6982885c6ce151b17d1d2841985042ce58e1b94af5dc14ab8268b3d02e7de3d6.js" async="async" data-turbo-track="reload"></script>

This is currently the encouraged way to manage JavaScript in a Rails application but the Rails team has worked towards giving developers some freedom to implement their custom bundling as well.

Custom Bundling

Using your own bundling system such as Webpack, Rollup, esbuild or other is also possible in cases where you require a more robust setup. Perhaps you would like to use TypeScript or implement your own configuration of React, Svelte or Vue. You could want a setup with Sass or Postcss. You might simply want to have more control on how dependencies are installed and where they end up. If you require a more convoluted setup this could be the right approach to take.

Key aspects on the custom bundling approach

  • The bundler choice and configuration is left completely up to you. This can either be a positive change, because you get more control or could mean that it requires an extra step when setting up the pipeline and a number of additional configuration files.
  • The Rails team has made available the jsbundling-rails **gem that streamlines configuring your application with esbuild, Webpack or Rollup along with cssbundling-rails which is the equivalent to manage CSS bundling. Yarn is used in this case.
  • This approach requires yarn build --watch to be run alongside the Rails server process but using ./bin/dev will run both processes in one go.

In new Rails 7 apps, a bundler and CSS pre processor can be specified using the following command:

rails new myapp -j esbuild -c postcss

The options for bundlers and CSS pre processors are limited to the options jsbundling-rails and cssbundling-rails offer. See each of the repositories README files for details since they might provide a starting point and save you some time when creating a setup with your preferred tools.

After using this command, a scripts object with build and build:css tasks still need to be defined and configured in package.json. An example of how these tasks may look like using the previously selected bundler and pre-processor:

// previous file contents...
"scripts": {
    "build": "esbuild ./app/javascript/*.* --outfile=./app/assets/builds/application.js --bundle",
    "build:css": "postcss ./app/assets/stylesheets/application.postcss.css -o ./app/assets/builds/application.css"
},
// file continues...

Using this approach still couples it with Rails configuration which expects a few things:

  • The JS and CSS final output needs to be copied to app/assets/builds. This means your final transpiled .js and processed .css files are expected to be served from here.
  • Rails makes use of <%= stylesheet_link_tag "application", "data-turbo-track": "reload" %> and <%= javascript_include_tag "application", "data-turbo-track": "reload", defer: true %> to look for a bundled application.js and a application.css in the builds directory and expect these to exist.

Other than that, it seems JavaScript files and CSS files can be put together in a flexible way. However, using the stylesheet_link_tag method to add link tags to the head of the document seems to still require bundled files to be in the builds folder:

<%= stylesheet_link_tag "application", "data-turbo-track": "reload" %>
<%= stylesheet_link_tag "style", "data-turbo-track": "reload" %>
<%= javascript_include_tag "application", "data-turbo-track": "reload", defer: true %>

In the example above a link tag pointing at app/assets/builds/style.css will also be included in the rendered HTML.

How does Rails determines that the builds folder should be where compiled assets are meant to be stored? This is decided by the jsbundling-rails and cssbundling-rails codebases, in their default internal configuration.

How about creating a JavaScript module?

The same way a bundled CSS file is expected to be in /builds when using stylesheet_link_tag, the same is expected for a bundle JS file when using javascript_include_tag.

By default, using this custom bundling approach, Rails uses app/javascript/application.js as an entry point to compile files and you could split your scripts within this folder and import them in, as well as any modules installed via Yarn, this is what the file looks like:

// Entry point for the build script in your package.json
import "@hotwired/turbo-rails"
import "./controllers"

Creating a new module in app/javascript/external.mjs shows how Rails picks up the change when the file is imported into application.js and that the .mjs extension can be used with no issues:

export const external_message = "External module loaded";

export function result() {
  return 3 + 3;
}

What about TypeScript?

Typescript can be added in a few steps, check out Noel Rappin’s post on how to get TypeScript up and running.

Here’s a breakdown of an example setup that builds on the previous steps, start by installing the typescript, tsc-watch and a configuration package. I’ve used @tsconfig/recommended:

yarn add --dev typescript tsc-watch @tsconfig/recommended

Then we want to run the TypeScript checker before esbuild transpiles the code so a watch:ts command was added along side of a failure:ts command to run on failure to the package.json scripts object:

"scripts": {
    "build": "esbuild ./app/javascript/application.ts --outfile=./app/assets/builds/application.js --bundle",
    "build:css": "postcss ./app/assets/stylesheets/application.postcss.css -o ./app/assets/builds/application.css",
    "failure:ts": "rm ./app/assets/builds/application.js && rm ./app/assets/builds/application.js.map",
    "watch:ts": "tsc-watch --noClear -p ./tsconfig.json --onSuccess \"yarn build\" --onFailure \"yarn failure:ts\""
},

This requires a tsconfig.json, this might be tricky to configure if you don’t do it often so here’s the configuration I’ve used:

{
  "extends": "@tsconfig/recommended/tsconfig.json",
  "compilerOptions": {
    "target": "ES2015",
    "lib": [
      "dom",
      "dom.iterable",
      "esnext"
    ],
    "allowJs": true,
    "skipLibCheck": true,
    "strict": true,
    "forceConsistentCasingInFileNames": true,
    "noEmit": true,
    "esModuleInterop": true,
    "module": "esnext",
    "moduleResolution": "node",
    "resolveJsonModule": true,
    "isolatedModules": true,
    "jsx": "preserve",
    "downlevelIteration": true
  },
  "$schema": "https://json.schemastore.org/tsconfig",
  "display": "Recommended",
  "include": [
    "./app/javascript/**/*.ts"
  ],
  "exclude": [
    "./node_modules"
  ]
}

Next, it’s required to rename the entry file at app/javascript/application.js to application.ts so that the TypeScript checker picks up on it.

Finally, the contents [Procfile.dev](http://Procfile.dev) need to be edited in order to run the TS watch command instead of the build one. We are running the esbuild command via ts-watch and that’s why it does not need to be in the Procfile:

web: bin/rails server -p 2077
js: yarn watch:ts
css: yarn build:css --watch

Running ./bin/dev in the terminal will start the tasks and the track changes as well as run TypeScript checks on any .ts files in the ./app/javascript directory.

Conclusion

With Rails 7, the framework now ships with an Import Maps approach by default but it does leave options for more complex setups with custom bundling which still have to be done “the Rails way” in some sense. This is noticeable, for example, when a there are assigned default entry points for scripts and pre processed styles. It does help developers that are looking to get slightly more control over their bundling and this seems to be a step in the right direction.

As the Rails getting started guide says:

If you learn "The Rails Way" you'll probably discover a tremendous increase in productivity. If you persist in bringing old habits from other languages to your Rails development, and trying to use patterns you learned elsewhere, you may have a less happy experience.

This does become true when, for instance, trying to place the files in custom directories since Rails still expects entry files to exist in certain folders and to be used or when trying to decouple asset bundling completely from the framework. For those wanting more complex setups it is completely possible to get them working but it can lead to a greater effort investment on the developer side and it might mean that some Rails helper methods might have to be set aside in those cases, creating a more decoupled solution.

As with everything, each approach shows advantages and disadvantages so it is very much dependant on the use case which one to go for.

Did you find this article valuable?

Support Cristiano by becoming a sponsor. Any amount is appreciated!