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)
config/initializers/vite_cache_buster.rb
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

Let’s fix it with a monkey patch!

April 2023

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

GitHub Email LinkedIn