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 70 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
70 commits
Select commit Hold shift + click to select a range
e2af193
add component controlled cache
reeganviljoen Oct 8, 2024
d71dc5f
add changelog
reeganviljoen Oct 8, 2024
e7f7397
fix cacahe implementatation to work with all methods
reeganviljoen Oct 15, 2024
9a21b4c
fix cacahe implementatation to work with all methods
reeganviljoen Oct 15, 2024
6d2462e
fix lint
reeganviljoen Oct 15, 2024
a8073b7
yeah I know it aint working, I am tired however, taking a nother look…
reeganviljoen Oct 16, 2024
7163934
fix cache
reeganviljoen Nov 5, 2024
fe41b05
fix lint
reeganviljoen Nov 5, 2024
a415840
fix
reeganviljoen Nov 5, 2024
f8215a2
modulerize code
reeganviljoen Nov 5, 2024
05091d5
more cleanup
reeganviljoen Nov 5, 2024
3d22c2b
Apply suggestions from code review
reeganviljoen Nov 7, 2024
f1773bd
Update lib/view_component/cacheable.rb
reeganviljoen Nov 7, 2024
ccc755a
fix alphebtization
reeganviljoen Nov 18, 2024
c9622eb
add cache suhggestions
reeganviljoen Nov 19, 2024
d142634
fix legacy ruby specs
reeganviljoen Nov 21, 2024
10ffb42
Apply suggestions from code review
reeganviljoen Mar 23, 2025
2c87f77
code review feedback
reeganviljoen Mar 26, 2025
2aa0b30
make module fully optional;
reeganviljoen Mar 26, 2025
d6a2516
fix specs
reeganviljoen Mar 26, 2025
6094406
fix lint
reeganviljoen Mar 26, 2025
e5de30e
fix coberage
reeganviljoen Mar 26, 2025
8e971d5
add inherited component test
reeganviljoen Mar 26, 2025
f5c2fce
fix tests
reeganviljoen Mar 26, 2025
e3425a5
merge inherited values
reeganviljoen Mar 26, 2025
bc894d6
fix tests
reeganviljoen Mar 26, 2025
b79c3eb
fix lint
reeganviljoen Mar 26, 2025
60cf752
add polish
reeganviljoen Mar 27, 2025
48a222f
add wip docs
reeganviljoen Mar 27, 2025
bf9ea17
fix tests
reeganviljoen Mar 27, 2025
f6f19b7
fix lint
reeganviljoen Mar 27, 2025
87359f9
fix coverage
reeganviljoen Mar 27, 2025
4dcd622
fix lint
reeganviljoen Mar 27, 2025
17389e3
fix lint
reeganviljoen Mar 27, 2025
a6f7710
fix missing coverage
reeganviljoen Apr 1, 2025
13a4417
add format and varaiant to cache_digest
reeganviljoen Apr 1, 2025
d912555
add format and varaiant to cache_digest
reeganviljoen Apr 1, 2025
7413ad5
fix coverage
reeganviljoen Apr 1, 2025
7ed8d28
add retrive ccache key to be consistent with rails
reeganviljoen May 1, 2025
b2807a7
Fix linting
reeganviljoen May 1, 2025
a1d8421
fix changelog stuff
reeganviljoen May 1, 2025
d03928c
refactor cache logic
reeganviljoen May 2, 2025
64636b4
add identifier
reeganviljoen May 2, 2025
7876b14
compuye cache keys
reeganviljoen May 5, 2025
8a21be1
fix lint
reeganviljoen May 5, 2025
540b2d8
refactor
reeganviljoen May 6, 2025
1b2988a
add set
reeganviljoen May 6, 2025
cf313a9
Add cache refistry and alighn cache with how action view does it
reeganviljoen May 6, 2025
03683fe
namespace registry
reeganviljoen May 6, 2025
a0c74eb
add magic comment
reeganviljoen May 6, 2025
8f45ac8
fix failing rails 6 specs
reeganviljoen May 7, 2025
50943a1
Add the start of an actual digestor
reeganviljoen May 7, 2025
da7c685
add template digetor that usses an ast
reeganviljoen Jul 2, 2025
40b5040
refactor digetor a bit
reeganviljoen Jul 2, 2025
d9bb9f1
fix indentation
reeganviljoen Jul 2, 2025
52607fc
refactor
reeganviljoen Jul 3, 2025
c771954
get pr up top date
reeganviljoen Jul 3, 2025
5af2bc7
fix merge issue
reeganviljoen Jul 3, 2025
9d88e3a
try get tests to pass
reeganviljoen Jul 3, 2025
3542beb
try get tests to pass
reeganviljoen Jul 3, 2025
4f58de4
fix rails 8 test
reeganviljoen Jul 3, 2025
b75e0c2
fix soem artifcats
reeganviljoen Jul 3, 2025
1d81e9d
fix test
reeganviljoen Jul 3, 2025
35f68a8
make primer pass
reeganviljoen Jul 3, 2025
1102a50
fix linting
reeganviljoen Jul 3, 2025
b5a6587
fix linting
reeganviljoen Jul 3, 2025
299ff9d
fix alloactor spec
reeganviljoen Jul 3, 2025
9aa9dd0
fix tests
reeganviljoen Jul 3, 2025
f2df735
make primer pass
reeganviljoen Jul 3, 2025
9e93a5a
add inline erb cache component test
reeganviljoen Jul 3, 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
3 changes: 3 additions & 0 deletions Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ PATH
view_component (4.0.0.rc1)
activesupport (>= 7.1.0, < 8.1)
concurrent-ruby (~> 1)
haml (~> 6)
slim (~> 5)
temple (~> 0.10)

