Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

Ruby on Rails  Module 4 – Views, forms and assets

Assets: Propshaft, import maps and CSS

How Rails 8.1 names, serves and caches CSS and JavaScript with Propshaft and import maps, what bin/importmap audit checks, and when to add a bundler.

  • Beginner
  • 15 minutes
  • Examples run with Ruby 4.0.7 and rails 8.1.4
  • By MySmartCoPilot
Free lesson, Pro answers: 15 minutes
Free: the whole lesson. With a pass: the model answers to its 2 interview questions.

What you will learn

  • Explain how Propshaft names, serves and caches the files under app/assets
  • Read an import map and add a vendored pin that bin/importmap audit can check
  • Predict when a cached page keeps old JavaScript, and fix it with stale_when_importmap_changes
  • Configure a nonce-based Content Security Policy that lets the import-map scripts run
  • Choose when to switch to jsbundling-rails, cssbundling-rails or tailwindcss-rails

Before you start

What you’ll learn · Before you start

What you will learn

  • Explain how Propshaft names, serves and caches the files under app/assets
  • Read an import map and add a vendored pin that bin/importmap audit can check
  • Predict when a cached page keeps old JavaScript, and fix it with stale_when_importmap_changes
  • Configure a nonce-based Content Security Policy that lets the import-map scripts run
  • Choose when to switch to jsbundling-rails, cssbundling-rails or tailwindcss-rails

Before you start

On this page

A browser may keep a stylesheet for a year, yet the morning after you change one rule every visitor must see the new one. A new Rails 8.1 app solves that without Node.js or a build step: Propshaft: The asset pipeline of a new Rails 8.1 app: it gives every asset a name that ends in a hash of its contents and serves the file unchanged, without compiling or bundling it. gives each file a name made from its contents, and an import map: A JSON object in a script tag of type importmap that tells the browser which file each JavaScript module name stands for, so modules can import each other by name without a bundler. tells the browser which file each JavaScript module name means. The runs on this page take apart the Kaksha app’s own head tags: the 8 characters in application-8b441ae0.css, the 304 response that would keep yesterday’s JavaScript, and the one script tag a strict policy refuses.

By the end you can say where every asset URL of a new app comes from, add and audit a pinned package, keep cached pages honest after a deploy, and decide when the app needs a bundler after all.

Propshaft names every file after its contents

The layout of a new app asks for its styles with stylesheet_link_tag :app, which links every .css file under app/assets. Propshaft, the asset pipeline: The part of a Rails app that turns the files under app/assets and the other asset paths into URLs a browser can cache, and serves them; in Rails 8.1 that is Propshaft. of Rails 8.1, turns each file name into one that ends in a hash of the file. This run renders the head of a page, works the hash out by hand, then adds one rule to application.css and renders the same page again:

Where the 8 characters come from, and one edit Ruby · digest_probe.rb
# Where the long name of app/assets/stylesheets/application.css comes from, and what one edit does to it.
# Boots the lesson's copy of the Kaksha app (a default `rails new`, Rails 8.1.4, Propshaft 1.3.2) in development and
# renders a page head through Rack, with no server and no browser.
ENV["RAILS_ENV"] ||= "development"
ENV["BUNDLE_GEMFILE"] = File.expand_path("kaksha/Gemfile", __dir__)

require "bundler/setup"
require "rack/mock_request"
require_relative "kaksha/config/application"
Rails.application.initialize!

class HeadProbeController < ApplicationController
  def show
    render inline: <<~ERB
      <head><%= stylesheet_link_tag :app, "data-turbo-track": "reload" %></head>
    ERB
  end
end
Rails.application.routes.draw { get "/" => "head_probe#show" }

def page_head
  status, headers, body = Rails.application.call(Rack::MockRequest.env_for("/", "HTTP_HOST" => "kaksha.test"))
  html = +""
  body.each { |chunk| html << chunk }
  [status, headers["link"], html[/<link [^>]+>/]]
end

css = Rails.root.join("app/assets/stylesheets/application.css")
version = Rails.application.config.assets.version
EXTRA_RULE = "\n.fee-due { font-weight: bold; }\n"
original = File.read(css).delete_suffix(EXTRA_RULE) # a run that stopped half-way leaves the folder as it found it
File.write(css, original)
at_exit { File.write(css, original) }

status, link_header, tag = page_head
puts "1. The page head (status #{status})"
puts "   tag:         #{tag}"
puts "   Link header: #{link_header}"

name_digest = tag[/application-(\h{8})\.css/, 1]
sha = Digest::SHA1.hexdigest(original + version.to_s)
puts
puts "2. Where the 8 characters come from"
puts "   config.assets.version:              #{version.inspect}"
puts "   SHA1(file contents + version):      #{sha}"
puts "   first 8 characters:                 #{sha[0, 8]}"
puts "   the 8 characters in the file name:  #{name_digest}"
puts "   the same? #{sha[0, 8] == name_digest}"

File.write(css, original + EXTRA_RULE)
Rails.application.assets.load_path.cache_sweeper.execute_if_updated
_, link_after, tag_after = page_head
puts
puts "3. One rule added to application.css, then the same page"
puts "   tag:         #{tag_after}"
puts "   Link header: #{link_after}"
puts "   new name? #{tag_after != tag}"

Output

1. The page head (status 200)
   tag:         <link rel="stylesheet" href="/assets/application-8b441ae0.css" data-turbo-track="reload" />
   Link header: </assets/application-8b441ae0.css>; rel=preload; as=style; nopush

2. Where the 8 characters come from
   config.assets.version:              "1.0"
   SHA1(file contents + version):      8b441ae02e09b7d7fb3f5d41e77c15714e3f925a
   first 8 characters:                 8b441ae0
   the 8 characters in the file name:  8b441ae0
   the same? true

