Full-stack

Vite-lizing Rails: get live reload and hot replacement with Vite Ruby

If you’re interested in translating or adapting this post, please.

Recently, I upgraded my AnyCable demo application to Ruby 3 and Rails 7 with its new asset management tooling. As a result, assets:precompile became fast as lightning, but we lost one important productivity feature along the way: live reloading. Switching back to Webpacker in 2022 was not a good idea. Luckily, Vite Ruby had been on my radar for quite some time, so I decided to give it a try.

Rails has had an answer to the assets problem since the Assets Pipeline (Sprockets) was introduced. That was an important step forward for the entire world of web development frameworks, not only for Ruby and Rails.

Then, the frontend revolution began, and we, the Rails community, needed to catch up. So, Webpacker was born. Although it served its purpose well, it always felt like Webpacker was a “foreign body” in the Rails ecosystem.

The Rails team works hard to improve the documentation around the modern Asset Pipeline and the variety of available choices. Follow this pull request to learn more.

Rails 7 turned a new page in the history of asset bundlers. Webpacker has been retired; instead, we have a handful of official ways of dealing with frontend: import maps, jsbundling-rails, cssbundling-rails, tailwindcss-rails. All of these were built on top of modern tooling, play nice with Rails, and are easy to work with. Well, except maybe for the confusion this diversity can cause developers.

The problem is that they provide a Sprockets-like experience, that is, build-oriented. But for many developers, instant feedback is important, they got used to it. So, the question is: what is the modern alternative to webpack-dev-server? And my answer is Vite.

In this post, I’d like to share my Vite Ruby setup (using the AnyCable demo) and I’ll cover the following topics:

  • Getting started with Vite on Rails
  • Live reload and HRM
  • To dockerize Vite, or not

Getting started with Vite on Rails

Migrating from “<whatever>bundling-rails” to Vite was almost as simple as stated in the vite_rails documentation: install the gem and run the installation rake task (bundle exec vite install).

You can find the corresponding commit here.

I replaced the javascript_include_tag and stylesheet_link_tag helpers with vite_javascript_tag and vite_stylesheet_tag respectively, and updated the sourceCodeDir value in vite.json to frontend (since my setup deviates from the Rails app/javascript approach).

I also created the frontend/entrypoints/application.css file to point to my styles/index.css (previously used by esbuild to compile the app/assets/builds/application.css).

After these minor chages, I expected my application to work without any additional changes (backed by the Vite Ruby auto build feature). But instead, I saw this in my server logs:

Building with Vite ⚡️
vite v2.9.13 building for development...

transforming...

✓ 13 modules transformed.

Could not resolve './**/*_controller.js' from frontend/controllers/index.js
error during build:
Error: Could not resolve './**/*_controller.js' from frontend/controllers/index.js
    at error (/app/node_modules/rollup/dist/shared/rollup.js:198:30)
    at ModuleLoader.handleResolveId (/app/node_modules/rollup/dist/shared/rollup.js:22508:24)
    at /app/node_modules/rollup/dist/shared/rollup.js:22471:26

Build with Vite failed! ❌

We relied on esbuild-rails support for glob imports (import './**/*_controller.js') to auto-load Stimulus controllers, but now, once switching to Vite, we no longer have this.

Luckily, we have import.meta.globEager, which returns the path-module map, so we can use it:

const controllers = import.meta.globEager("./**/*_controller.js");

for (let path in controllers) {
  let module = controllers[path];
  let name = path.match(/\.\/(.+)_controller\.js$/)[1].replaceAll("/", "--");
  application.register(name, module.default);
}

Looks like a bit of hackery involved. No worries, we have the stimulus-vite-helpers plugin which can do this for us with just a single line of code:

import { registerControllers } from "stimulus-vite-helpers";

const controllers = import.meta.globEager("./**/*_controller.js");
registerControllers(application, controllers);

Nice! And that’s it: we’ve just migrated our application to Vite Ruby. But, do you recall why we were doing that in the first place?

Live reload and HMR

In the auto-build mode, Vite Ruby compiles the assets on demand, one output file per entrypoint; just like good old Sprockets:

Serving auto-built assets with Vite Ruby

Serving auto-built assets with Vite Ruby

This is how Rubyists might use Vite in development; however, the main selling points of Vite are “Instant Server Start” and “Lightning Fast HMR” (HMR stands for hot module replacement). How can we get there? We should run a Vite development server!

With Vite Ruby, it’s as simple as running bin/vite dev. Here’s the page loaded with the help of the dev server:

Serving assets via Vite dev server

Serving assets via Vite dev server

Now we have many JavaScript files loaded: all our dependencies and custom modules (files)—but only those needed for this particular page. The source code is processed on-the-fly using Rollup under-the-hood, and third-party (NPM) libraries are also pre-compiled (this time via esbuild). But you don’t need to worry about all these fancy frontend technologies, Vite has got you covered.

This is where “Instant Server Start” comes from. What about HMR?

Hot module replacement is a technology which makes it possible to refresh the current state of a browser’s JavaScript environment without reloading the enitre page (by just reloading a module). Not every piece of JavaScript code can be hot-reloaded, but modern frameworks such as Vue and React are compatible with this tech. And so is Stimulus, by the way.

