Yesterday I've added turbo/hotwire to a big rails application which evolved over several years from rails 3, to 4 to 5 to 6 and now to rails 7.
The 6-7 version updates went pretty smooth. But now we would like to rewrite our javascript-coffeescript kruft to a more modern approach.
The app is very big, so directly replacing everything is virtually impossible. That's why I introduced an incremental approach.
The 7 version is still running sprockets. (Glad I didn't introduce webpacker in version 6, which would have resulted in an even bigger kruft).
New situation
The new situation will keep sprockets for the current javascript/coffeescript, sass css and other assets.
It will introduce esbuild for building the new javascript.
The directory structure used is the following:
app/assets/builds
: is the output folder of esbuild javascriptsapp/assets/javascripts
: contains the legacy scriptsapp/javascript
: contains the new javascripts
Changes in Gemfile
- gem uglifier
+ gem terser
+ gem turbo-rails
+ jsbundling-rails
+ stimulus-rails
Configure sprockets / deployment
Sprockets needs to to include the esbuild build directory to embed the new javascript content.
Changes in app/assets/config/manifest.js
+//= link_tree ../build/
Add the build directory to the assets paths in config/initializers/assets.rb
Rails.application.config.assets.paths << Rails.root.join('app/assets/builds')
Uglifier was crashing on production deployment of the esbuild javascript files (those are already handled). This was solved be replacing uglifier with terser in my Gemfile
.
This also needs to be enabled in the config/environments/production.rb
file.
- config.assets.js_compressor = Uglifier.new(harmony: true)
+ config.assets.js_compressor = :terser
Required legacy javascript changes
Every legacy javascript file that uses the ready eventhandler is replaced by the turbo:load
event
- $(document).ready ->
+$(document).on "turbo:load", ->
New javacript changes
The new new javascript files cannot be called application.js
because it has the same name as the legacy name. Because the same name is generated to solve this, I use app/javascript/app.js
:
// Entry point for the build script in your package.json
import { Turbo } from "@hotwired/turbo-rails"
window.Turbo = Turbo
import "./controllers"
Contents of app/javascript/controllers/index.js
// This file is auto-generated by ./bin/rails stimulus:manifest:update
// Run that command whenever you add a new controller or create them with
// ./bin/rails generate stimulus controllerName
import { application } from "./application"
import LegacyJsController from "./legacy_js_controller"
application.register("legacy-js", LegacyJsController)
Load the new javascripts
Add the new javascript include to the layout template of the project and `views/layouts/application.html.erb'
= <%= javascript_include_tag "application", defer: true %>
+ <%= javascript_include_tag "app", defer: true %>
- <body>
+ <body 'data-turbo'= <%= @turbo ? true : false %>' >
Because Turbo requires 422 status code on invalid form result, I've disabled Turbo by default. I've tried enabling it by default, which worked pretty good except for form-validation errors. And there are a LOT of places this happen, so for the incremental update it's better to slowly convert/change all pages for using turbo.
When a controller-action uses turbo it can set the @turbo variable to true.
The idea it to slowly introduce this to every controller. When I'm confident it works (almost) everywhere this can be inverted/removed.
Add hotwire/turbo to the package.json file
Contents of package.json
{
"name": "projectname",
"private": true,
"dependencies": {
"@hotwired/stimulus": "^3.0",
"@hotwired/turbo-rails": "^7.3.0",
"esbuild": "^0.17.11"
},
"scripts": {
"build": "esbuild app/javascript/*.* --bundle --sourcemap --outdir=app/assets/builds --public-path=assets"
},
"version": "0.1.0"
}
(Build with yarn
)
Running the app
Running the app now happens via bin/dev
which uses Foreman so esbuild automaticly builds the new javascript:
web: unset PORT && bin/rails server
js: yarn build --watch
How it is going
Currently I'm slowly moving legacy javascript methods to stimulus controllers.
Specialised autocomplete-inputs, select2 inputs are converted ony by one to stimulus controllers.
My experience is that simulus is very good in auto-enabling inputs on loading ajax content.
Even my legacy html-updates via ajax are updated by stimulus when they are introduced into the DOM.
I really like this solution, it's possible to keep the old legacy javascript running and build new things with the turbo/stimulus approach