3. One rule added to application.css, then the same page
   tag:         <link rel="stylesheet" href="/assets/application-6a683c7b.css" data-turbo-track="reload" />
   Link header: </assets/application-6a683c7b.css>; rel=preload; as=style; nopush
   new name? true

Recorded with ruby 4.0.7 (revision 229531a6cf) +PRISM [arm64-darwin23] on macOS 26 arm64. To run it yourself: ruby digest_probe.rb

Part 2 of the output shows the rule: the first 8 hex characters of the SHA1 of the file’s contents plus the asset version ("1.0", set in config/initializers/assets.rb) are 8b441ae0, the same 8 characters as in the link. That part of the name is the asset digest: The hash in an asset's file name, such as 8b441ae0 in application-8b441ae0.css: Propshaft makes it from the file's contents and the asset version, so new contents always get a new name.. One added rule makes it 6a683c7b, so the page now names a file no browser has seen. The response also carries a Link header with rel=preload, which lets the browser start the download before it has read the whole page.

Never write /assets/application.css into a template

A hand-written path has no digest, so it bypasses the whole scheme: browsers that cached it keep the old contents. Always go through a helper (stylesheet_link_tag, image_tag, asset_path), which asks Propshaft for the current name.

Because a name can never point at different contents, the file can be cached for as long as you like. Step through one page load to see who asks whom, and why the long cache is safe:

Interactive · message by message One stylesheet, from the layout to the browser cache Step through one page load, then the edit that gives the stylesheet a new name and a fresh download.
One stylesheet, from the layout to the browser cache, step 6 of 6. Messages so far: Browser to Rails app: GET /timetable; Rails app to Propshaft: name of application.css?; Rails app to Browser: HTML and Link header; Browser to Propshaft: GET application-8b441ae0; Propshaft to Browser: file, max-age 1 year.Browserholds 8b441ae0Rails apprenders the layoutPropshaftapplication-6a683c7b.cssGET /timetablename of application.css?HTML and Link headerGET application-8b441ae0file, max-age 1 yearnext page asks for 6a683c7b

The drawing shows the whole scene; its 6 steps are listed below.

Every step as text
  1. The page is requested. The browser asks for the timetable page. Its layout calls stylesheet_link_tag :app, which needs the current file name of every stylesheet under app/assets.
  2. The helper asks for a name. Propshaft hashes the contents of application.css together with the asset version "1.0" (SHA1) and keeps the first 8 hex characters: 8b441ae0.
  3. The name goes into the page. The HTML carries a link tag with href /assets/application-8b441ae0.css, and the response adds a Link header with rel=preload, so the browser can start the download early.
  4. The browser fetches the file. The browser asks for /assets/application-8b441ae0.css. In development Propshaft answers it; in production the web server answers from public/assets, which a deploy wrote once.
  5. Cached for a year. The file comes back with cache-control max-age of one year. That is safe: the contents behind this name cannot change, because a change would make a different name.
  6. One edit, a new name. Add one rule to application.css and the hash changes. The next page names application-6a683c7b.css, which the browser has never seen, so it downloads it; the old copy is never asked for again.

Propshaft does not compile anything. Unlike Sprockets, the pipeline of older Rails versions, it never runs Sass, concatenates files or minifies them; it digests and serves what is in its load path, and rewrites url(…) references inside CSS to their digested names. A compiler, when you need one, comes from another gem that writes its output into app/assets/builds, where Propshaft picks it up like any other file.

Key idea

An asset’s URL contains a hash of its contents, so a changed file is a new URL. That is what makes a year-long cache safe: an old name is never reused for new contents.

Checkpoint

Check: Propshaft names each file after its contents

  1. Question 1 of 2 Where do the 8 characters in application-8b441ae0.css come from?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: The first 8 hex characters of a SHA1 of the file contents and the asset version

    Propshaft hashes the file contents together with config.assets.version ("1.0" in config/initializers/assets.rb) with SHA1 and keeps the first 8 hex characters. The run worked it out by hand and matched the link: 8b441ae0.

  2. Question 2 of 2 You add one rule to application.css and reload the page. What does the stylesheet link tag name now?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: A new file name, because the hash of the contents changed

    The digest is a hash of the contents, so any change gives a new name. The page now names a file no browser has cached, while the old name keeps its old contents.

A deploy writes each file once, and a manifest finds it

In development Propshaft hashes files as requests ask for them. In production nothing is hashed at request time: the deploy runs bin/rails assets:precompile once, which copies every file of the load path into public/assets under its digested name and writes a manifest that maps each logical name to that file. Here is that command on the same app:

assets:precompile writes the digested files Ruby · sessions/propshaft_digest_session.rb
require_relative "session"

# The Kaksha app beside this folder is a default `rails new` (Rails 8.1.4, Propshaft 1.3.2).
Dir.chdir File.expand_path("../kaksha", __dir__)

# public/assets is what the command below writes; start without it and leave without it, so every run is the same.
FileUtils.rm_rf("public/assets")
at_exit { FileUtils.rm_rf("public/assets") }

Session.run(<<~'SH')
  # What a deploy runs once (SECRET_KEY_BASE_DUMMY=1 lets the production config load without credentials):
  RAILS_ENV=production SECRET_KEY_BASE_DUMMY=1 bin/rails assets:precompile
  ls public/assets | grep -E '^(application|turbo\.min)-'
  # The manifest maps each name a helper asks for to the digested file:
  ruby -rjson -e 'm = JSON.parse(File.read("public/assets/.manifest.json")); puts m.size; %w[application.css application.js turbo.min.js].each { |k| puts "#{k} => #{m[k]}" }'
SH

Output