GEM
remote: https://rubygems.org/
Expand Down
14 changes: 9 additions & 5 deletions docs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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*

Expand All @@ -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.

Expand Down Expand Up @@ -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.
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>
```
13 changes: 5 additions & 8 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 @@ -50,11 +51,11 @@ def config
include Rails.application.routes.url_helpers if defined?(Rails) && Rails.application
include ERB::Escape
include ActiveSupport::CoreExt::ERBUtil

include ViewComponent::InlineTemplate
include ViewComponent::Slotable
include ViewComponent::Translatable
include ViewComponent::WithContentHelper
include ViewComponent::Cacheable
Copy link
Contributor

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


# For CSRF authenticity tokens in forms
delegate :form_authenticity_token, :protect_against_forgery?, :config, to: :helpers
Expand All @@ -69,10 +70,12 @@ def config
delegate :content_security_policy_nonce, to: :helpers

# Config option that strips trailing whitespace in templates before compiling them.
class_attribute :__vc_strip_trailing_whitespace, instance_accessor: false, instance_predicate: false, default: false

class_attribute :__vc_response_format, instance_accessor: false, instance_predicate: false, default: nil

class_attribute :__vc_strip_trailing_whitespace, instance_accessor: false, instance_predicate: false
self.__vc_strip_trailing_whitespace = false # class_attribute:default doesn't work until Rails 5.2

attr_accessor :__vc_original_view_context
attr_reader :current_template

Expand Down Expand Up @@ -312,12 +315,6 @@ def virtual_path
self.class.virtual_path
end

# For caching, such as #cache_if
# @private
def view_cache_dependencies
[]
end

# The current request. Use sparingly as doing so introduces coupling that
# inhibits encapsulation & reuse, often making testing difficult.
#
Expand Down
21 changes: 21 additions & 0 deletions lib/view_component/cache_digestor.rb
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
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
86 changes: 86 additions & 0 deletions lib/view_component/cacheable.rb
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)
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_options = __vc_cache_options.dup

super
end
end
end
12 changes: 10 additions & 2 deletions lib/view_component/compiler.rb
Original file line number Diff line number Diff line change
Expand Up @@ -97,13 +97,21 @@ def define_render_template_for
safe_call = template.safe_method_name_call
@component.define_method(:render_template_for) do |_|
@current_template = template
instance_exec(&safe_call)
if @component.respond_to?(:__vc_render_cacheable)
@component.__vc_render_cacheable(safe_call)
else
instance_exec(&safe_call)
end
end
else
compiler = self
@component.define_method(:render_template_for) do |details|
if (@current_template = compiler.find_templates_for(details).first)
instance_exec(&@current_template.safe_method_name_call)
if @component.respond_to?(:__vc_render_cacheable)
@component.__vc_render_cacheable(@current_template.safe_method_name_call)
else
instance_exec(&@current_template.safe_method_name_call)
end
else
raise MissingTemplateError.new(self.class.name, details)
end
Expand Down
69 changes: 69 additions & 0 deletions lib/view_component/prism_render_dependency_extractor.rb
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
Loading
Loading