Caching ViewComponents

Over the past several months, we've started building a library of ViewComponents that we use app-wide at Craftwork.

A handful of views started to feel a little sluggish:

  • chat and SMS interface
  • lists of projects
  • lists of teams

We've tried to keep things simple so that we can ship new features very quickly without much overhead, but when the time came to start adding some performance improvements, I reached for the tried-and-true eager loading and caching hammers. I wrote about how I think about performance here.

Because I'm late to the view-component-game, I was a little surprised to learn that fragment caching wasn't supported for view component out of the box.

We implement view components in several different ways. A handful use the call method directly, while most others use a Ruby class paired with a template.

Here's an example of all the places we render avatars or avatar groups:

Avatars in the Craftwork UI

Our AvatarComponent is an example that uses call, and really farms out the logic to a helper method called generate_avatar.

module Ui
  class AvatarComponent < ApplicationComponent
    include AvatarHelper

    def initialize(user:, size: :md, classes: "", prefers_initials: false, link_to_user: true)
      @user = user
      @size = size
      @classes = classes
      @prefers_initials = prefers_initials
      @link_to_user = link_to_user
    end

    def call
      if @link_to_user
        link_to user_path(@user), class: "hover:no-underline hover:opacity-100" do
          generate_avatar(user: @user, size: @size, classes: @classes, prefers_initials: @prefers_initials)
        end
      else
        generate_avatar(user: @user, size: @size, classes: @classes, prefers_initials: @prefers_initials)
      end
    end
  end
end

My intuition was that I could wrap the contents of our call method inside of a cache block to use fragment caching.

def call
  cache(cache_key) do
    if @link_to_user
      link_to user_path(@user), class: "hover:no-underline hover:opacity-100" do
        generate_avatar(user: @user, size: @size, classes: @classes, prefers_initials: @prefers_initials)
      end
    else
      generate_avatar(user: @user, size: @size, classes: @classes, prefers_initials: @prefers_initials)
    end
  end
end

After combing the logs, I found this line: Couldn't find template for digesting: /ui/avatar_component

When digging in the rails source I found the line where it's logging that: https://github.com/rails/rails/blob/9fd8b33ebba3c281c6cc5bbf8f48cde38c6fb0da/actionview/lib/action_view/digestor.rb#L63

image.png

Because we're not using a "rails template" and instead we're using the template backed by view component, the cache method from Rails wasn't able to find the template in order to digest it and build a cache key based on the structure of the markup in the template. (At least that's what I gather, please lmk on Twitter if you understand it differently: @cjav_dev.)

Instead of depending on fragment caching, I decided to try rolling our own using the lower-level Rails.cache.fetch method:

def call
  Rails.cache.fetch(cache_key) do
    if @link_to_user
      link_to user_path(@user), class: "hover:no-underline hover:opacity-100" do
        generate_avatar(user: @user, size: @size, classes: @classes, prefers_initials: @prefers_initials)
      end
    else
      generate_avatar(user: @user, size: @size, classes: @classes, prefers_initials: @prefers_initials)
    end
  end
end

This worked fine for our AvatarComponent! 🎉

Now let's try in a component that uses an html.erb template: AvatarGroupComponent.

module Ui
  class AvatarGroupComponent < ApplicationComponent
    include AvatarHelper
    attr_reader :users

    MAX_USERS = 4

    def initialize(users:, size: :md, prefers_initials: false, link_to_user: true)
      @users = users.uniq
      @size = size
      @prefers_initials = prefers_initials
      @link_to_user = link_to_user
    end

    def display_users
      @users[...MAX_USERS]
    end

    def additional?
      @users.size > MAX_USERS
    end

    def additional_count
      @users.size - MAX_USERS
    end

    def additional_users
      @users[MAX_USERS..]
    end
  end
end

The template looks like this:

<%= Rails.cache.fetch(cache_key) do %>
  <ul class="group flex relative isolate">
    <% display_users.map.with_index do |user, i| %>
      <li class="<%= "-ml-3 z-n#{i} group-hover:-ml-1" if i.positive? %> hover:z-0 transition-all duration-300">
        <%= render Ui::AvatarComponent.new(user: user, size: @size, link_to_user: @link_to_user) %>
      </li>
    <% end %>
    <% if additional? %>
      <li class="-ml-3 z-[-4] hover:z-0 group-hover:-ml-1 transition-all duration-300">
        <%= generate_avatar(size: @size, classes: "relative", additional_count: additional_count, additional_names: additional_users.map(&:name).join(", ")) %>
      </li>
    <% end %>
  </ul>
<% end %>

No dice. See all these little </ul>s?

image.png

Side note: you can clear the Rails cache from the Rails console with Rails.cache.clear and fetch items from the cache with Rails.cache.read("<key>").

I wanted to do some digging, so I printed the cache keys to the logs and then fetched them from the cache in Rails console:

Logs:

image.png

Cached value:

image.png

If you've worked with ERB rendering, you've probably already spotted the issue. We're only storing the last line of content evaluated by the block.

Rails.cache.fetch(key) { some_content_stuff } looks in the KV store, in our case Redis, for the given key. If it doesn't find anything, it evaluates the block and writes a new entry for the given key with the value returned by evaluating the block.