# What a deploy runs once (SECRET_KEY_BASE_DUMMY=1 lets the production config load without credentials):
$ RAILS_ENV=production SECRET_KEY_BASE_DUMMY=1 bin/rails assets:precompile
Using vips to process variants requires the libvips library. Please install libvips using the instructions on the libvips website.
Writing application-8b441ae0.css
Writing application-bfcdf840.js
Writing controllers/hello_controller-708796bd.js
Writing controllers/index-ee64e1f1.js
Writing controllers/application-3affb389.js
Writing stimulus.min-4b1e420e.js
Writing stimulus-d59b3b7f.js
Writing stimulus.min-2395e199.js.map
Writing stimulus-importmap-autoloader-64cc03e1.js
Writing stimulus-autoloader-9d447422.js
Writing stimulus-loading-1fc53fe7.js
Writing turbo.min-5eb167c2.js.map
Writing turbo-736fb387.js
Writing turbo.min-9fd88cd5.js
Writing actiontext.esm-c376325e.js
Writing actiontext-c9c6c481.js
Writing trix-4bf79781.js
Writing trix-65afdb1d.css
Writing action_cable-5212cfee.js
Writing actioncable.esm-e0ec9819.js
Writing actioncable-ac25813f.js
Writing activestorage.esm-81bb34bc.js
Writing activestorage-f9e46063.js
Writing rails-ujs.esm-e925103b.js
Writing rails-ujs-20eaf715.js
$ ls public/assets | grep -E '^(application|turbo\.min)-'
application-8b441ae0.css
application-bfcdf840.js
turbo.min-5eb167c2.js.map
turbo.min-9fd88cd5.js
# The manifest maps each name a helper asks for to the digested file:
$ ruby -rjson -e 'm = JSON.parse(File.read("public/assets/.manifest.json")); puts m.size; %w[application.css application.js turbo.min.js].each { |k| puts "#{k} => #{m[k]}" }'
25
application.css => {"digested_path" => "application-8b441ae0.css", "integrity" => nil}
application.js => {"digested_path" => "application-bfcdf840.js", "integrity" => nil}
turbo.min.js => {"digested_path" => "turbo.min-9fd88cd5.js", "integrity" => nil}

Recorded with ruby 4.0.7 (revision 229531a6cf) +PRISM [arm64-darwin23] on macOS 26 arm64. To run it yourself: ruby propshaft_digest_session.rb

The first line of output is Active Storage noting that this machine has no libvips image library; it has nothing to do with assets. Then come 25 Writing lines, one per file of the load path, including Turbo, Stimulus and Action Cable files that ship inside gems. application-8b441ae0.css has the same digest as in development, because the contents are the same. The manifest has 25 entries, and stylesheet_link_tag :app in production reads the name from it instead of hashing anything.

The generated config/environments/production.rb serves public/assets with cache-control: public, max-age=31536000, one year, and the development server adds immutable. A web server or CDN in front of the app can serve these files without asking Rails at all.

Checkpoint

Check: Precompiled files and the manifest

  1. Question 1 of 2 In production, how does stylesheet_link_tag :app find the digested name of application.css?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: It reads public/assets/.manifest.json, which assets:precompile wrote

    bin/rails assets:precompile copies every file of the load path into public/assets under its digested name and writes .manifest.json. In the run it wrote 25 files and the manifest had 25 entries.

  2. Question 2 of 2 The generated production config serves public/assets with max-age of one year. Why is that safe?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: A deploy that changes a file writes it under a new name, so a cached name never goes stale

    A long cache is only dangerous when the same URL can return different contents. With a digest in every name, new contents always arrive under a new URL.

An import map turns module names into files

JavaScript modules import each other by name: the generated application.js says import "@hotwired/turbo-rails", not a URL. A bundler would replace those names at build time. An import map leaves them alone and gives the browser a JSON object, in the page head, that maps each name to a file. config/importmap.rb declares the names, and bin/importmap json prints the map the layout will send:

The pins of a new app, their JSON, and the audit Ruby · sessions/importmap_tour_session.rb
require_relative "session"

# The Kaksha app beside this folder is a default `rails new` (Rails 8.1.4, importmap-rails 2.2.3).
Dir.chdir File.expand_path("../kaksha", __dir__)

# The run adds a vendored module and its pin line; put the app back as it was afterwards, so every run starts the same.
importmap = File.read("config/importmap.rb").sub(/^pin "kaksha-fee-format".*\n/, "")
File.write("config/importmap.rb", importmap)
FileUtils.rm_f("vendor/javascript/kaksha-fee-format.js")
at_exit do
  File.write("config/importmap.rb", importmap)
  FileUtils.rm_f("vendor/javascript/kaksha-fee-format.js")
end

Session.run(<<~'SH')
  # The pins of a new app:
  cat config/importmap.rb
  # The JSON the layout hands to the browser:
  bin/importmap json
  # The pins that carry a version, which is what the audit can check:
  bin/importmap packages
  bin/importmap audit
  # What bin/importmap pin writes, done by hand for a small module of our own (pin itself asks the JSPM API):
  printf 'export function rupees(paise) {\n  return (paise / 100).toFixed(2)\n}\n' > vendor/javascript/kaksha-fee-format.js
  printf 'pin "kaksha-fee-format" # @1.2.0\n' >> config/importmap.rb
  bin/importmap packages
  bin/importmap json | grep kaksha-fee-format
SH

Output

# The pins of a new app:
$ cat config/importmap.rb
# Pin npm packages by running ./bin/importmap

