Skip to content

Add first class component cache #2126

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 59 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
59 commits
Select commit Hold shift + click to select a range
157ee29
add component controlled cache
reeganviljoen Oct 8, 2024
44c1548
add changelog
reeganviljoen Oct 8, 2024
832144d
fix cacahe implementatation to work with all methods
reeganviljoen Oct 15, 2024
df94ff5
fix cacahe implementatation to work with all methods
reeganviljoen Oct 15, 2024
6bea6cd
fix lint
reeganviljoen Oct 15, 2024
bf90711
yeah I know it aint working, I am tired however, taking a nother look…
reeganviljoen Oct 16, 2024
bd69401
fix cache
reeganviljoen Nov 5, 2024
1030f85
fix lint
reeganviljoen Nov 5, 2024
94cb0b2
fix
reeganviljoen Nov 5, 2024
7c5e390
modulerize code
reeganviljoen Nov 5, 2024
b046bad
cleanup
reeganviljoen Nov 5, 2024
3bba26f
more cleanup
reeganviljoen Nov 5, 2024
8cb68a7
Apply suggestions from code review
reeganviljoen Nov 7, 2024
821f1f5
Update lib/view_component/cacheable.rb
reeganviljoen Nov 7, 2024
f7b520c
fix alphebtization
reeganviljoen Nov 18, 2024
46688cc
add cache suhggestions
reeganviljoen Nov 19, 2024
d772dfc
fix legacy ruby specs
reeganviljoen Nov 21, 2024
c1e8122
Apply suggestions from code review
reeganviljoen Mar 23, 2025
532246c
code review feedback
reeganviljoen Mar 26, 2025
16c76ec
make module fully optional;
reeganviljoen Mar 26, 2025
0c4c420
fix specs
reeganviljoen Mar 26, 2025
6ad979f
fix lint
reeganviljoen Mar 26, 2025
ce74df1
fix coberage
reeganviljoen Mar 26, 2025
5394a30
add inherited component test
reeganviljoen Mar 26, 2025
6fdf078
fix tests
reeganviljoen Mar 26, 2025
b2004f6
merge inherited values
reeganviljoen Mar 26, 2025
29cdd6e
fix tests
reeganviljoen Mar 26, 2025
56247c7
fix lint
reeganviljoen Mar 26, 2025
19f986b
add polish
reeganviljoen Mar 27, 2025
4ed3f44
add wip docs
reeganviljoen Mar 27, 2025
5e0c01b
fix tests
reeganviljoen Mar 27, 2025
0216e4b
fix lint
reeganviljoen Mar 27, 2025
395e0fc
fix coverage
reeganviljoen Mar 27, 2025
7c73d96
fix lint
reeganviljoen Mar 27, 2025
0e7247a
fix lint
reeganviljoen Mar 27, 2025
a2502aa
fix missing coverage
reeganviljoen Apr 1, 2025
7e4b470
fix head tests
reeganviljoen Apr 1, 2025
b57d125
add format and varaiant to cache_digest
reeganviljoen Apr 1, 2025
6610ef2
add format and varaiant to cache_digest
reeganviljoen Apr 1, 2025
c3ae775
fix coverage
reeganviljoen Apr 1, 2025
889d3c2
remove cacheable from base
reeganviljoen Apr 30, 2025
3a185c4
Fix rfebase mistakes
reeganviljoen May 1, 2025
9ae3207
add retrive ccache key to be consistent with rails
reeganviljoen May 1, 2025
b50370c
fix spec
reeganviljoen May 1, 2025
f994d27
Fix linting
reeganviljoen May 1, 2025
ae4e016
fix changelog stuff
reeganviljoen May 1, 2025
d24f12e
refactor cache logic
reeganviljoen May 2, 2025
fcdfee6
clean up stuf added in last rebase
reeganviljoen May 2, 2025
72d3b79
add identifier
reeganviljoen May 2, 2025
cc6cce5
compuye cache keys
reeganviljoen May 5, 2025
5aa11cf
fix lint
reeganviljoen May 5, 2025
efc6cc0
refactor
reeganviljoen May 6, 2025
d29c66d
add set
reeganviljoen May 6, 2025
750d1d3
Add cache refistry and alighn cache with how action view does it
reeganviljoen May 6, 2025
fa8cc40
namespace registry
reeganviljoen May 6, 2025
f847289
add magic comment
reeganviljoen May 6, 2025
b833ea4
fix failing rails 6 specs
reeganviljoen May 7, 2025
c5866af
Fix spelling mistake
reeganviljoen May 7, 2025
acadf3f
Add the start of an actual digestor
reeganviljoen May 7, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 9 additions & 5 deletions docs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,15 @@ nav_order: 6

