DEV Community

PikachuEXE
PikachuEXE

Posted on

Cells - Introduction

Background

GitHub has recently posted an article about view_component:
https://github.blog/2020-12-15-encapsulating-ruby-on-rails-views/
Before it gets too popular I think I should share my experience with cells

So that developers can have another chance to re-think and pick what to use for "encapsulated view components".

Scope of this post

(1) Only "concept cells", not view_component style cells.
The "default"/view_component naming style makes it difficult to just copy & rename a folder to bootstrap a new view component

(2) File Structure, Inputs, Rendering, View Inheritance & Caching
Testing - I am too lazy to do it (rarely beneficial for me)
Layout - Never used it.

File Structure

(Modified from cells old README)
(Remember this is for concept cells)

app
├── concepts
│   ├── copyable_url_box
│   │   ├── cell.rb
│   │   ├── views
│   │   │   ├── show.haml
Enter fullscreen mode Exit fullscreen mode

File content

class CopyableUrlBox::Cell < Cell::Concept
  # ..
end
Enter fullscreen mode Exit fullscreen mode

If you want to put these files into a non-default folder like app/resources
You need to update the view_paths

class CopyableUrlBox::Cell < Cell::Concept
  self.view_paths = Array("app/resources") + view_paths
  # ..
end
Enter fullscreen mode Exit fullscreen mode

Inputs

The official doc suggests using this:

concept("comment/cell", @comment) #=> return a cell
Enter fullscreen mode Exit fullscreen mode

This gives you access to an attribute model (which is @comment) inside the cell object by default

But I prefer passing a hash so I do this:

class ApplicationConceptCell < Cell::Concept
  module Cells3HashInputBehaviourReplication
    # Since in cells 3.x
    # When a hash is passed in
    # Everything in hash is injected as ivars
    # So we want to replicate the behaviour here
    #
    # This override works on cells 4.1.5
    def initialize(model, options={})
      if model.is_a?(::Hash)
        model.each_pair do |key, value|
          instance_variable_set("@#{key}", value)
        end

        # To avoid ivar `@model` being set in super with the Hash
        # We need to fetch value from hash and put it into `super`
        # Even usually it's `nil`
        super(model.fetch(:model) { nil }, options)
      else
        super
      end
    end
  end
  prepend Cells3HashInputBehaviourReplication
end

# Remember you have to inherit from the right cell!
class CopyableUrlBox::Cell < ApplicationConceptCell
  private

  # region Inputs

  # Contract Something
  # attr_reader :input_name

  Contract And[::String, Send[:present?]]
  attr_reader :url

  # endregion Inputs
end
Enter fullscreen mode Exit fullscreen mode

This gives me access to input values as long as I defined them via attr_reader.
Oh what's the Contract XXX above attr_reader?
They are from contracts.ruby and completely optional and won't be explained in this post.
You can safely ignore those and maybe study that gem later.

I am also using another way to handle inputs which is to put input values into inputs attribute via a value object.

require "contracted_value"

class ApplicationConceptCell2222 < Cell::Concept
  module HashInputsAsImmutableInputs
    def initialize(model, options={})
      if model.is_a?(::Hash)
        @inputs = self.class.inputs_class.new(model)

        # To avoid ivar `@model` being set in super with the Hash
        # We need to fetch value from hash and put it into `super`
        # Even usually it's `nil`
        super(model.fetch(:model) { nil }, options)
      else
        super
      end
    end

    private

    attr_reader :inputs
  end
  prepend HashInputsAsImmutableInputs
  class_attribute(
    :inputs_class,
    instance_reader: false,
    instance_writer: false,
  )
  class << self
    def define_inputs(&block)
      new_inputs_class = Class.new(::ContractedValue::Value)
      new_inputs_class.include(::Contracts::Core)
      new_inputs_class.include(::Contracts::Builtin)
      new_inputs_class.class_eval(&block)

      self.inputs_class = new_inputs_class
    end
  end