pin "application"
pin "@hotwired/turbo-rails", to: "turbo.min.js"
pin "@hotwired/stimulus", to: "stimulus.min.js"
pin "@hotwired/stimulus-loading", to: "stimulus-loading.js"
pin_all_from "app/javascript/controllers", under: "controllers"
# The JSON the layout hands to the browser:
$ bin/importmap json
{
  "imports": {
    "application": "/assets/application-bfcdf840.js",
    "@hotwired/turbo-rails": "/assets/turbo.min-9fd88cd5.js",
    "@hotwired/stimulus": "/assets/stimulus.min-4b1e420e.js",
    "@hotwired/stimulus-loading": "/assets/stimulus-loading-1fc53fe7.js",
    "controllers/application": "/assets/controllers/application-3affb389.js",
    "controllers/hello_controller": "/assets/controllers/hello_controller-708796bd.js",
    "controllers": "/assets/controllers/index-ee64e1f1.js"
  }
}
# The pins that carry a version, which is what the audit can check:
$ bin/importmap packages
$ bin/importmap audit
No vulnerable packages found
# What bin/importmap pin writes, done by hand for a small module of our own (pin itself asks the JSPM API):
$ printf 'export function rupees(paise) {\n  return (paise / 100).toFixed(2)\n}\n' > vendor/javascript/kaksha-fee-format.js
$ printf 'pin "kaksha-fee-format" # @1.2.0\n' >> config/importmap.rb
$ bin/importmap packages
kaksha-fee-format 1.2.0
$ bin/importmap json | grep kaksha-fee-format
    "kaksha-fee-format": "/assets/kaksha-fee-format-34a6a2ce.js",

Recorded with ruby 4.0.7 (revision 229531a6cf) +PRISM [arm64-darwin23] on macOS 26 arm64. To run it yourself: ruby importmap_tour_session.rb

The first part of the run is config/importmap.rb with its four pin lines and one pin_all_from, then the JSON: seven names, each pointing at a digested file under /assets. pin_all_from "app/javascript/controllers" is why three controller files appear without a line each. The layout’s javascript_importmap_tags writes this JSON into a <script type="importmap"> tag, a modulepreload link for each file so they download in parallel, and one module script with import "application". Step through what the browser does with those names:

Interactive · message by message How the browser finds each module by name Follow the module names of a new app from the first import to the Stimulus controllers.
How the browser finds each module by name, step 5 of 5. Messages so far: Browser to Import map: "application"?; Import map to Browser: application-bfcdf840.js; /assets to Browser: application.js; Browser to Import map: "@hotwired/turbo-rails"?; Browser to Import map: "controllers"?.Browserruns the modulesImport map7 names, 7 files/assetsdigested files"application"?application-bfcdf840.jsapplication.js"@hotwired/turbo-rails"?"controllers"?

The drawing shows the whole scene; its 5 steps are listed below.

Every step as text
  1. A bare name, not a URL. The last tag in the head is a module script with one line, import "application". "application" is not a path, so the browser looks it up in the import map instead of guessing a URL.
  2. The map gives the file. The map answers /assets/application-bfcdf840.js. The modulepreload links in the head have already started that download, so it is often there before it is asked for.
  3. application.js arrives. The generated application.js has two lines: import "@hotwired/turbo-rails" and import "controllers". Each is another bare name for the same map.
  4. Turbo comes from a gem. The map says "@hotwired/turbo-rails" is /assets/turbo.min-9fd88cd5.js. That file ships inside the turbo-rails gem, not in vendor/javascript, which is why its pin carries no version.
  5. Controllers load themselves. "controllers" is index-ee64e1f1.js, which hands every name that pin_all_from pinned under controllers/ to Stimulus, so a new controller file needs no new line in config/importmap.rb.

The map has to come before any script that imports by name, which is why the helper writes it first. Every path in it is digested, so an import map is cached exactly like the stylesheet above: a changed controller is a new path in the map.

Checkpoint

Check: How an import map resolves module names

  1. Question 1 of 2 application.js says import "@hotwired/turbo-rails". Where does the browser learn which URL that is?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: From the import map, the JSON in a script tag of type importmap in the page head

    A bare name such as "@hotwired/turbo-rails" is not a URL, so the browser looks it up in the import map that javascript_importmap_tags wrote into the head.

  2. Question 2 of 2 You add app/javascript/controllers/fee_controller.js to a new app. What must change in config/importmap.rb?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Nothing: pin_all_from already pins every file of that folder

    pin_all_from "app/javascript/controllers", under: "controllers" maps each file of the folder to a name such as controllers/fee_controller, so a new controller is in the next page's map.

The audit checks only the pins that carry a version

bin/importmap pin <package> asks the JSPM.org API to resolve a package, downloads the file into vendor/javascript/, and adds a pin line ending in a comment with the version, such as # @1.2.0. The downloaded file is served by your own app from then on, so no page depends on a CDN at run time. bin/importmap audit sends the name and version of every pin that has one to the npm registry’s advisory service and exits with status 1 when any has a known vulnerability, and the generated config/ci.rb already runs it as one of its steps.

Look back at the second part of the run. On a new app, bin/importmap packages prints nothing and the audit says “No vulnerable packages found”: none of the four pins has a version comment, because Turbo and Stimulus ship inside the turbo-rails and stimulus-rails gems. The audit passed because it had nothing to check. Those files change when you update the gems, and bin/bundler-audit, another step of the same config/ci.rb, checks gems.

The last commands write, by hand, the two things pin writes, for a small module of the app’s own: the file vendor/javascript/kaksha-fee-format.js and the line pin "kaksha-fee-format" # @1.2.0. Now packages lists kaksha-fee-format 1.2.0, so the audit would send it, and the JSON maps the new name to /assets/kaksha-fee-format-34a6a2ce.js.

A green audit is only as wide as its list

Read bin/importmap packages next to the audit: every pin it does not list is one the audit cannot see. Keep the version comment that pin writes, and remove pins you no longer import, so the list matches what the browser loads.

Checkpoint

