Important points about migration from Sprockets to Propshaft for asset handling in modern Ruby-on-Rails are explained, based on my own experience.
Background
Propshaft was introduced in Rails 7. Unlike Sprockets, Propshaft does not parse, transpile, bundle, or process files. It only digests assets, generates fingerprinted paths, sets HTTP caching headers, and serves files directly from the load path.
Bundling assets (CSS and JavaScript) was essential in the past due to
limited bandwidth and client performance constraints. Having an
all-in-one tool like Sprockets made sense because asset
concatenation and minification were mandatory. However, with modern
network bandwidth and faster client engines, JavaScript minification is
no longer strictly mandatory for applications that don’t rely heavily on
client-side frameworks. In such cases, heavy asset pipelines like
Sprockets can be overly complicated. This led to tools like
Importmap (importmap-rails gem), which serves unbundled
JavaScript directly to browsers.
Admittedly, some apps, such as those that rely on React, do require JavaScript/CSS bundling. But even in such cases, decoupling the two jobs is beneficial, as modern modern Rails can, that is:
- Bundling is delegated to dedicated build tools
(e.g.,
esbuild,webpack,sass). - Serving & Fingerprinting is delegated to Propshaft.
Anyhow, the Rails core team’s primary asset management focus has shifted from Sprockets to Propshaft. So, adopting Propshaft ensures long-term framework compatibility and is highly recommended in my opinion.
Unfortunately, though, the migration from Sprockets to
Propshaft isn’t automatic, and there are some traps. Here, I
summarise the important points I have noticed during my attempt of
migration, which took some time but was successful in the end. My Rails
app for the migration was first built in Rails-5 with
webpacker (Rails’ integration of Webpack), which
was the standard at the time, and Bootstrap and
jQuery. Later, its webpacker integration was
replaced with esbuild when the app was upgraded to Rails 7.
Many Rails configuration files and initialization setup were
incrementally modified in every update with Rails versions. I have no
idea how “standard” those files in my Rails app was before this
migration from Sprockets to Propshaft. Anyway, the
configuration files in your Rails apps with Sprockets may
differ from mine in many places. So, please take this this article with
caution! Some of them may not be applicable to your Rails apps. Still, I
am hoping this article provides some useful information.
1. Gemfile
Add gem "propshaft" and remove Sprockets-related gems
(sprockets-rails, rails-ujs,
sass-rails, sassc-rails).
If your app requires JavaScript/CSS bundling (common when upgrading existing apps), include the integration gems:
gem "jsbundling-rails"
gem "cssbundling-rails"
# Optional deployment/serving gems (Rails 8 default):
gem "kamal", require: false
gem "thruster", require: falseConversely, if your Gemfile contains
gem importmap-rails, the two bundling Gems should not be
included.
For your information, esbuild and webpack
are common JS libraries for Rails. In the following sections, I lay out
an example with esbuild.
2. Procfile.dev & Development Workflow
Rails’ basic command to start the server has been always
rails server. Then, at one stage (Rails 6?), the Rails
official doc started to recommend running bin/dev instead
of rails server. However, at that time,
bin/dev was just an extremely simple wrapper of
rails server like this, doing nothing but calling
rails server under the hood:
exec "./bin/rails", "server", *ARGVUnder Sprockets, the Rails server watched file changes in
development natively, and this is why rails server
sufficed. By contrast, Propshaft does not watch or compile
assets, and therefore compilation must be delegated to external build
tools managed via foreman, which would be automatically installed from Gem at the first execution of `bin/dev` in default if not found in your development environment. The modern bin/dev with
Propshaft is designed to launch foreman to run
multiple processes simultaneously; its core part looks like this:
export PORT="${PORT:-3000}"
exec foreman start -f Procfile.dev --env /dev/null "$@"If you kept using the old-school, Sprockets-era
bin/dev to start a Rails server, the Rails server would not
track file changes in development in real time.
Your Procfile.dev (called from bin/dev)
defines these background watchers (example using yarn, as
opposed to npm):
web: bin/rails server -p 3000
js: yarn build --watch
css: yarn build:css --watchSprockets does not use Procfile.dev, even
though it may be present in modern Rails apps in default.
When running bin/dev, server output (to STDOUT) tags each log stream accordingly:
17:10:53 web.1 |
17:10:53 js.1 |
17:10:53 css.1 |as opposed to starting with a String of “D, [” or
“I, [” or similar like: significant .
I, [2021-08-25T02:06:14.072907 #87875] INFO -- : Completed 200 OK in 9883ms (Views: 9818.4ms | ActiveRecord: 16.8ms | Allocations: 478639)Asset Cache Clearing
Before starting the server for the first time after switching to
Propshaft, or when running git pull on secondary
machines, clear all legacy precompiled assets (perhaps made with
bin/rails assets:precompile) and temporary caches:
bin/rails assets:clobber
bin/rails tmp:clearThis removes public/assets/ directory, which should not
exist in development. Note that the directory is reserved for production
asset precompilation. If stale assets remain in
public/assets/ locally, Rails will serve those static files
instead of reflecting real-time edits made by
yarn build --watch or similar.
3. JavaScript and CSS Configuration
applicaiton.html.erb.
The default entry file for the HTML rendering with Rails is
app/views/layouts/application.html.erb.
With Propshaft, it should look like this, where
type: "module" is essential when loading
esbuild / ES module bundles! Note that the option
defer: true is Default when
type: "module".
<head>
<%= csrf_meta_tags %>
<%= csp_meta_tag %>
<%# Links to app/assets/builds/application.css %>
<%= stylesheet_link_tag "application", "data-turbo-track": "reload" %>
<%# Links to app/assets/builds/application.js %>
<%= javascript_include_tag "application", "data-turbo-track": "reload", type: "module" %>
</head>application.js
The default Javascript entry file is
app/javascript/application.js (as defined in
app/views/layouts/application.html.erb).
Sprockets relied on its comment-based directives of
//= require in application.js to concat files
in a specific order, which Propshaft simply ignores.
application.js with Sprockets may look like:
// ❌ Sprockets Syntax (Broken in Propshaft)
//= require rails-ujs
//= require turbolinks
//= require_tree .
// If using Bootstrap
//= require jquery
import Rails from "@rails/ujs"
Rails.start()All the lines of //= require should be removed and
replaced with:
// ✅ Propshaft / Modern Rails Syntax (app/javascript/application.js)
import { Turbo } from "@hotwired/turbo-rails"
Turbo.session.drive = false // unless you want to enable Turbo globaly
import "./controllers" // Stimulus controllers
// If using Bootstrap
import * as bootstrap from "bootstrap"
// If using jquery, write these. With Sprockets, `//= require jquery` may suffice.
import $ from "jquery";
window.$=$;
window.jQuery = $;Another important point is the removal of @rails/ujs and
its initialization statement of Rails.start(), both of
which are requirements with Sprockets to
import "@hotwired/turbo-rails" registers event listeners on
document (in order to intercept link clicks and form
submissions, etc), but are harmful with Propshaft.
package.json
package.json needs critical updates. Basically, define
build and watch commands in it. Here is part of my
package.json, excluding the part common with
Sprockets-based apps, where my app uses yarn for JS
packaging handling and esbuild for bundling:
{
"dependencies": {
# ...
"autoprefixer": "^10.5.4",
"esbuild": "^0.28.1",
"nodemon": "^3.1.14",
"postcss": "^8.5.25",
"postcss-cli": "^11.0.1",
"sass": "^1.102.0",
},
"scripts": {
"build": "esbuild app/javascript/*.js --bundle --sourcemap --format=esm --outdir=app/assets/builds --public-path=/assets",
"build:css:compile": "sass ./app/assets/stylesheets/application.bootstrap.scss:./app/assets/builds/application.css --no-source-map --load-path=node_modules",
"build:css:prefix": "postcss ./app/assets/builds/application.css --use=autoprefixer --output=./app/assets/builds/application.css",
"build:css": "yarn build:css:compile && yarn build:css:prefix",
"watch:css": "sass --watch ./app/assets/stylesheets/application.bootstrap.scss:./app/assets/builds/application.css --no-source-map --load-path=node_modules"
},The versions will vary, depending on availability. Note that my app
is not using nodemon as a watcher, which seems to be the
default for Rails-8.1. If you use nodemon, the
corresponding line may be like:
"watch:css": "nodemon --watch ./app/assets/stylesheets/ --ext scss --exec \"yarn build:css\""CSS
In custom CSS/Sass files, update legacy Sprockets asset helpers of
asset-url() and image-url() to standard CSS
syntax url():
- ❌ background: asset-url(“logo.png”);
- ✅ background: url(“/assets/logo.png”); (or relative url(“logo.png”))
4. Handling
:destroy actions & non-GET link_to
There is a serious Problem with the legacy method:
:delete (or :post or
:put/:patch).
In Sprockets, rails-ujs listened to click events on
<a> tags with data-method="delete" and
dynamically generated a hidden HTML form behind the scenes
to issue a POST/DELETE request.
So, removing Sprockets and rails-ujs breaks legacy
link_to ..., method: :delete:
<%# ❌ Old Sprockets / Rails UJS Syntax %>
<%= link_to "Destroy", @article,
method: :delete,
data: { confirm: "Are you sure?" } %>Instead, use:
<%# ✅ Modern Propshaft + Turbo Syntax %>
<%= link_to "Destroy", @article,
data: {
turbo: true,
turbo_method: :delete,
turbo_confirm: "Are you sure?"
},
class: "btn btn-danger btn-sm" %> <%# if with Bootstrap %>Alternatively, the use of button_to is highly
recommended for DELETE :destroy because it renders
a native HTML <form> element, allowing
DELETE requests to work even in the client environment
where JavaScript is disabled.
Warning: Never put button_to inside
another <form> block, as nested forms cause a DOM
pollution and invalid request methods — in fact, Rails interpretation
for rendering would result in an unintended, serious consequence for you
in the first place as of Rails-8.1.
For button_to, the following is an example. Notice the
critical difference from link_to about :method
and turbo_method, and also {turbo: true}
(unless Turbo is globally enabled in your app, defined in
application.js).
<%= button_to "Destroy", @article,
method: :delete,
form: {data: {
turbo: true,
turbo_confirm: "Are you sure?"
} },
class: "btn btn-danger btn-sm",
form_class: "d-inline" %> <%# if with Bootstrap %>which produces an HTML like this:
<form data-turbo="true" data-turbo-confirm="Are you sure?" class="d-inline" method="post" action="/articles/12345">
<input type="hidden" name="_method" value="delete" autocomplete="off">
<button class="btn btn-danger btn-sm" type="submit">Destroy</button>
<input type="hidden" name="authenticity_token" value="xxxxx" autocomplete="off">
</form>Now, after usual bundle install,
yarn install, and restarting your Rails server with
bin/dev, let’s hope everything will work fine with
Propshaft. Happy coding!



コメントを追加