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.

Share this? Copy link

Feedback? Email me!

Hi! 👋 I’m Matt Brictson, a software engineer in San Francisco. This site is my excuse to practice UI design, fuss over CSS, and share my interest in open source. I blog about Rails, design patterns, and other development topics.

Recent articles

RSS
View all posts →

Open source projects

mattbrictson/nextgen

Generate your next Rails app interactively!

11
Updated 1 day ago

mattbrictson/tomo

A friendly CLI for deploying Rails apps ✨

360
Updated 1 day ago

More on GitHub →