## main

* Add experimental support for caching.

*Reegan Viljoen*

* Introduce component-local config and migrate `strip_trailing_whitespace` to use it under the hood.

*Simon Fish*

* Add docs about Slack channel in Ruby Central workspace. (Join us! #oss-view-component). Email [email protected] for an invite.
* Add docs about Slack channel in Ruby Central workspace. (Join us! #oss-view-component). Email <[email protected]> for an invite.

*Joel Hawksley

Expand Down Expand Up @@ -1470,7 +1474,7 @@ Run into an issue with this release? [Let us know](https://github.com/ViewCompon

*Joel Hawksley*

* The ViewComponent team at GitHub is hiring! We're looking for a Rails engineer with accessibility experience: [https://boards.greenhouse.io/github/jobs/4020166](https://boards.greenhouse.io/github/jobs/4020166). Reach out to [email protected] with any questions!
* The ViewComponent team at GitHub is hiring! We're looking for a Rails engineer with accessibility experience: [https://boards.greenhouse.io/github/jobs/4020166](https://boards.greenhouse.io/github/jobs/4020166). Reach out to <[email protected]> with any questions!

* The ViewComponent team is hosting a happy hour at RailsConf. Join us for snacks, drinks, and stickers: [https://www.eventbrite.com/e/viewcomponent-happy-hour-tickets-304168585427](https://www.eventbrite.com/e/viewcomponent-happy-hour-tickets-304168585427)

Expand Down Expand Up @@ -2234,7 +2238,7 @@ Run into an issue with this release? [Let us know](https://github.com/ViewCompon

*Matheus Richard*

* Are you interested in building the future of ViewComponent? GitHub is looking to hire a Senior Engineer to work on Primer ViewComponents and ViewComponent. Apply here: [US/Canada](https://github.com/careers) / [Europe](https://boards.greenhouse.io/github/jobs/3132294). Feel free to reach out to [email protected] with any questions.
* Are you interested in building the future of ViewComponent? GitHub is looking to hire a Senior Engineer to work on Primer ViewComponents and ViewComponent. Apply here: [US/Canada](https://github.com/careers) / [Europe](https://boards.greenhouse.io/github/jobs/3132294). Feel free to reach out to <[email protected]> with any questions.

*Joel Hawksley*

Expand All @@ -2252,7 +2256,7 @@ Run into an issue with this release? [Let us know](https://github.com/ViewCompon

## 2.31.0

_Note: This release includes an underlying change to Slots that may affect incorrect usage of the API, where Slots were set on a line prefixed by `<%=`. The result of setting a Slot shouldn't be returned. (`<%`)_
*Note: This release includes an underlying change to Slots that may affect incorrect usage of the API, where Slots were set on a line prefixed by `<%=`. The result of setting a Slot shouldn't be returned. (`<%`)*

* Add `#with_content` to allow setting content without a block.

Expand Down Expand Up @@ -2700,7 +2704,7 @@ _Note: This release includes an underlying change to Slots that may affect incor

* The gem name is now `view_component`.
* ViewComponent previews are now accessed at `/rails/view_components`.
* ViewComponents can _only_ be rendered with the instance syntax: `render(MyComponent.new)`. Support for all other syntaxes has been removed.
* ViewComponents can *only* be rendered with the instance syntax: `render(MyComponent.new)`. Support for all other syntaxes has been removed.
* ActiveModel::Validations have been removed. ViewComponent generators no longer include validations.
* In Rails 6.1, no monkey patching is used.
* `to_component_class` has been removed.
Expand Down
42 changes: 42 additions & 0 deletions docs/guide/caching.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
---
layout: default
title: Caching
parent: How-to guide
---

# Caching

Experimental
{: .label }

Components can implement caching by marking the depndencies that a digest can be built om using the cache_on macro, like so:

```ruby
class CacheComponent < ViewComponent::Base
include ViewComponent::Cacheable

cache_on :foo, :bar
attr_reader :foo, :bar

def initialize(foo:, bar:)
@foo = foo
@bar = bar
end
end
```

```erb
<p><%= view_cache_dependencies %></p>

<p><%= Time.zone.now %>"></p>
<p><%= "#{foo} #{bar}" %></p>
```

will result in:

```html
<p>foo-bar</p>

<p>2025-03-27 16:46:10 UTC</p>
<p> foo bar</p>
```
28 changes: 23 additions & 5 deletions lib/view_component/base.rb
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# frozen_string_literal: true

require "action_view"
require "view_component/cacheable"
require "active_support/configurable"
require "view_component/collection"
require "view_component/compile_cache"
Expand Down Expand Up @@ -111,12 +112,10 @@ def render_in(view_context, &block)

if render?
rendered_template = render_template_for(@__vc_variant, __vc_request&.format&.to_sym).to_s

# Avoid allocating new string when output_preamble and output_postamble are blank
if output_preamble.blank? && output_postamble.blank?
rendered_template
if respond_to?(:__vc_render_cacheable)
__vc_render_cacheable(rendered_template)
else
safe_output_preamble + rendered_template + safe_output_postamble
__vc_render_template(rendered_template)
end
else
""
Expand Down Expand Up @@ -271,6 +270,18 @@ def view_cache_dependencies
[]
end

# For handling the output_preamble and output_postamble
#
# @private
def __vc_render_template(rendered_template)
# Avoid allocating new string when output_preamble and output_postamble are blank
if output_preamble.blank? && output_postamble.blank?
rendered_template
else
safe_output_preamble + rendered_template + safe_output_postamble
end
end

# For caching, such as #cache_if
#
# @private
Expand All @@ -293,6 +304,13 @@ def __vc_request
@__vc_request ||= controller.request if controller.respond_to?(:request)
end

# For use in caching
#
# @private
def __vc_format
__vc_request&.format&.to_sym
end

# The content passed to the component instance as a block.
#
# @return [String]
Expand Down
19 changes: 19 additions & 0 deletions lib/view_component/cache_digestor.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# # frozen_string_literal: true

module ViewComponent
class CacheDigestor
@@digest_mutex = Mutex.new

class << self
def digest(name:, finder:, format: nil, dependencies: nil)
if dependencies.nil? || dependencies.empty?
cache_key = "#{name}.#{format}"
else
dependencies_suffix = dependencies.flatten.tap(&:compact!).join(".")
cache_key = "#{name}.#{format}.#{dependencies_suffix}"
end
cache_key
end
end
end
end
20 changes: 20 additions & 0 deletions lib/view_component/cache_registry.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# frozen_string_literal: true

module ViewComponent
module CachingRegistry
extend self

def caching?
ActiveSupport::IsolatedExecutionState[:view_component_caching] ||= false
end

def track_caching
caching_was = ActiveSupport::IsolatedExecutionState[:view_component_caching]
ActiveSupport::IsolatedExecutionState[:action_view_caching] = true

yield
ensure
ActiveSupport::IsolatedExecutionState[:view_component_caching] = caching_was
end
end
end
83 changes: 83 additions & 0 deletions lib/view_component/cacheable.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
# frozen_string_literal: true

require "set"
require "view_component/cache_registry"
require "view_component/cache_digestor"

module ViewComponent::Cacheable
extend ActiveSupport::Concern

included do
class_attribute :__vc_cache_dependencies, default: Set[:format, :__vc_format, :identifier]

# For caching, such as #cache_if
#
# @private
def view_cache_dependencies
return if __vc_cache_dependencies.blank? || __vc_cache_dependencies.none? || __vc_cache_dependencies.nil?

computed_view_cache_dependencies = __vc_cache_dependencies.map { |dep| if respond_to?(dep) then public_send(dep) end }
combined_fragment_cache_key(ActiveSupport::Cache.expand_cache_key(computed_view_cache_dependencies))
end

# Render component from cache if possible
#
# @private
def __vc_render_cacheable(rendered_template)
if __vc_cache_dependencies != [:format, :__vc_format]
ViewComponent::CachingRegistry.track_caching do
template_fragment(rendered_template)
end
else
__vc_render_template(rendered_template)
end
end

def template_fragment(rendered_template)
if content = read_fragment(rendered_template)
@view_renderer.cache_hits[@current_template&.virtual_path] = :hit if defined?(@view_renderer)
content
else
@view_renderer.cache_hits[@current_template&.virtual_path] = :miss if defined?(@view_renderer)
write_fragment(rendered_template)
end
end

def read_fragment(rendered_template)
Rails.cache.fetch(component_digest)
end

def write_fragment(rendered_template)
content = __vc_render_template(rendered_template)
Rails.cache.fetch(component_digest) do
content
end
content
end

def combined_fragment_cache_key(key)
cache_key = [:view_component, ENV["RAILS_CACHE_ID"] || ENV["RAILS_APP_VERSION"], key]
cache_key.flatten!(1)
cache_key.compact!
cache_key
end

def component_digest
component_name = self.class.name.demodulize.underscore
ViewComponent::CacheDigestor.digest(name: component_name, format: format, finder: @lookup_context, dependencies: view_cache_dependencies)
end
end

class_methods do
# For caching the component
def cache_on(*args)
__vc_cache_dependencies.merge(args)
end

def inherited(child)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We took this for a spin in a few cases of our cached partials and view components that we are familiar with and largely it works for the base cases. This is working to correctly bust a cached VC when it changes or things in the render path 'above' (partials or VCs) it change.

What we aren't seeing is when a VC renders another VC or partial as a child that downstream changes of the cached VC pick up changes and handle it. I'm going to fork your branch and offer some test cases based on an approach we use in the vc fragment caching gem (what we're currently solving this with)

Effectively we want some way for a child partial or VC change (in either the .rb or template) to bust the cached parent.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@JWShuff like touch: true in rails ?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

also big thanks for testing it, I can look at adding the touch true thing later this week

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@reeganviljoen I'd expect us to test this with changes to child partials.

child.__vc_cache_dependencies = __vc_cache_dependencies.dup

super
end
end
end
2 changes: 2 additions & 0 deletions test/sandbox/app/components/cache_component.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
<p class='cache-component__cache-key'><%= view_cache_dependencies %></p>
<p class='cache-component__cache-message' data-time=data-time="<%= Time.zone.now %>"><%= "#{foo} #{bar}" %></p>
14 changes: 14 additions & 0 deletions test/sandbox/app/components/cache_component.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
# frozen_string_literal: true

class CacheComponent < ViewComponent::Base
include ViewComponent::Cacheable

cache_on :foo, :bar

attr_reader :foo, :bar

def initialize(foo:, bar:)
@foo = foo
@bar = bar
end
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
<p class='cache-component__cache-key'><%= view_cache_dependencies %></p>

<p class='cache-component__cache-message' data-time=data-time="<%= Time.zone.now %>"><%= "#{foo} #{bar}" %></p>
7 changes: 7 additions & 0 deletions test/sandbox/app/components/inherited_cache_component.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# frozen_string_literal: true

class InheritedCacheComponent < CacheComponent
def initialize(foo:, bar:)
super
end
end
3 changes: 3 additions & 0 deletions test/sandbox/app/components/no_cache_component.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
<p class='cache-component__cache-key'><%= view_cache_dependencies %></p>

<p class='cache-component__cache-message' data-time=data-time="<%= Time.zone.now %>"><%= "#{foo} #{bar}" %></p>
12 changes: 12 additions & 0 deletions test/sandbox/app/components/no_cache_component.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
# frozen_string_literal: true

class NoCacheComponent < ViewComponent::Base
include ViewComponent::Cacheable

attr_reader :foo, :bar

def initialize(foo:, bar:)
@foo = foo
@bar = bar
end
end
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,12 @@ def controller_inline
render(ControllerInlineComponent.new(message: "bar"))
end

def controller_inline_cached
foo = params[:foo] || "foo"
bar = params[:bar] || "bar"
render(CacheComponent.new(foo: foo, bar: bar))
end

def controller_inline_with_block
render(ControllerInlineWithBlockComponent.new(message: "bar").tap do |c|
c.with_slot(name: "baz")
Expand Down
1 change: 1 addition & 0 deletions test/sandbox/config/routes.rb
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
get :inline_products, to: "integration_examples#inline_products"
get :cached, to: "integration_examples#cached"
get :render_check, to: "integration_examples#render_check"
get :controller_inline_cached, to: "integration_examples#controller_inline_cached"
get :controller_inline, to: "integration_examples#controller_inline"
get :controller_inline_with_block, to: "integration_examples#controller_inline_with_block"
get :controller_inline_baseline, to: "integration_examples#controller_inline_baseline"
Expand Down
32 changes: 31 additions & 1 deletion test/sandbox/test/rendering_test.rb
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@reeganviljoen I am not a git-wizard, much to my eternal shame, but I set up a fork and adjusted the spec approach here to use the integration examples/controllers to assert the behavior. Feel free to take, leave, or otherwise.

JWShuff#1

There's a wider challenge around partial/template digesting, and on a quiet day I'll port our spec suite over to the VC style and get it implemented so we have all the permutations we know of that need to cache appropriately.

Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ def test_render_inline_allocations
if Rails.version.to_f < 8.0
{"3.3.8" => 128, "3.3.0" => 140, "3.2.8" => 126, "3.1.7" => 126, "3.0.7" => 135}
elsif Rails.version.split(".").first(2).map(&:to_i) == [8, 0]
{"3.5.0" => 121, "3.4.3" => 125, "3.3.8" => 137}
{"3.5.0" => 121, "3.4.3" => 125, "3.3.8" => 138}
else
{"3.4.3" => 123}
end
Expand Down Expand Up @@ -1260,4 +1260,34 @@ def test_render_anonymous_component_without_template
render_inline(mock_component.new)
end
end

def test_cache_component
return if Rails.version < "7.0"

component = CacheComponent.new(foo: "foo", bar: "bar")
render_inline(component)

assert_selector(".cache-component__cache-key", text: component.view_cache_dependencies)
assert_selector(".cache-component__cache-message", text: "foo bar")

render_inline(CacheComponent.new(foo: "foo", bar: "bar"))

assert_selector(".cache-component__cache-key", text: component.view_cache_dependencies)

new_component = CacheComponent.new(foo: "foo", bar: "baz")
render_inline(new_component)

assert_selector(".cache-component__cache-key", text: new_component.view_cache_dependencies)
assert_selector(".cache-component__cache-message", text: "foo baz")
end

def test_no_cache_compoennt
return if Rails.version < "7.0"

component = NoCacheComponent.new(foo: "foo", bar: "bar")
render_inline(component)

assert_selector(".cache-component__cache-key", text: component.view_cache_dependencies)
assert_selector(".cache-component__cache-message", text: "foo bar")
end
end
Loading