Surprising breaking change during Rails upgrade in sprockets

Evgeniy Demin
4 min readAug 10, 2023

--

I have noticed one strange behavior about how Rails serves static files. It looks like a breaking change because I’m upgrading Rails 5.2 to 6.1. After spending some time debugging it, I decided to share this short story with you.

P.S. It isn’t the first unexpected issue I encountered during the Rails upgrade. I recommend you look at these stories:

Photo by Erik Mclean on Unsplash

In development and test environments, it’s a typical pattern to serve static files by Rails. In my case, with Rails 5.2, I could easily access all the images through the server. However, with Rails 6.1, I noticed that some of the files can’t be found.

I couldn’t understand why this behavior had changed. I would be okay if all of them were missing, but this wasn’t the case. Let’s get into specifics.

We have three icons served (I narrowed down the number for you to describe the problem):

  • something.svg
  • something-supercool.svg
  • anything-supercool.svg

All of these are served fine with Rails 5.2. However, one of them is failing with Rails 6.1. Would you like to guess which one?

And the answer is: something-supercool.svg

Now, you might ask yourself, why is that? I was surprised and confused. Although I found a quick workaround by playing with names to make it work, the intense feeling of solving the puzzle was tempting.

Unfortunately, the stack trace of the error was very poor.

ActionController::RoutingError (No route matches [GET] "/something-supercool.svg") 

After a quick lookup, I saw this error is coming from ActionDispatch::DebugExceptions middleware.

def call(env)
request = ActionDispatch::Request.new env
_, headers, body = response = @app.call(env)

if headers["X-Cascade"] == "pass"
body.close if body.respond_to?(:close)
raise ActionController::RoutingError, "No route matches [#{env['REQUEST_METHOD']}] #{env['PATH_INFO'].inspect}"
end

response
rescue Exception => exception
# ...
end

So now we need to find out when we set headers["X-Cascade"] to "pass". After several debug points and a little clue where to look, we come to Sprockets.

# Returns a 404 Not Found response tuple
def not_found_response(env)
if head_request?(env)
[ 404, { "content-type" => "text/plain", "content-length" => "0", "x-cascade" => "pass" }, [] ]
else
[ 404, { "content-type" => "text/plain", "content-length" => "9", "x-cascade" => "pass" }, [ "Not found" ] ]
end
end

Indeed, Sprockets can’t find the icon. Let’s add a few more lines from the middleware’s call method for the whole picture.

case status
# ...
when :not_found
logger.info "#{msg} 404 Not Found (#{time_elapsed.call}ms)"
not_found_response(env)

The status is defined here:

if asset.nil?
status = :not_found
elsif fingerprint && asset.etag != fingerprint
status = :not_found
# ...

And this is how we load the asset variable.

path = full_path # In our case "something-supercool.svg" 

# Strip fingerprint
if fingerprint = path_fingerprint(path)
path = path.sub("-#{fingerprint}", '')
end

# ...

# Look up the asset.
asset = find_asset(path)

# Fallback to looking up the asset with the full path.
# This will make assets that are hashed with webpack or
# other js bundlers work consistently between production
# and development pipelines.
if asset.nil? && (asset = find_asset(full_path))
if_match = asset.etag if fingerprint
fingerprint = asset.etag
end

You may already find that we modify the path to eliminate fingerprint.

def path_fingerprint(path)
path[/-([0-9a-zA-Z]{7,128})\.[^.]+\z/, 1]
end

Looking at this Regexp, we can say that something-supercool.svg path becomes just something.svg.

Okay, we understand this now, but …

Why would anything-supercool.svg work if we cut the path there too?

Let’s look through the flow together.

  1. change the path from anything-supercool.svg to anything.svg
  2. try to load asset and it fails as there is no such file
  3. try to load asset with full original path and it succeeds

In the case of something-supercool.svg, the flow is different.

  1. change the path to something.svg
  2. try to load asset and it succeeds as there is a file something.svg (another one)
  3. define status as :not_found because asset.etag is not equal to extracted fingerprint

Now, we have a complete picture of what is happening and why.

Last question remains, what has changed since Rails 5.2?

In Rails 5.2, Sprocket had a different Regexp to extract the fingerprint.

def path_fingerprint(path)
path[/-([0-9a-f]{7,128})\.[^.]+\z/, 1]
end

And this breaking change was introduced by David Heinemeier Hansson (DHH) with this PR to ensure compatibility with esbuilds’ base32 digests.

Unfortunately, I couldn’t find any mentions of this breaking change in the Changelog, although I see that some people had the same experience.

Conclusion

This whole story reminded me of my recent conclusion during migration from Classic to Zeitwerk loader.

Ruby on Rails is a world of convention over configuration, and apparently, prefers underscores in file names.

Thank you for your time!

Please consider subscribing or adding me in social networks!

And don’t forget to share what you think about the topic. Just write me a message. I would be happy to discuss the topic or anything else with you!

--

--

Evgeniy Demin
Evgeniy Demin

Written by Evgeniy Demin

Ruby & Golang practitioner. Remote expert. Open-source contributor. Beginner blogger. Join my network to grow your expertise.