Vite uses plugins to provide HMR capabilities (the bundler itself only provides the API). So, we need to add Stimulus HMR to our configuration:

import StimulusHMR from 'vite-plugin-stimulus-hmr'

export default {
  plugins: [
    StimulusHMR(),
  ],
};

Now we can open a page which has an element with a Stimulus controller attached and try to play with it:

Stimulus HMR demo

You see that? Our JavaScript code is getting reloaded and controllers are being re-connected, while page contents stay unchanged (the input field, for example). This is the hot module replacement in action.

As I’ve already mentioned, HMR only works with compatible JavaScript code. What if we want to react to, say, HTML template changes? We can use the ever reliable live reload via vite-plugin-full-reload. Here is our final configuration:

import { defineConfig } from "vite";
import RubyPlugin from "vite-plugin-ruby";
import StimulusHMR from "vite-plugin-stimulus-hmr";
import FullReload from "vite-plugin-full-reload";

export default defineConfig({
  plugins: [
    RubyPlugin(),
    StimulusHMR(),
    // You can specify any paths you want to watch for changes
    FullReload(["app/views/**/*.erb"])
  ],
});

Dockerizing Vite, or not

As you probably know, I’m building my apps in a Dockerized environment. Setting up Vite Ruby to work within Docker is pretty straightforward:

  • We add volumes to keep Vite assets:
x-backend: &backend
  # ...
  volumes:
    # ...
    - vite_dev:/app/public/vite-dev
    - vite_test:/app/public/vite-test

volumes:
  # ...
  vite_dev:
  vite_test:
  • We define a new service to run a Vite dev server:
vite:
  <<: *backend
  command: ./bin/vite dev
  volumes:
    - ..:/app:cached
    - bundle:/usr/local/bundle
    - node_modules:/app/node_modules
    - vite_dev:/app/public/vite-dev
    - vite_test:/app/public/vite-test
  ports:
    - "3036:3036"
  • Finally, we “connect” our Rails app to the vite service by providing the VITE_RUBY_HOST value:
x-backend: &backend
  environment:
    # ...
    VITE_RUBY_HOST: ${VITE_HOST:-vite}

Now we can run docker-compose up vite (or dip up vite) to run a dev server.

Note that I made it possible to provide a different Vite host in the configuration (${VITE_HOST:-vite}). This could be used to have an alternative, hybrid configuration: Rails running in Docker and Vite running locally.

We use Vite mostly in frontend-heavy projects, i.e., projects involving JavaScript frameworks and a dedicated frontend team. That usually involves having advanced DX machinery (linters, git hooks, IDE extensions, and so on), which in most cases doesn’t play well with Docker. That’s why we make it possible to fallback to local system development (for frontend only).

See this commit for the relevant changes.

But we’re using a Ruby gem (vite_ruby) to manage Vite configuration, so does this mean that now we have to run the full, massive Rails application locally just for the sake of a tiny Vite wrapper? Of course, not. Let me show you a better way.

First, we isolate vite_ruby by keeping a separate Gemfile for it (and other possible frontend dependencies):

# gemfiles/frontend.gemfile
source "https://rubygems.org"

# https://github.com/ElMassimo/vite_ruby
gem "vite_rails"

We include it into our main Gemfile by using eval_gemfile "gemfiles/frontend.gemfile" (this way we can use Vite helpers in the Rails app or run commands in production).

Then, we define a custom bin/vite command, which uses this frontend.gemfile:

#!/bin/bash

cd $(dirname $0)/..

export BUNDLE_GEMFILE=./gemfiles/frontend.gemfile
bundle check > /dev/null || bundle install

bundle exec vite $@

This is the same trick I use for RuboCop: a bundle exec wrapper using a custom Gemfile and auto-installing dependencies. All you need is Ruby (yeah, you still need it, but not all other system deps).

Now, you can launch a Vite dev server as usual:

bin/vite dev

And you can launch a dockerized Rails application “connected” to this locally running server by specifying the VITE_HOST parameter:

VITE_HOST=host.docker.internal dip rails s

NOTE: It’s important to set "host": "0.0.0.0" in the config/vite.json to make a dev server accessible from Docker containers.

With Dip, we can go further and provide a useful shortcut to be used for hybrid development:

# dip.yml
# ...
interaction:
  frontend:
    description: Frontend development tasks
    subcommands:
      rails:
        description: Run Rails server pointing to a local Vite dev server
        service: web
        environment:
          VITE_HOST: host.docker.internal
        compose:
          run_options: [ service-ports, use-aliases ]

No, you don’t need to think about hosts, just run dip frontend rails and that’s it.

Wrapping things up

So, there you have it. We have a Ruby Vite setup with working live reload, hot replacement, and our demand for instant gratification has been fully restored! Feel free to share this setup and use it in your own projects—I hope it comes in handy!


And one more thing: if you have a problem or project in need, whether it’s Ruby related, or not, Evil Martians are ready to help! Get in touch!

Humans! We come in peace and bring cookies. We also care about your privacy: if you want to know more or withdraw your consent, please see the Privacy Policy.