end

# Remember you have to inherit from the right cell!
class CopyableUrlBox2222::Cell < ApplicationConceptCell2222
  private

  # region Inputs

  define_inputs do
    attribute(
      :url,
      contract: And[::String, Send[:present?]],
    )
  end

  # endregion Inputs
end
Enter fullscreen mode Exit fullscreen mode

Above implemented via my own gem contracted_value and feel free to swap it with any other value object implementation which can be gems or your own code.

Rendering

To use a cell, by default just invoke #call

And you can do it by:

  • Get result string directly
  • Create a cell, do something in the middle, get result string later (no need to invoke #call right after cell creation, and feel free to add new public methods as your own API)
# controller
render(
  html: concept("copyable_url_box/cell, url: "...").call
)
Enter fullscreen mode Exit fullscreen mode

Since I use cells-rails, #call returns HTML safe string.
If you don't use cells-rails you might need to do extra work or include extra module to make that happen (check doc yourself).

In cell class, it looks like this:

class CopyableUrlBox::Cell < ApplicationConceptCell
  # This is actually optional if you use >= 4.1
  # BUT you can make this return something else
  def show
    render
  end

  # inputs and other stuff...
end
Enter fullscreen mode Exit fullscreen mode

Why #show? If you read the doc you will know that
concept_cell.call == concept_cell.call(:show)
You are free to use other names but not recommended.

The templates can access anything inside the cell.
Yes this includes

  • the inputs (yes even they are private attributes)
  • any public/protected/private methods
  • Some helper methods are delegated to controller if cells-rails is used
  • Extra helper methods from helper modules included into the cell

No more "locals" when rendering. What you see (in cell objects) is what you get (in templates).

Having template file(s) is optional.
Returning a tag directly in #show also works.

class WebsiteLogo::Cell < ApplicationConceptCell
  def show
    image_tag(
      website_logo_image_path,
      alt: "Logo",
      class: "...",
    )
  end

  private

  def website_logo_image_path
    case inputs.variant
    when :small
      "..."
    else
      "..."
    end
  end

  # inputs and other stuff...
end
Enter fullscreen mode Exit fullscreen mode

View Inheritance

I generally prefer composition over inheritance (using a group of other cells instead a cell) but sometimes being able to use templates from parent cell class is more convenient.
I use it when I have several cells for page content of several page types but the layout is similar (same number of sections but different content).
Just read the view inheritance document yourself.

Caching

Best part comes last.
Caching is easy, cache invalidation is not.
But let's talk about how to enable caching first.

Enabling caching is quite easy according to caching doc:

class CopyableUrlBox::Cell < ApplicationConceptCell
  cache :show
end
Enter fullscreen mode Exit fullscreen mode

Yup. The end.
Except when you pass different inputs into it, the cell will give you the same (cached) output. Which is why cache invalidation is the hard part.

First get some basic concept by looking at the caching doc

cache :show, expires_in: 10.minutes
Enter fullscreen mode Exit fullscreen mode

This is just making the cached content expires after some time, nope not what we want.

cache :show { |model, options| "comment/#{model.id}/#{model.updated_at}" }
cache :show, :if => lambda { |*| has_changed? }
cache :show, :tags: lambda { |model, options| "comment-#{model.id}" }
Enter fullscreen mode Exit fullscreen mode

Looks messy? Let me give you my version

cache(
  :show,
  :cache_key,
  expires_in: :cache_valid_time_period_length,
  if: :can_be_cached?,
)

def can_be_cached?
  # Use following code when child cell(s) is/are used
  # [
  #   child_cell_1,
  # ].all?(&:can_be_cached?)

  true
end

def cache_valid_time_period_length
  # Use following code when child cell(s) is/are used
  # [
  #   child_cell_1,
  # ].map(&:cache_valid_time_period_length).min

  # Long time
  100.years
end

def cache_key
  {
    current_locale: ::I18n.config.locale,

    url: Digest::MD5.hexdigest(url),
  }
end
Enter fullscreen mode Exit fullscreen mode

If language changed (::I18n.config.locale) or input value different (url), a different cache key would be used and the render result (cached or not) would be different

But hold on, what if I updated the logic/template and now I want it to return a result rendered with latest logic?

This is what I did:

class ApplicationConceptCell < Cell::Concept
  DEFAULT_CLASS_LEVEL_CACHE_KEY = {
    # ONLY change this when there are too many cells caching needs updated
    # Also only affects cells that has `#cache_key` binding to `super`

    # Yes I use cells since 2015
    design: :v2015_12_22_1001,
  }.freeze
  private_constant :DEFAULT_CLASS_LEVEL_CACHE_KEY

  def self.cache_key
    DEFAULT_CLASS_LEVEL_CACHE_KEY
  end

  def cache_key
    self.class.cache_key
  end
end

class CopyableUrlBox::Cell < ApplicationConceptCell
  cache(
    :show,
    :cache_key,
    expires_in: :cache_valid_time_period_length,
    if: :can_be_cached?,
  )

  def can_be_cached?
    # Use following code when child cell(s) is/are used
    # [
    #   child_cell_1,
    # ].all?(&:can_be_cached?)

    true
  end

  def cache_valid_time_period_length
    # Use following code when child cell(s) is/are used
    # [
    #   child_cell_1,
    # ].map(&:cache_valid_time_period_length).min

    # Long time
    100.years
  end

  def self.cache_key
    super.merge(
      logic: :v2020_12_18_1727,
    )
  end

  def cache_key
    super.merge(
      current_locale: ::I18n.config.locale,

      url: Digest::MD5.hexdigest(url),
    )
  end
end
Enter fullscreen mode Exit fullscreen mode

Notice that the above code all use cache_key.

But Rails 5.2 introduced cache versioning and we should use that too.

Caching - With Cache Versioning

The following code enable cache versioning

class ApplicationConceptCell < Cell::Concept
  DEFAULT_CLASS_LEVEL_CACHE_KEY = {
    # Empty
  }.freeze
  private_constant :DEFAULT_CLASS_LEVEL_CACHE_KEY

  DEFAULT_CLASS_LEVEL_CACHE_VERSION = {
    # ONLY change this when there are too many cells caching needs updated
    # Also only affects cells that has `#cache_key` binding to `super`

    # Yes I use cells since 2015
    design: :v2015_12_22_1001,
  }.freeze
  private_constant :DEFAULT_CLASS_LEVEL_CACHE_VERSION

  def self.cache_key
    DEFAULT_CLASS_LEVEL_CACHE_KEY
  end

  def cache_key
    self.class.cache_key
  end

  def self.cache_version
    DEFAULT_CLASS_LEVEL_CACHE_VERSION
  end

  def cache_version
    self.class.cache_key
  end
end

class ProductCardWide::Cell < ApplicationConceptCell
  cache(
    :show,
    :cache_key,
    expires_in: :cache_valid_time_period_length,
    if: :can_be_cached?,
    version: :cache_version,
  )

  def can_be_cached?
    # Use following code when child cell(s) is/are used
    # [
    #   child_cell_1,
    # ].all?(&:can_be_cached?)

    true
  end

  def cache_valid_time_period_length
    # Use following code when child cell(s) is/are used
    # [
    #   child_cell_1,
    # ].map(&:cache_valid_time_period_length).min

    # Long time
    100.years
  end

  # def self.cache_key
  #   super
  # end

  def cache_key
    super.merge(
      current_locale: ::I18n.config.locale,

      # Assume product is an active record object 
      product: product.id,
    )
  end

  def self.cache_version
    super.merge(
      logic:    :v2017_12_22_1124,
    )
  end

  def cache_version
    super.merge(
      # Assume product is an active record object 
      product: product.updated_at,
    )
  end
end
Enter fullscreen mode Exit fullscreen mode

Point is when calling .cache in class, add option version.

Logic/Template/Translation/Images updated?

You can of course manually update the class level cache key/version.

But let's see how I make them auto update themselves.

Caching - Class Level Cache Key/Version Auto Update

1. Template Updates
This assumes every template update should cause cache invalidation (so even a refactor would invalidate the cache).

class Some::Cell < ApplicationConceptCell
  def self.cache_version
    super.merge(
      template: TEMPLATE_FILES_CONTENT_CACHE_KEY,
    )
  end

  # This generates the cache key/version from the content of all direct templates for this cell (templates from parent cells or custom paths NOT included)
  # Any change (even a refactor) would cause the value to change
  TEMPLATE_FILES_CONTENT_CACHE_KEY = begin
    view_folder_path = File.expand_path("views", __dir__)
    file_paths = Dir.glob(File.join(view_folder_path, "**", "*"))

    file_digests = file_paths.map do |file_path|
      ::Digest::MD5.hexdigest(File.read(file_path))
    end

    ::Digest::MD5.hexdigest(file_digests.join(""))
  end
  private_constant :TEMPLATE_FILES_CONTENT_CACHE_KEY
end
Enter fullscreen mode Exit fullscreen mode

2. Translation Updates
This requires you to have separate folders of translation files for different components.

class Some::Cell < ApplicationConceptCell
  def self.cache_version
    super.merge(
      translations: TRANSLATION_FILES_CONTENT_CACHE_KEY,
    )
  end

  # This generates the cache key/version from the content of all specified folder's files
  # Any change (even a refactor) would cause the value to change
  TRANSLATION_FILES_CONTENT_CACHE_KEY = begin
    folder_path = ::Rails.root.join(
      "config/locales/your/cell/translation/folder/path",
    )
    file_paths = Dir.glob(File.join(folder_path, "**", "*"))

    file_digests = file_paths.map do |file_path|
      next nil unless File.file?(file_path)

      ::Digest::MD5.hexdigest(File.read(file_path))
    end

    ::Digest::MD5.hexdigest(file_digests.join(""))
  end
  private_constant :TRANSLATION_FILES_CONTENT_CACHE_KEY
end
Enter fullscreen mode Exit fullscreen mode

3. Image Updates

class Some::Cell < ApplicationConceptCell
  def self.cache_version
    super.merge(
      images: IMAGE_FILES_CONTENT_CACHE_KEY,
    )
  end

  # This generates the cache key/version from the content of all specified folder's files
  # Any change (even a refactor) would cause the value to change
  IMAGE_FILES_CONTENT_CACHE_KEY = begin
    folder_paths = [
      # "app/assets/images/path/to/images",
    ]
    asset_logical_paths = [
      # "app/assets/images/path/to/images.jpg",
    ].map do |asset_logical_path|
      ::Rails.root.join(asset_logical_path)
    end
    file_paths = folder_paths.inject([]) do |paths, folder_path|
      paths + ::Dir.glob(::File.join(::Rails.root.join(folder_path), "**", "*"))
    end + asset_logical_paths

    file_digests = file_paths.map do |file_path|
      next nil unless File.file?(file_path)

      ::Digest::MD5.hexdigest(File.read(file_path))
    end

    ::Digest::MD5.hexdigest(file_digests.join(""))
  end
  private_constant :ASSET_FILES_CONTENT_CACHE_KEY
end
Enter fullscreen mode Exit fullscreen mode

4. Cell Logic Updates
Nope. You have to update cache key/version manually for this kind of updates.

The End

I still got some more to share about caching but let's save it for another post. (This post is for "beginners")

The code above is extracted from an existing project so let me know if there is any mistake.

Took me almost 3 months to finish this article (o.0)

Latest comments (0)