-
Notifications
You must be signed in to change notification settings - Fork 452
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
base: main
Are you sure you want to change the base?
Changes from all commits
157ee29
44c1548
832144d
df94ff5
6bea6cd
bf90711
bd69401
1030f85
94cb0b2
7c5e390
b046bad
3bba26f
8cb68a7
821f1f5
f7b520c
46688cc
d772dfc
c1e8122
532246c
16c76ec
0c4c420
6ad979f
ce74df1
5394a30
6fdf078
b2004f6
29cdd6e
56247c7
19f986b
4ed3f44
5e0c01b
0216e4b
395e0fc
7c73d96
0e7247a
a2502aa
7e4b470
b57d125
6610ef2
c3ae775
889d3c2
3a185c4
9ae3207
b50370c
f994d27
ae4e016
d24f12e
fcdfee6
72d3b79
cc6cce5
5aa11cf
efc6cc0
d29c66d
750d1d3
fa8cc40
f847289
b833ea4
c5866af
acadf3f
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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 | ||
|
||
|
@@ -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) | ||
|
||
|
@@ -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* | ||
|
||
|
@@ -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. | ||
|
||
|
@@ -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. | ||
|
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> | ||
``` |
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 |
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 |
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) | ||
child.__vc_cache_dependencies = __vc_cache_dependencies.dup | ||
|
||
super | ||
end | ||
end | ||
end |
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> |
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> |
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 |
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> |
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 |
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. 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. |
There was a problem hiding this comment.
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.There was a problem hiding this comment.
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 ?
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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.