Migration from Sprockets to Propshaft in Ruby-on-Rails

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:

  1. Bundling is delegated to dedicated build tools (e.g., esbuild, webpack, sass).
  2. 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",  false
gem "thruster",  false

Conversely, 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", *ARGV

Under 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 --watch

Sprockets 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:clear

This 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”))

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: , 
      data: { confirm: "Are you sure?" } %>

Instead, use:

<%# ✅ Modern Propshaft + Turbo Syntax %>
<%= link_to "Destroy", @article, 
      data: { 
        turbo: ,
        turbo_method: ,
        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: ,
      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!

コメントを追加