The trick is to use with_output_buffer to collect the content generated while evaluating an erb template and return that from the block so that we store all the lines of generaetd HTML in the cache. (Technically, we also want to make it a String instead of an ActionView::OutputBuffer, but whatevs, we can clean that up later:

<% puts "Writing avatar group #{cache_key.join("/")}" %>
<%= Rails.cache.fetch(cache_key.join("/")) do %>
  <% with_output_buffer do %>
    <ul class="group flex relative isolate">
      <% display_users.map.with_index do |user, i| %>
        <li class="<%= "-ml-3 z-n#{i} group-hover:-ml-1" if i.positive? %> hover:z-0 transition-all duration-300">
          <%= render Ui::AvatarComponent.new(user: user, size: @size, link_to_user: @link_to_user) %>
        </li>
      <% end %>
      <% if additional? %>
        <li class="-ml-3 z-[-4] hover:z-0 group-hover:-ml-1 transition-all duration-300">
          <%= generate_avatar(size: @size, classes: "relative", additional_count: additional_count, additional_names: additional_users.map(&:name).join(", ")) %>
        </li>
      <% end %>
    </ul>
  <% end %>
<% end %>

image.png

This works! Now, let's clean it up and move it into the parent class so it can be used in all components. I opted to implement two different methods, one for when we're caching from the template and another when we're working in the call method:

class ApplicationComponent < ViewComponent::Base
  def cache_key
    raise NotImplementedError, "You must implement the cache_key method in your component"
  end

  # Rails' built in fragment caching doesn't work well
  # with view_component because it can't find the template to
  # digest it. Instead, we'll manually cache the component.
  #
  # I only got this working with two different versions of the method
  # one for working within ERB and a different one for directly using
  # within the `call` method of a component.
  #
  # `cache_component` is for use with ERB and uses the `with_output_buffer`
  # to collect up and capture all of the HTML emitted by the block.
  #
  # Note, you have to use `<%=` to call this method, not `<%` so that
  # the cached content is actually output to the page when we have
  # a cache hit!
  #
  # Example:
  #
  # <%= cache_component do %>
  #   <div>
  #      <%= some_content %>
  #   </div>
  # <% end %>
  def cache_component(custom_cache_key: nil, &block)
    key = Array.wrap(custom_cache_key || cache_key).flatten.join("/")

    Rails.logger.info("Checking view component cache for #{key}")
    Rails.cache.fetch(key) do
      Rails.logger.info("Writing view component cache for #{key}")
      with_output_buffer(&block).html_safe
    end
  end

  # `cache_component_call` is for use within the `call` method of a component
  # it calls the block and caches the result of the block.
  def cache_component_call(custom_cache_key: nil, &block)
    key = Array.wrap(custom_cache_key || cache_key).flatten.join("/")

    Rails.logger.info("Checking view component cache for #{key}")
    Rails.cache.fetch(key) do
      Rails.logger.info("Writing view component cache for #{key}")
      block.call
    end
  end
end

Here's the working, cached components:

AvatarComponent

module Ui
  class AvatarComponent < ApplicationComponent
    include AvatarHelper

    def initialize(user:, size: :md, classes: "", prefers_initials: false, link_to_user: true)
      @user = user
      @size = size
      @classes = classes
      @prefers_initials = prefers_initials
      @link_to_user = link_to_user
    end

    def call
      cache_component_call do
        if @link_to_user
          link_to user_path(@user), class: "hover:no-underline hover:opacity-100" do
            generate_avatar(user: @user, size: @size, classes: @classes, prefers_initials: @prefers_initials)
          end
        else
          generate_avatar(user: @user, size: @size, classes: @classes, prefers_initials: @prefers_initials)
        end
      end
    end

    def cache_key
      [
        GIT_COMMIT,
        'av',
        @user&.id,
        @user&.updated_at,
        @size,
        @classes,
        @prefers_initials,
        @link_to_user
      ]
    end
  end
end

AvatarGroupComponent

module Ui
  class AvatarGroupComponent < ApplicationComponent
    include AvatarHelper
    attr_reader :users

    MAX_USERS = 4

    def initialize(users:, size: :md, prefers_initials: false, link_to_user: true)
      @users = users.uniq
      @size = size
      @prefers_initials = prefers_initials
      @link_to_user = link_to_user
    end

    def display_users
      @users[...MAX_USERS]
    end

    def additional?
      @users.size > MAX_USERS
    end

    def additional_count
      @users.size - MAX_USERS
    end

    def additional_users
      @users[MAX_USERS..]
    end

    def cache_key
      [
        GIT_COMMIT,
        'av-group',
        @users.map(&:id).sort,
        @users.maximum(:updated_at),
        @size,
        @prefers_initials,
        @link_to_user
      ]
    end
  end
end

And it's template:

<%= cache_component do %>
  <ul class="group flex relative isolate">
    <% display_users.map.with_index do |user, i| %>
      <li class="<%= "-ml-3 z-n#{i} group-hover:-ml-1" if i.positive? %> hover:z-0 transition-all duration-300">
        <%= render Ui::AvatarComponent.new(user: user, size: @size, link_to_user: @link_to_user) %>
      </li>
    <% end %>
    <% if additional? %>
      <li class="-ml-3 z-[-4] hover:z-0 group-hover:-ml-1 transition-all duration-300">
        <%= generate_avatar(size: @size, classes: "relative", additional_count: additional_count, additional_names: additional_users.map(&:name).join(", ")) %>
      </li>
    <% end %>
  </ul>
<% end %>

Did you notice the GIT_COMMIT constant referenced in the cache key? There's an initializer that sets GIT_COMMIT to the latest git commit when we deploy to Render. Since we don't have any template digesting, I figure the next best thing is always to bust caches when we deploy. That way if the underlying structure to any cached component changes we don't need to digest the template. This is definitely more brute force than using template digests and we could likely make our cache key a little smarter by digesting the contents of the view component ruby class and it's template, but I suspect we'll get a lot of mileage out of this improvement. Obv, we'll have a couple requests after each deploy where we'll warm the cache with avatars.

How are you caching your view components?! Where are the holes in my implementation?!


Edit:

Thanks for the feedback, Joel Drapper!

image.png