rails
Safari’s misbehaved CSS cache breaks the vite_rails developer experience
Let’s fix it with a monkey patch!
The vite_rails gem brings Vite’s excellent frontend developer experience to Rails apps. It’s my preferred frontend tooling for most Rails projects. One of its headline features is hot reloading of CSS: touch a CSS or SCSS file, and the changes are rendered instantly in the browser.
Unless the browser is Safari, unfortunately. There is a known bug where Safari renders an older, cached version of an app’s CSS, rather than the updated one provided by Vite. Vite correctly sends its CSS with the cache-control: no-cache
header, but Safari ignores the header and caches it anyway.
As of Safari 16.4, there is no easy way to disable this unwanted caching behavior; even the “Disable Caches” option in the dev tools network tab has no effect.
Official workaround
As a workaround, the vite_rails documentation recommends that developers move all CSS into the JavaScript build pipeline and use vite_javascript_tag
instead of vite_stylesheet_tag
. This works, but re-architecting an app’s frontend entry points seems like a drastic step. Especially when the problem occurs only in development mode, and only in Safari.
Cache-buster patch
Arguably a lighter touch solution, requiring no frontend CSS/JS code changes, is to add a cache-buster to the CSS URL. This technique has been used since the earliest days of the web to defeat caches. A set of small monkey-patches to the Rails stylesheet helper and vite_rails does the trick:
return unless Rails.env.development?
# Add a `?cb=[RAND]` cache buster to stylesheet URLs to defeat Safari's
# overzealous caching
module StylesheetDevelopmentCacheBusting
def stylesheet_link_tag(*sources)
options = sources.extract_options!.stringify_keys
fingerprint = SecureRandom.hex(4)
sources = sources.map { |name| "#{name}?cb=#{fingerprint}" }
super(*sources, **options)
end
end
ActiveSupport.on_load(:action_view) do
include StylesheetDevelopmentCacheBusting
end
# Patch Vite to strip out the `?cb=[RAND]` parameter so it doesn't interfere
# with asset resolution
module ViteDevServerProxyPatch
def normalize_uri(...)
super.sub(/[?&]cb=[^&]*$/, "")
end
end
ViteRuby::DevServerProxy.prepend(ViteDevServerProxyPatch)
This patch only loads in development mode, which lowers the risk of unexpected side-effects.
Now, when a stylesheet is referenced like this:
<%= vite_stylesheet_tag("application.scss") %>
The generated link tag will include a random cache-buster on every page load in development:
<link
rel="stylesheet"
href="/vite-dev/entrypoints/application.scss?cb=f55d7ceb"
/>
This guarantees that Safari will request the CSS rather than using a stale version from cache. The result is that we get Vite’s awesome hot-reloading experience in Safari, and we didn’t have to reorganize our frontend code!
Caveats
Monkey patch solutions like this do have risks, but our production code is not dependent on the patch, which limits the scope of the impact. If a future version of Rails or Vite causes the patch to break, we can easily roll back the patch by deleting a single file.
You just read
Safari’s misbehaved CSS cache breaks the vite_rails developer experience
Let’s fix it with a monkey patch!
Share this post? Copy link
About the author
Hi! I’m a Ruby and CSS enthusiast, regular open source contributor, software engineer, and occasional blogger writing from the San Francisco Bay Area. Thanks for stopping by! —Matt