Check: What bin/importmap audit can check

  1. Question 1 of 2 On a new Rails 8.1 app, bin/importmap audit prints "No vulnerable packages found". What did it check?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: Nothing: no pin carries a version comment, so it had no package to send

    The audit sends the name and version of each versioned pin to the npm registry's advisory service. A new app has none, so the green result covers nothing; the gem-provided files are checked by bin/bundler-audit.

  2. Question 2 of 2 Which of these does bin/importmap pin write? Choose all that apply.

    Choose every answer that is right.

    Show the answer: Question 2 of 2

    Answer:

    • The downloaded file in vendor/javascript/
    • A pin line in config/importmap.rb ending in a version comment

    pin writes two things, the file and the line with its version. The run wrote both by hand for kaksha-fee-format, and bin/importmap packages then listed kaksha-fee-format 1.2.0.

A cached page must not keep yesterday’s JavaScript

An action that uses conditional GET answers 304 Not Modified when the browser’s copy is still current, so the page is not sent again. Its ETag is built from what the action passes to stale? or fresh_when, usually the record. A timetable whose rows did not change keeps its ETag, even after a deploy that changed application.js, and the browser keeps a page whose import map names the old file. This run shows both sides, with two controllers that differ in one line:

The same 304 action, with and without stale_when_importmap_changes Ruby · etag_probe.rb
# What stale_when_importmap_changes does for a page that answers 304 Not Modified.
# Two controllers render the same timetable page with conditional GET (stale? with the page's own ETag). One inherits
# the generated ApplicationController, which has the line; the other inherits ActionController::Base, which has not.
# Between the requests, app/javascript/application.js changes, as it does in a deploy.
ENV["RAILS_ENV"] ||= "development"
ENV["BUNDLE_GEMFILE"] = File.expand_path("kaksha/Gemfile", __dir__)

require "bundler/setup"
require "rack/mock_request"
require_relative "kaksha/config/application"
Rails.application.initialize!

PAGE = <<~ERB
  <!DOCTYPE html>
  <html><head><%= javascript_importmap_tags %></head><body>Batch timetable, week 3</body></html>
ERB

class TimetableController < ApplicationController # has stale_when_importmap_changes
  def show
    render inline: PAGE if stale?(etag: "timetable-week-3")
  end
end

class PlainTimetableController < ActionController::Base # the same action, without the line
  def show
    render inline: PAGE if stale?(etag: "timetable-week-3")
  end
end

Rails.application.routes.draw do
  get "/timetable" => "timetable#show"
  get "/plain-timetable" => "plain_timetable#show"
end

def get(path, if_none_match = nil)
  env = Rack::MockRequest.env_for(path, "HTTP_HOST" => "kaksha.test")
  env["HTTP_IF_NONE_MATCH"] = if_none_match if if_none_match
  status, headers, body = Rails.application.call(env)
  html = +""
  body.each { |chunk| html << chunk }
  body.close if body.respond_to?(:close)
  [status, headers["etag"], html[%r{"application": "(/assets/application-\h{8}\.js)"}, 1]]
end

js = Rails.root.join("app/javascript/application.js")
EXTRA_LINE = "\nconsole.debug(\"timetable week 3\")\n"
original = File.read(js).delete_suffix(EXTRA_LINE) # a run that stopped half-way leaves the folder as it found it
File.write(js, original)
at_exit { File.write(js, original) }

puts "Before the change"
pages = { "/timetable" => "with the line", "/plain-timetable" => "without it" }
etags = {}
pages.each do |path, label|
  status, etag, script = get(path)
  etags[path] = etag
  puts "  GET #{path} (#{label}): #{status}, application.js at #{script}"
  status, = get(path, etag)
  puts "  again with If-None-Match: #{status}"
end

File.write(js, original + EXTRA_LINE)
Rails.application.assets.load_path.cache_sweeper.execute_if_updated
Rails.application.importmap.cache_sweeper.execute_if_updated

puts
puts "After one line is added to app/javascript/application.js"
pages.each do |path, label|
  status, etag, script = get(path, etags[path])
  if status == 304
    puts "  GET #{path} (#{label}) with the old ETag: 304, so the browser keeps its copy, which loads the old file"
  else
    puts "  GET #{path} (#{label}) with the old ETag: #{status}, a new ETag? #{etag != etags[path]}, application.js at #{script}"
  end
end

Output

Before the change
  GET /timetable (with the line): 200, application.js at /assets/application-bfcdf840.js
  again with If-None-Match: 304
  GET /plain-timetable (without it): 200, application.js at /assets/application-bfcdf840.js
  again with If-None-Match: 304

After one line is added to app/javascript/application.js
  GET /timetable (with the line) with the old ETag: 200, a new ETag? true, application.js at /assets/application-91cc7155.js
  GET /plain-timetable (without it) with the old ETag: 304, so the browser keeps its copy, which loads the old file

Recorded with ruby 4.0.7 (revision 229531a6cf) +PRISM [arm64-darwin23] on macOS 26 arm64. To run it yourself: ruby etag_probe.rb

Before the change both answer 200, then 304 to the same ETag. After one line is added to application.js, its file becomes application-91cc7155.js. The controller that inherits the generated ApplicationController answers 200 with a new ETag, because stale_when_importmap_changes mixes the digest of the import map into the ETag that stale? and fresh_when compute for an HTML page. The other answers 304, and its visitors keep running application-bfcdf840.js until their copy expires.

Tip

The generated ApplicationController already has the line. Keep it there, and make every controller inherit from ApplicationController, not straight from ActionController::Base, so conditional GET: A request that carries a validator, an ETag or a time, from an earlier response, so the server can answer 304 with no body when the page has not changed since. More in “Filters, errors and HTTP features” stays correct after each deploy.

Checkpoint

