-
Notifications
You must be signed in to change notification settings - Fork 461
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
e2af193
d71dc5f
e7f7397
9a21b4c
6d2462e
a8073b7
7163934
fe41b05
a415840
f8215a2
05091d5
3d22c2b
f1773bd
ccc755a
c9622eb
d142634
10ffb42
2c87f77
2aa0b30
d6a2516
6094406
e5de30e
8e971d5
f5c2fce
e3425a5
bc894d6
b79c3eb
60cf752
48a222f
bf9ea17
f6f19b7
87359f9
4dcd622
17389e3
a6f7710
13a4417
d912555
7413ad5
7ed8d28
b2807a7
a1d8421
d03928c
64636b4
7876b14
8a21be1
540b2d8
1b2988a
cf313a9
03683fe
a0c74eb
8f45ac8
50943a1
da7c685
40b5040
d9bb9f1
52607fc
c771954
5af2bc7
9d88e3a
3542beb
4f58de4
b75e0c2
1d81e9d
35f68a8
1102a50
b5a6587
299ff9d
9aa9dd0
f2df735
9e93a5a
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 |
---|---|---|
|
@@ -14,6 +14,10 @@ nav_order: 6 | |
|
||
*Joel Hawksley*, *Blake Williams* | ||
|
||
* Add experimental support for caching. | ||
|
||
*Reegan Viljoen* | ||
|
||
## 4.0.0.rc1 | ||
|
||
Almost six years after releasing [v1.0.0](https://github.com/ViewComponent/view_component/releases/tag/v1.0.0), we're proud to ship the first release candidate of ViewComponent 4. This release marks a shift towards a Long Term Support model for the project, having reached significant feature maturity. While contributions are always welcome, we're unlikely to accept further breaking changes or major feature additions. | ||
|
@@ -317,7 +321,7 @@ This release makes the following breaking changes: | |
|
||
## 3.23.0 | ||
|
||
* 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 | ||
|
||
|
@@ -1773,7 +1777,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) | ||
|
||
|
@@ -2537,7 +2541,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* | ||
|
||
|
@@ -2555,7 +2559,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. | ||
|
||
|
@@ -3003,7 +3007,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,21 @@ | ||
# # frozen_string_literal: true | ||
|
||
require "view_component/template_dependency_extractor" | ||
|
||
module ViewComponent | ||
class CacheDigestor | ||
def initialize(component:) | ||
@component = component | ||
end | ||
|
||
def digest | ||
template = @component.current_template | ||
if template.nil? && template == :inline_call | ||
[] | ||
else | ||
template_string = template.source | ||
ViewComponent::TemplateDependencyExtractor.new(template_string, template.details.handler).extract | ||
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,86 @@ | ||
# frozen_string_literal: true | ||
|
||
require "view_component/cache_registry" | ||
require "view_component/cache_digestor" | ||
|
||
module ViewComponent::Cacheable | ||
extend ActiveSupport::Concern | ||
|
||
included do | ||
class_attribute :__vc_cache_options, default: Set[:identifier] | ||
class_attribute :__vc_cache_dependencies, default: Set.new | ||
|
||
# For caching, such as #cache_if | ||
# | ||
# @private | ||
def view_cache_dependencies | ||
self.class.__vc_cache_dependencies.map { |dep| public_send(dep) } | ||
end | ||
|
||
def view_cache_options | ||
return if __vc_cache_options.blank? | ||
|
||
computed_view_cache_options = __vc_cache_options.map { |opt| if respond_to?(opt) then public_send(opt) end } | ||
combined_fragment_cache_key(ActiveSupport::Cache.expand_cache_key(computed_view_cache_options + component_digest)) | ||
end | ||
|
||
# Render component from cache if possible | ||
# | ||
# @private | ||
def __vc_render_cacheable(safe_call) | ||
if (__vc_cache_options - [:identifier]).any? | ||
ViewComponent::CachingRegistry.track_caching do | ||
template_fragment(safe_call) | ||
end | ||
else | ||
instance_exec(&safe_call) | ||
end | ||
end | ||
|
||
def template_fragment | ||
if (content = read_fragment) | ||
@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 | ||
end | ||
end | ||
|
||
def read_fragment | ||
Rails.cache.fetch(view_cache_options) | ||
end | ||
|
||
def write_fragment | ||
content = instance_exec(&safe_call) | ||
Rails.cache.fetch(view_cache_options) 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 | ||
ViewComponent::CacheDigestor.new(component: self).digest | ||
end | ||
end | ||
|
||
class_methods do | ||
# For caching the component | ||
def cache_on(*args) | ||
__vc_cache_options.merge(args) | ||
end | ||
|
||
def inherited(child) | ||
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. 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 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. @JWShuff like touch: true in rails ? 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. 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 commentThe 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_options = __vc_cache_options.dup | ||
|
||
super | ||
end | ||
end | ||
end |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,69 @@ | ||
# frozen_string_literal: true | ||
|
||
require "prism" | ||
|
||
module ViewComponent | ||
class PrismRenderDependencyExtractor | ||
def initialize(code) | ||
@code = code | ||
@dependencies = [] | ||
end | ||
|
||
def extract | ||
result = Prism.parse(@code) | ||
walk(result.value) | ||
@dependencies | ||
end | ||
|
||
private | ||
|
||
def walk(node) | ||
return unless node.respond_to?(:child_nodes) | ||
|
||
if node.is_a?(Prism::CallNode) && render_call?(node) | ||
extract_render_target(node) | ||
end | ||
|
||
node.child_nodes.each { |child| walk(child) if child } | ||
end | ||
|
||
def render_call?(node) | ||
node.receiver.nil? && node.name == :render | ||
end | ||
|
||
def extract_render_target(node) | ||
args = node.arguments&.arguments | ||
return unless args && !args.empty? | ||
|
||
first_arg = args.first | ||
|
||
if first_arg.is_a?(Prism::CallNode) && | ||
first_arg.name == :new && | ||
first_arg.receiver.is_a?(Prism::ConstantPathNode) || first_arg.receiver.is_a?(Prism::ConstantReadNode) | ||
|
||
const = extract_constant_path(first_arg.receiver) | ||
@dependencies << const if const | ||
end | ||
end | ||
|
||
def extract_constant_path(const_node) | ||
parts = [] | ||
current = const_node | ||
|
||
while current | ||
case current | ||
when Prism::ConstantPathNode | ||
parts.unshift(current.child.name) | ||
current = current.parent | ||
when Prism::ConstantReadNode | ||
parts.unshift(current.name) | ||
break | ||
else | ||
break | ||
end | ||
end | ||
|
||
parts.join("::") | ||
end | ||
end | ||
end |
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.
Suggestion, nitpick and nonblocking: Can we slide this in alphabetically above? I'd think it'd be ~ line 32