Skip to content
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

Add first class component cache #2126

Open
wants to merge 40 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
40 commits
Select commit Hold shift + click to select a range
82459f0
add component controlled cache
reeganviljoen Oct 8, 2024
6dd7b90
add changelog
reeganviljoen Oct 8, 2024
3f09c7d
fix cacahe implementatation to work with all methods
reeganviljoen Oct 15, 2024
39ebb6f
fix cacahe implementatation to work with all methods
reeganviljoen Oct 15, 2024
a44ade4
fix lint
reeganviljoen Oct 15, 2024
18fdafb
yeah I know it aint working, I am tired however, taking a nother look…
reeganviljoen Oct 16, 2024
d869441
fix cache
reeganviljoen Nov 5, 2024
5e42752
fix lint
reeganviljoen Nov 5, 2024
c31701b
fix
reeganviljoen Nov 5, 2024
a2dcff4
modulerize code
reeganviljoen Nov 5, 2024
dfb1fad
cleanup
reeganviljoen Nov 5, 2024
1223a41
more cleanup
reeganviljoen Nov 5, 2024
5090a6b
Apply suggestions from code review
reeganviljoen Nov 7, 2024
78ac83b
Update lib/view_component/cacheable.rb
reeganviljoen Nov 7, 2024
ee10eea
fix alphebtization
reeganviljoen Nov 18, 2024
1395282
add cache suhggestions
reeganviljoen Nov 19, 2024
50d04c1
fix legacy ruby specs
reeganviljoen Nov 21, 2024
9c27a32
Apply suggestions from code review
reeganviljoen Mar 23, 2025
1631045
code review feedback
reeganviljoen Mar 26, 2025
10a86bb
make module fully optional;
reeganviljoen Mar 26, 2025
bc26709
fix specs
reeganviljoen Mar 26, 2025
6afb80b
fix lint
reeganviljoen Mar 26, 2025
dce590f
fix coberage
reeganviljoen Mar 26, 2025
cf55bbb
add inherited component test
reeganviljoen Mar 26, 2025
cad3ce9
fix tests
reeganviljoen Mar 26, 2025
1038ee2
merge inherited values
reeganviljoen Mar 26, 2025
e5228e7
fix tests
reeganviljoen Mar 26, 2025
50eaded
fix lint
reeganviljoen Mar 26, 2025
def21f7
add polish
reeganviljoen Mar 27, 2025
88cb2a6
add wip docs
reeganviljoen Mar 27, 2025
0d1ef6d
fix tests
reeganviljoen Mar 27, 2025
e39fb61
fix lint
reeganviljoen Mar 27, 2025
86a1ead
fix coverage
reeganviljoen Mar 27, 2025
4f4ce4a
fix lint
reeganviljoen Mar 27, 2025
e93e8b6
fix lint
reeganviljoen Mar 27, 2025
af9017d
fix missing coverage
reeganviljoen Apr 1, 2025
9bd4c2d
fix head tests
reeganviljoen Apr 1, 2025
8dac0dc
add format and varaiant to cache_digest
reeganviljoen Apr 1, 2025
471eeb1
add format and varaiant to cache_digest
reeganviljoen Apr 1, 2025
bcc6493
fix coverage
reeganviljoen Apr 1, 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
6 changes: 3 additions & 3 deletions docs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,15 +50,15 @@ nav_order: 6

*Reegan Viljoen*

* Add HomeStyler AI to list of companies using ViewComponent.
* Add experimental support for caching.

*JP Balarini*
*Reegan Viljoen*

## 3.21.0

* Updates testing docs to include an example of how to use with RSpec.

*Rylan Bowers*
*Rylanview_cache Bowers*

* Add `--skip-suffix` option to component generator.

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 @@ -113,12 +114,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 @@ -273,6 +272,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 @@ -295,6 +306,13 @@ def __vc_request
@__vc_request ||= controller.request if controller.respond_to?(:request)
end

# Fo 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
44 changes: 44 additions & 0 deletions lib/view_component/cacheable.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
# frozen_string_literal: true

module ViewComponent::Cacheable
extend ActiveSupport::Concern

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

# 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?

__vc_cache_dependencies.filter_map { |dep| send(dep) }.join("-")
end

# Render component from cache if possible
#
# @private
def __vc_render_cacheable(rendered_template)
if __vc_cache_dependencies != [:format, :__vc_format]
Rails.cache.fetch(view_cache_dependencies) do
Copy link
Member

Choose a reason for hiding this comment

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

Is this cache key sufficiently unique? Would this cache the component across changes to the component/sidecar files?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

@joelhawksley I don't really understand your question, the cache doesn't really care about how the component renders its content(i.e call, vs sidecar vs normal template vs inline template), it just cares about if the parameters marked for cache are changed

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

@joelhawksley I have added the variant and format to the cache digest to make sure that it is more unique and that a change to either would generate a unique cache key

__vc_render_template(rendered_template)
end
else
__vc_render_template(rendered_template)
end
end
end

class_methods do
# For caching the component
def cache_on(*args)
__vc_cache_dependencies.push(*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
28 changes: 27 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 @@ -16,7 +16,7 @@ def test_render_inline_allocations
MyComponent.ensure_compiled

allocations = (Rails.version.to_f >= 8.0) ?
{"3.5.0" => 121, "3.4.2" => 125, "3.3.7" => 137} :
{"3.5.0" => 119, "3.4.2" => 125, "3.3.7" => 138} :
{"3.3.7" => 128, "3.3.0" => 140, "3.2.8" => 126, "3.1.7" => 126, "3.0.7" => 135}

assert_allocations(**allocations) do
Expand Down Expand Up @@ -1255,4 +1255,30 @@ def test_render_anonymous_component_without_template
render_inline(mock_component.new)
end
end

def test_cache_component
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
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