Check: Cached pages after a JavaScript change

  1. Question 1 of 2 A timetable action on a controller that inherits ActionController::Base uses stale?(etag: "timetable-week-3"). After a deploy that changes application.js, a returning visitor sends the old ETag. What happens?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: It answers 304, and the visitor keeps a page whose import map names the old application.js

    Without stale_when_importmap_changes the ETag depends only on the timetable, so the 304 keeps the old page and with it the old import map.

  2. Question 2 of 2 What does stale_when_importmap_changes add to the ETag of an HTML page?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: The digest of the import map

    The generated ApplicationController calls stale_when_importmap_changes, which mixes the import map's digest into the ETag that stale? and fresh_when compute for HTML. In the run that controller answered 200 with a new ETag.

A nonce lets your scripts run and refuses the rest

A Content Security Policy: A response header that tells the browser which scripts and other resources a page may load or run, for example only files from the app's own origin and tags that carry this response's nonce. is a header the server sends with the page to name which scripts the browser is allowed to run. With script-src 'self' 'nonce-N', a script runs only if it is a file from the app’s own origin or its tag carries the attribute nonce="N", where N is a random value made for this one response. A script that an attacker manages to slip into the page cannot know N, so the browser refuses it. The generated config/initializers/content_security_policy.rb holds the setup, commented out; this run turns it on with 16 random bytes (24 characters in base64) as the nonce of every response:

Which tags in the head carry the response's nonce Ruby · csp_nonce_probe.rb
# A nonce-based Content Security Policy on the Kaksha app's page head: which script tags carry the response's nonce.
# The policy is the commented-out example of config/initializers/content_security_policy.rb, made strict (scripts
# only from the app's own origin or with the nonce), with a fresh random nonce for every response.
ENV["RAILS_ENV"] ||= "development"
ENV["BUNDLE_GEMFILE"] = File.expand_path("kaksha/Gemfile", __dir__)

require "bundler/setup"
require "rack/mock_request"
require_relative "kaksha/config/application"

Rails.application.configure do
  config.content_security_policy do |policy|
    policy.default_src :self
    policy.script_src  :self
    policy.object_src  :none
  end
  config.content_security_policy_nonce_generator = ->(_request) { SecureRandom.base64(16) }
  config.content_security_policy_nonce_directives = %w[script-src]
  config.content_security_policy_nonce_auto = true
end
Rails.application.initialize!

class NoticeBoardController < ApplicationController
  def show
    render inline: <<~ERB
      <head>
        <%= javascript_importmap_tags %>
        <%= javascript_tag "window.kakshaTerm = 3" %>
        <script>window.addedByHand = true</script>
      </head>
    ERB
  end
end
Rails.application.routes.draw { get "/" => "notice_board#show" }

def fetch
  _, headers, body = Rails.application.call(Rack::MockRequest.env_for("/", "HTTP_HOST" => "kaksha.test"))
  html = +""
  body.each { |chunk| html << chunk }
  [headers["content-security-policy"], html]
end

policy, html = fetch
nonce = policy[/'nonce-([^']+)'/, 1]
puts "Content-Security-Policy: #{policy.sub(nonce, "N")}"
puts "  (N is this response's nonce: #{nonce.length} random characters)"
puts
puts "Tags in the page head, and whether each carries N:"
html.scan(/<(?:script|link)\b[^>]*>/).each do |tag|
  shown = tag.sub(nonce, "N").sub(/ href="[^"]+"/, ' href="…"')
  carries = tag.include?(%(nonce="#{nonce}"))
  puts "  #{carries ? "yes" : "NO "}  #{shown}"
end

second_policy, = fetch
puts
puts "A second request gets a different nonce? #{second_policy[/'nonce-([^']+)'/, 1] != nonce}"

Output

Content-Security-Policy: default-src 'self'; script-src 'self' 'nonce-N'; object-src 'none'
  (N is this response's nonce: 24 random characters)

Tags in the page head, and whether each carries N:
  yes  <script type="importmap" data-turbo-track="reload" nonce="N">
  yes  <link rel="modulepreload" href="…" nonce="N">
  yes  <link rel="modulepreload" href="…" nonce="N">
  yes  <link rel="modulepreload" href="…" nonce="N">
  yes  <link rel="modulepreload" href="…" nonce="N">
  yes  <link rel="modulepreload" href="…" nonce="N">
  yes  <link rel="modulepreload" href="…" nonce="N">
  yes  <link rel="modulepreload" href="…" nonce="N">
  yes  <script type="module" nonce="N">
  yes  <script nonce="N">
  NO   <script>

A second request gets a different nonce? true

Recorded with ruby 4.0.7 (revision 229531a6cf) +PRISM [arm64-darwin23] on macOS 26 arm64. To run it yourself: ruby csp_nonce_probe.rb

Every tag that javascript_importmap_tags writes carries N: the import map, the seven preload links and the module script. The javascript_tag carries it because content_security_policy_nonce_auto = true adds it to javascript_tag, javascript_include_tag and stylesheet_link_tag. The <script> typed by hand in the template has none, so the browser would refuse it; give it nonce="<%= content_security_policy_nonce %>" or move its code into a module. A second request gets a different nonce, which is what MDN asks of a nonce: a new one for every response.

The commented example uses the session id as the nonce

The generated file suggests request.session.id.to_s, which stays the same for every page of a visit. Use SecureRandom.base64(16), as this run does, so a nonce seen on one page is useless on the next.

Checkpoint

Check: Nonces in a Content Security Policy

  1. Question 1 of 2 With the run's policy and content_security_policy_nonce_auto = true, which tags carry the response's nonce? Choose all that apply.

    Choose every answer that is right.

    Show the answer: Question 1 of 2

    Answer:

    • The script written by javascript_tag
    • The script tag of type importmap
    • The modulepreload links

    Ten of the eleven tags carried N. The hand-written script needs nonce="<%= content_security_policy_nonce %>" or its code moved into a module.

  2. Question 2 of 2 The generated initializer suggests request.session.id.to_s as the nonce generator. Why does the run use SecureRandom.base64(16) instead?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: A nonce should be new for every response, and the session id stays the same for the whole visit

    16 random bytes per response (24 characters in base64) means a nonce seen on one page cannot be reused on another. A session id repeats on every page of a visit.

When the default stack is not enough

Import maps load ES modules as they are; nothing compiles them. A file with JSX or TypeScript has to be compiled before a browser can run it, and a CSS framework built from utility classes needs a build that scans your templates. Each case has its own gem, and each writes its output into app/assets/builds, where Propshaft digests and serves it as before:

You need Add What it brings
ES modules, Stimulus, Turbo nothing import maps, the default
JSX or TypeScript jsbundling-rails esbuild, Bun, Rollup or webpack; Node.js or Bun on the machine
Tailwind CSS without Node.js tailwindcss-rails the standalone Tailwind CLI, through the tailwindcss-ruby gem
Sass, PostCSS, Bootstrap or Bulma cssbundling-rails a Node.js build with yarn

Both bundling gems hook their build into assets:precompile, so a deploy runs the bundler first and Propshaft afterwards, and bin/dev runs their watchers beside the server in development. Their READMEs say when each fits: cssbundling-rails is for an app that already uses Node.js for its JavaScript, while an app on import maps can avoid Node.js entirely with the standalone builds of tailwindcss-rails or dartsass-rails.

The rule of thumb

Stay on import maps until a file needs compiling. The first .jsx or .ts file is the moment to add jsbundling-rails; Tailwind alone is not, because tailwindcss-rails needs no Node.js.

Checkpoint

Check: When to add a bundler or a CSS build

  1. Question 1 of 2 A new screen of the app is written in JSX. What do you add?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: jsbundling-rails, with esbuild, Bun, Rollup or webpack to compile it

    The bundler writes its output into app/assets/builds, and Propshaft digests and serves it like any other file.

  2. Question 2 of 2 An app on import maps wants Tailwind CSS and has no Node.js on its servers. Which gem fits?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: tailwindcss-rails, which brings the standalone Tailwind CLI through the tailwindcss-ruby gem

    Tailwind alone is no reason to add Node.js: tailwindcss-rails writes app/assets/builds/tailwind.css with a standalone executable.

Practise

Every question below comes from the runs on this page: the digest of application.css, the manifest, the seven pins, the audit that had nothing to check, the 304 that kept old JavaScript, and the nonce on each tag. Answer one and its explanation opens at once.

Practice that adapts to you

Practise this lesson

10 questions about 7 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.

  1. Question 1 of 10 Two servers build the same commit at different times. Do they give application.css the same digest?

    Choose one answer.

    Show the answer: Question 1 of 10

    Answer: Yes: the digest depends only on the contents and the asset version

    Because the name is a function of the contents, every server and every deploy agrees on it, which is why a page rendered by one server can name a file served by another.

  2. Question 2 of 10 You change config.assets.version from "1.0" to "1.1" and change no file. What happens to the asset names?

    Choose one answer.

    Show the answer: Question 2 of 10

    Answer: Every digest changes, so every file gets a new name

    The digest is SHA1(contents + version). The comment in config/initializers/assets.rb says to change the version when you want to expire all your assets at once.

  3. Question 3 of 10 How many entries did the manifest of the new app hold after assets:precompile?

    Type a number.

    Show the answer: Question 3 of 10

    Answer: 25 entries

    The run printed 25 Writing lines, one per file of the load path (the app's own files and those that Turbo, Stimulus, Action Cable, Action Text, Active Storage and rails-ujs ship in their gems), and the manifest's size, 25.

  4. Question 4 of 10 A CDN still holds application-8b441ae0.css from yesterday. Today's release renders pages that name application-6a683c7b.css. What does a visitor get?

    Choose one answer.

    Show the answer: Question 4 of 10

    Answer: The new file: the CDN has never seen that name, so it fetches it from the app

    Digested names turn a deploy into new URLs, so no cache needs clearing; the old file simply stops being requested.

  5. Question 5 of 10 How many module names does the import map of a new Rails 8.1 app hold?

    Type a number.

    A hint

    Count the pins, then the files that pin_all_from adds from app/javascript/controllers.

    Show the answer: Question 5 of 10

    Answer: 7 names

    Four pin lines (application, @hotwired/turbo-rails, @hotwired/stimulus, @hotwired/stimulus-loading) and three names from pin_all_from (controllers/application, controllers/hello_controller and controllers). The JSON of the run lists seven, and the head carries seven modulepreload links.

  6. Question 6 of 10 A teammate pins a package with bin/importmap pin, then deletes the version comment at the end of its pin line to tidy the file. What does the audit do with that package now?

    Choose one answer.

    Show the answer: Question 6 of 10

    Answer: It skips it: without the version comment the package no longer appears in bin/importmap packages

    The version comment is the audit's only source. Deleting it makes the audit's green result cover one package fewer, which is why the lesson says to read packages next to the audit.

  7. Question 7 of 10 A JSON endpoint on the same ApplicationController uses fresh_when. Does stale_when_importmap_changes change its ETag after a JavaScript deploy?

    Choose one answer.

    Show the answer: Question 7 of 10

    Answer: No: it adds the import map digest only when the request format is HTML

    stale_when_importmap_changes registers an ETag part that is the import map digest when the request format is HTML, and nothing otherwise.

  8. Question 8 of 10 Where does a response's nonce appear?

    Choose one answer.

    Show the answer: Question 8 of 10

    Answer: In the Content-Security-Policy header and in the nonce attribute of each allowed tag

    The run printed script-src 'self' 'nonce-N' in the header and nonce="N" on ten tags of the head; the tag without it is the one the browser refuses.

  9. Question 9 of 10 What do jsbundling-rails, cssbundling-rails and tailwindcss-rails have in common?

    Choose one answer.

    Show the answer: Question 9 of 10

    Answer: They write their output into app/assets/builds, which Propshaft digests and serves

    Each gem hooks its build into assets:precompile and writes into app/assets/builds, so the digest and the manifest work for compiled output exactly as for plain files.

  10. Question 10 of 10 Do you need to run assets:precompile while you develop?

    Choose one answer.

    Show the answer: Question 10 of 10

    Answer: No: in development Propshaft hashes and serves files as they are requested

    precompile is for production, where nothing is hashed at request time; development computes digests on demand.

Interview questions

Warm-up: what does Propshaft do to an asset’s file name, and why does that make long caching safe?

It adds a digest: the first 8 hex characters of a SHA1 of the file’s contents and the asset version, so application.css is served as application-8b441ae0.css. A change to the contents changes the digest, so the page names a new file, which no browser or CDN has cached, while the old name keeps its old contents forever. That is why the files can be served with a cache lifetime of a year: a name is never reused for different contents. In production the names are computed once by bin/rails assets:precompile, which writes the files to public/assets and a manifest the helpers read.

Warm-up: on a new app, bin/importmap audit says no vulnerable packages were found. What did it check?

Nothing. The audit sends the name and version of each pin that ends in a version comment, which bin/importmap pin writes, to the npm registry’s advisory service, and exits with status 1 when one has a known vulnerability. A new app’s pins for Turbo and Stimulus point at files inside the turbo-rails and stimulus-rails gems and carry no version, so bin/importmap packages lists nothing and the audit has nothing to send. Those files are checked as gems, by bin/bundler-audit, which the generated config/ci.rb runs beside the import-map audit. Once you pin a package, it appears in the list and the audit checks it.

Two questions in the module bank go further than this page: choosing a build for a screen written in JSX, and explaining to a security auditor what a nonce does and does not stop. Work out your own answer first, then open the model answer.

Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.

  1. Mid to senior

    A Rails 8.1 app runs on import maps and Propshaft, with a dozen Stimulus controllers. A new fee dashboard is to be written as React components in JSX. What do you change, what happens to the existing controllers, and how does the deploy change?

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  2. Mid to senior

    You turn on a nonce-based Content Security Policy in a Rails 8.1 app. A security auditor asks what it protects against, what it does not, and how you would show that it works before the release. Answer them.

    Model answer, Pro. First step and follow-ups

Flashcards · 7 cards

Recall it, then check

Say the answer to yourself before you look: recalling it is what makes it stick.

  1. Why can Propshaft's asset files be cached for a year?

    Each file is served under a name that ends in a hash of its contents, so changed contents get a new URL and an old name never points at new contents.

  2. What does bin/rails assets:precompile do for production?

    It writes every digested file once, together with a manifest through which Rails finds the digested name of each asset.

  3. What is an import map, and what does pin_all_from save you?

    JSON in the page head that maps each module name, such as @hotwired/turbo-rails, to a digested file, so modules import each other by name with no bundler. pin_all_from adds a whole folder, such as the Stimulus controllers, without a line for each file.

  4. Which pins does bin/importmap audit check, and what covers Turbo and Stimulus in a new app?

    Only pins that carry a version comment. A new app's Turbo and Stimulus pins come from gems, which bin/bundler-audit checks.

  5. After a JavaScript change, why can a page keep answering 304 with yesterday's scripts?

    Its ETag does not change when only the JavaScript does. stale_when_importmap_changes, which the generated ApplicationController calls, mixes the import map's digest into the ETag.

  6. How does a nonce-based Content Security Policy decide which scripts run?

    Tags that carry this response's nonce, such as the import-map tags, run, and a script without it is refused. Each response needs a fresh nonce of its own.

  7. When do you add jsbundling-rails, tailwindcss-rails or cssbundling-rails?

    jsbundling-rails when code needs compiling, such as JSX or TypeScript; tailwindcss-rails for Tailwind without Node.js; cssbundling-rails for a CSS build that runs on Node.js.

Key takeaways

  • Propshaft serves each file under a name that ends in a hash of its contents, so a changed file gets a new URL and a year-long cache is safe; in production assets:precompile writes those files and a manifest once.
  • An import map is JSON in the page head that maps each module name, such as @hotwired/turbo-rails, to a digested file, and pin_all_from adds a folder of controllers without a line each.
  • bin/importmap audit checks only pins that carry a version comment; a new app’s Turbo and Stimulus pins come from gems, which bin/bundler-audit checks instead.
  • A conditional-GET action keeps answering 304 after a JavaScript change unless stale_when_importmap_changes mixes the import map’s digest into the ETag, as the generated ApplicationController does.
  • A nonce-based Content Security Policy lets the import-map tags run and refuses a script without the nonce, provided the nonce is new for every response.
  • Add jsbundling-rails when a file needs compiling (JSX, TypeScript), tailwindcss-rails for Tailwind without Node.js, and cssbundling-rails for a Node.js CSS build.

Terms in this lesson

asset digest
The hash in an asset's file name, such as 8b441ae0 in application-8b441ae0.css: Propshaft makes it from the file's contents and the asset version, so new contents always get a new name.
asset pipeline
The part of a Rails app that turns the files under app/assets and the other asset paths into URLs a browser can cache, and serves them; in Rails 8.1 that is Propshaft.
Content Security Policy
A response header that tells the browser which scripts and other resources a page may load or run, for example only files from the app's own origin and tags that carry this response's nonce.
import map
A JSON object in a script tag of type importmap that tells the browser which file each JavaScript module name stands for, so modules can import each other by name without a bundler.
Propshaft
The asset pipeline of a new Rails 8.1 app: it gives every asset a name that ends in a hash of its contents and serves the file unchanged, without compiling or bundling it.

References

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.