From e2af193fa8c7c66b3fcedeb82dd4b8426ae43856 Mon Sep 17 00:00:00 2001 From: Reegan Viljoen Date: Tue, 8 Oct 2024 21:53:34 +0200 Subject: [PATCH 001/122] add component controlled cache --- .tool-versions | 2 +- lib/view_component/base.rb | 5 ++++ lib/view_component/cache_on.rb | 27 +++++++++++++++++++ .../app/components/cache_component.html.erb | 2 ++ .../sandbox/app/components/cache_component.rb | 12 +++++++++ test/sandbox/test/rendering_test.rb | 17 ++++++++++++ 6 files changed, 64 insertions(+), 1 deletion(-) create mode 100644 lib/view_component/cache_on.rb create mode 100644 test/sandbox/app/components/cache_component.html.erb create mode 100644 test/sandbox/app/components/cache_component.rb diff --git a/.tool-versions b/.tool-versions index a72ead61f..ae5ecdb2b 100644 --- a/.tool-versions +++ b/.tool-versions @@ -1 +1 @@ -ruby 3.4.3 +ruby 3.4.2 diff --git a/lib/view_component/base.rb b/lib/view_component/base.rb index 51bc9619f..402ef670c 100644 --- a/lib/view_component/base.rb +++ b/lib/view_component/base.rb @@ -14,6 +14,7 @@ require "view_component/template" require "view_component/translatable" require "view_component/with_content_helper" +require "view_component/cache_on" module ActionView class OutputBuffer @@ -55,6 +56,10 @@ def config include ViewComponent::Slotable include ViewComponent::Translatable include ViewComponent::WithContentHelper + include ViewComponent::CacheOn + + RESERVED_PARAMETER = :content + VC_INTERNAL_DEFAULT_FORMAT = :html # For CSRF authenticity tokens in forms delegate :form_authenticity_token, :protect_against_forgery?, :config, to: :helpers diff --git a/lib/view_component/cache_on.rb b/lib/view_component/cache_on.rb new file mode 100644 index 000000000..938bc9355 --- /dev/null +++ b/lib/view_component/cache_on.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +module ViewComponent::CacheOn + extend ActiveSupport::Concern + + included do + def cache_key + @vc_cache_args = vc_cache_args.map { |method| send(method) } if defined?(vc_cache_args) + + @vc_cache_key = Digest::MD5.hexdigest(@vc_cache_args.join) + end + end + + class_methods do + def cache_on(*args) + define_method(:vc_cache_args) { args } + end + + def call + if cache_key + Rails.cache.fetch(cache_key) { super } + else + super + end + end + end +end diff --git a/test/sandbox/app/components/cache_component.html.erb b/test/sandbox/app/components/cache_component.html.erb new file mode 100644 index 000000000..c58061f24 --- /dev/null +++ b/test/sandbox/app/components/cache_component.html.erb @@ -0,0 +1,2 @@ +

<%= cache_key %>

+

<%= "#{foo} #{bar}" %>

diff --git a/test/sandbox/app/components/cache_component.rb b/test/sandbox/app/components/cache_component.rb new file mode 100644 index 000000000..77fd73587 --- /dev/null +++ b/test/sandbox/app/components/cache_component.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +class CacheComponent < ViewComponent::Base + cache_on :foo, :bar + + attr_reader :foo, :bar + + def initialize(foo:, bar:) + @foo = foo + @bar = bar + end +end diff --git a/test/sandbox/test/rendering_test.rb b/test/sandbox/test/rendering_test.rb index fb456f290..3864dc2f0 100644 --- a/test/sandbox/test/rendering_test.rb +++ b/test/sandbox/test/rendering_test.rb @@ -1321,4 +1321,21 @@ def test_around_render assert_text("Hi!") end + + def test_cache_component + component = CacheComponent.new(foo: "foo", bar: "bar") + render_inline(component) + + assert_selector(".cache-component__cache-key", text: component.cache_key) + 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.cache_key) + + render_inline(CacheComponent.new(foo: "foo", bar: "baz")) + + refute_selector(".cache-component__cache-key", text: component.cache_key) + refute_selector(".cache-component__cache-message", text: "foo bar") + end end From d71dc5fb48687b17bdddbb4a55a7c994d0fd97ac Mon Sep 17 00:00:00 2001 From: Reegan Viljoen Date: Tue, 8 Oct 2024 22:02:54 +0200 Subject: [PATCH 002/122] add changelog --- docs/CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 3e7193c7e..42181a3a5 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -457,6 +457,10 @@ This release makes the following breaking changes: *Javier Aranda* +* Add first class component cache. + + *Reegan Viljoen* + ## 3.17.0 * Use struct instead openstruct in lib code. From e7f73970da2c08dc5262cb9e966c90b0c4ca1dc2 Mon Sep 17 00:00:00 2001 From: Reegan Viljoen Date: Tue, 15 Oct 2024 21:27:10 +0200 Subject: [PATCH 003/122] fix cacahe implementatation to work with all methods --- lib/view_component/base.rb | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/lib/view_component/base.rb b/lib/view_component/base.rb index 402ef670c..74afeb353 100644 --- a/lib/view_component/base.rb +++ b/lib/view_component/base.rb @@ -81,6 +81,19 @@ def config attr_accessor :__vc_original_view_context attr_reader :current_template + # TODO + # + # @return [String] + def cache_key + @vc_cache_key = if defined?(__vc_cache_args) + Digest::MD5.hexdigest( + __vc_cache_args.map { |method| send(method) }.join("-") + ) + else + nil + end + end + # Components render in their own view context. Helpers and other functionality # require a reference to the original Rails view context, an instance of # `ActionView::Base`. Use this method to set a reference to the original @@ -371,6 +384,16 @@ def __vc_render_in_block_provided? defined?(@view_context) && @view_context && @__vc_render_in_block end + # TODO + 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 + def __vc_content_set_by_with_content_defined? defined?(@__vc_content_set_by_with_content) end @@ -523,6 +546,14 @@ def sidecar_files(extensions) (sidecar_files - [identifier] + sidecar_directory_files + nested_component_files).uniq end + def cache_on(*args) + class_eval <<~RUBY, __FILE__, __LINE__ + 1 + def __vc_cache_args + #{args} + end + RUBY + end + # Render a component for each element in a collection ([documentation](/guide/collections)): # # ```ruby From 9a21b4cc52349b613c268f834a3693c07f258a22 Mon Sep 17 00:00:00 2001 From: Reegan Viljoen Date: Tue, 15 Oct 2024 21:28:07 +0200 Subject: [PATCH 004/122] fix cacahe implementatation to work with all methods --- lib/view_component/base.rb | 5 +---- lib/view_component/cache_on.rb | 27 --------------------------- 2 files changed, 1 insertion(+), 31 deletions(-) delete mode 100644 lib/view_component/cache_on.rb diff --git a/lib/view_component/base.rb b/lib/view_component/base.rb index 74afeb353..0770e3100 100644 --- a/lib/view_component/base.rb +++ b/lib/view_component/base.rb @@ -14,7 +14,6 @@ require "view_component/template" require "view_component/translatable" require "view_component/with_content_helper" -require "view_component/cache_on" module ActionView class OutputBuffer @@ -56,7 +55,6 @@ def config include ViewComponent::Slotable include ViewComponent::Translatable include ViewComponent::WithContentHelper - include ViewComponent::CacheOn RESERVED_PARAMETER = :content VC_INTERNAL_DEFAULT_FORMAT = :html @@ -81,7 +79,7 @@ def config attr_accessor :__vc_original_view_context attr_reader :current_template - # TODO + # Compoents can have a cache key that is used to cache the rendered output. # # @return [String] def cache_key @@ -384,7 +382,6 @@ def __vc_render_in_block_provided? defined?(@view_context) && @view_context && @__vc_render_in_block end - # TODO 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? diff --git a/lib/view_component/cache_on.rb b/lib/view_component/cache_on.rb deleted file mode 100644 index 938bc9355..000000000 --- a/lib/view_component/cache_on.rb +++ /dev/null @@ -1,27 +0,0 @@ -# frozen_string_literal: true - -module ViewComponent::CacheOn - extend ActiveSupport::Concern - - included do - def cache_key - @vc_cache_args = vc_cache_args.map { |method| send(method) } if defined?(vc_cache_args) - - @vc_cache_key = Digest::MD5.hexdigest(@vc_cache_args.join) - end - end - - class_methods do - def cache_on(*args) - define_method(:vc_cache_args) { args } - end - - def call - if cache_key - Rails.cache.fetch(cache_key) { super } - else - super - end - end - end -end From 6d2462ea0d3206f303b722a89fb34b921d06f346 Mon Sep 17 00:00:00 2001 From: Reegan Viljoen Date: Tue, 15 Oct 2024 21:30:44 +0200 Subject: [PATCH 005/122] fix lint --- lib/view_component/base.rb | 2 -- 1 file changed, 2 deletions(-) diff --git a/lib/view_component/base.rb b/lib/view_component/base.rb index 0770e3100..1d4a8acc1 100644 --- a/lib/view_component/base.rb +++ b/lib/view_component/base.rb @@ -87,8 +87,6 @@ def cache_key Digest::MD5.hexdigest( __vc_cache_args.map { |method| send(method) }.join("-") ) - else - nil end end From a8073b7568dae2edba558b8bdca028042d4822fa Mon Sep 17 00:00:00 2001 From: Reegan Viljoen Date: Wed, 16 Oct 2024 22:51:13 +0200 Subject: [PATCH 006/122] yeah I know it aint working, I am tired however, taking a nother look tomorow --- lib/view_component/base.rb | 38 ++++++++++--------- .../app/components/cache_component.html.erb | 2 +- test/sandbox/test/rendering_test.rb | 6 +-- 3 files changed, 24 insertions(+), 22 deletions(-) diff --git a/lib/view_component/base.rb b/lib/view_component/base.rb index 1d4a8acc1..865964eee 100644 --- a/lib/view_component/base.rb +++ b/lib/view_component/base.rb @@ -72,24 +72,15 @@ 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_cache_dependencies, instance_accessor: false, instance_predicate: false, default: [] + 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 - # Compoents can have a cache key that is used to cache the rendered output. - # - # @return [String] - def cache_key - @vc_cache_key = if defined?(__vc_cache_args) - Digest::MD5.hexdigest( - __vc_cache_args.map { |method| send(method) }.join("-") - ) - end - end - # Components render in their own view context. Helpers and other functionality # require a reference to the original Rails view context, an instance of # `ActionView::Base`. Use this method to set a reference to the original @@ -329,7 +320,16 @@ def virtual_path # For caching, such as #cache_if # @private def view_cache_dependencies - [] + self.class.view_cache_dependencies + end + + alias_method :component_cache_dependencies, :view_cache_dependencies + + # For caching, such as #cache_if + # + # @private + def format + @__vc_variant if defined?(@__vc_variant) end # The current request. Use sparingly as doing so introduces coupling that @@ -542,11 +542,13 @@ def sidecar_files(extensions) end def cache_on(*args) - class_eval <<~RUBY, __FILE__, __LINE__ + 1 - def __vc_cache_args - #{args} - end - RUBY + __vc_cache_dependencies.push(*args) + end + + def view_cache_dependencies + return unless __vc_cache_dependencies.any? + + __vc_cache_dependencies.filter_map { |dep| send(dep) } end # Render a component for each element in a collection ([documentation](/guide/collections)): diff --git a/test/sandbox/app/components/cache_component.html.erb b/test/sandbox/app/components/cache_component.html.erb index c58061f24..802208680 100644 --- a/test/sandbox/app/components/cache_component.html.erb +++ b/test/sandbox/app/components/cache_component.html.erb @@ -1,2 +1,2 @@ -

<%= cache_key %>

+

<%= view_cache_dependencies %>

<%= "#{foo} #{bar}" %>

diff --git a/test/sandbox/test/rendering_test.rb b/test/sandbox/test/rendering_test.rb index 3864dc2f0..8925a965a 100644 --- a/test/sandbox/test/rendering_test.rb +++ b/test/sandbox/test/rendering_test.rb @@ -1326,16 +1326,16 @@ def test_cache_component component = CacheComponent.new(foo: "foo", bar: "bar") render_inline(component) - assert_selector(".cache-component__cache-key", text: component.cache_key) + assert_selector(".cache-component__cache-key", text: component.component_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.cache_key) + assert_selector(".cache-component__cache-key", text: component.component_cache_dependencies) render_inline(CacheComponent.new(foo: "foo", bar: "baz")) - refute_selector(".cache-component__cache-key", text: component.cache_key) + refute_selector(".cache-component__cache-key", text: component.component_cache_dependencies) refute_selector(".cache-component__cache-message", text: "foo bar") end end From 7163934a932cbe1285534f6f9677173f9d379b76 Mon Sep 17 00:00:00 2001 From: Reegan Viljoen Date: Tue, 5 Nov 2024 10:46:27 +0200 Subject: [PATCH 007/122] fix cache --- lib/view_component/base.rb | 8 ++++++++ test/sandbox/test/rendering_test.rb | 6 +++--- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/lib/view_component/base.rb b/lib/view_component/base.rb index 865964eee..5144bf736 100644 --- a/lib/view_component/base.rb +++ b/lib/view_component/base.rb @@ -346,6 +346,12 @@ def __vc_request @__vc_request ||= controller.request if controller.respond_to?(:request) end + def view_cache_dependencies + return unless __vc_cache_dependencies.present? && __vc_cache_dependencies.any? + + __vc_cache_dependencies.filter_map { |dep| send(dep) } + end + # The content passed to the component instance as a block. # # @return [String] @@ -609,6 +615,8 @@ def render_template_for(requested_details) child.instance_variable_set(:@__vc_ancestor_calls, vc_ancestor_calls) end + child.__vc_cache_dependencies = __vc_cache_dependencies.dup + super end diff --git a/test/sandbox/test/rendering_test.rb b/test/sandbox/test/rendering_test.rb index 8925a965a..4dd021503 100644 --- a/test/sandbox/test/rendering_test.rb +++ b/test/sandbox/test/rendering_test.rb @@ -1326,16 +1326,16 @@ def test_cache_component component = CacheComponent.new(foo: "foo", bar: "bar") render_inline(component) - assert_selector(".cache-component__cache-key", text: component.component_cache_dependencies) + 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.component_cache_dependencies) + assert_selector(".cache-component__cache-key", text: component.view_cache_dependencies) render_inline(CacheComponent.new(foo: "foo", bar: "baz")) - refute_selector(".cache-component__cache-key", text: component.component_cache_dependencies) + refute_selector(".cache-component__cache-key", text: component.view_cache_dependencies) refute_selector(".cache-component__cache-message", text: "foo bar") end end From fe41b05ea2ebab8309f8fef015fe495ba928cc88 Mon Sep 17 00:00:00 2001 From: Reegan Viljoen Date: Tue, 5 Nov 2024 10:53:10 +0200 Subject: [PATCH 008/122] fix lint --- lib/view_component/base.rb | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/lib/view_component/base.rb b/lib/view_component/base.rb index 5144bf736..2bc89d052 100644 --- a/lib/view_component/base.rb +++ b/lib/view_component/base.rb @@ -317,14 +317,6 @@ def virtual_path self.class.virtual_path end - # For caching, such as #cache_if - # @private - def view_cache_dependencies - self.class.view_cache_dependencies - end - - alias_method :component_cache_dependencies, :view_cache_dependencies - # For caching, such as #cache_if # # @private @@ -346,6 +338,9 @@ def __vc_request @__vc_request ||= controller.request if controller.respond_to?(:request) end + # For caching, such as #cache_if + # + # @private def view_cache_dependencies return unless __vc_cache_dependencies.present? && __vc_cache_dependencies.any? From a4158403771d869b76281f13fe7e3c34f6bdaced Mon Sep 17 00:00:00 2001 From: Reegan Viljoen Date: Tue, 5 Nov 2024 10:58:05 +0200 Subject: [PATCH 009/122] fix --- lib/view_component/base.rb | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/lib/view_component/base.rb b/lib/view_component/base.rb index 2bc89d052..37872bf5c 100644 --- a/lib/view_component/base.rb +++ b/lib/view_component/base.rb @@ -317,6 +317,15 @@ def virtual_path self.class.virtual_path end + # For caching, such as #cache_if + # + # @private + def view_cache_dependencies + return unless __vc_cache_dependencies.present? && __vc_cache_dependencies.any? + + __vc_cache_dependencies.filter_map { |dep| send(dep) } + end + # For caching, such as #cache_if # # @private From f8215a23887a00e48fd9c8dc9c3dd307df0f39bf Mon Sep 17 00:00:00 2001 From: Reegan Viljoen Date: Tue, 5 Nov 2024 22:38:02 +0200 Subject: [PATCH 010/122] modulerize code --- lib/view_component/base.rb | 13 ++------ lib/view_component/cacheable.rb | 57 +++++++++++++++++++++++++++++++++ 2 files changed, 59 insertions(+), 11 deletions(-) create mode 100644 lib/view_component/cacheable.rb diff --git a/lib/view_component/base.rb b/lib/view_component/base.rb index 37872bf5c..f510dc903 100644 --- a/lib/view_component/base.rb +++ b/lib/view_component/base.rb @@ -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" @@ -55,6 +56,7 @@ def config include ViewComponent::Slotable include ViewComponent::Translatable include ViewComponent::WithContentHelper + include ViewComponent::Cacheable RESERVED_PARAMETER = :content VC_INTERNAL_DEFAULT_FORMAT = :html @@ -390,15 +392,6 @@ def __vc_render_in_block_provided? defined?(@view_context) && @view_context && @__vc_render_in_block end - 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 - def __vc_content_set_by_with_content_defined? defined?(@__vc_content_set_by_with_content) end @@ -619,8 +612,6 @@ def render_template_for(requested_details) child.instance_variable_set(:@__vc_ancestor_calls, vc_ancestor_calls) end - child.__vc_cache_dependencies = __vc_cache_dependencies.dup - super end diff --git a/lib/view_component/cacheable.rb b/lib/view_component/cacheable.rb new file mode 100644 index 000000000..075a7eaed --- /dev/null +++ b/lib/view_component/cacheable.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +module ViewComponent::Cacheable + extend ActiveSupport::Concern + + included do + class_attribute :__vc_cache_dependencies, default: [] + + # For caching, such as #cache_if + # + # @private + def view_cache_dependencies + return unless __vc_cache_dependencies.present? && __vc_cache_dependencies.any? + + __vc_cache_dependencies.map { |dep| send(dep) }.compact + end + + # For handeling 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 determing if a template is rendered with cache or not + # + # @private + def __vc_render_cacheable(rendered_template) + if view_cache_dependencies.present? + Rails.cache.fetch(view_cache_dependencies) do + __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) + child.__vc_cache_dependencies = __vc_cache_dependencies.dup + + super + end + end +end From 05091d5e81450b7768b400e075e3cfaaa99e5658 Mon Sep 17 00:00:00 2001 From: Reegan Viljoen Date: Tue, 5 Nov 2024 22:43:19 +0200 Subject: [PATCH 011/122] more cleanup --- docs/CHANGELOG.md | 8 ++++---- lib/view_component/cacheable.rb | 1 - 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 42181a3a5..6ab7fc409 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -385,6 +385,10 @@ This release makes the following breaking changes: *JP Balarini* +* Add first class component cache. + + *Reegan Viljoen* + ## 3.21.0 * Updates testing docs to include an example of how to use with RSpec. @@ -457,10 +461,6 @@ This release makes the following breaking changes: *Javier Aranda* -* Add first class component cache. - - *Reegan Viljoen* - ## 3.17.0 * Use struct instead openstruct in lib code. diff --git a/lib/view_component/cacheable.rb b/lib/view_component/cacheable.rb index 075a7eaed..2328768aa 100644 --- a/lib/view_component/cacheable.rb +++ b/lib/view_component/cacheable.rb @@ -42,7 +42,6 @@ def __vc_render_cacheable(rendered_template) end class_methods do - # For caching the component def cache_on(*args) __vc_cache_dependencies.push(*args) From 3d22c2bf4848692efe500b09e194a62ef221c7b9 Mon Sep 17 00:00:00 2001 From: Reegan Viljoen <62689748+reeganviljoen@users.noreply.github.com> Date: Thu, 7 Nov 2024 07:46:39 +0200 Subject: [PATCH 012/122] Apply suggestions from code review Co-authored-by: Emil Kampp <40206+ekampp@users.noreply.github.com> --- lib/view_component/cacheable.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/view_component/cacheable.rb b/lib/view_component/cacheable.rb index 2328768aa..10581df02 100644 --- a/lib/view_component/cacheable.rb +++ b/lib/view_component/cacheable.rb @@ -12,7 +12,7 @@ module ViewComponent::Cacheable def view_cache_dependencies return unless __vc_cache_dependencies.present? && __vc_cache_dependencies.any? - __vc_cache_dependencies.map { |dep| send(dep) }.compact + __vc_cache_dependencies.filter_map { |dep| send(dep) } end # For handeling the output_preamble and output_postamble From f1773bd4131944824065b585c69ad661de5938c2 Mon Sep 17 00:00:00 2001 From: Reegan Viljoen <62689748+reeganviljoen@users.noreply.github.com> Date: Thu, 7 Nov 2024 07:46:49 +0200 Subject: [PATCH 013/122] Update lib/view_component/cacheable.rb Co-authored-by: Emil Kampp <40206+ekampp@users.noreply.github.com> --- lib/view_component/cacheable.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/view_component/cacheable.rb b/lib/view_component/cacheable.rb index 10581df02..09a5db885 100644 --- a/lib/view_component/cacheable.rb +++ b/lib/view_component/cacheable.rb @@ -10,7 +10,7 @@ module ViewComponent::Cacheable # # @private def view_cache_dependencies - return unless __vc_cache_dependencies.present? && __vc_cache_dependencies.any? + return if __vc_cache_dependencies.blank? || __vc_cache_dependencies.none? __vc_cache_dependencies.filter_map { |dep| send(dep) } end From ccc755abfc64243b54b018ac0c7520152954f4e6 Mon Sep 17 00:00:00 2001 From: reeganviljoen Date: Mon, 18 Nov 2024 23:05:56 +0200 Subject: [PATCH 014/122] fix alphebtization --- lib/view_component/base.rb | 1 - 1 file changed, 1 deletion(-) diff --git a/lib/view_component/base.rb b/lib/view_component/base.rb index f510dc903..b38ffd38e 100644 --- a/lib/view_component/base.rb +++ b/lib/view_component/base.rb @@ -51,7 +51,6 @@ 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 From c9622eb1eaf67edc23efd74331828b782aa33fdc Mon Sep 17 00:00:00 2001 From: reeganviljoen Date: Tue, 19 Nov 2024 22:17:06 +0200 Subject: [PATCH 015/122] add cache suhggestions --- test/sandbox/app/components/cache_component.html.erb | 2 +- .../app/controllers/integration_examples_controller.rb | 6 ++++++ test/sandbox/config/routes.rb | 1 + 3 files changed, 8 insertions(+), 1 deletion(-) diff --git a/test/sandbox/app/components/cache_component.html.erb b/test/sandbox/app/components/cache_component.html.erb index 802208680..9d20954cb 100644 --- a/test/sandbox/app/components/cache_component.html.erb +++ b/test/sandbox/app/components/cache_component.html.erb @@ -1,2 +1,2 @@

<%= view_cache_dependencies %>

-

<%= "#{foo} #{bar}" %>

+

"><%= "#{foo} #{bar}" %>

diff --git a/test/sandbox/app/controllers/integration_examples_controller.rb b/test/sandbox/app/controllers/integration_examples_controller.rb index 686328846..81b545fd2 100644 --- a/test/sandbox/app/controllers/integration_examples_controller.rb +++ b/test/sandbox/app/controllers/integration_examples_controller.rb @@ -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:, bar:)) + end + def controller_inline_with_block render(ControllerInlineWithBlockComponent.new(message: "bar").tap do |c| c.with_slot(name: "baz") diff --git a/test/sandbox/config/routes.rb b/test/sandbox/config/routes.rb index 284f15de5..256d5b99f 100644 --- a/test/sandbox/config/routes.rb +++ b/test/sandbox/config/routes.rb @@ -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" From d14263438727e84619dff77e4127b9b3722345d6 Mon Sep 17 00:00:00 2001 From: reeganviljoen Date: Thu, 21 Nov 2024 22:29:21 +0200 Subject: [PATCH 016/122] fix legacy ruby specs --- test/sandbox/app/controllers/integration_examples_controller.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/sandbox/app/controllers/integration_examples_controller.rb b/test/sandbox/app/controllers/integration_examples_controller.rb index 81b545fd2..1aefaed49 100644 --- a/test/sandbox/app/controllers/integration_examples_controller.rb +++ b/test/sandbox/app/controllers/integration_examples_controller.rb @@ -14,7 +14,7 @@ def controller_inline def controller_inline_cached foo = params[:foo] || "foo" bar = params[:bar] || "bar" - render(CacheComponent.new(foo:, bar:)) + render(CacheComponent.new(foo: foo, bar: bar)) end def controller_inline_with_block From 10ffb42768c4bddd3c1520636ae4d5873abd9e76 Mon Sep 17 00:00:00 2001 From: Reegan Viljoen <62689748+reeganviljoen@users.noreply.github.com> Date: Sun, 23 Mar 2025 17:24:00 +0200 Subject: [PATCH 017/122] Apply suggestions from code review Co-authored-by: Joel Hawksley --- docs/CHANGELOG.md | 6 +----- lib/view_component/cacheable.rb | 4 ++-- 2 files changed, 3 insertions(+), 7 deletions(-) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 6ab7fc409..5b7a11dde 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -381,11 +381,7 @@ This release makes the following breaking changes: *Reegan Viljoen* -* Add HomeStyler AI to list of companies using ViewComponent. - - *JP Balarini* - -* Add first class component cache. +* Add experimental support for caching. *Reegan Viljoen* diff --git a/lib/view_component/cacheable.rb b/lib/view_component/cacheable.rb index 09a5db885..14691ff34 100644 --- a/lib/view_component/cacheable.rb +++ b/lib/view_component/cacheable.rb @@ -15,7 +15,7 @@ def view_cache_dependencies __vc_cache_dependencies.filter_map { |dep| send(dep) } end - # For handeling the output_preamble and output_postamble + # For handling the output_preamble and output_postamble # # @private def __vc_render_template(rendered_template) @@ -27,7 +27,7 @@ def __vc_render_template(rendered_template) end end - # For determing if a template is rendered with cache or not + # Render component from cache if possible # # @private def __vc_render_cacheable(rendered_template) From 2c87f77e8c4248c9ff02398a7b0e27933566174f Mon Sep 17 00:00:00 2001 From: reeganviljoen Date: Wed, 26 Mar 2025 20:53:59 +0200 Subject: [PATCH 018/122] code review feedback --- test/sandbox/app/components/cache_component.html.erb | 1 + test/sandbox/app/components/cache_component.rb | 2 ++ test/sandbox/test/rendering_test.rb | 7 ++++--- 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/test/sandbox/app/components/cache_component.html.erb b/test/sandbox/app/components/cache_component.html.erb index 9d20954cb..c0c5856a8 100644 --- a/test/sandbox/app/components/cache_component.html.erb +++ b/test/sandbox/app/components/cache_component.html.erb @@ -1,2 +1,3 @@

<%= view_cache_dependencies %>

+<%# <% binding.irb %>

"><%= "#{foo} #{bar}" %>

diff --git a/test/sandbox/app/components/cache_component.rb b/test/sandbox/app/components/cache_component.rb index 77fd73587..0236f0b47 100644 --- a/test/sandbox/app/components/cache_component.rb +++ b/test/sandbox/app/components/cache_component.rb @@ -1,6 +1,8 @@ # frozen_string_literal: true class CacheComponent < ViewComponent::Base + include ViewComponent::Cacheable + cache_on :foo, :bar attr_reader :foo, :bar diff --git a/test/sandbox/test/rendering_test.rb b/test/sandbox/test/rendering_test.rb index 4dd021503..d19f39404 100644 --- a/test/sandbox/test/rendering_test.rb +++ b/test/sandbox/test/rendering_test.rb @@ -1333,9 +1333,10 @@ def test_cache_component assert_selector(".cache-component__cache-key", text: component.view_cache_dependencies) - render_inline(CacheComponent.new(foo: "foo", bar: "baz")) + new_component = CacheComponent.new(foo: "foo", bar: "baz") + render_inline(new_component) - refute_selector(".cache-component__cache-key", text: component.view_cache_dependencies) - refute_selector(".cache-component__cache-message", text: "foo bar") + assert_selector(".cache-component__cache-key", text: new_component.view_cache_dependencies) + assert_selector(".cache-component__cache-message", text: "foo baz") end end From 2aa0b30e10bde0924a1ea6779e9fd31c5230adb3 Mon Sep 17 00:00:00 2001 From: reeganviljoen Date: Wed, 26 Mar 2025 21:05:12 +0200 Subject: [PATCH 019/122] make module fully optional; --- lib/view_component/cacheable.rb | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/lib/view_component/cacheable.rb b/lib/view_component/cacheable.rb index 14691ff34..a473b895e 100644 --- a/lib/view_component/cacheable.rb +++ b/lib/view_component/cacheable.rb @@ -15,17 +15,6 @@ def view_cache_dependencies __vc_cache_dependencies.filter_map { |dep| send(dep) } 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 # Render component from cache if possible # From d6a2516de1e314723255e40e7d7bb8503d5e0723 Mon Sep 17 00:00:00 2001 From: reeganviljoen Date: Wed, 26 Mar 2025 21:17:27 +0200 Subject: [PATCH 020/122] fix specs --- test/sandbox/app/components/inherited_cache_component.rb | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 test/sandbox/app/components/inherited_cache_component.rb diff --git a/test/sandbox/app/components/inherited_cache_component.rb b/test/sandbox/app/components/inherited_cache_component.rb new file mode 100644 index 000000000..e3c3b90dd --- /dev/null +++ b/test/sandbox/app/components/inherited_cache_component.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +class InheritedCacheComponent < CacheComponent + + def initialize(foo:, bar:) + super(foo: foo, bar: bar) + end +end From 6094406e1f0656892b4043e86921e766331ced1b Mon Sep 17 00:00:00 2001 From: reeganviljoen Date: Wed, 26 Mar 2025 21:19:11 +0200 Subject: [PATCH 021/122] fix lint --- lib/view_component/cacheable.rb | 1 - 1 file changed, 1 deletion(-) diff --git a/lib/view_component/cacheable.rb b/lib/view_component/cacheable.rb index a473b895e..979587c0b 100644 --- a/lib/view_component/cacheable.rb +++ b/lib/view_component/cacheable.rb @@ -15,7 +15,6 @@ def view_cache_dependencies __vc_cache_dependencies.filter_map { |dep| send(dep) } end - # Render component from cache if possible # # @private From e5de30e41288624404b8ebd4100c91ee07b9c54b Mon Sep 17 00:00:00 2001 From: reeganviljoen Date: Wed, 26 Mar 2025 21:25:15 +0200 Subject: [PATCH 022/122] fix coberage --- .../app/components/no_cache_component.html.erb | 4 ++++ test/sandbox/app/components/no_cache_component.rb | 12 ++++++++++++ test/sandbox/test/rendering_test.rb | 8 ++++++++ 3 files changed, 24 insertions(+) create mode 100644 test/sandbox/app/components/no_cache_component.html.erb create mode 100644 test/sandbox/app/components/no_cache_component.rb diff --git a/test/sandbox/app/components/no_cache_component.html.erb b/test/sandbox/app/components/no_cache_component.html.erb new file mode 100644 index 000000000..1f49d0aab --- /dev/null +++ b/test/sandbox/app/components/no_cache_component.html.erb @@ -0,0 +1,4 @@ +

<%= view_cache_dependencies %>

+<%# <% binding.irb %> + +

"><%= "#{foo} #{bar}" %>

diff --git a/test/sandbox/app/components/no_cache_component.rb b/test/sandbox/app/components/no_cache_component.rb new file mode 100644 index 000000000..4b078e19a --- /dev/null +++ b/test/sandbox/app/components/no_cache_component.rb @@ -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 diff --git a/test/sandbox/test/rendering_test.rb b/test/sandbox/test/rendering_test.rb index d19f39404..30f9a2e68 100644 --- a/test/sandbox/test/rendering_test.rb +++ b/test/sandbox/test/rendering_test.rb @@ -1339,4 +1339,12 @@ def test_cache_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_component + component = NoCacheComponent.new(foo: "foo", bar: "bar") + render_inline(NoCacheComponent.new(foo: "foo", bar: "bar")) + + assert_selector(".cache-component__cache-key", text: component.view_cache_dependencies) + assert_selector(".cache-component__cache-message", text: "foo bar") + end end From 8e971d5b13553178d98f311a81721712ca0b3aa2 Mon Sep 17 00:00:00 2001 From: reeganviljoen Date: Wed, 26 Mar 2025 21:31:23 +0200 Subject: [PATCH 023/122] add inherited component test --- docs/CHANGELOG.md | 2 +- .../app/components/cache_component.html.erb | 1 - .../inherited_cache_component.html.erb | 3 +++ .../components/inherited_cache_component.rb | 3 +-- .../app/components/no_cache_component.html.erb | 1 - test/sandbox/test/rendering_test.rb | 18 ++++++++++++++++++ 6 files changed, 23 insertions(+), 5 deletions(-) create mode 100644 test/sandbox/app/components/inherited_cache_component.html.erb diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 5b7a11dde..4f7e09677 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -389,7 +389,7 @@ This release makes the following breaking changes: * 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. diff --git a/test/sandbox/app/components/cache_component.html.erb b/test/sandbox/app/components/cache_component.html.erb index c0c5856a8..9d20954cb 100644 --- a/test/sandbox/app/components/cache_component.html.erb +++ b/test/sandbox/app/components/cache_component.html.erb @@ -1,3 +1,2 @@

<%= view_cache_dependencies %>

-<%# <% binding.irb %>

"><%= "#{foo} #{bar}" %>

diff --git a/test/sandbox/app/components/inherited_cache_component.html.erb b/test/sandbox/app/components/inherited_cache_component.html.erb new file mode 100644 index 000000000..fccbe87a4 --- /dev/null +++ b/test/sandbox/app/components/inherited_cache_component.html.erb @@ -0,0 +1,3 @@ +

<%= view_cache_dependencies %>

+ +

"><%= "#{foo} #{bar}" %>

diff --git a/test/sandbox/app/components/inherited_cache_component.rb b/test/sandbox/app/components/inherited_cache_component.rb index e3c3b90dd..c1de347a1 100644 --- a/test/sandbox/app/components/inherited_cache_component.rb +++ b/test/sandbox/app/components/inherited_cache_component.rb @@ -1,8 +1,7 @@ # frozen_string_literal: true class InheritedCacheComponent < CacheComponent - def initialize(foo:, bar:) - super(foo: foo, bar: bar) + super end end diff --git a/test/sandbox/app/components/no_cache_component.html.erb b/test/sandbox/app/components/no_cache_component.html.erb index 1f49d0aab..fccbe87a4 100644 --- a/test/sandbox/app/components/no_cache_component.html.erb +++ b/test/sandbox/app/components/no_cache_component.html.erb @@ -1,4 +1,3 @@

<%= view_cache_dependencies %>

-<%# <% binding.irb %>

"><%= "#{foo} #{bar}" %>

diff --git a/test/sandbox/test/rendering_test.rb b/test/sandbox/test/rendering_test.rb index 30f9a2e68..7fbb1e027 100644 --- a/test/sandbox/test/rendering_test.rb +++ b/test/sandbox/test/rendering_test.rb @@ -1340,6 +1340,24 @@ def test_cache_component assert_selector(".cache-component__cache-message", text: "foo baz") end + def test_cache_component + component = InheritedCacheComponent.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(InheritedCacheComponent.new(foo: "foo", bar: "bar")) + + assert_selector(".cache-component__cache-key", text: component.view_cache_dependencies) + + new_component = InheritedCacheComponent.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_component component = NoCacheComponent.new(foo: "foo", bar: "bar") render_inline(NoCacheComponent.new(foo: "foo", bar: "bar")) From f5c2fcef12c6d0ff758ffed1acd4e1d9653de5c9 Mon Sep 17 00:00:00 2001 From: reeganviljoen Date: Wed, 26 Mar 2025 21:34:47 +0200 Subject: [PATCH 024/122] fix tests --- test/sandbox/app/components/inherited_cache_component.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/sandbox/app/components/inherited_cache_component.rb b/test/sandbox/app/components/inherited_cache_component.rb index c1de347a1..fe025914a 100644 --- a/test/sandbox/app/components/inherited_cache_component.rb +++ b/test/sandbox/app/components/inherited_cache_component.rb @@ -2,6 +2,6 @@ class InheritedCacheComponent < CacheComponent def initialize(foo:, bar:) - super + super(foo: foo, bar: bar) end end From e3425a5bd0915930964f7f295f1d161cfb216286 Mon Sep 17 00:00:00 2001 From: reeganviljoen Date: Wed, 26 Mar 2025 21:51:51 +0200 Subject: [PATCH 025/122] merge inherited values --- lib/view_component/cacheable.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/view_component/cacheable.rb b/lib/view_component/cacheable.rb index 979587c0b..3daba6530 100644 --- a/lib/view_component/cacheable.rb +++ b/lib/view_component/cacheable.rb @@ -36,7 +36,7 @@ def cache_on(*args) end def inherited(child) - child.__vc_cache_dependencies = __vc_cache_dependencies.dup + child.__vc_cache_dependencies + __vc_cache_dependencies.dup if __vc_cache_dependencies.present? super end From bc894d627259c5a7ce141e5ca36f6df620d70e79 Mon Sep 17 00:00:00 2001 From: reeganviljoen Date: Wed, 26 Mar 2025 21:54:01 +0200 Subject: [PATCH 026/122] fix tests --- test/sandbox/test/rendering_test.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/sandbox/test/rendering_test.rb b/test/sandbox/test/rendering_test.rb index 7fbb1e027..20906bda3 100644 --- a/test/sandbox/test/rendering_test.rb +++ b/test/sandbox/test/rendering_test.rb @@ -1340,7 +1340,7 @@ def test_cache_component assert_selector(".cache-component__cache-message", text: "foo baz") end - def test_cache_component + def test_inherited_cache_component component = InheritedCacheComponent.new(foo: "foo", bar: "bar") render_inline(component) From b79c3ebe5754bd263d65fd209b599d48c36c6e3f Mon Sep 17 00:00:00 2001 From: reeganviljoen Date: Wed, 26 Mar 2025 21:55:21 +0200 Subject: [PATCH 027/122] fix lint --- test/sandbox/app/components/inherited_cache_component.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/sandbox/app/components/inherited_cache_component.rb b/test/sandbox/app/components/inherited_cache_component.rb index fe025914a..c1de347a1 100644 --- a/test/sandbox/app/components/inherited_cache_component.rb +++ b/test/sandbox/app/components/inherited_cache_component.rb @@ -2,6 +2,6 @@ class InheritedCacheComponent < CacheComponent def initialize(foo:, bar:) - super(foo: foo, bar: bar) + super end end From 60cf7520cb4cf271bf129eba6acad7bd89f2df8c Mon Sep 17 00:00:00 2001 From: reeganviljoen Date: Thu, 27 Mar 2025 20:03:32 +0200 Subject: [PATCH 028/122] add polish --- lib/view_component/cacheable.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/view_component/cacheable.rb b/lib/view_component/cacheable.rb index 3daba6530..a4541f325 100644 --- a/lib/view_component/cacheable.rb +++ b/lib/view_component/cacheable.rb @@ -12,7 +12,7 @@ module ViewComponent::Cacheable def view_cache_dependencies return if __vc_cache_dependencies.blank? || __vc_cache_dependencies.none? - __vc_cache_dependencies.filter_map { |dep| send(dep) } + __vc_cache_dependencies.filter_map { |dep| send(dep) }.join('-') end # Render component from cache if possible @@ -36,7 +36,7 @@ def cache_on(*args) end def inherited(child) - child.__vc_cache_dependencies + __vc_cache_dependencies.dup if __vc_cache_dependencies.present? + child.__vc_cache_dependencies = __vc_cache_dependencies.dup super end From 48a222fbf1aa282fb161cec86c41a9d0abbc1ea8 Mon Sep 17 00:00:00 2001 From: reeganviljoen Date: Thu, 27 Mar 2025 20:04:02 +0200 Subject: [PATCH 029/122] add wip docs --- docs/guide/caching.md | 42 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 docs/guide/caching.md diff --git a/docs/guide/caching.md b/docs/guide/caching.md new file mode 100644 index 000000000..14f92c0de --- /dev/null +++ b/docs/guide/caching.md @@ -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 +

<%= view_cache_dependencies %>

+ +

<%= Time.zone.now %>">

+

<%= "#{foo} #{bar}" %>

+ +``` +will result in +```html +

foo-bar

+ +

2025-03-27 16:46:10 UTC

+

foo bar

+``` + From bf9ea17e9a7c0f861db763d211fd1ad2c511ae2e Mon Sep 17 00:00:00 2001 From: reeganviljoen Date: Thu, 27 Mar 2025 20:15:20 +0200 Subject: [PATCH 030/122] fix tests --- test/sandbox/test/rendering_test.rb | 26 -------------------------- 1 file changed, 26 deletions(-) diff --git a/test/sandbox/test/rendering_test.rb b/test/sandbox/test/rendering_test.rb index 20906bda3..d19f39404 100644 --- a/test/sandbox/test/rendering_test.rb +++ b/test/sandbox/test/rendering_test.rb @@ -1339,30 +1339,4 @@ def test_cache_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_inherited_cache_component - component = InheritedCacheComponent.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(InheritedCacheComponent.new(foo: "foo", bar: "bar")) - - assert_selector(".cache-component__cache-key", text: component.view_cache_dependencies) - - new_component = InheritedCacheComponent.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_component - component = NoCacheComponent.new(foo: "foo", bar: "bar") - render_inline(NoCacheComponent.new(foo: "foo", bar: "bar")) - - assert_selector(".cache-component__cache-key", text: component.view_cache_dependencies) - assert_selector(".cache-component__cache-message", text: "foo bar") - end end From f6f19b7be7fec4a44952b8edfd7e1a642a28573b Mon Sep 17 00:00:00 2001 From: reeganviljoen Date: Thu, 27 Mar 2025 20:18:40 +0200 Subject: [PATCH 031/122] fix lint --- lib/view_component/cacheable.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/view_component/cacheable.rb b/lib/view_component/cacheable.rb index a4541f325..bacb601ad 100644 --- a/lib/view_component/cacheable.rb +++ b/lib/view_component/cacheable.rb @@ -12,7 +12,7 @@ module ViewComponent::Cacheable def view_cache_dependencies return if __vc_cache_dependencies.blank? || __vc_cache_dependencies.none? - __vc_cache_dependencies.filter_map { |dep| send(dep) }.join('-') + __vc_cache_dependencies.filter_map { |dep| send(dep) }.join("-") end # Render component from cache if possible @@ -36,7 +36,7 @@ def cache_on(*args) end def inherited(child) - child.__vc_cache_dependencies = __vc_cache_dependencies.dup + child.__vc_cache_dependencies = __vc_cache_dependencies.dup super end From 87359f99dec984045a58da92e1d7e5e46f4a6136 Mon Sep 17 00:00:00 2001 From: reeganviljoen Date: Thu, 27 Mar 2025 20:40:56 +0200 Subject: [PATCH 032/122] fix coverage --- lib/view_component/cacheable.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/view_component/cacheable.rb b/lib/view_component/cacheable.rb index bacb601ad..717020298 100644 --- a/lib/view_component/cacheable.rb +++ b/lib/view_component/cacheable.rb @@ -10,9 +10,9 @@ module ViewComponent::Cacheable # # @private def view_cache_dependencies - return if __vc_cache_dependencies.blank? || __vc_cache_dependencies.none? + return if __vc_cache_dependencies.blank? || __vc_cache_dependencies.none? || __vc_cache_dependencies.nil? - __vc_cache_dependencies.filter_map { |dep| send(dep) }.join("-") + __vc_cache_dependencies.filter_map { |dep| send(dep) }.join('-') end # Render component from cache if possible From 4dcd622a46c8c972a3b8ed7ea295aa3d7cfdd5da Mon Sep 17 00:00:00 2001 From: reeganviljoen Date: Thu, 27 Mar 2025 20:43:56 +0200 Subject: [PATCH 033/122] fix lint --- docs/guide/caching.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/guide/caching.md b/docs/guide/caching.md index 14f92c0de..365536131 100644 --- a/docs/guide/caching.md +++ b/docs/guide/caching.md @@ -30,13 +30,13 @@ end

<%= Time.zone.now %>">

<%= "#{foo} #{bar}" %>

- ``` -will result in + +will result in: + ```html

foo-bar

2025-03-27 16:46:10 UTC

foo bar

``` - From 17389e37a5376766e63a265696292cf76fc72e53 Mon Sep 17 00:00:00 2001 From: reeganviljoen Date: Thu, 27 Mar 2025 20:44:04 +0200 Subject: [PATCH 034/122] fix lint --- lib/view_component/cacheable.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/view_component/cacheable.rb b/lib/view_component/cacheable.rb index 717020298..45e769a57 100644 --- a/lib/view_component/cacheable.rb +++ b/lib/view_component/cacheable.rb @@ -12,7 +12,7 @@ module ViewComponent::Cacheable 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('-') + __vc_cache_dependencies.filter_map { |dep| send(dep) }.join("-") end # Render component from cache if possible From a6f77109a422a78f52b8ed802ea8519a3be7f263 Mon Sep 17 00:00:00 2001 From: reeganviljoen Date: Tue, 1 Apr 2025 20:36:57 +0200 Subject: [PATCH 035/122] fix missing coverage --- test/sandbox/test/rendering_test.rb | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/test/sandbox/test/rendering_test.rb b/test/sandbox/test/rendering_test.rb index d19f39404..3c31ba640 100644 --- a/test/sandbox/test/rendering_test.rb +++ b/test/sandbox/test/rendering_test.rb @@ -1339,4 +1339,12 @@ def test_cache_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 From 13a44178ae0dc87203ec4c2eddda578e3cae8cdb Mon Sep 17 00:00:00 2001 From: reeganviljoen Date: Tue, 1 Apr 2025 20:55:57 +0200 Subject: [PATCH 036/122] add format and varaiant to cache_digest --- lib/view_component/cacheable.rb | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/view_component/cacheable.rb b/lib/view_component/cacheable.rb index 45e769a57..ada29f8a8 100644 --- a/lib/view_component/cacheable.rb +++ b/lib/view_component/cacheable.rb @@ -18,8 +18,9 @@ def view_cache_dependencies # Render component from cache if possible # # @private - def __vc_render_cacheable(rendered_template) + def __vc_render_cacheable(rendered_template, variant = nil, format = nil) if view_cache_dependencies.present? + view_cache_dependencies = view_cache_dependencies + [variant, format] Rails.cache.fetch(view_cache_dependencies) do __vc_render_template(rendered_template) end From d9125553f99c593d89a1f25fd4c58f8ba76ffae9 Mon Sep 17 00:00:00 2001 From: reeganviljoen Date: Tue, 1 Apr 2025 21:27:37 +0200 Subject: [PATCH 037/122] add format and varaiant to cache_digest --- lib/view_component/cacheable.rb | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/lib/view_component/cacheable.rb b/lib/view_component/cacheable.rb index ada29f8a8..1905f933e 100644 --- a/lib/view_component/cacheable.rb +++ b/lib/view_component/cacheable.rb @@ -4,7 +4,7 @@ module ViewComponent::Cacheable extend ActiveSupport::Concern included do - class_attribute :__vc_cache_dependencies, default: [] + class_attribute :__vc_cache_dependencies, default: [:format, :__vc_format] # For caching, such as #cache_if # @@ -18,9 +18,8 @@ def view_cache_dependencies # Render component from cache if possible # # @private - def __vc_render_cacheable(rendered_template, variant = nil, format = nil) - if view_cache_dependencies.present? - view_cache_dependencies = view_cache_dependencies + [variant, format] + def __vc_render_cacheable(rendered_template) + if view_cache_dependencies != [:format, :__vc_format] Rails.cache.fetch(view_cache_dependencies) do __vc_render_template(rendered_template) end From 7413ad509c82985d4ceb51481f41cb73d9f70a08 Mon Sep 17 00:00:00 2001 From: reeganviljoen Date: Tue, 1 Apr 2025 21:32:54 +0200 Subject: [PATCH 038/122] fix coverage --- lib/view_component/cacheable.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/view_component/cacheable.rb b/lib/view_component/cacheable.rb index 1905f933e..851948d79 100644 --- a/lib/view_component/cacheable.rb +++ b/lib/view_component/cacheable.rb @@ -19,7 +19,7 @@ def view_cache_dependencies # # @private def __vc_render_cacheable(rendered_template) - if view_cache_dependencies != [:format, :__vc_format] + if __vc_cache_dependencies != [:format, :__vc_format] Rails.cache.fetch(view_cache_dependencies) do __vc_render_template(rendered_template) end From 7ed8d2868e6a29a77db160d0ddba588c87847dbf Mon Sep 17 00:00:00 2001 From: reeganviljoen Date: Thu, 1 May 2025 10:05:51 +0200 Subject: [PATCH 039/122] add retrive ccache key to be consistent with rails --- lib/view_component/cacheable.rb | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/lib/view_component/cacheable.rb b/lib/view_component/cacheable.rb index 851948d79..46b240439 100644 --- a/lib/view_component/cacheable.rb +++ b/lib/view_component/cacheable.rb @@ -12,7 +12,7 @@ module ViewComponent::Cacheable 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("-") + __vc_cache_dependencies.filter_map { |dep| retrieve_cache_key(send(dep)) }.join("&") end # Render component from cache if possible @@ -27,6 +27,18 @@ def __vc_render_cacheable(rendered_template) __vc_render_template(rendered_template) end end + + private + + def retrieve_cache_key(key) + case + when key.respond_to?(:cache_key_with_version) then key.cache_key_with_version + when key.respond_to?(:cache_key) then key.cache_key + when key.is_a?(Array) then key.map { |element| retrieve_cache_key(element) }.to_param + when key.respond_to?(:to_a) then retrieve_cache_key(key.to_a) + else key.to_param + end.to_s + end end class_methods do From b2807a78692ddcb83dd24b110448b7f3b86705ee Mon Sep 17 00:00:00 2001 From: reeganviljoen Date: Thu, 1 May 2025 12:36:20 +0200 Subject: [PATCH 040/122] Fix linting --- lib/view_component/cacheable.rb | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/lib/view_component/cacheable.rb b/lib/view_component/cacheable.rb index 46b240439..2bead11f4 100644 --- a/lib/view_component/cacheable.rb +++ b/lib/view_component/cacheable.rb @@ -28,17 +28,17 @@ def __vc_render_cacheable(rendered_template) end end - private - - def retrieve_cache_key(key) - case - when key.respond_to?(:cache_key_with_version) then key.cache_key_with_version - when key.respond_to?(:cache_key) then key.cache_key - when key.is_a?(Array) then key.map { |element| retrieve_cache_key(element) }.to_param - when key.respond_to?(:to_a) then retrieve_cache_key(key.to_a) - else key.to_param - end.to_s - end + private + + def retrieve_cache_key(key) + case + when key.respond_to?(:cache_key_with_version) then key.cache_key_with_version + when key.respond_to?(:cache_key) then key.cache_key + when key.is_a?(Array) then key.map { |element| retrieve_cache_key(element) }.to_param + when key.respond_to?(:to_a) then retrieve_cache_key(key.to_a) + else key.to_param + end.to_s + end end class_methods do From a1d84212c27c5050b2c55ce7aa823930cca1c0e4 Mon Sep 17 00:00:00 2001 From: reeganviljoen Date: Thu, 1 May 2025 12:48:57 +0200 Subject: [PATCH 041/122] fix changelog stuff --- docs/CHANGELOG.md | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 4f7e09677..e66833d3f 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -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 joelhawksley@github.com for an invite. +* Add docs about Slack channel in Ruby Central workspace. (Join us! #oss-view-component). Email for an invite. *Joel Hawksley @@ -381,15 +385,15 @@ This release makes the following breaking changes: *Reegan Viljoen* -* Add experimental support for caching. +* Add HomeStyler AI to list of companies using ViewComponent. - *Reegan Viljoen* + *JP Balarini* ## 3.21.0 * Updates testing docs to include an example of how to use with RSpec. - *Rylanview_cache Bowers* + *Rylan Bowers* * Add `--skip-suffix` option to component generator. @@ -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 joelhawksley@github.com 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 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 joelhawksley@github.com 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 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. From d03928cf819d3ebe7b8baaa7a3fb6e88152b126a Mon Sep 17 00:00:00 2001 From: reeganviljoen Date: Fri, 2 May 2025 22:08:45 +0200 Subject: [PATCH 042/122] refactor cache logic --- lib/view_component/cacheable.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/view_component/cacheable.rb b/lib/view_component/cacheable.rb index 2bead11f4..4f70d1f61 100644 --- a/lib/view_component/cacheable.rb +++ b/lib/view_component/cacheable.rb @@ -12,7 +12,7 @@ module ViewComponent::Cacheable def view_cache_dependencies return if __vc_cache_dependencies.blank? || __vc_cache_dependencies.none? || __vc_cache_dependencies.nil? - __vc_cache_dependencies.filter_map { |dep| retrieve_cache_key(send(dep)) }.join("&") + retrieve_cache_key(__vc_cache_dependencies) end # Render component from cache if possible @@ -36,7 +36,7 @@ def retrieve_cache_key(key) when key.respond_to?(:cache_key) then key.cache_key when key.is_a?(Array) then key.map { |element| retrieve_cache_key(element) }.to_param when key.respond_to?(:to_a) then retrieve_cache_key(key.to_a) - else key.to_param + else public_send(key).to_param end.to_s end end From 64636b4267ba34747e372cc35e7ea7ea1f7dcc08 Mon Sep 17 00:00:00 2001 From: reeganviljoen Date: Fri, 2 May 2025 22:30:09 +0200 Subject: [PATCH 043/122] add identifier --- lib/view_component/cacheable.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/view_component/cacheable.rb b/lib/view_component/cacheable.rb index 4f70d1f61..257d83521 100644 --- a/lib/view_component/cacheable.rb +++ b/lib/view_component/cacheable.rb @@ -4,7 +4,7 @@ module ViewComponent::Cacheable extend ActiveSupport::Concern included do - class_attribute :__vc_cache_dependencies, default: [:format, :__vc_format] + class_attribute :__vc_cache_dependencies, default: [:format, :__vc_format, :identifier] # For caching, such as #cache_if # @@ -36,7 +36,7 @@ def retrieve_cache_key(key) when key.respond_to?(:cache_key) then key.cache_key when key.is_a?(Array) then key.map { |element| retrieve_cache_key(element) }.to_param when key.respond_to?(:to_a) then retrieve_cache_key(key.to_a) - else public_send(key).to_param + when respond_to?(key) then public_send(key).to_param end.to_s end end From 7876b14fc92157c8f29d7225d947f810ecbfb365 Mon Sep 17 00:00:00 2001 From: reeganviljoen Date: Mon, 5 May 2025 08:24:56 +0200 Subject: [PATCH 044/122] compuye cache keys --- lib/view_component/cacheable.rb | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/lib/view_component/cacheable.rb b/lib/view_component/cacheable.rb index 257d83521..502cebab8 100644 --- a/lib/view_component/cacheable.rb +++ b/lib/view_component/cacheable.rb @@ -12,7 +12,8 @@ module ViewComponent::Cacheable def view_cache_dependencies return if __vc_cache_dependencies.blank? || __vc_cache_dependencies.none? || __vc_cache_dependencies.nil? - retrieve_cache_key(__vc_cache_dependencies) + computed_view_cache_dependencies = __vc_cache_dependencies.map { |dep| if respond_to?(dep) then public_send(dep) end } + retrieve_cache_key(computed_view_cache_dependencies) end # Render component from cache if possible @@ -35,8 +36,8 @@ def retrieve_cache_key(key) when key.respond_to?(:cache_key_with_version) then key.cache_key_with_version when key.respond_to?(:cache_key) then key.cache_key when key.is_a?(Array) then key.map { |element| retrieve_cache_key(element) }.to_param - when key.respond_to?(:to_a) then retrieve_cache_key(key.to_a) - when respond_to?(key) then public_send(key).to_param + when key.respond_to?(:to_a) then retrieve_cache_key(key.to_a) + else key.to_param end.to_s end end From 8a21be1133768fc21b679e5324faa8efc30fd1d9 Mon Sep 17 00:00:00 2001 From: reeganviljoen Date: Mon, 5 May 2025 08:31:09 +0200 Subject: [PATCH 045/122] fix lint --- lib/view_component/cacheable.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/view_component/cacheable.rb b/lib/view_component/cacheable.rb index 502cebab8..c739cd8a4 100644 --- a/lib/view_component/cacheable.rb +++ b/lib/view_component/cacheable.rb @@ -36,7 +36,7 @@ def retrieve_cache_key(key) when key.respond_to?(:cache_key_with_version) then key.cache_key_with_version when key.respond_to?(:cache_key) then key.cache_key when key.is_a?(Array) then key.map { |element| retrieve_cache_key(element) }.to_param - when key.respond_to?(:to_a) then retrieve_cache_key(key.to_a) + when key.respond_to?(:to_a) then retrieve_cache_key(key.to_a) else key.to_param end.to_s end From 540b2d87c008d550fe3c07c81605caff6bac7d86 Mon Sep 17 00:00:00 2001 From: reeganviljoen Date: Tue, 6 May 2025 18:03:51 +0200 Subject: [PATCH 046/122] refactor --- lib/view_component/cacheable.rb | 14 +------------- 1 file changed, 1 insertion(+), 13 deletions(-) diff --git a/lib/view_component/cacheable.rb b/lib/view_component/cacheable.rb index c739cd8a4..4bbe4d1a0 100644 --- a/lib/view_component/cacheable.rb +++ b/lib/view_component/cacheable.rb @@ -13,7 +13,7 @@ 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 } - retrieve_cache_key(computed_view_cache_dependencies) + ActiveSupport::Cache.expand_cache_key(computed_view_cache_dependencies) end # Render component from cache if possible @@ -28,18 +28,6 @@ def __vc_render_cacheable(rendered_template) __vc_render_template(rendered_template) end end - - private - - def retrieve_cache_key(key) - case - when key.respond_to?(:cache_key_with_version) then key.cache_key_with_version - when key.respond_to?(:cache_key) then key.cache_key - when key.is_a?(Array) then key.map { |element| retrieve_cache_key(element) }.to_param - when key.respond_to?(:to_a) then retrieve_cache_key(key.to_a) - else key.to_param - end.to_s - end end class_methods do From 1b2988a71e60798db04f0313cf27a1ad79e9ca37 Mon Sep 17 00:00:00 2001 From: reeganviljoen Date: Tue, 6 May 2025 18:12:15 +0200 Subject: [PATCH 047/122] add set --- lib/view_component/cacheable.rb | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/lib/view_component/cacheable.rb b/lib/view_component/cacheable.rb index 4bbe4d1a0..04e2a48c0 100644 --- a/lib/view_component/cacheable.rb +++ b/lib/view_component/cacheable.rb @@ -1,10 +1,12 @@ # frozen_string_literal: true +require 'set' + module ViewComponent::Cacheable extend ActiveSupport::Concern included do - class_attribute :__vc_cache_dependencies, default: [:format, :__vc_format, :identifier] + class_attribute :__vc_cache_dependencies, default: Set[:format, :__vc_format, :identifier] # For caching, such as #cache_if # @@ -33,7 +35,7 @@ def __vc_render_cacheable(rendered_template) class_methods do # For caching the component def cache_on(*args) - __vc_cache_dependencies.push(*args) + __vc_cache_dependencies.merge(args) end def inherited(child) From cf313a9973f054fe21af2a3577f268b8019646ba Mon Sep 17 00:00:00 2001 From: reeganviljoen Date: Tue, 6 May 2025 22:43:39 +0200 Subject: [PATCH 048/122] Add cache refistry and alighn cache with how action view does it --- lib/view_component/cache_registry.rb | 16 +++++++++++++ lib/view_component/cacheable.rb | 36 +++++++++++++++++++++++++--- 2 files changed, 49 insertions(+), 3 deletions(-) create mode 100644 lib/view_component/cache_registry.rb diff --git a/lib/view_component/cache_registry.rb b/lib/view_component/cache_registry.rb new file mode 100644 index 000000000..d11e0b48d --- /dev/null +++ b/lib/view_component/cache_registry.rb @@ -0,0 +1,16 @@ +module CachingRegistry # :nodoc: + extend self + + def caching? + ActiveSupport::IsolatedExecutionState[:action_view_caching] ||= false + end + + def track_caching + caching_was = ActiveSupport::IsolatedExecutionState[:action_view_caching] + ActiveSupport::IsolatedExecutionState[:action_view_caching] = true + + yield + ensure + ActiveSupport::IsolatedExecutionState[:action_view_caching] = caching_was + end +end diff --git a/lib/view_component/cacheable.rb b/lib/view_component/cacheable.rb index 04e2a48c0..ac0eedd3c 100644 --- a/lib/view_component/cacheable.rb +++ b/lib/view_component/cacheable.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true require 'set' +require 'view_component/cache_registry' module ViewComponent::Cacheable extend ActiveSupport::Concern @@ -15,7 +16,7 @@ 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 } - ActiveSupport::Cache.expand_cache_key(computed_view_cache_dependencies) + combined_fragment_cache_key(ActiveSupport::Cache.expand_cache_key(computed_view_cache_dependencies)) end # Render component from cache if possible @@ -23,13 +24,42 @@ def view_cache_dependencies # @private def __vc_render_cacheable(rendered_template) if __vc_cache_dependencies != [:format, :__vc_format] - Rails.cache.fetch(view_cache_dependencies) do - __vc_render_template(rendered_template) + 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(view_cache_dependencies) + end + + def write_fragment(rendered_template) + content = __vc_render_template(rendered_template) + Rails.cache.fetch(view_cache_dependencies) 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 end class_methods do From 03683fecb4190b7c52f281f6ea0ed73d7487b043 Mon Sep 17 00:00:00 2001 From: reeganviljoen Date: Tue, 6 May 2025 22:47:46 +0200 Subject: [PATCH 049/122] namespace registry --- lib/view_component/cache_registry.rb | 11 +++++++---- lib/view_component/cacheable.rb | 2 +- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/lib/view_component/cache_registry.rb b/lib/view_component/cache_registry.rb index d11e0b48d..45946549c 100644 --- a/lib/view_component/cache_registry.rb +++ b/lib/view_component/cache_registry.rb @@ -1,16 +1,19 @@ -module CachingRegistry # :nodoc: + +module ViewComponent + module CachingRegistry # :nodoc: extend self def caching? - ActiveSupport::IsolatedExecutionState[:action_view_caching] ||= false + ActiveSupport::IsolatedExecutionState[:view_component_caching] ||= false end def track_caching - caching_was = ActiveSupport::IsolatedExecutionState[:action_view_caching] + caching_was = ActiveSupport::IsolatedExecutionState[:view_component_caching] ActiveSupport::IsolatedExecutionState[:action_view_caching] = true yield ensure - ActiveSupport::IsolatedExecutionState[:action_view_caching] = caching_was + ActiveSupport::IsolatedExecutionState[:view_component_caching] = caching_was end + end end diff --git a/lib/view_component/cacheable.rb b/lib/view_component/cacheable.rb index ac0eedd3c..3d0846f3d 100644 --- a/lib/view_component/cacheable.rb +++ b/lib/view_component/cacheable.rb @@ -24,7 +24,7 @@ def view_cache_dependencies # @private def __vc_render_cacheable(rendered_template) if __vc_cache_dependencies != [:format, :__vc_format] - CachingRegistry.track_caching do + ViewComponent::CachingRegistry.track_caching do template_fragment(rendered_template) end else From a0c74eb6634d682114959bc7cef8e53c42f53a3f Mon Sep 17 00:00:00 2001 From: reeganviljoen Date: Tue, 6 May 2025 22:49:12 +0200 Subject: [PATCH 050/122] add magic comment --- lib/view_component/cache_registry.rb | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/view_component/cache_registry.rb b/lib/view_component/cache_registry.rb index 45946549c..bbd643cb3 100644 --- a/lib/view_component/cache_registry.rb +++ b/lib/view_component/cache_registry.rb @@ -1,4 +1,5 @@ - +# frozen_string_literal: true + module ViewComponent module CachingRegistry # :nodoc: extend self From 8f45ac85c4941a77fd23f93b396dd34adf4ac6ff Mon Sep 17 00:00:00 2001 From: reeganviljoen Date: Wed, 7 May 2025 08:01:29 +0200 Subject: [PATCH 051/122] fix failing rails 6 specs --- test/sandbox/test/rendering_test.rb | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/test/sandbox/test/rendering_test.rb b/test/sandbox/test/rendering_test.rb index 3c31ba640..854a81038 100644 --- a/test/sandbox/test/rendering_test.rb +++ b/test/sandbox/test/rendering_test.rb @@ -1323,6 +1323,8 @@ def test_around_render end def test_cache_component + return if Rails.version < "7.0" + component = CacheComponent.new(foo: "foo", bar: "bar") render_inline(component) @@ -1341,6 +1343,8 @@ def test_cache_component end def test_no_cache_compoennt + return if Rails.version < "7.0" + component = NoCacheComponent.new(foo: "foo", bar: "bar") render_inline(component) From 50943a1883ae6885e036b9be67a4285a3c124540 Mon Sep 17 00:00:00 2001 From: reeganviljoen Date: Wed, 7 May 2025 22:22:56 +0200 Subject: [PATCH 052/122] Add the start of an actual digestor --- lib/view_component/cache_digestor.rb | 19 +++++++++++++++++++ lib/view_component/cache_registry.rb | 4 ++-- lib/view_component/cacheable.rb | 16 +++++++++++----- 3 files changed, 32 insertions(+), 7 deletions(-) create mode 100644 lib/view_component/cache_digestor.rb diff --git a/lib/view_component/cache_digestor.rb b/lib/view_component/cache_digestor.rb new file mode 100644 index 000000000..b0309a8bc --- /dev/null +++ b/lib/view_component/cache_digestor.rb @@ -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 diff --git a/lib/view_component/cache_registry.rb b/lib/view_component/cache_registry.rb index bbd643cb3..f90535c96 100644 --- a/lib/view_component/cache_registry.rb +++ b/lib/view_component/cache_registry.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true - + module ViewComponent - module CachingRegistry # :nodoc: + module CachingRegistry extend self def caching? diff --git a/lib/view_component/cacheable.rb b/lib/view_component/cacheable.rb index 3d0846f3d..9c5318d5d 100644 --- a/lib/view_component/cacheable.rb +++ b/lib/view_component/cacheable.rb @@ -1,7 +1,8 @@ # frozen_string_literal: true -require 'set' -require 'view_component/cache_registry' +require "set" +require "view_component/cache_registry" +require "view_component/cache_digestor" module ViewComponent::Cacheable extend ActiveSupport::Concern @@ -25,7 +26,7 @@ def view_cache_dependencies def __vc_render_cacheable(rendered_template) if __vc_cache_dependencies != [:format, :__vc_format] ViewComponent::CachingRegistry.track_caching do - template_fragment(rendered_template) + template_fragment(rendered_template) end else __vc_render_template(rendered_template) @@ -43,12 +44,12 @@ def template_fragment(rendered_template) end def read_fragment(rendered_template) - Rails.cache.fetch(view_cache_dependencies) + Rails.cache.fetch(component_digest) end def write_fragment(rendered_template) content = __vc_render_template(rendered_template) - Rails.cache.fetch(view_cache_dependencies) do + Rails.cache.fetch(component_digest) do content end content @@ -60,6 +61,11 @@ def combined_fragment_cache_key(key) 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 From da7c685e5afe9e4a87e98a83ad5b84bdaaa6e3ec Mon Sep 17 00:00:00 2001 From: reeganviljoen Date: Wed, 2 Jul 2025 16:30:02 +0200 Subject: [PATCH 053/122] add template digetor that usses an ast --- lib/view_component/cache_digestor.rb | 82 +++++++++++++++++-- lib/view_component/cacheable.rb | 33 ++++---- .../prism_render_dependency_extractor.rb | 70 ++++++++++++++++ .../templat_dependency_extractor.rb | 46 +++++++++++ lib/view_component/template_ast_builder.rb | 39 +++++++++ .../app/components/cache_component.html.erb | 2 + 6 files changed, 250 insertions(+), 22 deletions(-) create mode 100644 lib/view_component/prism_render_dependency_extractor.rb create mode 100644 lib/view_component/templat_dependency_extractor.rb create mode 100644 lib/view_component/template_ast_builder.rb diff --git a/lib/view_component/cache_digestor.rb b/lib/view_component/cache_digestor.rb index b0309a8bc..b9bf4a39b 100644 --- a/lib/view_component/cache_digestor.rb +++ b/lib/view_component/cache_digestor.rb @@ -1,19 +1,85 @@ # # frozen_string_literal: true +require 'view_component/templat_dependency_extractor' + module ViewComponent class CacheDigestor - @@digest_mutex = Mutex.new + def initialize(component:) + @component= component.class + end - class << self - def digest(name:, finder:, format: nil, dependencies: nil) - if dependencies.nil? || dependencies.empty? - cache_key = "#{name}.#{format}" + def digest + gather_templates + @templates.map do |template| + if template.type == :file + template_string = template.send(:source) + ViewComponent::TemplateDependencyExtractor.new(template_string, template.extension.to_sym).extract else - dependencies_suffix = dependencies.flatten.tap(&:compact!).join(".") - cache_key = "#{name}.#{format}.#{dependencies_suffix}" + # A digest cant be built for inline calls as there is no template to parse + [] end - cache_key end end + + def gather_templates + @templates ||= + begin + templates = @component.sidecar_files( + ActionView::Template.template_handler_extensions + ).map do |path| + # Extract format and variant from template filename + this_format, variant = + File + .basename(path) # "variants_component.html+mini.watch.erb" + .split(".")[1..-2] # ["html+mini", "watch"] + .join(".") # "html+mini.watch" + .split("+") # ["html", "mini.watch"] + .map(&:to_sym) # [:html, :"mini.watch"] + + out = Template.new( + component: @component, + type: :file, + path: path, + lineno: 0, + extension: path.split(".").last, + this_format: this_format.to_s.split(".").last&.to_sym, # strip locale from this_format, see #2113 + variant: variant + ) + + out + end + + component_instance_methods_on_self = @component.instance_methods(false) + + ( + @component.ancestors.take_while { |ancestor| ancestor != ViewComponent::Base } - @component.included_modules + ).flat_map { |ancestor| ancestor.instance_methods(false).grep(/^call(_|$)/) } + .uniq + .each do |method_name| + templates << Template.new( + component: @component, + type: :inline_call, + this_format: ViewComponent::Base::VC_INTERNAL_DEFAULT_FORMAT, + variant: method_name.to_s.include?("call_") ? method_name.to_s.sub("call_", "").to_sym : nil, + method_name: method_name, + defined_on_self: component_instance_methods_on_self.include?(method_name) + ) + end + + if @component.inline_template.present? + templates << Template.new( + component: @component, + type: :inline, + path: @component.inline_template.path, + lineno: @component.inline_template.lineno, + source: @component.inline_template.source.dup, + extension: @component.inline_template.language + ) + end + + templates + end + end + end end diff --git a/lib/view_component/cacheable.rb b/lib/view_component/cacheable.rb index 9c5318d5d..b58995955 100644 --- a/lib/view_component/cacheable.rb +++ b/lib/view_component/cacheable.rb @@ -8,23 +8,29 @@ module ViewComponent::Cacheable extend ActiveSupport::Concern included do - class_attribute :__vc_cache_dependencies, default: Set[:format, :__vc_format, :identifier] + + 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 - return if __vc_cache_dependencies.blank? || __vc_cache_dependencies.none? || __vc_cache_dependencies.nil? + self.class.__vc_cache_dependencies.map { |dep| public_send(dep) } + end - 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)) + 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(rendered_template) - if __vc_cache_dependencies != [:format, :__vc_format] + if __vc_cache_options.any? ViewComponent::CachingRegistry.track_caching do template_fragment(rendered_template) end @@ -34,7 +40,7 @@ def __vc_render_cacheable(rendered_template) end def template_fragment(rendered_template) - if content = read_fragment(rendered_template) + if content = read_fragment @view_renderer.cache_hits[@current_template&.virtual_path] = :hit if defined?(@view_renderer) content else @@ -43,13 +49,13 @@ def template_fragment(rendered_template) end end - def read_fragment(rendered_template) - Rails.cache.fetch(component_digest) + def read_fragment + Rails.cache.fetch(view_cache_options) end def write_fragment(rendered_template) content = __vc_render_template(rendered_template) - Rails.cache.fetch(component_digest) do + Rails.cache.fetch(view_cache_options) do content end content @@ -63,20 +69,19 @@ def combined_fragment_cache_key(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) + ViewComponent::CacheDigestor.new(component: self).digest end end class_methods do # For caching the component def cache_on(*args) - __vc_cache_dependencies.merge(args) + __vc_cache_options.merge(args) end def inherited(child) - child.__vc_cache_dependencies = __vc_cache_dependencies.dup - + child.__vc_cache_options = __vc_cache_options.dup + super end end diff --git a/lib/view_component/prism_render_dependency_extractor.rb b/lib/view_component/prism_render_dependency_extractor.rb new file mode 100644 index 000000000..e6ad92cbd --- /dev/null +++ b/lib/view_component/prism_render_dependency_extractor.rb @@ -0,0 +1,70 @@ + +# 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 diff --git a/lib/view_component/templat_dependency_extractor.rb b/lib/view_component/templat_dependency_extractor.rb new file mode 100644 index 000000000..403a8fb62 --- /dev/null +++ b/lib/view_component/templat_dependency_extractor.rb @@ -0,0 +1,46 @@ + +# frozen_string_literal: true + +require_relative 'template_ast_builder' +require_relative 'prism_render_dependency_extractor' + +module ViewComponent + class TemplateDependencyExtractor + def initialize(template_string, engine) + @template_string = template_string + @engine = engine + @dependencies = [] + end + + def extract + ast = TemplateAstBuilder.build(@template_string, @engine) + walk(ast.split(';')) + @dependencies.uniq + end + + private + + def walk(node) + return unless node.is_a?(Array) + + node.each { extract_from_ruby(_1) if _1.is_a?(String) } + end + + def extract_from_ruby(ruby_code) + return unless ruby_code.include?("render") + + @dependencies.concat PrismRenderDependencyExtractor.new(ruby_code).extract + extract_partial_or_layout(ruby_code) + end + + def extract_partial_or_layout(code) + partial_match = code.match(/partial:\s*["']([^"']+)["']/) + layout_match = code.match(/layout:\s*["']([^"']+)["']/) + direct_render = code.match(/render\s*\(?\s*["']([^"']+)["']/) + + @dependencies << partial_match[1] if partial_match + @dependencies << layout_match[1] if layout_match + @dependencies << direct_render[1] if direct_render + end + end +end diff --git a/lib/view_component/template_ast_builder.rb b/lib/view_component/template_ast_builder.rb new file mode 100644 index 000000000..9918189ab --- /dev/null +++ b/lib/view_component/template_ast_builder.rb @@ -0,0 +1,39 @@ + +# frozen_string_literal: true + +require 'temple' +require 'slim' +require 'haml' +require 'erb' + +module ViewComponent + class TemplateAstBuilder + class HamlTempleWrapper < Temple::Engine + def call(template) + engine = Haml::Engine.new(template, format: :xhtml) + html = engine.render + [:multi, [:static, html]] + end + end + + class ErbTempleWrapper < Temple::Engine + def call(template) + Temple::ERB::Engine.new.call(template) + end + end + + ENGINE_MAP = { + slim: -> { Slim::Engine.new }, + haml: -> { HamlTempleWrapper.new }, + erb: -> { ErbTempleWrapper.new } + } + + def self.build(template_string, engine_name) + engine = ENGINE_MAP.fetch(engine_name.to_sym) do + raise ArgumentError, "Unsupported engine: #{engine_name.inspect}" + end.call + + engine.call(template_string) + end + end +end diff --git a/test/sandbox/app/components/cache_component.html.erb b/test/sandbox/app/components/cache_component.html.erb index 9d20954cb..1ba99c998 100644 --- a/test/sandbox/app/components/cache_component.html.erb +++ b/test/sandbox/app/components/cache_component.html.erb @@ -1,2 +1,4 @@

<%= view_cache_dependencies %>

"><%= "#{foo} #{bar}" %>

+ +<%= render(ButtonToComponent.new) %> From 40b50402b87fc6e1edae75f5851e00f82306dca9 Mon Sep 17 00:00:00 2001 From: reeganviljoen Date: Wed, 2 Jul 2025 16:49:07 +0200 Subject: [PATCH 054/122] refactor digetor a bit --- lib/view_component/cache_digestor.rb | 61 +--------------------------- 1 file changed, 2 insertions(+), 59 deletions(-) diff --git a/lib/view_component/cache_digestor.rb b/lib/view_component/cache_digestor.rb index b9bf4a39b..da1502c33 100644 --- a/lib/view_component/cache_digestor.rb +++ b/lib/view_component/cache_digestor.rb @@ -12,7 +12,7 @@ def digest gather_templates @templates.map do |template| if template.type == :file - template_string = template.send(:source) + template_string = template.source ViewComponent::TemplateDependencyExtractor.new(template_string, template.extension.to_sym).extract else # A digest cant be built for inline calls as there is no template to parse @@ -22,64 +22,7 @@ def digest end def gather_templates - @templates ||= - begin - templates = @component.sidecar_files( - ActionView::Template.template_handler_extensions - ).map do |path| - # Extract format and variant from template filename - this_format, variant = - File - .basename(path) # "variants_component.html+mini.watch.erb" - .split(".")[1..-2] # ["html+mini", "watch"] - .join(".") # "html+mini.watch" - .split("+") # ["html", "mini.watch"] - .map(&:to_sym) # [:html, :"mini.watch"] - - out = Template.new( - component: @component, - type: :file, - path: path, - lineno: 0, - extension: path.split(".").last, - this_format: this_format.to_s.split(".").last&.to_sym, # strip locale from this_format, see #2113 - variant: variant - ) - - out - end - - component_instance_methods_on_self = @component.instance_methods(false) - - ( - @component.ancestors.take_while { |ancestor| ancestor != ViewComponent::Base } - @component.included_modules - ).flat_map { |ancestor| ancestor.instance_methods(false).grep(/^call(_|$)/) } - .uniq - .each do |method_name| - templates << Template.new( - component: @component, - type: :inline_call, - this_format: ViewComponent::Base::VC_INTERNAL_DEFAULT_FORMAT, - variant: method_name.to_s.include?("call_") ? method_name.to_s.sub("call_", "").to_sym : nil, - method_name: method_name, - defined_on_self: component_instance_methods_on_self.include?(method_name) - ) - end - - if @component.inline_template.present? - templates << Template.new( - component: @component, - type: :inline, - path: @component.inline_template.path, - lineno: @component.inline_template.lineno, - source: @component.inline_template.source.dup, - extension: @component.inline_template.language - ) - end - - templates - end + @templates = @component.compiler.send(:gather_templates) end - end end From d9bb9f1befff6cd2e760d2a3a2f6c4c358d8dfea Mon Sep 17 00:00:00 2001 From: reeganviljoen Date: Wed, 2 Jul 2025 16:50:50 +0200 Subject: [PATCH 055/122] fix indentation --- lib/view_component/cache_digestor.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/view_component/cache_digestor.rb b/lib/view_component/cache_digestor.rb index da1502c33..9f2e32a29 100644 --- a/lib/view_component/cache_digestor.rb +++ b/lib/view_component/cache_digestor.rb @@ -5,7 +5,7 @@ module ViewComponent class CacheDigestor def initialize(component:) - @component= component.class + @component= component.class end def digest From 52607fcecd3a9b20d0bec2aa1fe67cb00d95cdd4 Mon Sep 17 00:00:00 2001 From: reeganviljoen Date: Thu, 3 Jul 2025 11:30:33 +0200 Subject: [PATCH 056/122] refactor --- lib/view_component/cache_digestor.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/view_component/cache_digestor.rb b/lib/view_component/cache_digestor.rb index 9f2e32a29..439a9dab5 100644 --- a/lib/view_component/cache_digestor.rb +++ b/lib/view_component/cache_digestor.rb @@ -22,7 +22,7 @@ def digest end def gather_templates - @templates = @component.compiler.send(:gather_templates) + @templates = @component.compiler.send(:gather_templates) end end end From c771954cd2cd7b175029553d894be95a13e6bf84 Mon Sep 17 00:00:00 2001 From: reeganviljoen Date: Thu, 3 Jul 2025 17:06:23 +0200 Subject: [PATCH 057/122] get pr up top date --- lib/view_component/base.rb | 32 ++----------------- lib/view_component/cache_digestor.rb | 23 +++++-------- lib/view_component/cacheable.rb | 20 ++++++------ lib/view_component/compiler.rb | 12 +++++-- .../templat_dependency_extractor.rb | 10 +++--- lib/view_component/template_ast_builder.rb | 13 ++++---- 6 files changed, 40 insertions(+), 70 deletions(-) diff --git a/lib/view_component/base.rb b/lib/view_component/base.rb index b38ffd38e..a02a97a95 100644 --- a/lib/view_component/base.rb +++ b/lib/view_component/base.rb @@ -75,7 +75,7 @@ def config # Config option that strips trailing whitespace in templates before compiling them. class_attribute :__vc_response_format, instance_accessor: false, instance_predicate: false, default: nil - class_attribute :__vc_cache_dependencies, instance_accessor: false, instance_predicate: false, default: [] + 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 @@ -318,15 +318,6 @@ def virtual_path self.class.virtual_path end - # For caching, such as #cache_if - # - # @private - def view_cache_dependencies - return unless __vc_cache_dependencies.present? && __vc_cache_dependencies.any? - - __vc_cache_dependencies.filter_map { |dep| send(dep) } - end - # For caching, such as #cache_if # # @private @@ -348,15 +339,6 @@ def __vc_request @__vc_request ||= controller.request if controller.respond_to?(:request) end - # For caching, such as #cache_if - # - # @private - def view_cache_dependencies - return unless __vc_cache_dependencies.present? && __vc_cache_dependencies.any? - - __vc_cache_dependencies.filter_map { |dep| send(dep) } - end - # The content passed to the component instance as a block. # # @return [String] @@ -372,7 +354,7 @@ def content end end - # Whether `content` has been passed to the component. + # Whether f render?`content` has been passed to the component. # # @return [Boolean] def content? @@ -543,16 +525,6 @@ def sidecar_files(extensions) (sidecar_files - [identifier] + sidecar_directory_files + nested_component_files).uniq end - def cache_on(*args) - __vc_cache_dependencies.push(*args) - end - - def view_cache_dependencies - return unless __vc_cache_dependencies.any? - - __vc_cache_dependencies.filter_map { |dep| send(dep) } - end - # Render a component for each element in a collection ([documentation](/guide/collections)): # # ```ruby diff --git a/lib/view_component/cache_digestor.rb b/lib/view_component/cache_digestor.rb index 439a9dab5..d7588c49f 100644 --- a/lib/view_component/cache_digestor.rb +++ b/lib/view_component/cache_digestor.rb @@ -1,28 +1,21 @@ # # frozen_string_literal: true -require 'view_component/templat_dependency_extractor' +require "view_component/templat_dependency_extractor" module ViewComponent class CacheDigestor def initialize(component:) - @component= component.class + @component = component end def digest - gather_templates - @templates.map do |template| - if template.type == :file - template_string = template.source - ViewComponent::TemplateDependencyExtractor.new(template_string, template.extension.to_sym).extract - else - # A digest cant be built for inline calls as there is no template to parse - [] - end + 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 - - def gather_templates - @templates = @component.compiler.send(:gather_templates) - end end end diff --git a/lib/view_component/cacheable.rb b/lib/view_component/cacheable.rb index b58995955..11ca36c8a 100644 --- a/lib/view_component/cacheable.rb +++ b/lib/view_component/cacheable.rb @@ -1,6 +1,5 @@ # frozen_string_literal: true -require "set" require "view_component/cache_registry" require "view_component/cache_digestor" @@ -8,7 +7,6 @@ module ViewComponent::Cacheable extend ActiveSupport::Concern included do - class_attribute :__vc_cache_options, default: Set[:identifier] class_attribute :__vc_cache_dependencies, default: Set.new @@ -29,23 +27,23 @@ def view_cache_options # Render component from cache if possible # # @private - def __vc_render_cacheable(rendered_template) - if __vc_cache_options.any? + def __vc_render_cacheable(safe_call) + if (__vc_cache_options - [:identifier]).any? ViewComponent::CachingRegistry.track_caching do - template_fragment(rendered_template) + template_fragment(safe_call) end else - __vc_render_template(rendered_template) + instance_exec(&safe_call) end end - def template_fragment(rendered_template) + 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(rendered_template) + write_fragment end end @@ -53,8 +51,8 @@ def read_fragment Rails.cache.fetch(view_cache_options) end - def write_fragment(rendered_template) - content = __vc_render_template(rendered_template) + def write_fragment + content = instance_exec(&safe_call) Rails.cache.fetch(view_cache_options) do content end @@ -81,7 +79,7 @@ def cache_on(*args) def inherited(child) child.__vc_cache_options = __vc_cache_options.dup - + super end end diff --git a/lib/view_component/compiler.rb b/lib/view_component/compiler.rb index d972e7611..8fe2973a7 100644 --- a/lib/view_component/compiler.rb +++ b/lib/view_component/compiler.rb @@ -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 diff --git a/lib/view_component/templat_dependency_extractor.rb b/lib/view_component/templat_dependency_extractor.rb index 403a8fb62..46dc0a460 100644 --- a/lib/view_component/templat_dependency_extractor.rb +++ b/lib/view_component/templat_dependency_extractor.rb @@ -1,8 +1,7 @@ - # frozen_string_literal: true -require_relative 'template_ast_builder' -require_relative 'prism_render_dependency_extractor' +require_relative "template_ast_builder" +require_relative "prism_render_dependency_extractor" module ViewComponent class TemplateDependencyExtractor @@ -14,7 +13,8 @@ def initialize(template_string, engine) def extract ast = TemplateAstBuilder.build(@template_string, @engine) - walk(ast.split(';')) + return @dependencies unless ast.present? + walk(ast.split(";")) @dependencies.uniq end @@ -35,7 +35,7 @@ def extract_from_ruby(ruby_code) def extract_partial_or_layout(code) partial_match = code.match(/partial:\s*["']([^"']+)["']/) - layout_match = code.match(/layout:\s*["']([^"']+)["']/) + layout_match = code.match(/layout:\s*["']([^"']+)["']/) direct_render = code.match(/render\s*\(?\s*["']([^"']+)["']/) @dependencies << partial_match[1] if partial_match diff --git a/lib/view_component/template_ast_builder.rb b/lib/view_component/template_ast_builder.rb index 9918189ab..dc40dcaeb 100644 --- a/lib/view_component/template_ast_builder.rb +++ b/lib/view_component/template_ast_builder.rb @@ -1,10 +1,9 @@ - # frozen_string_literal: true -require 'temple' -require 'slim' -require 'haml' -require 'erb' +require "temple" +require "slim" +require "haml" +require "erb" module ViewComponent class TemplateAstBuilder @@ -25,12 +24,12 @@ def call(template) ENGINE_MAP = { slim: -> { Slim::Engine.new }, haml: -> { HamlTempleWrapper.new }, - erb: -> { ErbTempleWrapper.new } + erb: -> { ErbTempleWrapper.new } } def self.build(template_string, engine_name) engine = ENGINE_MAP.fetch(engine_name.to_sym) do - raise ArgumentError, "Unsupported engine: #{engine_name.inspect}" + return nil end.call engine.call(template_string) From 5af2bc7eb2572395e71fdafe9a214e7a9402df60 Mon Sep 17 00:00:00 2001 From: reeganviljoen Date: Thu, 3 Jul 2025 17:12:01 +0200 Subject: [PATCH 058/122] fix merge issue --- lib/view_component/base.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/view_component/base.rb b/lib/view_component/base.rb index a02a97a95..7a17d412e 100644 --- a/lib/view_component/base.rb +++ b/lib/view_component/base.rb @@ -321,8 +321,8 @@ def virtual_path # For caching, such as #cache_if # # @private - def format - @__vc_variant if defined?(@__vc_variant) + def view_cache_dependencies + [] end # The current request. Use sparingly as doing so introduces coupling that From 9d88e3a4eb64a3f3aeab6918af13115eeadfa584 Mon Sep 17 00:00:00 2001 From: reeganviljoen Date: Thu, 3 Jul 2025 17:15:02 +0200 Subject: [PATCH 059/122] try get tests to pass --- lib/view_component/cacheable.rb | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/view_component/cacheable.rb b/lib/view_component/cacheable.rb index 11ca36c8a..2c6eeaf4f 100644 --- a/lib/view_component/cacheable.rb +++ b/lib/view_component/cacheable.rb @@ -10,6 +10,7 @@ module ViewComponent::Cacheable class_attribute :__vc_cache_options, default: Set[:identifier] class_attribute :__vc_cache_dependencies, default: Set.new + silence_redefinition_of_method(:view_cache_dependencies) # For caching, such as #cache_if # # @private From 3542bebae7faa3db04c764068aec6182b49f4b3e Mon Sep 17 00:00:00 2001 From: reeganviljoen Date: Thu, 3 Jul 2025 17:18:23 +0200 Subject: [PATCH 060/122] try get tests to pass --- lib/view_component/base.rb | 7 ------- lib/view_component/cacheable.rb | 1 - 2 files changed, 8 deletions(-) diff --git a/lib/view_component/base.rb b/lib/view_component/base.rb index 7a17d412e..8572f92ad 100644 --- a/lib/view_component/base.rb +++ b/lib/view_component/base.rb @@ -318,13 +318,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. # diff --git a/lib/view_component/cacheable.rb b/lib/view_component/cacheable.rb index 2c6eeaf4f..11ca36c8a 100644 --- a/lib/view_component/cacheable.rb +++ b/lib/view_component/cacheable.rb @@ -10,7 +10,6 @@ module ViewComponent::Cacheable class_attribute :__vc_cache_options, default: Set[:identifier] class_attribute :__vc_cache_dependencies, default: Set.new - silence_redefinition_of_method(:view_cache_dependencies) # For caching, such as #cache_if # # @private From 4f58de4d2cdbb31708e0c49d72ec60ee3c7d99b3 Mon Sep 17 00:00:00 2001 From: reeganviljoen Date: Thu, 3 Jul 2025 17:20:21 +0200 Subject: [PATCH 061/122] fix rails 8 test --- test/sandbox/test/rendering_test.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/sandbox/test/rendering_test.rb b/test/sandbox/test/rendering_test.rb index 854a81038..1dcd215b2 100644 --- a/test/sandbox/test/rendering_test.rb +++ b/test/sandbox/test/rendering_test.rb @@ -20,7 +20,7 @@ def test_render_inline_allocations MyComponent.__vc_ensure_compiled with_instrumentation_enabled_option(false) do - assert_allocations({"3.5" => 69, "3.4" => 74, "3.3" => 72, "3.2" => 71}) do + assert_allocations({"3.5" => 68, "3.4" => 74, "3.3" => 72, "3.2" => 71}) do render_inline(MyComponent.new) end end From b75e0c2f0b6bc6a0bbbf3813b11c784a5eaaf042 Mon Sep 17 00:00:00 2001 From: reeganviljoen Date: Thu, 3 Jul 2025 17:26:07 +0200 Subject: [PATCH 062/122] fix soem artifcats --- .tool-versions | 2 +- lib/view_component/base.rb | 5 +---- ...endency_extractor.rb => template_dependency_extractor.rb} | 0 3 files changed, 2 insertions(+), 5 deletions(-) rename lib/view_component/{templat_dependency_extractor.rb => template_dependency_extractor.rb} (100%) diff --git a/.tool-versions b/.tool-versions index ae5ecdb2b..a72ead61f 100644 --- a/.tool-versions +++ b/.tool-versions @@ -1 +1 @@ -ruby 3.4.2 +ruby 3.4.3 diff --git a/lib/view_component/base.rb b/lib/view_component/base.rb index 8572f92ad..bce609be9 100644 --- a/lib/view_component/base.rb +++ b/lib/view_component/base.rb @@ -57,9 +57,6 @@ def config include ViewComponent::WithContentHelper include ViewComponent::Cacheable - RESERVED_PARAMETER = :content - VC_INTERNAL_DEFAULT_FORMAT = :html - # For CSRF authenticity tokens in forms delegate :form_authenticity_token, :protect_against_forgery?, :config, to: :helpers @@ -347,7 +344,7 @@ def content end end - # Whether f render?`content` has been passed to the component. + # Whether `content` has been passed to the component. # # @return [Boolean] def content? diff --git a/lib/view_component/templat_dependency_extractor.rb b/lib/view_component/template_dependency_extractor.rb similarity index 100% rename from lib/view_component/templat_dependency_extractor.rb rename to lib/view_component/template_dependency_extractor.rb From 1d81e9dd7a07d2cbbf81f9c96d32dd8d34c30eec Mon Sep 17 00:00:00 2001 From: reeganviljoen Date: Thu, 3 Jul 2025 17:27:30 +0200 Subject: [PATCH 063/122] fix test --- lib/view_component/cache_digestor.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/view_component/cache_digestor.rb b/lib/view_component/cache_digestor.rb index d7588c49f..bf98bcaee 100644 --- a/lib/view_component/cache_digestor.rb +++ b/lib/view_component/cache_digestor.rb @@ -1,6 +1,6 @@ # # frozen_string_literal: true -require "view_component/templat_dependency_extractor" +require "view_component/template_dependency_extractor" module ViewComponent class CacheDigestor From 35f68a85d04b2f06c5acde4374e4278487113fa5 Mon Sep 17 00:00:00 2001 From: reeganviljoen Date: Thu, 3 Jul 2025 17:37:24 +0200 Subject: [PATCH 064/122] make primer pass --- Gemfile.lock | 1 + view_component.gemspec | 1 + 2 files changed, 2 insertions(+) diff --git a/Gemfile.lock b/Gemfile.lock index f4dada8f5..c3ebd8e04 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -4,6 +4,7 @@ PATH view_component (4.0.0.rc1) activesupport (>= 7.1.0, < 8.1) concurrent-ruby (~> 1) + temple (~> 0.10) GEM remote: https://rubygems.org/ diff --git a/view_component.gemspec b/view_component.gemspec index 8154e9f65..c1b0a2278 100644 --- a/view_component.gemspec +++ b/view_component.gemspec @@ -34,4 +34,5 @@ Gem::Specification.new do |spec| spec.add_runtime_dependency "activesupport", [">= 7.1.0", "< 8.1"] spec.add_runtime_dependency "concurrent-ruby", "~> 1" + spec.add_runtime_dependency "temple", "~> 0.10" end From 1102a50be87ef4ffe5cb7ed44c5e5b3e56ae8531 Mon Sep 17 00:00:00 2001 From: reeganviljoen Date: Thu, 3 Jul 2025 17:38:53 +0200 Subject: [PATCH 065/122] fix linting --- lib/view_component/prism_render_dependency_extractor.rb | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/lib/view_component/prism_render_dependency_extractor.rb b/lib/view_component/prism_render_dependency_extractor.rb index e6ad92cbd..318b503e6 100644 --- a/lib/view_component/prism_render_dependency_extractor.rb +++ b/lib/view_component/prism_render_dependency_extractor.rb @@ -1,7 +1,6 @@ - # frozen_string_literal: true -require 'prism' +require "prism" module ViewComponent class PrismRenderDependencyExtractor @@ -39,8 +38,8 @@ def extract_render_target(node) 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) + 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 From b5a65874c52783b65e3efc23b70b24ed949cc7e4 Mon Sep 17 00:00:00 2001 From: reeganviljoen Date: Thu, 3 Jul 2025 17:41:47 +0200 Subject: [PATCH 066/122] fix linting --- lib/view_component/cacheable.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/view_component/cacheable.rb b/lib/view_component/cacheable.rb index 11ca36c8a..e0c73925c 100644 --- a/lib/view_component/cacheable.rb +++ b/lib/view_component/cacheable.rb @@ -38,7 +38,7 @@ def __vc_render_cacheable(safe_call) end def template_fragment - if content = read_fragment + if (content = read_fragment) @view_renderer.cache_hits[@current_template&.virtual_path] = :hit if defined?(@view_renderer) content else From 299ff9d63e08307b2585f092df252fbe21d5f18c Mon Sep 17 00:00:00 2001 From: reeganviljoen Date: Thu, 3 Jul 2025 17:43:47 +0200 Subject: [PATCH 067/122] fix alloactor spec --- test/sandbox/test/rendering_test.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/sandbox/test/rendering_test.rb b/test/sandbox/test/rendering_test.rb index 1dcd215b2..d54bec2ca 100644 --- a/test/sandbox/test/rendering_test.rb +++ b/test/sandbox/test/rendering_test.rb @@ -20,7 +20,7 @@ def test_render_inline_allocations MyComponent.__vc_ensure_compiled with_instrumentation_enabled_option(false) do - assert_allocations({"3.5" => 68, "3.4" => 74, "3.3" => 72, "3.2" => 71}) do + assert_allocations({"3.5" => 68, "3.4" => 76, "3.3" => 72, "3.2" => 71}) do render_inline(MyComponent.new) end end From 9aa9dd0efb68f131e80cd415dce6c2b83a74327c Mon Sep 17 00:00:00 2001 From: reeganviljoen Date: Thu, 3 Jul 2025 20:18:46 +0200 Subject: [PATCH 068/122] fix tests --- test/sandbox/test/rendering_test.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/sandbox/test/rendering_test.rb b/test/sandbox/test/rendering_test.rb index d54bec2ca..1dcd215b2 100644 --- a/test/sandbox/test/rendering_test.rb +++ b/test/sandbox/test/rendering_test.rb @@ -20,7 +20,7 @@ def test_render_inline_allocations MyComponent.__vc_ensure_compiled with_instrumentation_enabled_option(false) do - assert_allocations({"3.5" => 68, "3.4" => 76, "3.3" => 72, "3.2" => 71}) do + assert_allocations({"3.5" => 68, "3.4" => 74, "3.3" => 72, "3.2" => 71}) do render_inline(MyComponent.new) end end From f2df73574ce0c8cc1315cfc14f345c2de63e6386 Mon Sep 17 00:00:00 2001 From: reeganviljoen Date: Thu, 3 Jul 2025 20:23:22 +0200 Subject: [PATCH 069/122] make primer pass --- Gemfile.lock | 2 ++ view_component.gemspec | 2 ++ 2 files changed, 4 insertions(+) diff --git a/Gemfile.lock b/Gemfile.lock index c3ebd8e04..b674e7e0f 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -4,6 +4,8 @@ PATH view_component (4.0.0.rc1) activesupport (>= 7.1.0, < 8.1) concurrent-ruby (~> 1) + haml (~> 6) + slim (~> 5) temple (~> 0.10) GEM diff --git a/view_component.gemspec b/view_component.gemspec index c1b0a2278..a98232d29 100644 --- a/view_component.gemspec +++ b/view_component.gemspec @@ -35,4 +35,6 @@ Gem::Specification.new do |spec| spec.add_runtime_dependency "activesupport", [">= 7.1.0", "< 8.1"] spec.add_runtime_dependency "concurrent-ruby", "~> 1" spec.add_runtime_dependency "temple", "~> 0.10" + spec.add_runtime_dependency "slim", "~> 5" + spec.add_runtime_dependency "haml", "~> 6" end From 9e93a5abf524e7d47f5e435335ef9f904615ac6d Mon Sep 17 00:00:00 2001 From: reeganviljoen Date: Thu, 3 Jul 2025 20:55:14 +0200 Subject: [PATCH 070/122] add inline erb cache component test --- .../app/components/inline_cache_component.rb | 21 +++++++++++++++++++ test/sandbox/test/rendering_test.rb | 20 ++++++++++++++++++ 2 files changed, 41 insertions(+) create mode 100644 test/sandbox/app/components/inline_cache_component.rb diff --git a/test/sandbox/app/components/inline_cache_component.rb b/test/sandbox/app/components/inline_cache_component.rb new file mode 100644 index 000000000..d7d6a4ea6 --- /dev/null +++ b/test/sandbox/app/components/inline_cache_component.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +class InlineCacheComponent < ViewComponent::Base + include ViewComponent::Cacheable + + cache_on :foo, :bar + + attr_reader :foo, :bar + + def initialize(foo:, bar:) + @foo = foo + @bar = bar + end + + erb_template <<~ERB +

<%= view_cache_dependencies %>

+

"><%= "\#{foo} \#{bar}" %>

+ + <%= render(ButtonToComponent.new) %> + ERB +end diff --git a/test/sandbox/test/rendering_test.rb b/test/sandbox/test/rendering_test.rb index 1dcd215b2..02764bf8f 100644 --- a/test/sandbox/test/rendering_test.rb +++ b/test/sandbox/test/rendering_test.rb @@ -1322,6 +1322,26 @@ def test_around_render assert_text("Hi!") end + def test_inline_cache_component + return if Rails.version < "7.0" + + component = InlineCacheComponent.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(InlineCacheComponent.new(foo: "foo", bar: "bar")) + + assert_selector(".cache-component__cache-key", text: component.view_cache_dependencies) + + new_component = InlineCacheComponent.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_cache_component return if Rails.version < "7.0" From 250084b0fbd00cf89f13825c88caa4dc6720006a Mon Sep 17 00:00:00 2001 From: Reegan Date: Wed, 28 Jan 2026 15:52:09 +0200 Subject: [PATCH 071/122] fix ci --- Gemfile.lock | 3 --- lib/view_component/base.rb | 11 ++++------- lib/view_component/cacheable.rb | 6 +++--- view_component.gemspec | 3 --- 4 files changed, 7 insertions(+), 16 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index c4a807912..1e4e78a16 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -5,9 +5,6 @@ PATH actionview (>= 7.1.0) activesupport (>= 7.1.0) concurrent-ruby (~> 1) - haml (~> 6) - slim (~> 5) - temple (~> 0.10) GEM remote: https://rubygems.org/ diff --git a/lib/view_component/base.rb b/lib/view_component/base.rb index b8e9ccf08..77c64fbfa 100644 --- a/lib/view_component/base.rb +++ b/lib/view_component/base.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true require "action_view" +require "view_component/cacheable" require "view_component/collection" require "view_component/compile_cache" require "view_component/compiler" @@ -55,6 +56,8 @@ def config include ViewComponent::WithContentHelper include ViewComponent::Cacheable + class_attribute :__vc_strip_trailing_whitespace, instance_accessor: false, instance_predicate: false, default: false + # For CSRF authenticity tokens in forms delegate :form_authenticity_token, :protect_against_forgery?, :config, to: :helpers @@ -306,7 +309,7 @@ def helpers # @private def method_missing(method_name, *args) # rubocop:disable Style/MissingRespondToMissing super - rescue => e # rubocop:disable Style/RescueStandardError + rescue => e # rubocop:disable Style/RescueStandardError e.set_backtrace e.backtrace.tap(&:shift) raise e, <<~MESSAGE.chomp if view_context && e.is_a?(NameError) && helpers.respond_to?(method_name) #{e.message} @@ -325,12 +328,6 @@ def virtual_path self.class.virtual_path end - # For caching, such as #cache_if - # @private - def - [] - end - if defined?(Rails::VERSION) && Rails::VERSION::MAJOR == 7 && Rails::VERSION::MINOR == 1 # Rails expects us to define `format` on all renderables, # but we do not know the `format` of a ViewComponent until runtime. diff --git a/lib/view_component/cacheable.rb b/lib/view_component/cacheable.rb index e0c73925c..3d6ccd1f2 100644 --- a/lib/view_component/cacheable.rb +++ b/lib/view_component/cacheable.rb @@ -37,13 +37,13 @@ def __vc_render_cacheable(safe_call) end end - def template_fragment + def template_fragment(safe_call) 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 + write_fragment(safe_call) end end @@ -51,7 +51,7 @@ def read_fragment Rails.cache.fetch(view_cache_options) end - def write_fragment + def write_fragment(safe_call) content = instance_exec(&safe_call) Rails.cache.fetch(view_cache_options) do content diff --git a/view_component.gemspec b/view_component.gemspec index ce5271edd..6b8d75f0f 100644 --- a/view_component.gemspec +++ b/view_component.gemspec @@ -36,7 +36,4 @@ Gem::Specification.new do |spec| spec.add_runtime_dependency "activesupport", supported_rails_version spec.add_runtime_dependency "actionview", supported_rails_version spec.add_runtime_dependency "concurrent-ruby", "~> 1" - spec.add_runtime_dependency "temple", "~> 0.10" - spec.add_runtime_dependency "slim", "~> 5" - spec.add_runtime_dependency "haml", "~> 6" end From 703ff0180f38047b91365a4d4690581d10659562 Mon Sep 17 00:00:00 2001 From: Reegan Date: Wed, 28 Jan 2026 16:11:08 +0200 Subject: [PATCH 072/122] fix primer ci failures by making ast lazy load dependecies --- lib/view_component/template_ast_builder.rb | 51 +++++++++++++------ .../template_dependency_extractor.rb | 12 +++++ 2 files changed, 47 insertions(+), 16 deletions(-) diff --git a/lib/view_component/template_ast_builder.rb b/lib/view_component/template_ast_builder.rb index dc40dcaeb..c84a56f3d 100644 --- a/lib/view_component/template_ast_builder.rb +++ b/lib/view_component/template_ast_builder.rb @@ -1,31 +1,50 @@ # frozen_string_literal: true -require "temple" -require "slim" -require "haml" require "erb" +begin + require "temple" +rescue LoadError + # Optional dependency: only needed for template AST extraction. +end + +begin + require "slim" +rescue LoadError + # Optional dependency: only needed when parsing Slim templates. +end + +begin + require "haml" +rescue LoadError + # Optional dependency: only needed when parsing Haml templates. +end + module ViewComponent class TemplateAstBuilder - class HamlTempleWrapper < Temple::Engine - def call(template) - engine = Haml::Engine.new(template, format: :xhtml) - html = engine.render - [:multi, [:static, html]] + if defined?(Temple) && defined?(Haml) + class HamlTempleWrapper < Temple::Engine + def call(template) + engine = Haml::Engine.new(template, format: :xhtml) + html = engine.render + [:multi, [:static, html]] + end end end - class ErbTempleWrapper < Temple::Engine - def call(template) - Temple::ERB::Engine.new.call(template) + if defined?(Temple) + class ErbTempleWrapper < Temple::Engine + def call(template) + Temple::ERB::Engine.new.call(template) + end end end - ENGINE_MAP = { - slim: -> { Slim::Engine.new }, - haml: -> { HamlTempleWrapper.new }, - erb: -> { ErbTempleWrapper.new } - } + ENGINE_MAP = {}.tap do |map| + map[:slim] = -> { Slim::Engine.new } if defined?(Slim) + map[:haml] = -> { HamlTempleWrapper.new } if defined?(HamlTempleWrapper) + map[:erb] = -> { ErbTempleWrapper.new } if defined?(ErbTempleWrapper) + end.freeze def self.build(template_string, engine_name) engine = ENGINE_MAP.fetch(engine_name.to_sym) do diff --git a/lib/view_component/template_dependency_extractor.rb b/lib/view_component/template_dependency_extractor.rb index 46dc0a460..24b1a53b2 100644 --- a/lib/view_component/template_dependency_extractor.rb +++ b/lib/view_component/template_dependency_extractor.rb @@ -13,6 +13,7 @@ def initialize(template_string, engine) def extract ast = TemplateAstBuilder.build(@template_string, @engine) + return extract_erb_fallback if ast.blank? && @engine.to_sym == :erb return @dependencies unless ast.present? walk(ast.split(";")) @dependencies.uniq @@ -42,5 +43,16 @@ def extract_partial_or_layout(code) @dependencies << layout_match[1] if layout_match @dependencies << direct_render[1] if direct_render end + + ERB_RUBY_TAG = /<%(=|-|#)?(.*?)%>/m + private_constant :ERB_RUBY_TAG + + def extract_erb_fallback + @template_string.scan(ERB_RUBY_TAG) do |(_, ruby_code)| + extract_from_ruby(ruby_code) + end + + @dependencies.uniq + end end end From 430616cd4dae2ebdf95453aec9fcc55f1274e7d6 Mon Sep 17 00:00:00 2001 From: Reegan Date: Wed, 28 Jan 2026 21:14:37 +0200 Subject: [PATCH 073/122] treat `temple`, `slim`, `haml` as optional dependencies (no boot-time hard requires), make parent component cache key changes when a rendered child component changes. --- docs/guide/caching.md | 14 +++- lib/view_component.rb | 1 + lib/view_component/base.rb | 2 - lib/view_component/cache_digestor.rb | 80 +++++++++++++++++-- lib/view_component/cacheable.rb | 2 +- lib/view_component/compiler.rb | 8 +- lib/view_component/fragment_caching.rb | 13 +++ .../cache_digestor_child_component.html.erb | 1 + .../cache_digestor_child_component.rb | 4 + .../cache_digestor_parent_component.html.erb | 3 + .../cache_digestor_parent_component.rb | 13 +++ test/sandbox/test/rendering_test.rb | 31 +++++++ 12 files changed, 156 insertions(+), 16 deletions(-) create mode 100644 lib/view_component/fragment_caching.rb create mode 100644 test/sandbox/app/components/cache_digestor_child_component.html.erb create mode 100644 test/sandbox/app/components/cache_digestor_child_component.rb create mode 100644 test/sandbox/app/components/cache_digestor_parent_component.html.erb create mode 100644 test/sandbox/app/components/cache_digestor_parent_component.rb diff --git a/docs/guide/caching.md b/docs/guide/caching.md index 365536131..61addb82c 100644 --- a/docs/guide/caching.md +++ b/docs/guide/caching.md @@ -9,7 +9,17 @@ parent: How-to guide Experimental {: .label } -Components can implement caching by marking the depndencies that a digest can be built om using the cache_on macro, like so: +Caching is experimental. + +To enable caching globally (opt-in), add this to an initializer: + +```ruby +require "view_component/fragment_caching" +``` + +Alternatively, you can opt in per-component by including `ViewComponent::Cacheable`. + +Components implement caching by marking the dependencies that should be included in the cache key using `cache_on`: ```ruby class CacheComponent < ViewComponent::Base @@ -28,7 +38,7 @@ end ```erb

<%= view_cache_dependencies %>

-

<%= Time.zone.now %>">

+

<%= Time.zone.now %>

<%= "#{foo} #{bar}" %>

``` diff --git a/lib/view_component.rb b/lib/view_component.rb index ac1102ed9..3674cca39 100644 --- a/lib/view_component.rb +++ b/lib/view_component.rb @@ -8,6 +8,7 @@ module ViewComponent extend ActiveSupport::Autoload autoload :Base + autoload :Cacheable autoload :Compiler autoload :CompileCache autoload :Config diff --git a/lib/view_component/base.rb b/lib/view_component/base.rb index 77c64fbfa..0441ad8a1 100644 --- a/lib/view_component/base.rb +++ b/lib/view_component/base.rb @@ -1,7 +1,6 @@ # frozen_string_literal: true require "action_view" -require "view_component/cacheable" require "view_component/collection" require "view_component/compile_cache" require "view_component/compiler" @@ -54,7 +53,6 @@ def config include ViewComponent::Slotable include ViewComponent::Translatable include ViewComponent::WithContentHelper - include ViewComponent::Cacheable class_attribute :__vc_strip_trailing_whitespace, instance_accessor: false, instance_predicate: false, default: false diff --git a/lib/view_component/cache_digestor.rb b/lib/view_component/cache_digestor.rb index bf98bcaee..d2c534db6 100644 --- a/lib/view_component/cache_digestor.rb +++ b/lib/view_component/cache_digestor.rb @@ -1,21 +1,87 @@ -# # frozen_string_literal: true +# frozen_string_literal: true +require "digest" require "view_component/template_dependency_extractor" module ViewComponent class CacheDigestor def initialize(component:) @component = component + @visited_components = {} 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 + digest_for_component(@component.class) + end + + private + + TEMPLATE_EXTENSIONS = %w[erb haml slim].freeze + private_constant :TEMPLATE_EXTENSIONS + + def digest_for_component(component_class) + return "" unless component_class.respond_to?(:name) + return "" unless component_class <= ViewComponent::Base + + return "" if @visited_components.key?(component_class.name) + + @visited_components[component_class.name] = true + + sources = [] + + identifier = component_class.identifier + sources << file_contents(identifier) if identifier + + inline_template = component_class.__vc_inline_template if component_class.respond_to?(:__vc_inline_template) + if inline_template + sources << inline_template.source + + dependencies = ViewComponent::TemplateDependencyExtractor.new(inline_template.source, inline_template.language).extract + sources.concat(dependency_digests(dependencies)) + end + + component_class.sidecar_files(TEMPLATE_EXTENSIONS).sort.each do |path| + template_source = file_contents(path) + next unless template_source + + sources << template_source + + handler = path.split(".").last + dependencies = ViewComponent::TemplateDependencyExtractor.new(template_source, handler).extract + sources.concat(dependency_digests(dependencies)) end + + component_class.sidecar_files(["yml"]).sort.each do |path| + sources << file_contents(path) + end + + Digest::SHA1.hexdigest(sources.compact.join("\n")) + end + + def dependency_digests(dependencies) + dependencies.filter_map do |dep| + next unless dep.match?(/\A[A-Z]/) + + klass = constantize(dep) + next unless klass + + digest_for_component(klass) + end + end + + def constantize(constant_name) + constant_name.split("::").reduce(Object) do |namespace, name| + namespace.const_get(name) + end + rescue NameError + nil + end + + def file_contents(path) + return unless path + return unless File.file?(path) + + File.read(path) end end end diff --git a/lib/view_component/cacheable.rb b/lib/view_component/cacheable.rb index 3d6ccd1f2..91333e4f7 100644 --- a/lib/view_component/cacheable.rb +++ b/lib/view_component/cacheable.rb @@ -21,7 +21,7 @@ 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)) + combined_fragment_cache_key(ActiveSupport::Cache.expand_cache_key(computed_view_cache_options + [component_digest])) end # Render component from cache if possible diff --git a/lib/view_component/compiler.rb b/lib/view_component/compiler.rb index ca9ed6c6d..759cec288 100644 --- a/lib/view_component/compiler.rb +++ b/lib/view_component/compiler.rb @@ -90,8 +90,8 @@ def define_render_template_for safe_call = template.safe_method_name_call @component.define_method(:render_template_for) do |_| @current_template = template - if @component.respond_to?(:__vc_render_cacheable) - @component.__vc_render_cacheable(safe_call) + if respond_to?(:__vc_render_cacheable) + __vc_render_cacheable(safe_call) else instance_exec(&safe_call) end @@ -100,8 +100,8 @@ def define_render_template_for compiler = self @component.define_method(:render_template_for) do |details| if (@current_template = compiler.find_templates_for(details).first) - if @component.respond_to?(:__vc_render_cacheable) - @component.__vc_render_cacheable(@current_template.safe_method_name_call) + if respond_to?(:__vc_render_cacheable) + __vc_render_cacheable(@current_template.safe_method_name_call) else instance_exec(&@current_template.safe_method_name_call) end diff --git a/lib/view_component/fragment_caching.rb b/lib/view_component/fragment_caching.rb new file mode 100644 index 000000000..f4f24d1a8 --- /dev/null +++ b/lib/view_component/fragment_caching.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +require "view_component" + +module ViewComponent + module FragmentCaching + def self.enable! + ViewComponent::Base.include(ViewComponent::Cacheable) unless ViewComponent::Base < ViewComponent::Cacheable + end + end +end + +ViewComponent::FragmentCaching.enable! diff --git a/test/sandbox/app/components/cache_digestor_child_component.html.erb b/test/sandbox/app/components/cache_digestor_child_component.html.erb new file mode 100644 index 000000000..fa7904e28 --- /dev/null +++ b/test/sandbox/app/components/cache_digestor_child_component.html.erb @@ -0,0 +1 @@ +v1 diff --git a/test/sandbox/app/components/cache_digestor_child_component.rb b/test/sandbox/app/components/cache_digestor_child_component.rb new file mode 100644 index 000000000..cb0c2af27 --- /dev/null +++ b/test/sandbox/app/components/cache_digestor_child_component.rb @@ -0,0 +1,4 @@ +# frozen_string_literal: true + +class CacheDigestorChildComponent < ViewComponent::Base +end diff --git a/test/sandbox/app/components/cache_digestor_parent_component.html.erb b/test/sandbox/app/components/cache_digestor_parent_component.html.erb new file mode 100644 index 000000000..471baed67 --- /dev/null +++ b/test/sandbox/app/components/cache_digestor_parent_component.html.erb @@ -0,0 +1,3 @@ +
+ <%= render CacheDigestorChildComponent.new %> +
diff --git a/test/sandbox/app/components/cache_digestor_parent_component.rb b/test/sandbox/app/components/cache_digestor_parent_component.rb new file mode 100644 index 000000000..0c81c0af5 --- /dev/null +++ b/test/sandbox/app/components/cache_digestor_parent_component.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +class CacheDigestorParentComponent < ViewComponent::Base + include ViewComponent::Cacheable + + cache_on :foo + + attr_reader :foo + + def initialize(foo:) + @foo = foo + end +end diff --git a/test/sandbox/test/rendering_test.rb b/test/sandbox/test/rendering_test.rb index 3276d1774..e14c6682f 100644 --- a/test/sandbox/test/rendering_test.rb +++ b/test/sandbox/test/rendering_test.rb @@ -1389,6 +1389,37 @@ def test_no_cache_compoennt assert_selector(".cache-component__cache-message", text: "foo bar") end + def test_cache_key_changes_when_child_component_template_changes + return if Rails.version < "7.0" + + child_template_path = CacheDigestorChildComponent.sidecar_files(["erb"]).first + original_template = File.read(child_template_path) + + Rails.cache.clear + ViewComponent::CompileCache.invalidate! + + key_v1 = CacheDigestorParentComponent.new(foo: "x").view_cache_options + render_inline(CacheDigestorParentComponent.new(foo: "x")) + assert_selector(".child", text: "v1") + assert(Rails.cache.exist?(key_v1)) + + File.write(child_template_path, original_template.sub("v1", "v2")) + ViewComponent::CompileCache.invalidate! + + key_v2 = CacheDigestorParentComponent.new(foo: "x").view_cache_options + refute_equal(key_v1, key_v2) + + render_inline(CacheDigestorParentComponent.new(foo: "x")) + assert_selector(".child", text: "v2") + ensure + Rails.cache.clear + ViewComponent::CompileCache.invalidate! + + if child_template_path && original_template + File.write(child_template_path, original_template) + end + end + def test_render_partial_with_yield render_inline(PartialWithYieldComponent.new) assert_text "hello world", exact: true, normalize_ws: true From 0a0d319095a9888ead3aa22e8d79b6fa493123ff Mon Sep 17 00:00:00 2001 From: Reegan Date: Wed, 28 Jan 2026 21:28:59 +0200 Subject: [PATCH 074/122] fix ci --- lib/view_component/base.rb | 6 ++++++ lib/view_component/compiler.rb | 4 ++-- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/lib/view_component/base.rb b/lib/view_component/base.rb index 0441ad8a1..95fc34ff5 100644 --- a/lib/view_component/base.rb +++ b/lib/view_component/base.rb @@ -326,6 +326,12 @@ def virtual_path self.class.virtual_path end + # For caching, such as #cache_if + # @private + def view_cache_dependencies + [] + end + if defined?(Rails::VERSION) && Rails::VERSION::MAJOR == 7 && Rails::VERSION::MINOR == 1 # Rails expects us to define `format` on all renderables, # but we do not know the `format` of a ViewComponent until runtime. diff --git a/lib/view_component/compiler.rb b/lib/view_component/compiler.rb index 759cec288..ee9c83e4a 100644 --- a/lib/view_component/compiler.rb +++ b/lib/view_component/compiler.rb @@ -90,7 +90,7 @@ def define_render_template_for safe_call = template.safe_method_name_call @component.define_method(:render_template_for) do |_| @current_template = template - if respond_to?(:__vc_render_cacheable) + if is_a?(ViewComponent::Cacheable) __vc_render_cacheable(safe_call) else instance_exec(&safe_call) @@ -100,7 +100,7 @@ def define_render_template_for compiler = self @component.define_method(:render_template_for) do |details| if (@current_template = compiler.find_templates_for(details).first) - if respond_to?(:__vc_render_cacheable) + if is_a?(ViewComponent::Cacheable) __vc_render_cacheable(@current_template.safe_method_name_call) else instance_exec(&@current_template.safe_method_name_call) From 9e3767f8ce54c6319fbe1f1c9174fbd32d106c64 Mon Sep 17 00:00:00 2001 From: Reegan Date: Wed, 28 Jan 2026 21:41:37 +0200 Subject: [PATCH 075/122] Refine fragment caching key + invalidation --- lib/view_component/cacheable.rb | 23 +++++++++++++------ .../cache_digestor_parent_component.html.erb | 2 +- test/sandbox/test/rendering_test.rb | 15 +++++++----- 3 files changed, 26 insertions(+), 14 deletions(-) diff --git a/lib/view_component/cacheable.rb b/lib/view_component/cacheable.rb index 91333e4f7..90bbe7f39 100644 --- a/lib/view_component/cacheable.rb +++ b/lib/view_component/cacheable.rb @@ -7,7 +7,6 @@ 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 @@ -18,17 +17,20 @@ def view_cache_dependencies end def view_cache_options - return if __vc_cache_options.blank? + return if self.class.__vc_cache_dependencies.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])) + template_key = __vc_cache_template_key + return if template_key.nil? + + cache_key_parts = [self.class.name, self.class.virtual_path, template_key, view_cache_dependencies, component_digest] + combined_fragment_cache_key(ActiveSupport::Cache.expand_cache_key(cache_key_parts)) end # Render component from cache if possible # # @private def __vc_render_cacheable(safe_call) - if (__vc_cache_options - [:identifier]).any? + if view_cache_options ViewComponent::CachingRegistry.track_caching do template_fragment(safe_call) end @@ -37,6 +39,13 @@ def __vc_render_cacheable(safe_call) end end + # @private + def __vc_cache_template_key + return unless defined?(@current_template) && @current_template + + [@current_template.call_method_name, @current_template.virtual_path] + end + def template_fragment(safe_call) if (content = read_fragment) @view_renderer.cache_hits[@current_template&.virtual_path] = :hit if defined?(@view_renderer) @@ -74,11 +83,11 @@ def component_digest class_methods do # For caching the component def cache_on(*args) - __vc_cache_options.merge(args) + __vc_cache_dependencies.merge(args) end def inherited(child) - child.__vc_cache_options = __vc_cache_options.dup + child.__vc_cache_dependencies = __vc_cache_dependencies.dup super end diff --git a/test/sandbox/app/components/cache_digestor_parent_component.html.erb b/test/sandbox/app/components/cache_digestor_parent_component.html.erb index 471baed67..f0145826b 100644 --- a/test/sandbox/app/components/cache_digestor_parent_component.html.erb +++ b/test/sandbox/app/components/cache_digestor_parent_component.html.erb @@ -1,3 +1,3 @@ -
+
<%= render CacheDigestorChildComponent.new %>
diff --git a/test/sandbox/test/rendering_test.rb b/test/sandbox/test/rendering_test.rb index e14c6682f..e311f7a04 100644 --- a/test/sandbox/test/rendering_test.rb +++ b/test/sandbox/test/rendering_test.rb @@ -1398,19 +1398,22 @@ def test_cache_key_changes_when_child_component_template_changes Rails.cache.clear ViewComponent::CompileCache.invalidate! - key_v1 = CacheDigestorParentComponent.new(foo: "x").view_cache_options + component_v1 = CacheDigestorParentComponent.new(foo: "x") + render_inline(component_v1) + assert_selector(".child", text: "v1") + time_v1 = page.find(".parent")["data-time"] + render_inline(CacheDigestorParentComponent.new(foo: "x")) assert_selector(".child", text: "v1") - assert(Rails.cache.exist?(key_v1)) + assert_equal(time_v1, page.find(".parent")["data-time"]) File.write(child_template_path, original_template.sub("v1", "v2")) ViewComponent::CompileCache.invalidate! - key_v2 = CacheDigestorParentComponent.new(foo: "x").view_cache_options - refute_equal(key_v1, key_v2) - - render_inline(CacheDigestorParentComponent.new(foo: "x")) + component_v2 = CacheDigestorParentComponent.new(foo: "x") + render_inline(component_v2) assert_selector(".child", text: "v2") + refute_equal(time_v1, page.find(".parent")["data-time"]) ensure Rails.cache.clear ViewComponent::CompileCache.invalidate! From 3cc370b2374fefa657965e9eb7dabe4070e76dc8 Mon Sep 17 00:00:00 2001 From: Reegan Date: Thu, 29 Jan 2026 08:14:16 +0200 Subject: [PATCH 076/122] Normalize cache_on dependencies and memoize digests --- lib/view_component/cache_digestor.rb | 6 +++++- lib/view_component/cacheable.rb | 4 ++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/lib/view_component/cache_digestor.rb b/lib/view_component/cache_digestor.rb index d2c534db6..c4d1260ba 100644 --- a/lib/view_component/cache_digestor.rb +++ b/lib/view_component/cache_digestor.rb @@ -8,6 +8,7 @@ class CacheDigestor def initialize(component:) @component = component @visited_components = {} + @digests = {} end def digest @@ -23,6 +24,9 @@ def digest_for_component(component_class) return "" unless component_class.respond_to?(:name) return "" unless component_class <= ViewComponent::Base + cached_digest = @digests[component_class.name] + return cached_digest if cached_digest + return "" if @visited_components.key?(component_class.name) @visited_components[component_class.name] = true @@ -55,7 +59,7 @@ def digest_for_component(component_class) sources << file_contents(path) end - Digest::SHA1.hexdigest(sources.compact.join("\n")) + @digests[component_class.name] = Digest::SHA1.hexdigest(sources.compact.join("\n")) end def dependency_digests(dependencies) diff --git a/lib/view_component/cacheable.rb b/lib/view_component/cacheable.rb index 90bbe7f39..b42fdcfeb 100644 --- a/lib/view_component/cacheable.rb +++ b/lib/view_component/cacheable.rb @@ -13,7 +13,7 @@ module ViewComponent::Cacheable # # @private def view_cache_dependencies - self.class.__vc_cache_dependencies.map { |dep| public_send(dep) } + self.class.__vc_cache_dependencies.map { |dep| send(dep) } end def view_cache_options @@ -76,7 +76,7 @@ def combined_fragment_cache_key(key) end def component_digest - ViewComponent::CacheDigestor.new(component: self).digest + @__vc_component_digest ||= ViewComponent::CacheDigestor.new(component: self).digest end end From fdb9b40ce633a0c8c4d94a333dcbfa4ecffa7d8e Mon Sep 17 00:00:00 2001 From: Reegan Date: Thu, 29 Jan 2026 08:18:57 +0200 Subject: [PATCH 077/122] update docs --- docs/guide/caching.md | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/docs/guide/caching.md b/docs/guide/caching.md index 61addb82c..ebfdde902 100644 --- a/docs/guide/caching.md +++ b/docs/guide/caching.md @@ -35,18 +35,16 @@ class CacheComponent < ViewComponent::Base end ``` +Notes: + +- `cache_on` accepts method names; the returned values are expanded via `ActiveSupport::Cache.expand_cache_key` (so Active Record models, `GlobalID`, arrays, and plain strings work as expected). +- Methods listed in `cache_on` may be private (the cache dependency reader calls `send`). +- The cache key includes a digest of the component source (Ruby + templates + i18n sidecars) and rendered child ViewComponents. +- Partial/layout string dependencies are not currently included in the digest; use `RAILS_CACHE_ID`/`RAILS_APP_VERSION` if you need to invalidate on deploy. + ```erb -

<%= view_cache_dependencies %>

+

<%= view_cache_dependencies.inspect %>

<%= Time.zone.now %>

<%= "#{foo} #{bar}" %>

``` - -will result in: - -```html -

foo-bar

- -

2025-03-27 16:46:10 UTC

-

foo bar

-``` From 63fd953f21537e96230564eed2beca938aaf7a4d Mon Sep 17 00:00:00 2001 From: Reegan Date: Thu, 29 Jan 2026 08:19:09 +0200 Subject: [PATCH 078/122] refactor dependecy extractor --- .../prism_render_dependency_extractor.rb | 53 +++++++------------ 1 file changed, 19 insertions(+), 34 deletions(-) diff --git a/lib/view_component/prism_render_dependency_extractor.rb b/lib/view_component/prism_render_dependency_extractor.rb index 318b503e6..6627f02ee 100644 --- a/lib/view_component/prism_render_dependency_extractor.rb +++ b/lib/view_component/prism_render_dependency_extractor.rb @@ -10,21 +10,27 @@ def initialize(code) end def extract - result = Prism.parse(@code) - walk(result.value) + root = Prism.parse(@code).value + walk(root) if root @dependencies end private def walk(node) - return unless node.respond_to?(:child_nodes) + stack = [node] - if node.is_a?(Prism::CallNode) && render_call?(node) - extract_render_target(node) - end + until stack.empty? + current = stack.pop + next unless current.is_a?(Prism::Node) + + extract_render_target(current) if current.is_a?(Prism::CallNode) && render_call?(current) + + children = current.child_nodes + next if children.empty? - node.child_nodes.each { |child| walk(child) if child } + children.reverse_each { |child| stack << child if child } + end end def render_call?(node) @@ -32,38 +38,17 @@ def render_call?(node) end def extract_render_target(node) - args = node.arguments&.arguments - return unless args && !args.empty? - - first_arg = args.first + first_arg = node.arguments&.arguments&.first + return unless first_arg.is_a?(Prism::CallNode) && first_arg.name == :new - 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) + receiver = first_arg.receiver + return unless receiver.is_a?(Prism::ConstantPathNode) || receiver.is_a?(Prism::ConstantReadNode) - const = extract_constant_path(first_arg.receiver) - @dependencies << const if const - end + @dependencies << extract_constant_path(receiver) 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("::") + const_node.location.slice end end end From 9e4bc2a75bf10abbe04825d0ced3a45b2753b4d4 Mon Sep 17 00:00:00 2001 From: Reegan Date: Thu, 29 Jan 2026 10:43:15 +0200 Subject: [PATCH 079/122] Optimize digest and template dependency extraction Avoid Temple for ERB by compiling templates to Ruby, and reduce allocations in digest/dependency extraction to keep caching overhead low. --- docs/CHANGELOG.md | 2 +- lib/view_component/cache_digestor.rb | 72 ++++++++++------- lib/view_component/template_ast_builder.rb | 78 +++++++++---------- .../template_dependency_extractor.rb | 51 +++++++----- 4 files changed, 111 insertions(+), 92 deletions(-) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index c2237a233..50dd44fd3 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -10,7 +10,7 @@ nav_order: 6 ## main -* Add experimental support for caching. +* Add experimental fragment caching. *Reegan Viljoen* diff --git a/lib/view_component/cache_digestor.rb b/lib/view_component/cache_digestor.rb index c4d1260ba..382727fae 100644 --- a/lib/view_component/cache_digestor.rb +++ b/lib/view_component/cache_digestor.rb @@ -7,8 +7,9 @@ module ViewComponent class CacheDigestor def initialize(component:) @component = component - @visited_components = {} @digests = {} + @file_cache = {} + @constant_cache = {} end def digest @@ -20,72 +21,89 @@ def digest TEMPLATE_EXTENSIONS = %w[erb haml slim].freeze private_constant :TEMPLATE_EXTENSIONS + IN_PROGRESS = :__vc_in_progress + private_constant :IN_PROGRESS + def digest_for_component(component_class) - return "" unless component_class.respond_to?(:name) + name = component_class.name + return "" unless name return "" unless component_class <= ViewComponent::Base - cached_digest = @digests[component_class.name] + cached_digest = @digests[name] + return "" if cached_digest == IN_PROGRESS return cached_digest if cached_digest - return "" if @visited_components.key?(component_class.name) - - @visited_components[component_class.name] = true + @digests[name] = IN_PROGRESS - sources = [] + digest = Digest::SHA1.new - identifier = component_class.identifier - sources << file_contents(identifier) if identifier + if (identifier = component_class.identifier) + update_digest(digest, file_contents(identifier)) + end inline_template = component_class.__vc_inline_template if component_class.respond_to?(:__vc_inline_template) if inline_template - sources << inline_template.source + update_digest(digest, inline_template.source) dependencies = ViewComponent::TemplateDependencyExtractor.new(inline_template.source, inline_template.language).extract - sources.concat(dependency_digests(dependencies)) + update_dependency_digests(digest, dependencies) end component_class.sidecar_files(TEMPLATE_EXTENSIONS).sort.each do |path| template_source = file_contents(path) next unless template_source - sources << template_source + update_digest(digest, template_source) - handler = path.split(".").last + handler = path.rpartition(".").last dependencies = ViewComponent::TemplateDependencyExtractor.new(template_source, handler).extract - sources.concat(dependency_digests(dependencies)) + update_dependency_digests(digest, dependencies) end component_class.sidecar_files(["yml"]).sort.each do |path| - sources << file_contents(path) + update_digest(digest, file_contents(path)) end - @digests[component_class.name] = Digest::SHA1.hexdigest(sources.compact.join("\n")) + @digests[name] = digest.hexdigest end - def dependency_digests(dependencies) - dependencies.filter_map do |dep| - next unless dep.match?(/\A[A-Z]/) + def update_dependency_digests(digest, dependencies) + dependencies.each do |dep| + next unless uppercase_constant?(dep) klass = constantize(dep) next unless klass - digest_for_component(klass) + update_digest(digest, digest_for_component(klass)) end end + def update_digest(digest, value) + return unless value + + digest.update(value) + digest.update("\n") + end + + def uppercase_constant?(dep) + return false unless dep + + first = dep.getbyte(0) + first && first >= 65 && first <= 90 + end + def constantize(constant_name) - constant_name.split("::").reduce(Object) do |namespace, name| - namespace.const_get(name) + @constant_cache.fetch(constant_name) do + @constant_cache[constant_name] = constant_name.split("::").reduce(Object) { |namespace, name| namespace.const_get(name) } end rescue NameError - nil + @constant_cache[constant_name] = nil end def file_contents(path) - return unless path - return unless File.file?(path) - - File.read(path) + @file_cache.fetch(path) do + @file_cache[path] = File.file?(path) ? File.read(path) : nil + end end end end diff --git a/lib/view_component/template_ast_builder.rb b/lib/view_component/template_ast_builder.rb index c84a56f3d..db0f292aa 100644 --- a/lib/view_component/template_ast_builder.rb +++ b/lib/view_component/template_ast_builder.rb @@ -1,57 +1,49 @@ # frozen_string_literal: true -require "erb" - -begin - require "temple" -rescue LoadError - # Optional dependency: only needed for template AST extraction. -end - -begin - require "slim" -rescue LoadError - # Optional dependency: only needed when parsing Slim templates. -end - -begin - require "haml" -rescue LoadError - # Optional dependency: only needed when parsing Haml templates. -end - module ViewComponent class TemplateAstBuilder - if defined?(Temple) && defined?(Haml) - class HamlTempleWrapper < Temple::Engine - def call(template) - engine = Haml::Engine.new(template, format: :xhtml) - html = engine.render - [:multi, [:static, html]] - end + def self.build(template_string, engine_name) + case engine_name.to_sym + when :erb + compile_erb(template_string) + when :slim + return nil unless load_slim? + + Slim::Engine.new.call(template_string) + when :haml + return nil unless load_haml? + + Haml::Engine.new.call(template_string) end end - if defined?(Temple) - class ErbTempleWrapper < Temple::Engine - def call(template) - Temple::ERB::Engine.new.call(template) - end - end + def self.compile_erb(template) + require "erb" + + ERB::Compiler.new("-").compile(template).first + rescue + nil end + private_class_method :compile_erb - ENGINE_MAP = {}.tap do |map| - map[:slim] = -> { Slim::Engine.new } if defined?(Slim) - map[:haml] = -> { HamlTempleWrapper.new } if defined?(HamlTempleWrapper) - map[:erb] = -> { ErbTempleWrapper.new } if defined?(ErbTempleWrapper) - end.freeze + def self.load_slim? + return true if defined?(Slim::Engine) - def self.build(template_string, engine_name) - engine = ENGINE_MAP.fetch(engine_name.to_sym) do - return nil - end.call + require "slim" + true + rescue LoadError + false + end + private_class_method :load_slim? + + def self.load_haml? + return true if defined?(Haml::Engine) - engine.call(template_string) + require "haml" + true + rescue LoadError + false end + private_class_method :load_haml? end end diff --git a/lib/view_component/template_dependency_extractor.rb b/lib/view_component/template_dependency_extractor.rb index 24b1a53b2..5534c0816 100644 --- a/lib/view_component/template_dependency_extractor.rb +++ b/lib/view_component/template_dependency_extractor.rb @@ -8,51 +8,60 @@ class TemplateDependencyExtractor def initialize(template_string, engine) @template_string = template_string @engine = engine - @dependencies = [] + @dependencies = Set.new end def extract - ast = TemplateAstBuilder.build(@template_string, @engine) - return extract_erb_fallback if ast.blank? && @engine.to_sym == :erb - return @dependencies unless ast.present? - walk(ast.split(";")) - @dependencies.uniq - end + engine = @engine.to_sym + ruby_source = TemplateAstBuilder.build(@template_string, engine) - private + if ruby_source.nil? + return extract_erb_fallback if engine == :erb - def walk(node) - return unless node.is_a?(Array) + return [] + end - node.each { extract_from_ruby(_1) if _1.is_a?(String) } + extract_from_ruby(ruby_source) + @dependencies.to_a end + private + def extract_from_ruby(ruby_code) return unless ruby_code.include?("render") - @dependencies.concat PrismRenderDependencyExtractor.new(ruby_code).extract + PrismRenderDependencyExtractor.new(ruby_code).extract.each { @dependencies << _1 } extract_partial_or_layout(ruby_code) end + PARTIAL_RENDER = /partial:\s*["']([^"']+)["']/ + LAYOUT_RENDER = /layout:\s*["']([^"']+)["']/ + DIRECT_RENDER = /render\s*\(?\s*["']([^"']+)["']/ + private_constant :PARTIAL_RENDER, :LAYOUT_RENDER, :DIRECT_RENDER + def extract_partial_or_layout(code) - partial_match = code.match(/partial:\s*["']([^"']+)["']/) - layout_match = code.match(/layout:\s*["']([^"']+)["']/) - direct_render = code.match(/render\s*\(?\s*["']([^"']+)["']/) + if (partial_match = code.match(PARTIAL_RENDER)) + @dependencies << partial_match[1] + end - @dependencies << partial_match[1] if partial_match - @dependencies << layout_match[1] if layout_match - @dependencies << direct_render[1] if direct_render + if (layout_match = code.match(LAYOUT_RENDER)) + @dependencies << layout_match[1] + end + + if (direct_render = code.match(DIRECT_RENDER)) + @dependencies << direct_render[1] + end end ERB_RUBY_TAG = /<%(=|-|#)?(.*?)%>/m private_constant :ERB_RUBY_TAG def extract_erb_fallback - @template_string.scan(ERB_RUBY_TAG) do |(_, ruby_code)| - extract_from_ruby(ruby_code) + @template_string.scan(ERB_RUBY_TAG) do |(_, tag_ruby)| + extract_from_ruby(tag_ruby) end - @dependencies.uniq + @dependencies.to_a end end end From dfc6fe6ad214eda35243fdc48d56fd6fc537366b Mon Sep 17 00:00:00 2001 From: Reegan Date: Thu, 12 Feb 2026 11:47:59 +0200 Subject: [PATCH 080/122] code review easy wins --- docs/CHANGELOG.md | 2 +- docs/guide/caching.md | 25 ++++++-------- lib/view_component.rb | 2 +- lib/view_component/base.rb | 3 +- lib/view_component/cache_digestor.rb | 31 ++++++++--------- lib/view_component/cache_registry.rb | 2 +- lib/view_component/compiler.rb | 4 +-- ...cheable.rb => experimentally_cacheable.rb} | 2 +- lib/view_component/fragment_caching.rb | 13 ------- .../sandbox/app/components/cache_component.rb | 2 +- .../cache_digestor_parent_component.rb | 2 +- ...digestor_partial_parent_component.html.erb | 3 ++ ...cache_digestor_partial_parent_component.rb | 13 +++++++ .../app/components/inline_cache_component.rb | 2 +- .../app/components/no_cache_component.rb | 2 +- .../shared/_cache_digestor_partial.html.erb | 1 + test/sandbox/test/rendering_test.rb | 34 ++++++++++++++----- 17 files changed, 78 insertions(+), 65 deletions(-) rename lib/view_component/{cacheable.rb => experimentally_cacheable.rb} (98%) delete mode 100644 lib/view_component/fragment_caching.rb create mode 100644 test/sandbox/app/components/cache_digestor_partial_parent_component.html.erb create mode 100644 test/sandbox/app/components/cache_digestor_partial_parent_component.rb create mode 100644 test/sandbox/app/views/shared/_cache_digestor_partial.html.erb diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 50dd44fd3..077699c5f 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -10,7 +10,7 @@ nav_order: 6 ## main -* Add experimental fragment caching. +* Add experimental support for caching by including `ViewComponent::ExperimentallyCacheable`. *Reegan Viljoen* diff --git a/docs/guide/caching.md b/docs/guide/caching.md index ebfdde902..df00b4ba4 100644 --- a/docs/guide/caching.md +++ b/docs/guide/caching.md @@ -11,19 +11,13 @@ Experimental Caching is experimental. -To enable caching globally (opt-in), add this to an initializer: - -```ruby -require "view_component/fragment_caching" -``` - -Alternatively, you can opt in per-component by including `ViewComponent::Cacheable`. +To enable caching, include `ViewComponent::ExperimentallyCacheable`. Components implement caching by marking the dependencies that should be included in the cache key using `cache_on`: ```ruby class CacheComponent < ViewComponent::Base - include ViewComponent::Cacheable + include ViewComponent::ExperimentallyCacheable cache_on :foo, :bar attr_reader :foo, :bar @@ -35,16 +29,17 @@ class CacheComponent < ViewComponent::Base end ``` -Notes: - -- `cache_on` accepts method names; the returned values are expanded via `ActiveSupport::Cache.expand_cache_key` (so Active Record models, `GlobalID`, arrays, and plain strings work as expected). -- Methods listed in `cache_on` may be private (the cache dependency reader calls `send`). -- The cache key includes a digest of the component source (Ruby + templates + i18n sidecars) and rendered child ViewComponents. -- Partial/layout string dependencies are not currently included in the digest; use `RAILS_CACHE_ID`/`RAILS_APP_VERSION` if you need to invalidate on deploy. - ```erb

<%= view_cache_dependencies.inspect %>

<%= Time.zone.now %>

<%= "#{foo} #{bar}" %>

``` + +`cache_on` accepts method names. Returned values are expanded via `ActiveSupport::Cache.expand_cache_key`, so Active Record models, `GlobalID`, arrays, and plain strings work as expected. + +Methods listed in `cache_on` may be private. + +The cache key includes a digest of component source (Ruby + templates + i18n sidecars) and rendered child ViewComponents. + +Partial/layout string dependencies are not currently included in the digest; modify `RAILS_CACHE_ID`/`RAILS_APP_VERSION` to invalidate on deploy. diff --git a/lib/view_component.rb b/lib/view_component.rb index 3674cca39..0a54a9859 100644 --- a/lib/view_component.rb +++ b/lib/view_component.rb @@ -8,11 +8,11 @@ module ViewComponent extend ActiveSupport::Autoload autoload :Base - autoload :Cacheable autoload :Compiler autoload :CompileCache autoload :Config autoload :Deprecation + autoload :ExperimentallyCacheable autoload :InlineTemplate autoload :Instrumentation autoload :Preview diff --git a/lib/view_component/base.rb b/lib/view_component/base.rb index 95fc34ff5..5e970f0e8 100644 --- a/lib/view_component/base.rb +++ b/lib/view_component/base.rb @@ -54,6 +54,7 @@ def config include ViewComponent::Translatable include ViewComponent::WithContentHelper + # 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 # For CSRF authenticity tokens in forms @@ -68,8 +69,6 @@ def config # For Content Security Policy nonces delegate :content_security_policy_nonce, to: :helpers - # Config option that strips trailing whitespace in templates before compiling them. - attr_accessor :__vc_original_view_context attr_reader :current_template diff --git a/lib/view_component/cache_digestor.rb b/lib/view_component/cache_digestor.rb index 382727fae..20d9284eb 100644 --- a/lib/view_component/cache_digestor.rb +++ b/lib/view_component/cache_digestor.rb @@ -18,9 +18,6 @@ def digest private - TEMPLATE_EXTENSIONS = %w[erb haml slim].freeze - private_constant :TEMPLATE_EXTENSIONS - IN_PROGRESS = :__vc_in_progress private_constant :IN_PROGRESS @@ -29,19 +26,21 @@ def digest_for_component(component_class) return "" unless name return "" unless component_class <= ViewComponent::Base - cached_digest = @digests[name] - return "" if cached_digest == IN_PROGRESS - return cached_digest if cached_digest + case (cached_digest = @digests[name]) + when IN_PROGRESS + return "" + when nil + else + return cached_digest + end @digests[name] = IN_PROGRESS digest = Digest::SHA1.new - if (identifier = component_class.identifier) - update_digest(digest, file_contents(identifier)) - end + update_digest(digest, file_contents(component_class.identifier)) - inline_template = component_class.__vc_inline_template if component_class.respond_to?(:__vc_inline_template) + inline_template = component_class.__vc_inline_template if inline_template update_digest(digest, inline_template.source) @@ -49,10 +48,8 @@ def digest_for_component(component_class) update_dependency_digests(digest, dependencies) end - component_class.sidecar_files(TEMPLATE_EXTENSIONS).sort.each do |path| + component_class.sidecar_files(ActionView::Template.template_handler_extensions).sort.each do |path| template_source = file_contents(path) - next unless template_source - update_digest(digest, template_source) handler = path.rpartition(".").last @@ -60,7 +57,7 @@ def digest_for_component(component_class) update_dependency_digests(digest, dependencies) end - component_class.sidecar_files(["yml"]).sort.each do |path| + component_class.sidecar_files(%w[yml yaml]).sort.each do |path| update_digest(digest, file_contents(path)) end @@ -94,13 +91,13 @@ def uppercase_constant?(dep) def constantize(constant_name) @constant_cache.fetch(constant_name) do - @constant_cache[constant_name] = constant_name.split("::").reduce(Object) { |namespace, name| namespace.const_get(name) } + @constant_cache[constant_name] = constant_name.safe_constantize end - rescue NameError - @constant_cache[constant_name] = nil end def file_contents(path) + return nil if path.nil? + @file_cache.fetch(path) do @file_cache[path] = File.file?(path) ? File.read(path) : nil end diff --git a/lib/view_component/cache_registry.rb b/lib/view_component/cache_registry.rb index f90535c96..8a7f74b53 100644 --- a/lib/view_component/cache_registry.rb +++ b/lib/view_component/cache_registry.rb @@ -10,7 +10,7 @@ def caching? def track_caching caching_was = ActiveSupport::IsolatedExecutionState[:view_component_caching] - ActiveSupport::IsolatedExecutionState[:action_view_caching] = true + ActiveSupport::IsolatedExecutionState[:view_component_caching] = true yield ensure diff --git a/lib/view_component/compiler.rb b/lib/view_component/compiler.rb index ee9c83e4a..4cff288b3 100644 --- a/lib/view_component/compiler.rb +++ b/lib/view_component/compiler.rb @@ -90,7 +90,7 @@ def define_render_template_for safe_call = template.safe_method_name_call @component.define_method(:render_template_for) do |_| @current_template = template - if is_a?(ViewComponent::Cacheable) + if is_a?(ViewComponent::ExperimentallyCacheable) __vc_render_cacheable(safe_call) else instance_exec(&safe_call) @@ -100,7 +100,7 @@ def define_render_template_for compiler = self @component.define_method(:render_template_for) do |details| if (@current_template = compiler.find_templates_for(details).first) - if is_a?(ViewComponent::Cacheable) + if is_a?(ViewComponent::ExperimentallyCacheable) __vc_render_cacheable(@current_template.safe_method_name_call) else instance_exec(&@current_template.safe_method_name_call) diff --git a/lib/view_component/cacheable.rb b/lib/view_component/experimentally_cacheable.rb similarity index 98% rename from lib/view_component/cacheable.rb rename to lib/view_component/experimentally_cacheable.rb index b42fdcfeb..b4ab9c875 100644 --- a/lib/view_component/cacheable.rb +++ b/lib/view_component/experimentally_cacheable.rb @@ -3,7 +3,7 @@ require "view_component/cache_registry" require "view_component/cache_digestor" -module ViewComponent::Cacheable +module ViewComponent::ExperimentallyCacheable extend ActiveSupport::Concern included do diff --git a/lib/view_component/fragment_caching.rb b/lib/view_component/fragment_caching.rb deleted file mode 100644 index f4f24d1a8..000000000 --- a/lib/view_component/fragment_caching.rb +++ /dev/null @@ -1,13 +0,0 @@ -# frozen_string_literal: true - -require "view_component" - -module ViewComponent - module FragmentCaching - def self.enable! - ViewComponent::Base.include(ViewComponent::Cacheable) unless ViewComponent::Base < ViewComponent::Cacheable - end - end -end - -ViewComponent::FragmentCaching.enable! diff --git a/test/sandbox/app/components/cache_component.rb b/test/sandbox/app/components/cache_component.rb index 0236f0b47..9633eb35d 100644 --- a/test/sandbox/app/components/cache_component.rb +++ b/test/sandbox/app/components/cache_component.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true class CacheComponent < ViewComponent::Base - include ViewComponent::Cacheable + include ViewComponent::ExperimentallyCacheable cache_on :foo, :bar diff --git a/test/sandbox/app/components/cache_digestor_parent_component.rb b/test/sandbox/app/components/cache_digestor_parent_component.rb index 0c81c0af5..cbab5752f 100644 --- a/test/sandbox/app/components/cache_digestor_parent_component.rb +++ b/test/sandbox/app/components/cache_digestor_parent_component.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true class CacheDigestorParentComponent < ViewComponent::Base - include ViewComponent::Cacheable + include ViewComponent::ExperimentallyCacheable cache_on :foo diff --git a/test/sandbox/app/components/cache_digestor_partial_parent_component.html.erb b/test/sandbox/app/components/cache_digestor_partial_parent_component.html.erb new file mode 100644 index 000000000..f034bf35d --- /dev/null +++ b/test/sandbox/app/components/cache_digestor_partial_parent_component.html.erb @@ -0,0 +1,3 @@ +
+ <%= render "shared/cache_digestor_partial" %> +
diff --git a/test/sandbox/app/components/cache_digestor_partial_parent_component.rb b/test/sandbox/app/components/cache_digestor_partial_parent_component.rb new file mode 100644 index 000000000..ce49c318c --- /dev/null +++ b/test/sandbox/app/components/cache_digestor_partial_parent_component.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +class CacheDigestorPartialParentComponent < ViewComponent::Base + include ViewComponent::ExperimentallyCacheable + + cache_on :foo + + attr_reader :foo + + def initialize(foo:) + @foo = foo + end +end diff --git a/test/sandbox/app/components/inline_cache_component.rb b/test/sandbox/app/components/inline_cache_component.rb index d7d6a4ea6..dd6a84d66 100644 --- a/test/sandbox/app/components/inline_cache_component.rb +++ b/test/sandbox/app/components/inline_cache_component.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true class InlineCacheComponent < ViewComponent::Base - include ViewComponent::Cacheable + include ViewComponent::ExperimentallyCacheable cache_on :foo, :bar diff --git a/test/sandbox/app/components/no_cache_component.rb b/test/sandbox/app/components/no_cache_component.rb index 4b078e19a..2635739cc 100644 --- a/test/sandbox/app/components/no_cache_component.rb +++ b/test/sandbox/app/components/no_cache_component.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true class NoCacheComponent < ViewComponent::Base - include ViewComponent::Cacheable + include ViewComponent::ExperimentallyCacheable attr_reader :foo, :bar diff --git a/test/sandbox/app/views/shared/_cache_digestor_partial.html.erb b/test/sandbox/app/views/shared/_cache_digestor_partial.html.erb new file mode 100644 index 000000000..8fca5d1fc --- /dev/null +++ b/test/sandbox/app/views/shared/_cache_digestor_partial.html.erb @@ -0,0 +1 @@ +partial-v1 diff --git a/test/sandbox/test/rendering_test.rb b/test/sandbox/test/rendering_test.rb index e311f7a04..4a94f4405 100644 --- a/test/sandbox/test/rendering_test.rb +++ b/test/sandbox/test/rendering_test.rb @@ -1340,8 +1340,6 @@ def test_around_render end def test_inline_cache_component - return if Rails.version < "7.0" - component = InlineCacheComponent.new(foo: "foo", bar: "bar") render_inline(component) @@ -1360,8 +1358,6 @@ def test_inline_cache_component end def test_cache_component - return if Rails.version < "7.0" - component = CacheComponent.new(foo: "foo", bar: "bar") render_inline(component) @@ -1380,8 +1376,6 @@ def test_cache_component end def test_no_cache_compoennt - return if Rails.version < "7.0" - component = NoCacheComponent.new(foo: "foo", bar: "bar") render_inline(component) @@ -1390,8 +1384,6 @@ def test_no_cache_compoennt end def test_cache_key_changes_when_child_component_template_changes - return if Rails.version < "7.0" - child_template_path = CacheDigestorChildComponent.sidecar_files(["erb"]).first original_template = File.read(child_template_path) @@ -1423,6 +1415,32 @@ def test_cache_key_changes_when_child_component_template_changes end end + def test_cache_key_does_not_change_when_partial_string_dependency_changes + partial_path = Rails.root.join("app/views/shared/_cache_digestor_partial.html.erb") + original_partial = File.read(partial_path) + + Rails.cache.clear + ViewComponent::CompileCache.invalidate! + + component_v1 = CacheDigestorPartialParentComponent.new(foo: "x") + render_inline(component_v1) + assert_selector(".partial-child", text: "partial-v1") + time_v1 = page.find(".partial-parent")["data-time"] + + File.write(partial_path, original_partial.sub("partial-v1", "partial-v2")) + ViewComponent::CompileCache.invalidate! + + render_inline(CacheDigestorPartialParentComponent.new(foo: "x")) + + assert_selector(".partial-child", text: "partial-v1") + assert_equal(time_v1, page.find(".partial-parent")["data-time"]) + ensure + Rails.cache.clear + ViewComponent::CompileCache.invalidate! + + File.write(partial_path, original_partial) if partial_path && original_partial + end + def test_render_partial_with_yield render_inline(PartialWithYieldComponent.new) assert_text "hello world", exact: true, normalize_ws: true From 2967a5e259316fbcda03401c0621d1148ba94b03 Mon Sep 17 00:00:00 2001 From: Reegan Date: Thu, 12 Feb 2026 11:51:33 +0200 Subject: [PATCH 081/122] code review: added a dedicated regression test for child partial dependency changes --- ..._digestor_child_partial_component.html.erb | 1 + .../cache_digestor_child_partial_component.rb | 4 +++ ...r_nested_partial_parent_component.html.erb | 3 +++ ...igestor_nested_partial_parent_component.rb | 13 ++++++++++ .../_cache_digestor_nested_partial.html.erb | 1 + test/sandbox/test/rendering_test.rb | 26 +++++++++++++++++++ 6 files changed, 48 insertions(+) create mode 100644 test/sandbox/app/components/cache_digestor_child_partial_component.html.erb create mode 100644 test/sandbox/app/components/cache_digestor_child_partial_component.rb create mode 100644 test/sandbox/app/components/cache_digestor_nested_partial_parent_component.html.erb create mode 100644 test/sandbox/app/components/cache_digestor_nested_partial_parent_component.rb create mode 100644 test/sandbox/app/views/shared/_cache_digestor_nested_partial.html.erb diff --git a/test/sandbox/app/components/cache_digestor_child_partial_component.html.erb b/test/sandbox/app/components/cache_digestor_child_partial_component.html.erb new file mode 100644 index 000000000..3afc7a63a --- /dev/null +++ b/test/sandbox/app/components/cache_digestor_child_partial_component.html.erb @@ -0,0 +1 @@ +<%= render "shared/cache_digestor_nested_partial" %> diff --git a/test/sandbox/app/components/cache_digestor_child_partial_component.rb b/test/sandbox/app/components/cache_digestor_child_partial_component.rb new file mode 100644 index 000000000..54eb4ef90 --- /dev/null +++ b/test/sandbox/app/components/cache_digestor_child_partial_component.rb @@ -0,0 +1,4 @@ +# frozen_string_literal: true + +class CacheDigestorChildPartialComponent < ViewComponent::Base +end diff --git a/test/sandbox/app/components/cache_digestor_nested_partial_parent_component.html.erb b/test/sandbox/app/components/cache_digestor_nested_partial_parent_component.html.erb new file mode 100644 index 000000000..cce2a9786 --- /dev/null +++ b/test/sandbox/app/components/cache_digestor_nested_partial_parent_component.html.erb @@ -0,0 +1,3 @@ +
+ <%= render CacheDigestorChildPartialComponent.new %> +
diff --git a/test/sandbox/app/components/cache_digestor_nested_partial_parent_component.rb b/test/sandbox/app/components/cache_digestor_nested_partial_parent_component.rb new file mode 100644 index 000000000..2776c3462 --- /dev/null +++ b/test/sandbox/app/components/cache_digestor_nested_partial_parent_component.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +class CacheDigestorNestedPartialParentComponent < ViewComponent::Base + include ViewComponent::ExperimentallyCacheable + + cache_on :foo + + attr_reader :foo + + def initialize(foo:) + @foo = foo + end +end diff --git a/test/sandbox/app/views/shared/_cache_digestor_nested_partial.html.erb b/test/sandbox/app/views/shared/_cache_digestor_nested_partial.html.erb new file mode 100644 index 000000000..adc577b1c --- /dev/null +++ b/test/sandbox/app/views/shared/_cache_digestor_nested_partial.html.erb @@ -0,0 +1 @@ +nested-v1 diff --git a/test/sandbox/test/rendering_test.rb b/test/sandbox/test/rendering_test.rb index 4a94f4405..3085ccd4b 100644 --- a/test/sandbox/test/rendering_test.rb +++ b/test/sandbox/test/rendering_test.rb @@ -1441,6 +1441,32 @@ def test_cache_key_does_not_change_when_partial_string_dependency_changes File.write(partial_path, original_partial) if partial_path && original_partial end + def test_cache_key_does_not_change_when_child_component_partial_dependency_changes + partial_path = Rails.root.join("app/views/shared/_cache_digestor_nested_partial.html.erb") + original_partial = File.read(partial_path) + + Rails.cache.clear + ViewComponent::CompileCache.invalidate! + + component_v1 = CacheDigestorNestedPartialParentComponent.new(foo: "x") + render_inline(component_v1) + assert_selector(".nested-partial-child", text: "nested-v1") + time_v1 = page.find(".nested-partial-parent")["data-time"] + + File.write(partial_path, original_partial.sub("nested-v1", "nested-v2")) + ViewComponent::CompileCache.invalidate! + + render_inline(CacheDigestorNestedPartialParentComponent.new(foo: "x")) + + assert_selector(".nested-partial-child", text: "nested-v1") + assert_equal(time_v1, page.find(".nested-partial-parent")["data-time"]) + ensure + Rails.cache.clear + ViewComponent::CompileCache.invalidate! + + File.write(partial_path, original_partial) if partial_path && original_partial + end + def test_render_partial_with_yield render_inline(PartialWithYieldComponent.new) assert_text "hello world", exact: true, normalize_ws: true From 4822177d67d1530364467de3c5ae4318adff5595 Mon Sep 17 00:00:00 2001 From: Reegan Date: Thu, 12 Feb 2026 11:54:57 +0200 Subject: [PATCH 082/122] code review: added shared integration-style spec approach from fork for potential adoption --- .../app/components/cache_component.html.erb | 3 +-- test/sandbox/test/integration_test.rb | 27 +++++++++++++++++++ 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/test/sandbox/app/components/cache_component.html.erb b/test/sandbox/app/components/cache_component.html.erb index 1ba99c998..4b968b659 100644 --- a/test/sandbox/app/components/cache_component.html.erb +++ b/test/sandbox/app/components/cache_component.html.erb @@ -1,4 +1,3 @@

<%= view_cache_dependencies %>

-

"><%= "#{foo} #{bar}" %>

- +

<%= "#{foo} #{bar}" %>

<%= render(ButtonToComponent.new) %> diff --git a/test/sandbox/test/integration_test.rb b/test/sandbox/test/integration_test.rb index 0de660347..5cd3ebc26 100644 --- a/test/sandbox/test/integration_test.rb +++ b/test/sandbox/test/integration_test.rb @@ -273,6 +273,33 @@ def test_rendering_component_with_caching Rails.cache.clear end + def test_rendering_cacheable_component_in_controller + Rails.cache.clear + ActionController::Base.perform_caching = true + + get "/controller_inline_cached?foo=foo&bar=bar" + assert_response :success + assert_select ".cache-component__cache-message", text: "foo bar" + first_time = response.body[/data-time(?:=data-time)?="([^"]+)"/, 1] + refute_nil first_time + + get "/controller_inline_cached?foo=foo&bar=bar" + assert_response :success + assert_select ".cache-component__cache-message", text: "foo bar" + second_time = response.body[/data-time(?:=data-time)?="([^"]+)"/, 1] + assert_equal first_time, second_time + + get "/controller_inline_cached?foo=foo&bar=baz" + assert_response :success + assert_select ".cache-component__cache-message", text: "foo baz" + third_time = response.body[/data-time(?:=data-time)?="([^"]+)"/, 1] + refute_nil third_time + refute_equal first_time, third_time + ensure + ActionController::Base.perform_caching = false + Rails.cache.clear + end + def test_optional_rendering_component_depending_on_request_context get "/render_check" assert_response :success From 805392d70a6f77c91b888a58fe1b1096ef274c1b Mon Sep 17 00:00:00 2001 From: Reegan Date: Thu, 12 Feb 2026 14:17:18 +0200 Subject: [PATCH 083/122] code review: consider reusing parser approach from existing project instead of custom implementation. --- .../template_dependency_extractor.rb | 22 ++++--------------- test/sandbox/test/integration_test.rb | 6 ++--- 2 files changed, 7 insertions(+), 21 deletions(-) diff --git a/lib/view_component/template_dependency_extractor.rb b/lib/view_component/template_dependency_extractor.rb index 5534c0816..37c32316d 100644 --- a/lib/view_component/template_dependency_extractor.rb +++ b/lib/view_component/template_dependency_extractor.rb @@ -1,5 +1,7 @@ # frozen_string_literal: true +require "action_view/render_parser" + require_relative "template_ast_builder" require_relative "prism_render_dependency_extractor" @@ -31,25 +33,9 @@ def extract_from_ruby(ruby_code) return unless ruby_code.include?("render") PrismRenderDependencyExtractor.new(ruby_code).extract.each { @dependencies << _1 } - extract_partial_or_layout(ruby_code) - end - - PARTIAL_RENDER = /partial:\s*["']([^"']+)["']/ - LAYOUT_RENDER = /layout:\s*["']([^"']+)["']/ - DIRECT_RENDER = /render\s*\(?\s*["']([^"']+)["']/ - private_constant :PARTIAL_RENDER, :LAYOUT_RENDER, :DIRECT_RENDER - - def extract_partial_or_layout(code) - if (partial_match = code.match(PARTIAL_RENDER)) - @dependencies << partial_match[1] - end - - if (layout_match = code.match(LAYOUT_RENDER)) - @dependencies << layout_match[1] - end - if (direct_render = code.match(DIRECT_RENDER)) - @dependencies << direct_render[1] + ActionView::RenderParser::Default.new("view_component/template", ruby_code).render_calls.each do |render_path| + @dependencies << render_path.gsub(%r{/_}, "/") end end diff --git a/test/sandbox/test/integration_test.rb b/test/sandbox/test/integration_test.rb index 5cd3ebc26..69093540f 100644 --- a/test/sandbox/test/integration_test.rb +++ b/test/sandbox/test/integration_test.rb @@ -280,19 +280,19 @@ def test_rendering_cacheable_component_in_controller get "/controller_inline_cached?foo=foo&bar=bar" assert_response :success assert_select ".cache-component__cache-message", text: "foo bar" - first_time = response.body[/data-time(?:=data-time)?="([^"]+)"/, 1] + first_time = css_select(".cache-component__cache-message").first["data-time"] refute_nil first_time get "/controller_inline_cached?foo=foo&bar=bar" assert_response :success assert_select ".cache-component__cache-message", text: "foo bar" - second_time = response.body[/data-time(?:=data-time)?="([^"]+)"/, 1] + second_time = css_select(".cache-component__cache-message").first["data-time"] assert_equal first_time, second_time get "/controller_inline_cached?foo=foo&bar=baz" assert_response :success assert_select ".cache-component__cache-message", text: "foo baz" - third_time = response.body[/data-time(?:=data-time)?="([^"]+)"/, 1] + third_time = css_select(".cache-component__cache-message").first["data-time"] refute_nil third_time refute_equal first_time, third_time ensure From ac584e4d37363f2bd0bdd5b335f4d48aa4613dd2 Mon Sep 17 00:00:00 2001 From: Reegan Date: Thu, 12 Feb 2026 14:19:30 +0200 Subject: [PATCH 084/122] add cacing benchmark --- performance/cache_benchmark.rb | 51 +++++++++++++++++++ .../cacheable_benchmark_component.html.erb | 1 + .../cacheable_benchmark_component.rb | 15 ++++++ ...non_cacheable_benchmark_component.html.erb | 1 + .../non_cacheable_benchmark_component.rb | 11 ++++ 5 files changed, 79 insertions(+) create mode 100644 performance/cache_benchmark.rb create mode 100644 performance/components/cacheable_benchmark_component.html.erb create mode 100644 performance/components/cacheable_benchmark_component.rb create mode 100644 performance/components/non_cacheable_benchmark_component.html.erb create mode 100644 performance/components/non_cacheable_benchmark_component.rb diff --git a/performance/cache_benchmark.rb b/performance/cache_benchmark.rb new file mode 100644 index 000000000..9a9bc3534 --- /dev/null +++ b/performance/cache_benchmark.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +require "benchmark/ips" + +# Configure Rails Environment +ENV["RAILS_ENV"] = "production" +require File.expand_path("../test/sandbox/config/environment.rb", __dir__) + +Rails.logger.level = 1 + +module Performance + require_relative "components/cacheable_benchmark_component" + require_relative "components/non_cacheable_benchmark_component" +end + +class BenchmarksController < ActionController::Base +end + +ActionController::Base.perform_caching = true +Rails.cache.clear + +BenchmarksController.view_paths = [File.expand_path("./views", __dir__)] +controller_view = BenchmarksController.new.view_context + +cacheable_warm_component = Performance::CacheableBenchmarkComponent.new(name: "Fox Mulder") +non_cacheable_component = Performance::NonCacheableBenchmarkComponent.new(name: "Fox Mulder") +cache_miss_counter = 0 + +# Prime compile + cache so we benchmark steady-state behavior. +controller_view.render(cacheable_warm_component) +controller_view.render(non_cacheable_component) + +Benchmark.ips do |x| + x.time = ENV.fetch("BENCHMARK_TIME", "10").to_i + x.warmup = ENV.fetch("BENCHMARK_WARMUP", "2").to_i + + x.report("non_cacheable") do + controller_view.render(non_cacheable_component) + end + + x.report("cacheable_miss") do + cache_miss_counter += 1 + controller_view.render(Performance::CacheableBenchmarkComponent.new(name: "Fox Mulder #{cache_miss_counter}")) + end + + x.report("cacheable_hit") do + controller_view.render(cacheable_warm_component) + end + + x.compare! +end diff --git a/performance/components/cacheable_benchmark_component.html.erb b/performance/components/cacheable_benchmark_component.html.erb new file mode 100644 index 000000000..111cb3d37 --- /dev/null +++ b/performance/components/cacheable_benchmark_component.html.erb @@ -0,0 +1 @@ +
<%= name %>
diff --git a/performance/components/cacheable_benchmark_component.rb b/performance/components/cacheable_benchmark_component.rb new file mode 100644 index 000000000..01a0d574d --- /dev/null +++ b/performance/components/cacheable_benchmark_component.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module Performance + class CacheableBenchmarkComponent < ViewComponent::Base + include ViewComponent::ExperimentallyCacheable + + cache_on :name + + attr_reader :name + + def initialize(name:) + @name = name + end + end +end diff --git a/performance/components/non_cacheable_benchmark_component.html.erb b/performance/components/non_cacheable_benchmark_component.html.erb new file mode 100644 index 000000000..ac8082fc0 --- /dev/null +++ b/performance/components/non_cacheable_benchmark_component.html.erb @@ -0,0 +1 @@ +
<%= name %>
diff --git a/performance/components/non_cacheable_benchmark_component.rb b/performance/components/non_cacheable_benchmark_component.rb new file mode 100644 index 000000000..888838f66 --- /dev/null +++ b/performance/components/non_cacheable_benchmark_component.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module Performance + class NonCacheableBenchmarkComponent < ViewComponent::Base + attr_reader :name + + def initialize(name:) + @name = name + end + end +end From 2471962a0d423b54aeaedab722f0c698f92237ff Mon Sep 17 00:00:00 2001 From: Reegan Viljoen <62689748+reeganviljoen@users.noreply.github.com> Date: Thu, 12 Feb 2026 14:24:20 +0200 Subject: [PATCH 085/122] Update docs/guide/caching.md Co-authored-by: Joel Hawksley --- docs/guide/caching.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/guide/caching.md b/docs/guide/caching.md index df00b4ba4..31d56ea8d 100644 --- a/docs/guide/caching.md +++ b/docs/guide/caching.md @@ -13,7 +13,7 @@ Caching is experimental. To enable caching, include `ViewComponent::ExperimentallyCacheable`. -Components implement caching by marking the dependencies that should be included in the cache key using `cache_on`: +Components implement caching by marking dependencies using `cache_on`: ```ruby class CacheComponent < ViewComponent::Base From 3d4131a05ac57d89aba3b30bbed3afde8e67d9b5 Mon Sep 17 00:00:00 2001 From: Reegan Date: Thu, 12 Feb 2026 14:31:40 +0200 Subject: [PATCH 086/122] fix code that was moved incorectly --- lib/view_component/base.rb | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/view_component/base.rb b/lib/view_component/base.rb index 5e970f0e8..34f1c5156 100644 --- a/lib/view_component/base.rb +++ b/lib/view_component/base.rb @@ -54,9 +54,6 @@ def config include ViewComponent::Translatable include ViewComponent::WithContentHelper - # 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 - # For CSRF authenticity tokens in forms delegate :form_authenticity_token, :protect_against_forgery?, :config, to: :helpers @@ -69,6 +66,9 @@ def config # For Content Security Policy nonces delegate :content_security_policy_nonce, to: :helpers + # Config:attr_writer :attr_names option that strips trailing whitespace in templates before compiling them. + class_attribute :__vc_strip_trailing_whitespace, instance_accessor: false, instance_predicate: false, default: false + attr_accessor :__vc_original_view_context attr_reader :current_template From a095f98eab9423c21435cc5a58cf5f064f3e45e0 Mon Sep 17 00:00:00 2001 From: Reegan Date: Thu, 12 Feb 2026 15:05:22 +0200 Subject: [PATCH 087/122] imrpove performnce --- .../experimentally_cacheable.rb | 71 ++++++++++++++----- performance/cache_benchmark.rb | 34 +++++---- .../cacheable_benchmark_component.rb | 5 ++ .../cache_condition_component.html.erb | 1 + .../components/cache_condition_component.rb | 18 +++++ test/sandbox/test/rendering_test.rb | 12 ++++ 6 files changed, 110 insertions(+), 31 deletions(-) create mode 100644 test/sandbox/app/components/cache_condition_component.html.erb create mode 100644 test/sandbox/app/components/cache_condition_component.rb diff --git a/lib/view_component/experimentally_cacheable.rb b/lib/view_component/experimentally_cacheable.rb index b4ab9c875..53aa4bba5 100644 --- a/lib/view_component/experimentally_cacheable.rb +++ b/lib/view_component/experimentally_cacheable.rb @@ -8,31 +8,33 @@ module ViewComponent::ExperimentallyCacheable included do class_attribute :__vc_cache_dependencies, default: Set.new + class_attribute :__vc_cache_if, default: nil # For caching, such as #cache_if # # @private def view_cache_dependencies - self.class.__vc_cache_dependencies.map { |dep| send(dep) } + @__vc_cache_dependencies ||= self.class.__vc_cache_dependencies.map { |dep| send(dep) } end def view_cache_options - return if self.class.__vc_cache_dependencies.blank? + return @__vc_cache_options if instance_variable_defined?(:@__vc_cache_options) + return @__vc_cache_options = nil if self.class.__vc_cache_dependencies.blank? template_key = __vc_cache_template_key - return if template_key.nil? + return @__vc_cache_options = nil if template_key.nil? - cache_key_parts = [self.class.name, self.class.virtual_path, template_key, view_cache_dependencies, component_digest] - combined_fragment_cache_key(ActiveSupport::Cache.expand_cache_key(cache_key_parts)) + cache_key_parts = [__vc_static_cache_key_parts(template_key), view_cache_dependencies] + @__vc_cache_options = combined_fragment_cache_key(ActiveSupport::Cache.expand_cache_key(cache_key_parts)) end # Render component from cache if possible # # @private def __vc_render_cacheable(safe_call) - if view_cache_options + if __vc_cache_enabled? && (cache_key = view_cache_options) ViewComponent::CachingRegistry.track_caching do - template_fragment(safe_call) + template_fragment(cache_key, safe_call) end else instance_exec(&safe_call) @@ -46,25 +48,23 @@ def __vc_cache_template_key [@current_template.call_method_name, @current_template.virtual_path] end - def template_fragment(safe_call) - if (content = read_fragment) + def template_fragment(cache_key, safe_call) + if (content = read_fragment(cache_key)) @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(safe_call) + write_fragment(cache_key, safe_call) end end - def read_fragment - Rails.cache.fetch(view_cache_options) + def read_fragment(cache_key) + Rails.cache.read(cache_key) end - def write_fragment(safe_call) + def write_fragment(cache_key, safe_call) content = instance_exec(&safe_call) - Rails.cache.fetch(view_cache_options) do - content - end + Rails.cache.write(cache_key, content) content end @@ -76,11 +76,47 @@ def combined_fragment_cache_key(key) end def component_digest - @__vc_component_digest ||= ViewComponent::CacheDigestor.new(component: self).digest + if ActionView::Base.cache_template_loading + self.class.instance_variable_get(:@__vc_component_digest) || + self.class.instance_variable_set(:@__vc_component_digest, ViewComponent::CacheDigestor.new(component: self).digest) + else + @__vc_component_digest ||= ViewComponent::CacheDigestor.new(component: self).digest + end + end + + def __vc_static_cache_key_parts(template_key) + digest = component_digest + key = [template_key, digest] + + static_key_cache = self.class.instance_variable_get(:@__vc_static_cache_key_parts) + unless static_key_cache + static_key_cache = {} + self.class.instance_variable_set(:@__vc_static_cache_key_parts, static_key_cache) + end + + static_key_cache[key] ||= [self.class.name, self.class.virtual_path, template_key, digest].freeze + end + + def __vc_cache_enabled? + cache_if = self.class.__vc_cache_if + return true if cache_if.nil? + + case cache_if + when Symbol, String + public_send(cache_if) + when Proc + instance_exec(&cache_if) + else + !!cache_if + end end end class_methods do + def cache_if(value = nil, &block) + self.__vc_cache_if = block || value + end + # For caching the component def cache_on(*args) __vc_cache_dependencies.merge(args) @@ -88,6 +124,7 @@ def cache_on(*args) def inherited(child) child.__vc_cache_dependencies = __vc_cache_dependencies.dup + child.__vc_cache_if = __vc_cache_if super end diff --git a/performance/cache_benchmark.rb b/performance/cache_benchmark.rb index 9a9bc3534..d392260bb 100644 --- a/performance/cache_benchmark.rb +++ b/performance/cache_benchmark.rb @@ -17,6 +17,8 @@ class BenchmarksController < ActionController::Base end ActionController::Base.perform_caching = true +original_cache = Rails.cache +Rails.cache = ActiveSupport::Cache::MemoryStore.new(size: 64.megabytes) Rails.cache.clear BenchmarksController.view_paths = [File.expand_path("./views", __dir__)] @@ -30,22 +32,26 @@ class BenchmarksController < ActionController::Base controller_view.render(cacheable_warm_component) controller_view.render(non_cacheable_component) -Benchmark.ips do |x| - x.time = ENV.fetch("BENCHMARK_TIME", "10").to_i - x.warmup = ENV.fetch("BENCHMARK_WARMUP", "2").to_i +begin + Benchmark.ips do |x| + x.time = ENV.fetch("BENCHMARK_TIME", "20").to_i + x.warmup = ENV.fetch("BENCHMARK_WARMUP", "5").to_i - x.report("non_cacheable") do - controller_view.render(non_cacheable_component) - end + x.report("non_cacheable") do + controller_view.render(non_cacheable_component) + end - x.report("cacheable_miss") do - cache_miss_counter += 1 - controller_view.render(Performance::CacheableBenchmarkComponent.new(name: "Fox Mulder #{cache_miss_counter}")) - end + x.report("cacheable_miss") do + cache_miss_counter += 1 + controller_view.render(Performance::CacheableBenchmarkComponent.new(name: "Fox Mulder #{cache_miss_counter}")) + end - x.report("cacheable_hit") do - controller_view.render(cacheable_warm_component) - end + x.report("cacheable_hit") do + controller_view.render(cacheable_warm_component) + end - x.compare! + x.compare! + end +ensure + Rails.cache = original_cache end diff --git a/performance/components/cacheable_benchmark_component.rb b/performance/components/cacheable_benchmark_component.rb index 01a0d574d..af13f835d 100644 --- a/performance/components/cacheable_benchmark_component.rb +++ b/performance/components/cacheable_benchmark_component.rb @@ -4,6 +4,7 @@ module Performance class CacheableBenchmarkComponent < ViewComponent::Base include ViewComponent::ExperimentallyCacheable + cache_if :cache_worthy? cache_on :name attr_reader :name @@ -11,5 +12,9 @@ class CacheableBenchmarkComponent < ViewComponent::Base def initialize(name:) @name = name end + + def cache_worthy? + !name.match?(/\d+\z/) + end end end diff --git a/test/sandbox/app/components/cache_condition_component.html.erb b/test/sandbox/app/components/cache_condition_component.html.erb new file mode 100644 index 000000000..61035c27e --- /dev/null +++ b/test/sandbox/app/components/cache_condition_component.html.erb @@ -0,0 +1 @@ +

<%= foo %>

diff --git a/test/sandbox/app/components/cache_condition_component.rb b/test/sandbox/app/components/cache_condition_component.rb new file mode 100644 index 000000000..5c229927e --- /dev/null +++ b/test/sandbox/app/components/cache_condition_component.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +class CacheConditionComponent < ViewComponent::Base + include ViewComponent::ExperimentallyCacheable + + cache_if :cache_enabled? + cache_on :foo + + attr_reader :foo + + def initialize(foo:) + @foo = foo + end + + def cache_enabled? + false + end +end diff --git a/test/sandbox/test/rendering_test.rb b/test/sandbox/test/rendering_test.rb index 3085ccd4b..28127daf0 100644 --- a/test/sandbox/test/rendering_test.rb +++ b/test/sandbox/test/rendering_test.rb @@ -1383,6 +1383,18 @@ def test_no_cache_compoennt assert_selector(".cache-component__cache-message", text: "foo bar") end + def test_cache_if_false_skips_caching + component = CacheConditionComponent.new(foo: "foo") + + render_inline(component) + first_time = page.find(".cache-condition-component__message")["data-time"] + + render_inline(component) + second_time = page.find(".cache-condition-component__message")["data-time"] + + refute_equal(first_time, second_time) + end + def test_cache_key_changes_when_child_component_template_changes child_template_path = CacheDigestorChildComponent.sidecar_files(["erb"]).first original_template = File.read(child_template_path) From 42d7f7b3b766f2a1b77e4a7b53f6baf71b1d8d1e Mon Sep 17 00:00:00 2001 From: Reegan Date: Thu, 12 Feb 2026 15:32:50 +0200 Subject: [PATCH 088/122] add refactors to improve benchmarks --- lib/view_component/cache_digestor.rb | 37 +++++++-------- .../experimentally_cacheable.rb | 40 ++++++++++------- lib/view_component/template_ast_builder.rb | 45 ++++++++++--------- 3 files changed, 66 insertions(+), 56 deletions(-) diff --git a/lib/view_component/cache_digestor.rb b/lib/view_component/cache_digestor.rb index 20d9284eb..bded33a17 100644 --- a/lib/view_component/cache_digestor.rb +++ b/lib/view_component/cache_digestor.rb @@ -22,17 +22,13 @@ def digest private_constant :IN_PROGRESS def digest_for_component(component_class) + return "" unless component_class <= ViewComponent::Base name = component_class.name return "" unless name - return "" unless component_class <= ViewComponent::Base - case (cached_digest = @digests[name]) - when IN_PROGRESS - return "" - when nil - else - return cached_digest - end + cached_digest = @digests[name] + return "" if cached_digest == IN_PROGRESS + return cached_digest if cached_digest @digests[name] = IN_PROGRESS @@ -42,28 +38,33 @@ def digest_for_component(component_class) inline_template = component_class.__vc_inline_template if inline_template - update_digest(digest, inline_template.source) - - dependencies = ViewComponent::TemplateDependencyExtractor.new(inline_template.source, inline_template.language).extract - update_dependency_digests(digest, dependencies) + inline_source = inline_template.source + update_digest(digest, inline_source) + update_template_dependency_digests(digest, inline_source, inline_template.language) end - component_class.sidecar_files(ActionView::Template.template_handler_extensions).sort.each do |path| + template_paths = component_class.sidecar_files(ActionView::Template.template_handler_extensions).sort + template_paths.each do |path| template_source = file_contents(path) update_digest(digest, template_source) - - handler = path.rpartition(".").last - dependencies = ViewComponent::TemplateDependencyExtractor.new(template_source, handler).extract - update_dependency_digests(digest, dependencies) + update_template_dependency_digests(digest, template_source, File.extname(path).delete_prefix(".")) end - component_class.sidecar_files(%w[yml yaml]).sort.each do |path| + i18n_paths = component_class.sidecar_files(%w[yml yaml]).sort + i18n_paths.each do |path| update_digest(digest, file_contents(path)) end @digests[name] = digest.hexdigest end + def update_template_dependency_digests(digest, template_source, handler) + return unless template_source&.include?("render") + + dependencies = ViewComponent::TemplateDependencyExtractor.new(template_source, handler).extract + update_dependency_digests(digest, dependencies) + end + def update_dependency_digests(digest, dependencies) dependencies.each do |dep| next unless uppercase_constant?(dep) diff --git a/lib/view_component/experimentally_cacheable.rb b/lib/view_component/experimentally_cacheable.rb index 53aa4bba5..4d67f3871 100644 --- a/lib/view_component/experimentally_cacheable.rb +++ b/lib/view_component/experimentally_cacheable.rb @@ -19,13 +19,15 @@ def view_cache_dependencies def view_cache_options return @__vc_cache_options if instance_variable_defined?(:@__vc_cache_options) - return @__vc_cache_options = nil if self.class.__vc_cache_dependencies.blank? + + dependencies = self.class.__vc_cache_dependencies + return @__vc_cache_options = nil if dependencies.empty? template_key = __vc_cache_template_key - return @__vc_cache_options = nil if template_key.nil? + return @__vc_cache_options = nil unless template_key - cache_key_parts = [__vc_static_cache_key_parts(template_key), view_cache_dependencies] - @__vc_cache_options = combined_fragment_cache_key(ActiveSupport::Cache.expand_cache_key(cache_key_parts)) + expanded_key = ActiveSupport::Cache.expand_cache_key([__vc_static_cache_key_parts(template_key), view_cache_dependencies]) + @__vc_cache_options = combined_fragment_cache_key(expanded_key) end # Render component from cache if possible @@ -76,25 +78,29 @@ def combined_fragment_cache_key(key) end def component_digest - if ActionView::Base.cache_template_loading - self.class.instance_variable_get(:@__vc_component_digest) || - self.class.instance_variable_set(:@__vc_component_digest, ViewComponent::CacheDigestor.new(component: self).digest) - else - @__vc_component_digest ||= ViewComponent::CacheDigestor.new(component: self).digest - end + return @__vc_component_digest ||= __vc_compute_component_digest unless ActionView::Base.cache_template_loading + + klass = self.class + digest = klass.instance_variable_get(:@__vc_component_digest) + return digest if digest + + klass.instance_variable_set(:@__vc_component_digest, __vc_compute_component_digest) + end + + def __vc_compute_component_digest + ViewComponent::CacheDigestor.new(component: self).digest end def __vc_static_cache_key_parts(template_key) + klass = self.class digest = component_digest - key = [template_key, digest] + call_method_name, template_virtual_path = template_key + cache_key = [call_method_name, template_virtual_path, digest] - static_key_cache = self.class.instance_variable_get(:@__vc_static_cache_key_parts) - unless static_key_cache - static_key_cache = {} - self.class.instance_variable_set(:@__vc_static_cache_key_parts, static_key_cache) - end + static_key_cache = klass.instance_variable_get(:@__vc_static_cache_key_parts) || + klass.instance_variable_set(:@__vc_static_cache_key_parts, {}) - static_key_cache[key] ||= [self.class.name, self.class.virtual_path, template_key, digest].freeze + static_key_cache[cache_key] ||= [klass.name, klass.virtual_path, [call_method_name, template_virtual_path].freeze, digest].freeze end def __vc_cache_enabled? diff --git a/lib/view_component/template_ast_builder.rb b/lib/view_component/template_ast_builder.rb index db0f292aa..d04d567c6 100644 --- a/lib/view_component/template_ast_builder.rb +++ b/lib/view_component/template_ast_builder.rb @@ -6,14 +6,8 @@ def self.build(template_string, engine_name) case engine_name.to_sym when :erb compile_erb(template_string) - when :slim - return nil unless load_slim? - - Slim::Engine.new.call(template_string) - when :haml - return nil unless load_haml? - - Haml::Engine.new.call(template_string) + else + compile_template_with_engine(template_string, engine_name) end end @@ -26,24 +20,33 @@ def self.compile_erb(template) end private_class_method :compile_erb - def self.load_slim? - return true if defined?(Slim::Engine) + def self.compile_template_with_engine(template_string, engine_name) + engine_class = load_template_engine(engine_name) + return nil unless engine_class - require "slim" - true - rescue LoadError - false + engine_class.new.call(template_string) + rescue + nil end - private_class_method :load_slim? + private_class_method :compile_template_with_engine - def self.load_haml? - return true if defined?(Haml::Engine) + def self.load_template_engine(engine_name) + engine_class = template_engine_class(engine_name) + return engine_class if engine_class - require "haml" - true + require engine_name.to_s + template_engine_class(engine_name) rescue LoadError - false + nil + end + private_class_method :load_template_engine + + def self.template_engine_class(engine_name) + engine_module_name = engine_name.to_s.tr("-", "_").split("_").map!(&:capitalize).join + Object.const_get("#{engine_module_name}::Engine") + rescue NameError + nil end - private_class_method :load_haml? + private_class_method :template_engine_class end end From 569fd91c4a6b62a53d4af3de765f9f4537b60ad2 Mon Sep 17 00:00:00 2001 From: Reegan Date: Thu, 12 Feb 2026 15:43:49 +0200 Subject: [PATCH 089/122] make benchmark cmponents more expensive too really show ccache performnce --- .../cacheable_benchmark_component.html.erb | 19 ++++++++++++++++++- .../cacheable_benchmark_component.rb | 19 +++++++++++++++++++ ...non_cacheable_benchmark_component.html.erb | 19 ++++++++++++++++++- .../non_cacheable_benchmark_component.rb | 19 +++++++++++++++++++ 4 files changed, 74 insertions(+), 2 deletions(-) diff --git a/performance/components/cacheable_benchmark_component.html.erb b/performance/components/cacheable_benchmark_component.html.erb index 111cb3d37..0351f7a61 100644 --- a/performance/components/cacheable_benchmark_component.html.erb +++ b/performance/components/cacheable_benchmark_component.html.erb @@ -1 +1,18 @@ -
<%= name %>
+
+
+

<%= name %> dashboard

+

Total score: <%= number_with_delimiter(total_score) %>

+
+
    + <% report_rows.each_with_index do |row, index| %> +
  • "> + <%= row[:label] %> + <%= number_to_currency(row[:amount]) %> + <%= pluralize(row[:events], "event") %> +
  • + <% end %> +
+
+ <%= safe_join(report_rows.first(6).map { |row| content_tag(:code, row[:slug]) }, " ") %> +
+
diff --git a/performance/components/cacheable_benchmark_component.rb b/performance/components/cacheable_benchmark_component.rb index af13f835d..eef1dcc90 100644 --- a/performance/components/cacheable_benchmark_component.rb +++ b/performance/components/cacheable_benchmark_component.rb @@ -16,5 +16,24 @@ def initialize(name:) def cache_worthy? !name.match?(/\d+\z/) end + + def report_rows + @report_rows ||= Array.new(36) do |index| + sequence = index + 1 + amount = ((sequence * 13.75) + (sequence % 4) * 2.125) + + { + label: "#{name} item #{sequence}", + amount: amount, + events: (sequence * 7) % 11 + 1, + score: ((sequence * 41) % 100) + 1, + slug: "#{name.downcase.tr(" ", "-")}-#{sequence}" + } + end + end + + def total_score + report_rows.sum { _1[:score] } + end end end diff --git a/performance/components/non_cacheable_benchmark_component.html.erb b/performance/components/non_cacheable_benchmark_component.html.erb index ac8082fc0..d1340b9c2 100644 --- a/performance/components/non_cacheable_benchmark_component.html.erb +++ b/performance/components/non_cacheable_benchmark_component.html.erb @@ -1 +1,18 @@ -
<%= name %>
+
+
+

<%= name %> dashboard

+

Total score: <%= number_with_delimiter(total_score) %>

+
+
    + <% report_rows.each_with_index do |row, index| %> +
  • "> + <%= row[:label] %> + <%= number_to_currency(row[:amount]) %> + <%= pluralize(row[:events], "event") %> +
  • + <% end %> +
+
+ <%= safe_join(report_rows.first(6).map { |row| content_tag(:code, row[:slug]) }, " ") %> +
+
diff --git a/performance/components/non_cacheable_benchmark_component.rb b/performance/components/non_cacheable_benchmark_component.rb index 888838f66..0d139ff7d 100644 --- a/performance/components/non_cacheable_benchmark_component.rb +++ b/performance/components/non_cacheable_benchmark_component.rb @@ -7,5 +7,24 @@ class NonCacheableBenchmarkComponent < ViewComponent::Base def initialize(name:) @name = name end + + def report_rows + @report_rows ||= Array.new(36) do |index| + sequence = index + 1 + amount = ((sequence * 13.75) + (sequence % 4) * 2.125) + + { + label: "#{name} item #{sequence}", + amount: amount, + events: (sequence * 7) % 11 + 1, + score: ((sequence * 41) % 100) + 1, + slug: "#{name.downcase.tr(" ", "-")}-#{sequence}" + } + end + end + + def total_score + report_rows.sum { _1[:score] } + end end end From 797c0d5b16e3d7adbc000459463e100f680b1f0c Mon Sep 17 00:00:00 2001 From: Reegan Date: Thu, 12 Feb 2026 16:01:37 +0200 Subject: [PATCH 090/122] fix ci --- .../template_dependency_extractor.rb | 38 ++++++++++++++++++- 1 file changed, 36 insertions(+), 2 deletions(-) diff --git a/lib/view_component/template_dependency_extractor.rb b/lib/view_component/template_dependency_extractor.rb index 37c32316d..7261e2aa6 100644 --- a/lib/view_component/template_dependency_extractor.rb +++ b/lib/view_component/template_dependency_extractor.rb @@ -1,6 +1,9 @@ # frozen_string_literal: true -require "action_view/render_parser" +begin + require "action_view/render_parser" +rescue LoadError +end require_relative "template_ast_builder" require_relative "prism_render_dependency_extractor" @@ -34,11 +37,42 @@ def extract_from_ruby(ruby_code) PrismRenderDependencyExtractor.new(ruby_code).extract.each { @dependencies << _1 } - ActionView::RenderParser::Default.new("view_component/template", ruby_code).render_calls.each do |render_path| + extract_render_paths(ruby_code).each do |render_path| @dependencies << render_path.gsub(%r{/_}, "/") end end + def extract_render_paths(ruby_code) + if defined?(ActionView::RenderParser::Default) + ActionView::RenderParser::Default.new("view_component/template", ruby_code).render_calls + else + extract_render_paths_fallback(ruby_code) + end + end + + PARTIAL_RENDER = /partial:\s*["']([^"']+)["']/ + LAYOUT_RENDER = /layout:\s*["']([^"']+)["']/ + DIRECT_RENDER = /render\s*\(?\s*["']([^"']+)["']/ + private_constant :PARTIAL_RENDER, :LAYOUT_RENDER, :DIRECT_RENDER + + def extract_render_paths_fallback(ruby_code) + matches = [] + + if (partial_match = ruby_code.match(PARTIAL_RENDER)) + matches << partial_match[1] + end + + if (layout_match = ruby_code.match(LAYOUT_RENDER)) + matches << layout_match[1] + end + + if (direct_match = ruby_code.match(DIRECT_RENDER)) + matches << direct_match[1] + end + + matches + end + ERB_RUBY_TAG = /<%(=|-|#)?(.*?)%>/m private_constant :ERB_RUBY_TAG From 81648c19abfe3f2dbe3cea8ccff559216377f15b Mon Sep 17 00:00:00 2001 From: Reegan Date: Thu, 12 Feb 2026 16:11:24 +0200 Subject: [PATCH 091/122] fix ci --- lib/view_component/system_test_helpers.rb | 18 +++++++----------- .../template_dependency_extractor.rb | 1 + 2 files changed, 8 insertions(+), 11 deletions(-) diff --git a/lib/view_component/system_test_helpers.rb b/lib/view_component/system_test_helpers.rb index c5677e63b..109419931 100644 --- a/lib/view_component/system_test_helpers.rb +++ b/lib/view_component/system_test_helpers.rb @@ -1,5 +1,7 @@ # frozen_string_literal: true +require "securerandom" + module ViewComponent module SystemTestHelpers include TestHelpers @@ -9,18 +11,12 @@ module SystemTestHelpers # @param layout [String] The (optional) layout to use. # @return [Proc] A block that can be used to visit the path of the inline rendered component. def with_rendered_component_path(fragment, layout: false, &block) - file = Tempfile.new( - ["rendered_#{fragment.class.name}", ".html"], - ViewComponentsSystemTestController.temp_dir - ) - begin - file.write(vc_test_controller.render_to_string(html: fragment.to_html.html_safe, layout: layout)) - file.rewind + filename = "rendered_#{fragment.class.name.gsub("::", "")}_#{SecureRandom.hex(8)}.html" + path = File.join(ViewComponentsSystemTestController.temp_dir, filename) + + File.write(path, vc_test_controller.render_to_string(html: fragment.to_html.html_safe, layout: layout)) - yield("/_system_test_entrypoint?file=#{file.path.split("/").last}") - ensure - file.unlink - end + yield("/_system_test_entrypoint?file=#{filename}") end end end diff --git a/lib/view_component/template_dependency_extractor.rb b/lib/view_component/template_dependency_extractor.rb index 7261e2aa6..ac501344b 100644 --- a/lib/view_component/template_dependency_extractor.rb +++ b/lib/view_component/template_dependency_extractor.rb @@ -50,6 +50,7 @@ def extract_render_paths(ruby_code) end end + # For Rails 7.1 wich doesnt have this built in PARTIAL_RENDER = /partial:\s*["']([^"']+)["']/ LAYOUT_RENDER = /layout:\s*["']([^"']+)["']/ DIRECT_RENDER = /render\s*\(?\s*["']([^"']+)["']/ From e70101e0f93a4973a8228e718429c1affd91b036 Mon Sep 17 00:00:00 2001 From: Reegan Date: Thu, 12 Feb 2026 16:27:55 +0200 Subject: [PATCH 092/122] add test for joels noted limitation --- .../cache_dependency_types_component.html.erb | 2 + .../cache_dependency_types_component.rb | 36 ++++++++++++++++ ..._digestor_layout_parent_component.html.erb | 5 +++ .../cache_digestor_layout_parent_component.rb | 13 ++++++ .../shared/_cache_digestor_layout.html.erb | 1 + test/sandbox/test/rendering_test.rb | 41 +++++++++++++++++++ 6 files changed, 98 insertions(+) create mode 100644 test/sandbox/app/components/cache_dependency_types_component.html.erb create mode 100644 test/sandbox/app/components/cache_dependency_types_component.rb create mode 100644 test/sandbox/app/components/cache_digestor_layout_parent_component.html.erb create mode 100644 test/sandbox/app/components/cache_digestor_layout_parent_component.rb create mode 100644 test/sandbox/app/views/shared/_cache_digestor_layout.html.erb diff --git a/test/sandbox/app/components/cache_dependency_types_component.html.erb b/test/sandbox/app/components/cache_dependency_types_component.html.erb new file mode 100644 index 000000000..1f3237fab --- /dev/null +++ b/test/sandbox/app/components/cache_dependency_types_component.html.erb @@ -0,0 +1,2 @@ +

<%= view_cache_dependencies.inspect %>

+

<%= view_cache_options.inspect %>

diff --git a/test/sandbox/app/components/cache_dependency_types_component.rb b/test/sandbox/app/components/cache_dependency_types_component.rb new file mode 100644 index 000000000..06bc0fa9a --- /dev/null +++ b/test/sandbox/app/components/cache_dependency_types_component.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +class CacheDependencyTypesRecord + include ActiveModel::Model + include GlobalID::Identification + + attr_accessor :id + + def self.find(id) + new(id: id) + end +end + +class CacheDependencyTypesComponent < ViewComponent::Base + include ViewComponent::ExperimentallyCacheable + + cache_on :record_gid, :tags, :label, :private_token + + attr_reader :record, :tags, :label + + def initialize(record:, tags:, label:) + @record = record + @tags = tags + @label = label + end + + def record_gid + record.to_global_id + end + + private + + def private_token + "private-token" + end +end diff --git a/test/sandbox/app/components/cache_digestor_layout_parent_component.html.erb b/test/sandbox/app/components/cache_digestor_layout_parent_component.html.erb new file mode 100644 index 000000000..ff7f52a4d --- /dev/null +++ b/test/sandbox/app/components/cache_digestor_layout_parent_component.html.erb @@ -0,0 +1,5 @@ +
+ <%= render layout: "shared/cache_digestor_layout" do %> + layout-body + <% end %> +
diff --git a/test/sandbox/app/components/cache_digestor_layout_parent_component.rb b/test/sandbox/app/components/cache_digestor_layout_parent_component.rb new file mode 100644 index 000000000..ef11450a7 --- /dev/null +++ b/test/sandbox/app/components/cache_digestor_layout_parent_component.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +class CacheDigestorLayoutParentComponent < ViewComponent::Base + include ViewComponent::ExperimentallyCacheable + + cache_on :foo + + attr_reader :foo + + def initialize(foo:) + @foo = foo + end +end diff --git a/test/sandbox/app/views/shared/_cache_digestor_layout.html.erb b/test/sandbox/app/views/shared/_cache_digestor_layout.html.erb new file mode 100644 index 000000000..ffdc53d01 --- /dev/null +++ b/test/sandbox/app/views/shared/_cache_digestor_layout.html.erb @@ -0,0 +1 @@ +
layout-v1 <%= yield %>
diff --git a/test/sandbox/test/rendering_test.rb b/test/sandbox/test/rendering_test.rb index 84aa9a8df..b7a4e3013 100644 --- a/test/sandbox/test/rendering_test.rb +++ b/test/sandbox/test/rendering_test.rb @@ -1412,6 +1412,21 @@ def test_cache_if_false_skips_caching refute_equal(first_time, second_time) end + def test_cache_on_expands_dependency_values_and_allows_private_methods + record = CacheDependencyTypesRecord.new(id: "42") + component = CacheDependencyTypesComponent.new(record: record, tags: ["alpha", "beta"], label: "plain-string") + + render_inline(component) + + expected_dependencies = [record.to_global_id, ["alpha", "beta"], "plain-string", "private-token"] + assert_equal(expected_dependencies, component.view_cache_dependencies) + + expanded_dependencies = ActiveSupport::Cache.expand_cache_key(expected_dependencies) + cache_key = component.view_cache_options.last + assert_includes(cache_key, expanded_dependencies) + assert_includes(expanded_dependencies, record.to_global_id.to_param) + end + def test_cache_key_changes_when_child_component_template_changes child_template_path = CacheDigestorChildComponent.sidecar_files(["erb"]).first original_template = File.read(child_template_path) @@ -1496,6 +1511,32 @@ def test_cache_key_does_not_change_when_child_component_partial_dependency_chang File.write(partial_path, original_partial) if partial_path && original_partial end + def test_cache_key_does_not_change_when_layout_string_dependency_changes + layout_path = Rails.root.join("app/views/shared/_cache_digestor_layout.html.erb") + original_layout = File.read(layout_path) + + Rails.cache.clear + ViewComponent::CompileCache.invalidate! + + component_v1 = CacheDigestorLayoutParentComponent.new(foo: "x") + render_inline(component_v1) + assert_selector(".layout-shell", text: "layout-v1") + time_v1 = page.find(".layout-parent")["data-time"] + + File.write(layout_path, original_layout.sub("layout-v1", "layout-v2")) + ViewComponent::CompileCache.invalidate! + + render_inline(CacheDigestorLayoutParentComponent.new(foo: "x")) + + assert_selector(".layout-shell", text: "layout-v1") + assert_equal(time_v1, page.find(".layout-parent")["data-time"]) + ensure + Rails.cache.clear + ViewComponent::CompileCache.invalidate! + + File.write(layout_path, original_layout) if layout_path && original_layout + end + def test_render_partial_with_yield render_inline(PartialWithYieldComponent.new) assert_text "hello world", exact: true, normalize_ws: true From ca6117113273c51a94d861aa79a76a3c3ee2ea91 Mon Sep 17 00:00:00 2001 From: Reegan Date: Thu, 12 Feb 2026 16:33:31 +0200 Subject: [PATCH 093/122] fix tests --- .../cache_dependency_types_component.rb | 17 +---------------- test/sandbox/test/rendering_test.rb | 6 +++--- 2 files changed, 4 insertions(+), 19 deletions(-) diff --git a/test/sandbox/app/components/cache_dependency_types_component.rb b/test/sandbox/app/components/cache_dependency_types_component.rb index 06bc0fa9a..76f2ad1a1 100644 --- a/test/sandbox/app/components/cache_dependency_types_component.rb +++ b/test/sandbox/app/components/cache_dependency_types_component.rb @@ -1,20 +1,9 @@ # frozen_string_literal: true -class CacheDependencyTypesRecord - include ActiveModel::Model - include GlobalID::Identification - - attr_accessor :id - - def self.find(id) - new(id: id) - end -end - class CacheDependencyTypesComponent < ViewComponent::Base include ViewComponent::ExperimentallyCacheable - cache_on :record_gid, :tags, :label, :private_token + cache_on :record, :tags, :label, :private_token attr_reader :record, :tags, :label @@ -24,10 +13,6 @@ def initialize(record:, tags:, label:) @label = label end - def record_gid - record.to_global_id - end - private def private_token diff --git a/test/sandbox/test/rendering_test.rb b/test/sandbox/test/rendering_test.rb index b7a4e3013..704b57434 100644 --- a/test/sandbox/test/rendering_test.rb +++ b/test/sandbox/test/rendering_test.rb @@ -1413,18 +1413,18 @@ def test_cache_if_false_skips_caching end def test_cache_on_expands_dependency_values_and_allows_private_methods - record = CacheDependencyTypesRecord.new(id: "42") + record = GlobalID.parse("gid://sandbox/CacheDependencyTypesRecord/42") component = CacheDependencyTypesComponent.new(record: record, tags: ["alpha", "beta"], label: "plain-string") render_inline(component) - expected_dependencies = [record.to_global_id, ["alpha", "beta"], "plain-string", "private-token"] + expected_dependencies = [record, ["alpha", "beta"], "plain-string", "private-token"] assert_equal(expected_dependencies, component.view_cache_dependencies) expanded_dependencies = ActiveSupport::Cache.expand_cache_key(expected_dependencies) cache_key = component.view_cache_options.last assert_includes(cache_key, expanded_dependencies) - assert_includes(expanded_dependencies, record.to_global_id.to_param) + assert_includes(expanded_dependencies, record.to_param) end def test_cache_key_changes_when_child_component_template_changes From 129765e9f62831a0c2cec9108bbb368d49276370 Mon Sep 17 00:00:00 2001 From: Reegan Date: Thu, 12 Feb 2026 16:42:01 +0200 Subject: [PATCH 094/122] fix ci --- lib/view_component/system_test_helpers.rb | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/lib/view_component/system_test_helpers.rb b/lib/view_component/system_test_helpers.rb index 109419931..273c7b274 100644 --- a/lib/view_component/system_test_helpers.rb +++ b/lib/view_component/system_test_helpers.rb @@ -1,5 +1,6 @@ # frozen_string_literal: true +require "base64" require "securerandom" module ViewComponent @@ -11,10 +12,17 @@ module SystemTestHelpers # @param layout [String] The (optional) layout to use. # @return [Proc] A block that can be used to visit the path of the inline rendered component. def with_rendered_component_path(fragment, layout: false, &block) + rendered_html = vc_test_controller.render_to_string(html: fragment.to_html.html_safe, layout: layout) + + if !layout + yield("data:text/html;base64,#{Base64.strict_encode64(rendered_html)}") + return + end + filename = "rendered_#{fragment.class.name.gsub("::", "")}_#{SecureRandom.hex(8)}.html" path = File.join(ViewComponentsSystemTestController.temp_dir, filename) - File.write(path, vc_test_controller.render_to_string(html: fragment.to_html.html_safe, layout: layout)) + File.write(path, rendered_html) yield("/_system_test_entrypoint?file=#{filename}") end From 98e2af96dcacd47e9174681fd865258c1ec7d598 Mon Sep 17 00:00:00 2001 From: Reegan Date: Thu, 12 Feb 2026 17:02:27 +0200 Subject: [PATCH 095/122] use ActionviewPrecompiler instread of our own stuff --- .../prism_render_dependency_extractor.rb | 54 ------------------- .../template_dependency_extractor.rb | 46 ++++++---------- 2 files changed, 15 insertions(+), 85 deletions(-) delete mode 100644 lib/view_component/prism_render_dependency_extractor.rb diff --git a/lib/view_component/prism_render_dependency_extractor.rb b/lib/view_component/prism_render_dependency_extractor.rb deleted file mode 100644 index 6627f02ee..000000000 --- a/lib/view_component/prism_render_dependency_extractor.rb +++ /dev/null @@ -1,54 +0,0 @@ -# frozen_string_literal: true - -require "prism" - -module ViewComponent - class PrismRenderDependencyExtractor - def initialize(code) - @code = code - @dependencies = [] - end - - def extract - root = Prism.parse(@code).value - walk(root) if root - @dependencies - end - - private - - def walk(node) - stack = [node] - - until stack.empty? - current = stack.pop - next unless current.is_a?(Prism::Node) - - extract_render_target(current) if current.is_a?(Prism::CallNode) && render_call?(current) - - children = current.child_nodes - next if children.empty? - - children.reverse_each { |child| stack << child if child } - end - end - - def render_call?(node) - node.receiver.nil? && node.name == :render - end - - def extract_render_target(node) - first_arg = node.arguments&.arguments&.first - return unless first_arg.is_a?(Prism::CallNode) && first_arg.name == :new - - receiver = first_arg.receiver - return unless receiver.is_a?(Prism::ConstantPathNode) || receiver.is_a?(Prism::ConstantReadNode) - - @dependencies << extract_constant_path(receiver) - end - - def extract_constant_path(const_node) - const_node.location.slice - end - end -end diff --git a/lib/view_component/template_dependency_extractor.rb b/lib/view_component/template_dependency_extractor.rb index ac501344b..42eb7078b 100644 --- a/lib/view_component/template_dependency_extractor.rb +++ b/lib/view_component/template_dependency_extractor.rb @@ -1,12 +1,8 @@ # frozen_string_literal: true -begin - require "action_view/render_parser" -rescue LoadError -end +require "actionview_precompiler" require_relative "template_ast_builder" -require_relative "prism_render_dependency_extractor" module ViewComponent class TemplateDependencyExtractor @@ -35,43 +31,31 @@ def extract def extract_from_ruby(ruby_code) return unless ruby_code.include?("render") - PrismRenderDependencyExtractor.new(ruby_code).extract.each { @dependencies << _1 } + extract_component_class_renders(ruby_code).each { @dependencies << _1 } extract_render_paths(ruby_code).each do |render_path| @dependencies << render_path.gsub(%r{/_}, "/") end end - def extract_render_paths(ruby_code) - if defined?(ActionView::RenderParser::Default) - ActionView::RenderParser::Default.new("view_component/template", ruby_code).render_calls - else - extract_render_paths_fallback(ruby_code) - end - end - - # For Rails 7.1 wich doesnt have this built in - PARTIAL_RENDER = /partial:\s*["']([^"']+)["']/ - LAYOUT_RENDER = /layout:\s*["']([^"']+)["']/ - DIRECT_RENDER = /render\s*\(?\s*["']([^"']+)["']/ - private_constant :PARTIAL_RENDER, :LAYOUT_RENDER, :DIRECT_RENDER + COMPONENT_RENDER = /(?:render|render_to_string)\s*\(?\s*([A-Z]\w*(?:::[A-Z]\w*)*)\.new\b/ + private_constant :COMPONENT_RENDER - def extract_render_paths_fallback(ruby_code) - matches = [] - - if (partial_match = ruby_code.match(PARTIAL_RENDER)) - matches << partial_match[1] - end + def extract_component_class_renders(ruby_code) + ruby_code.scan(COMPONENT_RENDER).flatten + end - if (layout_match = ruby_code.match(LAYOUT_RENDER)) - matches << layout_match[1] + def extract_render_paths(ruby_code) + render_calls = ActionviewPrecompiler::RenderParser.new(ruby_code).render_calls + render_calls.map do |call| + call.respond_to?(:virtual_path) ? call.virtual_path : call end + rescue ActionviewPrecompiler::PrismASTParser::CompilationError + require "actionview_precompiler/ast_parser/ripper" - if (direct_match = ruby_code.match(DIRECT_RENDER)) - matches << direct_match[1] + ActionviewPrecompiler::RenderParser.new(ruby_code, parser: ActionviewPrecompiler::RipperASTParser).render_calls.map do |call| + call.respond_to?(:virtual_path) ? call.virtual_path : call end - - matches end ERB_RUBY_TAG = /<%(=|-|#)?(.*?)%>/m From 8c690b9e09e66f7b220fe7341f61bdfdda8a742a Mon Sep 17 00:00:00 2001 From: Reegan Date: Fri, 13 Feb 2026 09:34:55 +0200 Subject: [PATCH 096/122] add action view precompiler --- Gemfile.lock | 12 ++++++++++++ gemfiles/rails_7.1.gemfile.lock | 3 +++ gemfiles/rails_7.2.gemfile.lock | 3 +++ gemfiles/rails_8.0.gemfile.lock | 3 +++ gemfiles/rails_8.1.gemfile.lock | 3 +++ view_component.gemspec | 1 + 6 files changed, 25 insertions(+) diff --git a/Gemfile.lock b/Gemfile.lock index 16b2fcb0c..65c7de328 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -3,6 +3,7 @@ PATH specs: view_component (4.3.0) actionview (>= 7.1.0) + actionview_precompiler (>= 0.4) activesupport (>= 7.1.0) concurrent-ruby (~> 1) @@ -55,6 +56,8 @@ GEM erubi (~> 1.11) rails-dom-testing (~> 2.2) rails-html-sanitizer (~> 1.6) + actionview_precompiler (0.4.0) + actionview (>= 6.0.a) activejob (8.1.2) activesupport (= 8.1.2) globalid (>= 0.3.6) @@ -145,6 +148,7 @@ GEM temple (>= 0.8.2) thor tilt + herb (0.8.9) herb (0.8.9-aarch64-linux-gnu) herb (0.8.9-aarch64-linux-musl) herb (0.8.9-arm-linux-gnu) @@ -182,6 +186,7 @@ GEM matrix (0.4.3) method_source (1.1.0) mini_mime (1.1.5) + mini_portile2 (2.8.9) minitest (6.0.1) prism (~> 1.5) minitest-mock (5.27.0) @@ -195,6 +200,9 @@ GEM net-smtp (0.5.1) net-protocol nio4r (2.7.5) + nokogiri (1.19.0) + mini_portile2 (~> 2.8.2) + racc (~> 1.4) nokogiri (1.19.0-aarch64-linux-gnu) racc (~> 1.4) nokogiri (1.19.0-aarch64-linux-musl) @@ -466,6 +474,7 @@ CHECKSUMS actionpack (8.1.2) sha256=ced74147a1f0daafaa4bab7f677513fd4d3add574c7839958f7b4f1de44f8423 actiontext (8.1.2) sha256=0bf57da22a9c19d970779c3ce24a56be31b51c7640f2763ec64aa72e358d2d2d actionview (8.1.2) sha256=80455b2588911c9b72cec22d240edacb7c150e800ef2234821269b2b2c3e2e5b + actionview_precompiler (0.4.0) sha256=33b6bd6ec4c1b856e02fdf5f6512c9eb4a92ac1c0545e941b3e354b7d540ed1c activejob (8.1.2) sha256=908dab3713b101859536375819f4156b07bdf4c232cc645e7538adb9e302f825 activemodel (8.1.2) sha256=e21358c11ce68aed3f9838b7e464977bc007b4446c6e4059781e1d5c03bcf33e activerecord (8.1.2) sha256=acfbe0cadfcc50fa208011fe6f4eb01cae682ebae0ef57145ba45380c74bcc44 @@ -498,6 +507,7 @@ CHECKSUMS ferrum (0.17.1) sha256=51d591120fc593e5a13b5d9d6474389f5145bb92a91e36eab147b5d096c8cbe7 globalid (1.3.0) sha256=05c639ad6eb4594522a0b07983022f04aa7254626ab69445a0e493aa3786ff11 haml (7.2.0) sha256=87fd2b71f7feab1724337b090a7d767f5ab2d42f08c974f3ead673f18cfcd55a + herb (0.8.9) sha256=8617e7eba753877cef231e3269a7e00788a9b67a91c3b79d1210ae20321b60f7 herb (0.8.9-aarch64-linux-gnu) sha256=9214cc24953c355f9ae785a95bb9362cdfc9fcd2ae4db2a32bc26c0b88f98c7f herb (0.8.9-aarch64-linux-musl) sha256=4ee883eed0935cfe2e508d0843b015d689ef9d13f00e9ec4a81a35acd061dc13 herb (0.8.9-arm-linux-gnu) sha256=9f83c76899fecd5e3429bbd66728226e7b75539cc59283829041479381a6ceea @@ -521,6 +531,7 @@ CHECKSUMS matrix (0.4.3) sha256=a0d5ab7ddcc1973ff690ab361b67f359acbb16958d1dc072b8b956a286564c5b method_source (1.1.0) sha256=181301c9c45b731b4769bc81e8860e72f9161ad7d66dd99103c9ab84f560f5c5 mini_mime (1.1.5) sha256=8681b7e2e4215f2a159f9400b5816d85e9d8c6c6b491e96a12797e798f8bccef + mini_portile2 (2.8.9) sha256=0cd7c7f824e010c072e33f68bc02d85a00aeb6fce05bb4819c03dfd3c140c289 minitest (6.0.1) sha256=7854c74f48e2e975969062833adc4013f249a4b212f5e7b9d5c040bf838d54bb minitest-mock (5.27.0) sha256=7040ed7185417a966920987eaa6eaf1be4ea1fc5b25bb03ff4703f98564a55b0 net-imap (0.6.2) sha256=08caacad486853c61676cca0c0c47df93db02abc4a8239a8b67eb0981428acc6 @@ -528,6 +539,7 @@ CHECKSUMS net-protocol (0.2.2) sha256=aa73e0cba6a125369de9837b8d8ef82a61849360eba0521900e2c3713aa162a8 net-smtp (0.5.1) sha256=ed96a0af63c524fceb4b29b0d352195c30d82dd916a42f03c62a3a70e5b70736 nio4r (2.7.5) sha256=6c90168e48fb5f8e768419c93abb94ba2b892a1d0602cb06eef16d8b7df1dca1 + nokogiri (1.19.0) sha256=e304d21865f62518e04f2bf59f93bd3a97ca7b07e7f03952946d8e1c05f45695 nokogiri (1.19.0-aarch64-linux-gnu) sha256=11a97ecc3c0e7e5edcf395720b10860ef493b768f6aa80c539573530bc933767 nokogiri (1.19.0-aarch64-linux-musl) sha256=eb70507f5e01bc23dad9b8dbec2b36ad0e61d227b42d292835020ff754fb7ba9 nokogiri (1.19.0-arm-linux-gnu) sha256=572a259026b2c8b7c161fdb6469fa2d0edd2b61cd599db4bbda93289abefbfe5 diff --git a/gemfiles/rails_7.1.gemfile.lock b/gemfiles/rails_7.1.gemfile.lock index d327227d8..0d93a0a31 100644 --- a/gemfiles/rails_7.1.gemfile.lock +++ b/gemfiles/rails_7.1.gemfile.lock @@ -3,6 +3,7 @@ PATH specs: view_component (4.3.0) actionview (>= 7.1.0) + actionview_precompiler (>= 0.4) activesupport (>= 7.1.0) concurrent-ruby (~> 1) @@ -60,6 +61,8 @@ GEM erubi (~> 1.11) rails-dom-testing (~> 2.2) rails-html-sanitizer (~> 1.6) + actionview_precompiler (0.4.0) + actionview (>= 6.0.a) activejob (7.1.6) activesupport (= 7.1.6) globalid (>= 0.3.6) diff --git a/gemfiles/rails_7.2.gemfile.lock b/gemfiles/rails_7.2.gemfile.lock index b738a3057..fd98e5390 100644 --- a/gemfiles/rails_7.2.gemfile.lock +++ b/gemfiles/rails_7.2.gemfile.lock @@ -3,6 +3,7 @@ PATH specs: view_component (4.3.0) actionview (>= 7.1.0) + actionview_precompiler (>= 0.4) activesupport (>= 7.1.0) concurrent-ruby (~> 1) @@ -55,6 +56,8 @@ GEM erubi (~> 1.11) rails-dom-testing (~> 2.2) rails-html-sanitizer (~> 1.6) + actionview_precompiler (0.4.0) + actionview (>= 6.0.a) activejob (7.2.3) activesupport (= 7.2.3) globalid (>= 0.3.6) diff --git a/gemfiles/rails_8.0.gemfile.lock b/gemfiles/rails_8.0.gemfile.lock index aa077f2a7..fa83392fe 100644 --- a/gemfiles/rails_8.0.gemfile.lock +++ b/gemfiles/rails_8.0.gemfile.lock @@ -3,6 +3,7 @@ PATH specs: view_component (4.3.0) actionview (>= 7.1.0) + actionview_precompiler (>= 0.4) activesupport (>= 7.1.0) concurrent-ruby (~> 1) @@ -52,6 +53,8 @@ GEM erubi (~> 1.11) rails-dom-testing (~> 2.2) rails-html-sanitizer (~> 1.6) + actionview_precompiler (0.4.0) + actionview (>= 6.0.a) activejob (8.0.4) activesupport (= 8.0.4) globalid (>= 0.3.6) diff --git a/gemfiles/rails_8.1.gemfile.lock b/gemfiles/rails_8.1.gemfile.lock index f56a56d30..c32d7d0cf 100644 --- a/gemfiles/rails_8.1.gemfile.lock +++ b/gemfiles/rails_8.1.gemfile.lock @@ -3,6 +3,7 @@ PATH specs: view_component (4.3.0) actionview (>= 7.1.0) + actionview_precompiler (>= 0.4) activesupport (>= 7.1.0) concurrent-ruby (~> 1) @@ -55,6 +56,8 @@ GEM erubi (~> 1.11) rails-dom-testing (~> 2.2) rails-html-sanitizer (~> 1.6) + actionview_precompiler (0.4.0) + actionview (>= 6.0.a) activejob (8.1.2) activesupport (= 8.1.2) globalid (>= 0.3.6) diff --git a/view_component.gemspec b/view_component.gemspec index 6b8d75f0f..233d6c022 100644 --- a/view_component.gemspec +++ b/view_component.gemspec @@ -35,5 +35,6 @@ Gem::Specification.new do |spec| supported_rails_version = [">= 7.1.0"] spec.add_runtime_dependency "activesupport", supported_rails_version spec.add_runtime_dependency "actionview", supported_rails_version + spec.add_runtime_dependency "actionview_precompiler", ">= 0.4" spec.add_runtime_dependency "concurrent-ruby", "~> 1" end From 5d868d7072d9d3e0bb755a06073c135c693cdc73 Mon Sep 17 00:00:00 2001 From: Reegan Date: Fri, 13 Feb 2026 09:44:49 +0200 Subject: [PATCH 097/122] fix tests --- lib/view_component/system_test_helpers.rb | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/lib/view_component/system_test_helpers.rb b/lib/view_component/system_test_helpers.rb index 273c7b274..266524ade 100644 --- a/lib/view_component/system_test_helpers.rb +++ b/lib/view_component/system_test_helpers.rb @@ -14,7 +14,7 @@ module SystemTestHelpers def with_rendered_component_path(fragment, layout: false, &block) rendered_html = vc_test_controller.render_to_string(html: fragment.to_html.html_safe, layout: layout) - if !layout + if use_inline_data_url?(layout) yield("data:text/html;base64,#{Base64.strict_encode64(rendered_html)}") return end @@ -26,5 +26,14 @@ def with_rendered_component_path(fragment, layout: false, &block) yield("/_system_test_entrypoint?file=#{filename}") end + + private + + def use_inline_data_url?(layout) + return false if layout + return false unless defined?(Capybara) && Capybara.respond_to?(:current_driver) + + Capybara.current_driver != :rack_test + end end end From c1afc0e108107d068abc8d57efe6be3a4ce468c8 Mon Sep 17 00:00:00 2001 From: Reegan Date: Fri, 13 Feb 2026 09:46:53 +0200 Subject: [PATCH 098/122] fix accidental commits --- .tool-versions | 3 ++- lib/view_component/base.rb | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/.tool-versions b/.tool-versions index b10db9099..0f80fd9a0 100644 --- a/.tool-versions +++ b/.tool-versions @@ -1 +1,2 @@ -ruby 4.1-dev +ruby 4.0.1 + diff --git a/lib/view_component/base.rb b/lib/view_component/base.rb index 7cae4c914..bd50641d3 100644 --- a/lib/view_component/base.rb +++ b/lib/view_component/base.rb @@ -67,7 +67,7 @@ def config # For Content Security Policy nonces delegate :content_security_policy_nonce, to: :helpers - # Config:attr_writer :attr_names option that strips trailing whitespace in templates before compiling them. + # 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 attr_accessor :__vc_original_view_context From 0208862e8207f78ef8e548fe3ba659b6f43535ab Mon Sep 17 00:00:00 2001 From: Reegan Date: Fri, 13 Feb 2026 10:03:20 +0200 Subject: [PATCH 099/122] fix rails-main --- gemfiles/rails_main.gemfile.lock | 3 +++ 1 file changed, 3 insertions(+) diff --git a/gemfiles/rails_main.gemfile.lock b/gemfiles/rails_main.gemfile.lock index 33cadee59..1287465f9 100644 --- a/gemfiles/rails_main.gemfile.lock +++ b/gemfiles/rails_main.gemfile.lock @@ -54,6 +54,8 @@ GIT erubi (~> 1.11) rails-dom-testing (~> 2.2) rails-html-sanitizer (~> 1.6) + actionview_precompiler (0.4.0) + actionview (>= 6.0.a) activejob (8.2.0.alpha) activesupport (= 8.2.0.alpha) globalid (>= 0.3.6) @@ -111,6 +113,7 @@ PATH specs: view_component (4.4.0) actionview (>= 7.1.0) + actionview_precompiler (>= 0.4) activesupport (>= 7.1.0) concurrent-ruby (~> 1) From bfa254beb6b1503064fb58f2ee1abf8977546ef7 Mon Sep 17 00:00:00 2001 From: Reegan Date: Fri, 13 Feb 2026 10:15:54 +0200 Subject: [PATCH 100/122] fix rails main --- gemfiles/rails_main.gemfile.lock | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/gemfiles/rails_main.gemfile.lock b/gemfiles/rails_main.gemfile.lock index 1287465f9..840043cee 100644 --- a/gemfiles/rails_main.gemfile.lock +++ b/gemfiles/rails_main.gemfile.lock @@ -7,8 +7,8 @@ GIT GIT remote: https://github.com/rails/rails.git - revision: 990198b7b5f4a3a3d0d59bc1bcac58efa405f527 - branch: main + revision: 58c94cbd8081ddefadf8e1824685051f52234791 + ref: 58c94cbd8081ddefadf8e1824685051f52234791 specs: actioncable (8.2.0.alpha) actionpack (= 8.2.0.alpha) @@ -41,7 +41,7 @@ GIT rails-html-sanitizer (~> 1.6) useragent (~> 0.16) actiontext (8.2.0.alpha) - action_text-trix (~> 2.1.15) + action_text-trix (~> 2.1.16) actionpack (= 8.2.0.alpha) activerecord (= 8.2.0.alpha) activestorage (= 8.2.0.alpha) @@ -54,8 +54,6 @@ GIT erubi (~> 1.11) rails-dom-testing (~> 2.2) rails-html-sanitizer (~> 1.6) - actionview_precompiler (0.4.0) - actionview (>= 6.0.a) activejob (8.2.0.alpha) activesupport (= 8.2.0.alpha) globalid (>= 0.3.6) @@ -81,6 +79,7 @@ GIT json logger (>= 1.4.2) minitest (>= 5.1) + psych (>= 4) securerandom (>= 0.3) tzinfo (~> 2.0, >= 2.0.5) uri (>= 0.13.1) @@ -120,8 +119,10 @@ PATH GEM remote: https://rubygems.org/ specs: - action_text-trix (2.1.15) + action_text-trix (2.1.16) railties + actionview_precompiler (0.4.0) + actionview (>= 6.0.a) addressable (2.8.7) public_suffix (>= 2.0.2, < 7.0) allocation_stats (0.1.5) From cbb7d02d071fecba0026f7a77fe38f9617a91f50 Mon Sep 17 00:00:00 2001 From: Reegan Date: Fri, 13 Feb 2026 10:27:52 +0200 Subject: [PATCH 101/122] fix ci --- Appraisals | 2 +- gemfiles/rails_main.gemfile | 2 +- lib/view_component/system_test_helpers.rb | 1 + test/sandbox/test/rendering_test.rb | 2 +- 4 files changed, 4 insertions(+), 3 deletions(-) diff --git a/Appraisals b/Appraisals index e0d1a66bd..fe4240e9a 100644 --- a/Appraisals +++ b/Appraisals @@ -48,7 +48,7 @@ appraise "rails-main" do ruby "4.1.0.dev" gem "rack", git: "https://github.com/rack/rack", ref: "8a4475a9f416a72e5b02bd7817e4a8ed684f29b0" - gem "rails", github: "rails/rails", branch: "main" + gem "rails", github: "rails/rails", ref: "58c94cbd8081ddefadf8e1824685051f52234791" group :development, :test do gem "turbo-rails", "~> 2" diff --git a/gemfiles/rails_main.gemfile b/gemfiles/rails_main.gemfile index 6e74cecbc..a4226758c 100644 --- a/gemfiles/rails_main.gemfile +++ b/gemfiles/rails_main.gemfile @@ -4,7 +4,7 @@ source "https://rubygems.org" ruby "4.1.0.dev" -gem "rails", github: "rails/rails", branch: "main" +gem "rails", github: "rails/rails", ref: "58c94cbd8081ddefadf8e1824685051f52234791" gem "rack", git: "https://github.com/rack/rack", ref: "8a4475a9f416a72e5b02bd7817e4a8ed684f29b0" group :development, :test do diff --git a/lib/view_component/system_test_helpers.rb b/lib/view_component/system_test_helpers.rb index 266524ade..070d0612c 100644 --- a/lib/view_component/system_test_helpers.rb +++ b/lib/view_component/system_test_helpers.rb @@ -31,6 +31,7 @@ def with_rendered_component_path(fragment, layout: false, &block) def use_inline_data_url?(layout) return false if layout + return true if defined?(ActionDispatch::SystemTestCase) && is_a?(ActionDispatch::SystemTestCase) return false unless defined?(Capybara) && Capybara.respond_to?(:current_driver) Capybara.current_driver != :rack_test diff --git a/test/sandbox/test/rendering_test.rb b/test/sandbox/test/rendering_test.rb index 704b57434..32508800d 100644 --- a/test/sandbox/test/rendering_test.rb +++ b/test/sandbox/test/rendering_test.rb @@ -20,7 +20,7 @@ def test_render_inline_allocations MyComponent.__vc_ensure_compiled with_instrumentation_enabled_option(false) do - assert_allocations({"4.1" => 67..68, "4.0" => 67, "3.4" => 72..74, "3.3" => 75, "3.2" => 78..79}) do + assert_allocations({"4.1" => 67..68, "4.0" => 67, "3.4" => 72..76, "3.3" => 75, "3.2" => 78..79}) do render_inline(MyComponent.new) end end From 78b526a26a1975ce763bf08daa2de4bf20d852ac Mon Sep 17 00:00:00 2001 From: Reegan Date: Fri, 13 Feb 2026 10:37:10 +0200 Subject: [PATCH 102/122] fix ci --- Appraisals | 2 +- gemfiles/rails_main.gemfile | 2 +- gemfiles/rails_main.gemfile.lock | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Appraisals b/Appraisals index fe4240e9a..e0d1a66bd 100644 --- a/Appraisals +++ b/Appraisals @@ -48,7 +48,7 @@ appraise "rails-main" do ruby "4.1.0.dev" gem "rack", git: "https://github.com/rack/rack", ref: "8a4475a9f416a72e5b02bd7817e4a8ed684f29b0" - gem "rails", github: "rails/rails", ref: "58c94cbd8081ddefadf8e1824685051f52234791" + gem "rails", github: "rails/rails", branch: "main" group :development, :test do gem "turbo-rails", "~> 2" diff --git a/gemfiles/rails_main.gemfile b/gemfiles/rails_main.gemfile index a4226758c..6e74cecbc 100644 --- a/gemfiles/rails_main.gemfile +++ b/gemfiles/rails_main.gemfile @@ -4,7 +4,7 @@ source "https://rubygems.org" ruby "4.1.0.dev" -gem "rails", github: "rails/rails", ref: "58c94cbd8081ddefadf8e1824685051f52234791" +gem "rails", github: "rails/rails", branch: "main" gem "rack", git: "https://github.com/rack/rack", ref: "8a4475a9f416a72e5b02bd7817e4a8ed684f29b0" group :development, :test do diff --git a/gemfiles/rails_main.gemfile.lock b/gemfiles/rails_main.gemfile.lock index 840043cee..c2de1e58f 100644 --- a/gemfiles/rails_main.gemfile.lock +++ b/gemfiles/rails_main.gemfile.lock @@ -7,8 +7,8 @@ GIT GIT remote: https://github.com/rails/rails.git - revision: 58c94cbd8081ddefadf8e1824685051f52234791 - ref: 58c94cbd8081ddefadf8e1824685051f52234791 + revision: 990198b7b5f4a3a3d0d59bc1bcac58efa405f527 + ref: main specs: actioncable (8.2.0.alpha) actionpack (= 8.2.0.alpha) From d08152047bffe7facd8650cacb890fdb5b943c6a Mon Sep 17 00:00:00 2001 From: Reegan Date: Fri, 13 Feb 2026 10:51:13 +0200 Subject: [PATCH 103/122] fix rails main --- gemfiles/rails_main.gemfile.lock | 61 ++++++++++++-------------------- 1 file changed, 23 insertions(+), 38 deletions(-) diff --git a/gemfiles/rails_main.gemfile.lock b/gemfiles/rails_main.gemfile.lock index c2de1e58f..f532e6a6d 100644 --- a/gemfiles/rails_main.gemfile.lock +++ b/gemfiles/rails_main.gemfile.lock @@ -7,8 +7,8 @@ GIT GIT remote: https://github.com/rails/rails.git - revision: 990198b7b5f4a3a3d0d59bc1bcac58efa405f527 - ref: main + revision: 58c94cbd8081ddefadf8e1824685051f52234791 + branch: main specs: actioncable (8.2.0.alpha) actionpack (= 8.2.0.alpha) @@ -142,7 +142,7 @@ GEM erubi (~> 1.4) parser (>= 2.4) smart_properties - bigdecimal (3.3.1) + bigdecimal (4.0.1) builder (3.3.0) capybara (3.40.0) addressable @@ -153,18 +153,18 @@ GEM rack-test (>= 0.6.3) regexp_parser (>= 1.5, < 3.0) xpath (~> 3.2) - concurrent-ruby (1.3.5) - connection_pool (2.5.4) + concurrent-ruby (1.3.6) + connection_pool (3.0.2) crass (1.0.6) cuprite (0.17) capybara (~> 3.0) ferrum (~> 0.17.0) - date (3.5.0) + date (3.5.1) diff-lcs (1.6.2) docile (1.4.1) drb (2.2.3) dry-initializer (3.2.0) - erb (6.0.0) + erb (6.0.1) erb_lint (0.9.0) activesupport better_html (>= 2.0.1) @@ -186,21 +186,22 @@ GEM thor tilt herb (0.8.2) - i18n (1.14.7) + i18n (1.14.8) concurrent-ruby (~> 1.0) - io-console (0.8.1) - irb (1.15.3) + io-console (0.8.2) + irb (1.17.0) pp (>= 0.6.0) + prism (>= 1.3.0) rdoc (>= 4.0.0) reline (>= 0.4.2) jbuilder (2.14.1) actionview (>= 7.0.0) activesupport (>= 7.0.0) - json (2.16.0) + json (2.18.1) language_server-protocol (3.17.0.5) lint_roller (1.1.0) logger (1.7.0) - loofah (2.24.1) + loofah (2.25.0) crass (~> 1.0.2) nokogiri (>= 1.12.0) m (1.6.2) @@ -217,8 +218,8 @@ GEM method_source (1.1.0) mini_mime (1.1.5) mini_portile2 (2.8.9) - minitest (5.26.2) - net-imap (0.5.12) + minitest (5.27.0) + net-imap (0.6.2) date net-protocol net-pop (0.1.2) @@ -228,25 +229,9 @@ GEM net-smtp (0.5.1) net-protocol nio4r (2.7.5) - nokogiri (1.18.10) + nokogiri (1.19.0) mini_portile2 (~> 2.8.2) racc (~> 1.4) - nokogiri (1.18.10-aarch64-linux-gnu) - racc (~> 1.4) - nokogiri (1.18.10-aarch64-linux-musl) - racc (~> 1.4) - nokogiri (1.18.10-arm-linux-gnu) - racc (~> 1.4) - nokogiri (1.18.10-arm-linux-musl) - racc (~> 1.4) - nokogiri (1.18.10-arm64-darwin) - racc (~> 1.4) - nokogiri (1.18.10-x86_64-darwin) - racc (~> 1.4) - nokogiri (1.18.10-x86_64-linux-gnu) - racc (~> 1.4) - nokogiri (1.18.10-x86_64-linux-musl) - racc (~> 1.4) parallel (1.27.0) parser (3.3.10.0) ast (~> 2.4.1) @@ -259,7 +244,7 @@ GEM actionpack (>= 7.0.0) activesupport (>= 7.0.0) rack - psych (5.2.6) + psych (5.3.1) date stringio public_suffix (6.0.2) @@ -271,7 +256,7 @@ GEM rack (>= 3.0.0) rack-test (2.2.0) rack (>= 1.3) - rackup (2.2.1) + rackup (2.3.1) rack (>= 3) rails-dom-testing (2.3.0) activesupport (>= 5.0.0) @@ -282,7 +267,7 @@ GEM nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0) rainbow (3.1.1) rake (13.3.1) - rdoc (6.15.1) + rdoc (7.2.0) erb psych (>= 4.0.0) tsort @@ -378,7 +363,7 @@ GEM standard-performance (1.8.0) lint_roller (~> 1.1) rubocop-performance (~> 1.25.0) - stringio (3.1.8) + stringio (3.2.0) tailwindcss-rails (4.4.0) railties (>= 7.0.0) tailwindcss-ruby (~> 4.0) @@ -392,9 +377,9 @@ GEM temple (0.10.4) terminal-table (4.0.0) unicode-display_width (>= 1.1.1, < 4) - thor (1.4.0) + thor (1.5.0) tilt (2.6.1) - timeout (0.4.4) + timeout (0.6.0) tsort (0.2.0) turbo-rails (2.0.20) actionpack (>= 7.1.0) @@ -418,7 +403,7 @@ GEM yard (0.9.37) yard-activesupport-concern (0.0.1) yard (>= 0.8) - zeitwerk (2.7.3) + zeitwerk (2.7.4) PLATFORMS aarch64-linux-gnu From 074d3807243930a8b6499321b29874cfc3ada985 Mon Sep 17 00:00:00 2001 From: Reegan Date: Fri, 13 Feb 2026 11:01:55 +0200 Subject: [PATCH 104/122] fix vale issues --- docs/guide/caching.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/guide/caching.md b/docs/guide/caching.md index 31d56ea8d..469900c5f 100644 --- a/docs/guide/caching.md +++ b/docs/guide/caching.md @@ -42,4 +42,4 @@ Methods listed in `cache_on` may be private. The cache key includes a digest of component source (Ruby + templates + i18n sidecars) and rendered child ViewComponents. -Partial/layout string dependencies are not currently included in the digest; modify `RAILS_CACHE_ID`/`RAILS_APP_VERSION` to invalidate on deploy. +Partial/layout string dependencies aren't currently included in the digest, to invalidate the cache on deploy modify `RAILS_CACHE_ID`/`RAILS_APP_VERSION`. From 4509dd815f8078c1cfafe8c9a706a03b4c93f766 Mon Sep 17 00:00:00 2001 From: Reegan Date: Sun, 14 Jun 2026 15:50:00 +0200 Subject: [PATCH 105/122] Address component caching review feedback --- docs/guide/caching.md | 74 ++++- lib/view_component/base.rb | 15 + lib/view_component/cache_digestor.rb | 38 +-- lib/view_component/cache_registry.rb | 4 - lib/view_component/compiler.rb | 16 +- .../experimentally_cacheable.rb | 86 ++--- lib/view_component/template_ast_builder.rb | 59 +--- .../template_dependency_extractor.rb | 5 +- lib/view_component/translatable.rb | 2 +- performance/cache_benchmark.rb | 2 +- .../cacheable_benchmark_component.rb | 4 +- .../erb_cached_benchmark_component.html.erb | 15 + .../erb_cached_benchmark_component.rb | 11 + .../erb_uncached_benchmark_component.html.erb | 15 + .../erb_uncached_benchmark_component.rb | 6 + .../haml_cached_benchmark_component.html.haml | 10 + .../haml_cached_benchmark_component.rb | 11 + ...aml_uncached_benchmark_component.html.haml | 10 + .../haml_uncached_benchmark_component.rb | 6 + .../components/handler_benchmark_component.rb | 30 ++ ...r_cached_benchmark_component.json.jbuilder | 11 + .../jbuilder_cached_benchmark_component.rb | 11 + ...uncached_benchmark_component.json.jbuilder | 11 + .../jbuilder_uncached_benchmark_component.rb | 6 + .../slim_cached_benchmark_component.html.slim | 10 + .../slim_cached_benchmark_component.rb | 11 + ...lim_uncached_benchmark_component.html.slim | 10 + .../slim_uncached_benchmark_component.rb | 6 + .../template_handler_cache_benchmark.rb | 92 ++++++ .../sandbox/app/components/cache_component.rb | 4 +- .../components/cache_condition_component.rb | 4 +- .../cache_dependency_types_component.rb | 4 +- ...e_digestor_haml_parent_component.html.haml | 2 + .../cache_digestor_haml_parent_component.rb | 15 + ...or_jbuilder_parent_component.json.jbuilder | 2 + ...ache_digestor_jbuilder_parent_component.rb | 15 + .../cache_digestor_layout_parent_component.rb | 4 +- ...igestor_nested_partial_parent_component.rb | 4 +- .../cache_digestor_parent_component.rb | 4 +- ...cache_digestor_partial_parent_component.rb | 4 +- ...e_digestor_slim_parent_component.html.slim | 2 + .../cache_digestor_slim_parent_component.rb | 15 + .../app/components/inline_cache_component.rb | 4 +- test/sandbox/test/rendering_test.rb | 293 ++++++++++++------ 44 files changed, 734 insertions(+), 229 deletions(-) create mode 100644 performance/components/erb_cached_benchmark_component.html.erb create mode 100644 performance/components/erb_cached_benchmark_component.rb create mode 100644 performance/components/erb_uncached_benchmark_component.html.erb create mode 100644 performance/components/erb_uncached_benchmark_component.rb create mode 100644 performance/components/haml_cached_benchmark_component.html.haml create mode 100644 performance/components/haml_cached_benchmark_component.rb create mode 100644 performance/components/haml_uncached_benchmark_component.html.haml create mode 100644 performance/components/haml_uncached_benchmark_component.rb create mode 100644 performance/components/handler_benchmark_component.rb create mode 100644 performance/components/jbuilder_cached_benchmark_component.json.jbuilder create mode 100644 performance/components/jbuilder_cached_benchmark_component.rb create mode 100644 performance/components/jbuilder_uncached_benchmark_component.json.jbuilder create mode 100644 performance/components/jbuilder_uncached_benchmark_component.rb create mode 100644 performance/components/slim_cached_benchmark_component.html.slim create mode 100644 performance/components/slim_cached_benchmark_component.rb create mode 100644 performance/components/slim_uncached_benchmark_component.html.slim create mode 100644 performance/components/slim_uncached_benchmark_component.rb create mode 100644 performance/template_handler_cache_benchmark.rb create mode 100644 test/sandbox/app/components/cache_digestor_haml_parent_component.html.haml create mode 100644 test/sandbox/app/components/cache_digestor_haml_parent_component.rb create mode 100644 test/sandbox/app/components/cache_digestor_jbuilder_parent_component.json.jbuilder create mode 100644 test/sandbox/app/components/cache_digestor_jbuilder_parent_component.rb create mode 100644 test/sandbox/app/components/cache_digestor_slim_parent_component.html.slim create mode 100644 test/sandbox/app/components/cache_digestor_slim_parent_component.rb diff --git a/docs/guide/caching.md b/docs/guide/caching.md index 469900c5f..6fef85f58 100644 --- a/docs/guide/caching.md +++ b/docs/guide/caching.md @@ -9,19 +9,18 @@ parent: How-to guide Experimental {: .label } -Caching is experimental. - -To enable caching, include `ViewComponent::ExperimentallyCacheable`. - -Components implement caching by marking dependencies using `cache_on`: +Caching is experimental. To cache a component, include `ViewComponent::ExperimentallyCacheable` and declare cache dependencies using `cache`: ```ruby class CacheComponent < ViewComponent::Base include ViewComponent::ExperimentallyCacheable - cache_on :foo, :bar attr_reader :foo, :bar + cache do + [foo, bar] + end + def initialize(foo:, bar:) @foo = foo @bar = bar @@ -30,16 +29,67 @@ end ``` ```erb -

<%= view_cache_dependencies.inspect %>

-

<%= Time.zone.now %>

<%= "#{foo} #{bar}" %>

``` -`cache_on` accepts method names. Returned values are expanded via `ActiveSupport::Cache.expand_cache_key`, so Active Record models, `GlobalID`, arrays, and plain strings work as expected. +Components that include `ViewComponent::ExperimentallyCacheable` but do not call `cache` render normally without fragment caching. + +Caching only reads and writes fragments when controller caching is enabled. + +## Dependencies + +The `cache` block is evaluated in the component instance context. Returned values are expanded via `ActiveSupport::Cache.expand_cache_key`, so Active Record models, `GlobalID`, arrays, plain strings, and values returned by private methods work as expected. + +```ruby +class UserComponent < ViewComponent::Base + include ViewComponent::ExperimentallyCacheable + + def initialize(user:) + @user = user + end + + cache do + [@user] + end +end +``` + +## Conditional caching + +Use `cache_if` to cache only when a condition is met: + +```ruby +class UserComponent < ViewComponent::Base + include ViewComponent::ExperimentallyCacheable + + cache_if :cacheable? + + cache do + [@user] + end + + def initialize(user:, cacheable: true) + @user = user + @cacheable = cacheable + end + + private + + def cacheable? + @cacheable + end +end +``` + +`cache_if` accepts a symbol, a boolean value, or a block. + +## Cache invalidation + +The cache key includes a digest of the component, its sidecar files, and ViewComponents rendered by the component. -Methods listed in `cache_on` may be private. +Caches are invalidated when the component source, sidecar templates, sidecar translations, or rendered child ViewComponents change. -The cache key includes a digest of component source (Ruby + templates + i18n sidecars) and rendered child ViewComponents. +## Limitations -Partial/layout string dependencies aren't currently included in the digest, to invalidate the cache on deploy modify `RAILS_CACHE_ID`/`RAILS_APP_VERSION`. +Changes to partial and layout string dependencies will not invalidate the cache. Modify `RAILS_CACHE_ID` or `RAILS_APP_VERSION` to invalidate these caches on deploy. diff --git a/lib/view_component/base.rb b/lib/view_component/base.rb index bd50641d3..66a66910f 100644 --- a/lib/view_component/base.rb +++ b/lib/view_component/base.rb @@ -205,6 +205,11 @@ def render_parent_to_string end end + # @private + def __vc_render_template(safe_call) + instance_exec(&safe_call) + end + # Optional content to be returned before the rendered template. # # @return [String] @@ -549,6 +554,16 @@ def sidecar_files(extensions) (sidecar_files - [identifier] + sidecar_directory_files + nested_component_files).uniq end + # @private + def sidecar_templates + sidecar_files(ActionView::Template.template_handler_extensions) + end + + # @private + def sidecar_translations + sidecar_files(%w[yml yaml]) + end + # Render a component for each element in a collection ([documentation](/guide/collections)): # # ```ruby diff --git a/lib/view_component/cache_digestor.rb b/lib/view_component/cache_digestor.rb index bded33a17..8b2b5ca22 100644 --- a/lib/view_component/cache_digestor.rb +++ b/lib/view_component/cache_digestor.rb @@ -5,26 +5,30 @@ module ViewComponent class CacheDigestor + def self.digest(component) + new(component: component).digest + end + def initialize(component:) - @component = component + @component_class = component.is_a?(Class) ? component : component.class @digests = {} @file_cache = {} @constant_cache = {} end def digest - digest_for_component(@component.class) + digest_for_component(@component_class) end private + # Prevents infinite recursion when components render each other cyclically. IN_PROGRESS = :__vc_in_progress private_constant :IN_PROGRESS def digest_for_component(component_class) return "" unless component_class <= ViewComponent::Base - name = component_class.name - return "" unless name + name = component_class.name || component_class.object_id cached_digest = @digests[name] return "" if cached_digest == IN_PROGRESS @@ -34,34 +38,32 @@ def digest_for_component(component_class) digest = Digest::SHA1.new - update_digest(digest, file_contents(component_class.identifier)) + update_digest(digest, cached_file_contents(component_class.identifier)) inline_template = component_class.__vc_inline_template if inline_template inline_source = inline_template.source update_digest(digest, inline_source) - update_template_dependency_digests(digest, inline_source, inline_template.language) + update_template_dependency_digests(digest, inline_source, inline_template.language, component_class.identifier) end - template_paths = component_class.sidecar_files(ActionView::Template.template_handler_extensions).sort - template_paths.each do |path| - template_source = file_contents(path) + component_class.sidecar_templates.sort.each do |path| + template_source = cached_file_contents(path) update_digest(digest, template_source) - update_template_dependency_digests(digest, template_source, File.extname(path).delete_prefix(".")) + update_template_dependency_digests(digest, template_source, File.extname(path).delete_prefix("."), path) end - i18n_paths = component_class.sidecar_files(%w[yml yaml]).sort - i18n_paths.each do |path| - update_digest(digest, file_contents(path)) + component_class.sidecar_translations.sort.each do |path| + update_digest(digest, cached_file_contents(path)) end @digests[name] = digest.hexdigest end - def update_template_dependency_digests(digest, template_source, handler) + def update_template_dependency_digests(digest, template_source, handler, identifier) return unless template_source&.include?("render") - dependencies = ViewComponent::TemplateDependencyExtractor.new(template_source, handler).extract + dependencies = ViewComponent::TemplateDependencyExtractor.new(template_source, handler, identifier: identifier).extract update_dependency_digests(digest, dependencies) end @@ -69,7 +71,7 @@ def update_dependency_digests(digest, dependencies) dependencies.each do |dep| next unless uppercase_constant?(dep) - klass = constantize(dep) + klass = cached_constantize(dep) next unless klass update_digest(digest, digest_for_component(klass)) @@ -90,13 +92,13 @@ def uppercase_constant?(dep) first && first >= 65 && first <= 90 end - def constantize(constant_name) + def cached_constantize(constant_name) @constant_cache.fetch(constant_name) do @constant_cache[constant_name] = constant_name.safe_constantize end end - def file_contents(path) + def cached_file_contents(path) return nil if path.nil? @file_cache.fetch(path) do diff --git a/lib/view_component/cache_registry.rb b/lib/view_component/cache_registry.rb index 8a7f74b53..1b2dc71a2 100644 --- a/lib/view_component/cache_registry.rb +++ b/lib/view_component/cache_registry.rb @@ -4,10 +4,6 @@ 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[:view_component_caching] = true diff --git a/lib/view_component/compiler.rb b/lib/view_component/compiler.rb index 4cff288b3..64cba8aa0 100644 --- a/lib/view_component/compiler.rb +++ b/lib/view_component/compiler.rb @@ -90,21 +90,13 @@ def define_render_template_for safe_call = template.safe_method_name_call @component.define_method(:render_template_for) do |_| @current_template = template - if is_a?(ViewComponent::ExperimentallyCacheable) - __vc_render_cacheable(safe_call) - else - instance_exec(&safe_call) - end + __vc_render_template(safe_call) end else compiler = self @component.define_method(:render_template_for) do |details| if (@current_template = compiler.find_templates_for(details).first) - if is_a?(ViewComponent::ExperimentallyCacheable) - __vc_render_cacheable(@current_template.safe_method_name_call) - else - instance_exec(&@current_template.safe_method_name_call) - end + __vc_render_template(@current_template.safe_method_name_call) else raise MissingTemplateError.new(self.class.name, details) end @@ -184,9 +176,7 @@ def gather_templates )] else path_parser = ActionView::Resolver::PathParser.new - templates = @component.sidecar_files( - ActionView::Template.template_handler_extensions - ).map do |path| + templates = @component.sidecar_templates.map do |path| details = path_parser.parse(path).details Template::File.new(component: @component, path: path, details: details) end diff --git a/lib/view_component/experimentally_cacheable.rb b/lib/view_component/experimentally_cacheable.rb index 4d67f3871..e70b80e2a 100644 --- a/lib/view_component/experimentally_cacheable.rb +++ b/lib/view_component/experimentally_cacheable.rb @@ -7,34 +7,37 @@ module ViewComponent::ExperimentallyCacheable extend ActiveSupport::Concern included do - class_attribute :__vc_cache_dependencies, default: Set.new + class_attribute :__vc_cache_key_block, default: nil class_attribute :__vc_cache_if, default: nil # For caching, such as #cache_if # # @private def view_cache_dependencies - @__vc_cache_dependencies ||= self.class.__vc_cache_dependencies.map { |dep| send(dep) } + return [] unless self.class.__vc_cache_key_block + + @__vc_cache_dependencies ||= Array(instance_exec(&self.class.__vc_cache_key_block)) end def view_cache_options return @__vc_cache_options if instance_variable_defined?(:@__vc_cache_options) - dependencies = self.class.__vc_cache_dependencies - return @__vc_cache_options = nil if dependencies.empty? + return @__vc_cache_options = nil unless self.class.__vc_cache_key_block template_key = __vc_cache_template_key return @__vc_cache_options = nil unless template_key - expanded_key = ActiveSupport::Cache.expand_cache_key([__vc_static_cache_key_parts(template_key), view_cache_dependencies]) - @__vc_cache_options = combined_fragment_cache_key(expanded_key) + @__vc_cache_options = cache_fragment_name( + [:view_component, view_cache_dependencies], + digest_path: __vc_component_digest_path(template_key) + ) end # Render component from cache if possible # # @private - def __vc_render_cacheable(safe_call) - if __vc_cache_enabled? && (cache_key = view_cache_options) + def __vc_render_template(safe_call) + if __vc_cache_enabled? && __vc_controller_perform_caching? && (cache_key = view_cache_options) ViewComponent::CachingRegistry.track_caching do template_fragment(cache_key, safe_call) end @@ -52,55 +55,49 @@ def __vc_cache_template_key def template_fragment(cache_key, safe_call) if (content = read_fragment(cache_key)) - @view_renderer.cache_hits[@current_template&.virtual_path] = :hit if defined?(@view_renderer) + record_fragment_cache(:hit) content else - @view_renderer.cache_hits[@current_template&.virtual_path] = :miss if defined?(@view_renderer) + record_fragment_cache(:miss) write_fragment(cache_key, safe_call) end end + def record_fragment_cache(status) + return unless Rails.application.config.view_component.instrumentation_enabled.present? + return unless defined?(@view_renderer) + + @view_renderer.cache_hits[@current_template&.virtual_path] = status + end + def read_fragment(cache_key) - Rails.cache.read(cache_key) + controller.read_fragment(cache_key) end def write_fragment(cache_key, safe_call) content = instance_exec(&safe_call) - Rails.cache.write(cache_key, content) + controller.write_fragment(cache_key, content) 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 return @__vc_component_digest ||= __vc_compute_component_digest unless ActionView::Base.cache_template_loading - klass = self.class - digest = klass.instance_variable_get(:@__vc_component_digest) - return digest if digest - - klass.instance_variable_set(:@__vc_component_digest, __vc_compute_component_digest) + self.class.__vc_component_digest end def __vc_compute_component_digest - ViewComponent::CacheDigestor.new(component: self).digest + ViewComponent::CacheDigestor.digest(self) end - def __vc_static_cache_key_parts(template_key) - klass = self.class + def __vc_component_digest_path(template_key) digest = component_digest call_method_name, template_virtual_path = template_key - cache_key = [call_method_name, template_virtual_path, digest] - - static_key_cache = klass.instance_variable_get(:@__vc_static_cache_key_parts) || - klass.instance_variable_set(:@__vc_static_cache_key_parts, {}) + [self.class.virtual_path, call_method_name, template_virtual_path, digest].compact.join(":") + end - static_key_cache[cache_key] ||= [klass.name, klass.virtual_path, [call_method_name, template_virtual_path].freeze, digest].freeze + def __vc_controller_perform_caching? + controller.respond_to?(:perform_caching) && controller.perform_caching end def __vc_cache_enabled? @@ -119,17 +116,30 @@ def __vc_cache_enabled? end class_methods do - def cache_if(value = nil, &block) - self.__vc_cache_if = block || value + def after_compile + super + + __vc_precompute_component_digest if ActionView::Base.cache_template_loading + end + + def __vc_component_digest + @__vc_component_digest ||= ViewComponent::CacheDigestor.digest(self) + end + + def __vc_precompute_component_digest + @__vc_component_digest = ViewComponent::CacheDigestor.digest(self) + end + + def cache(&block) + self.__vc_cache_key_block = block end - # For caching the component - def cache_on(*args) - __vc_cache_dependencies.merge(args) + def cache_if(value = nil, &block) + self.__vc_cache_if = block || value end def inherited(child) - child.__vc_cache_dependencies = __vc_cache_dependencies.dup + child.__vc_cache_key_block = __vc_cache_key_block child.__vc_cache_if = __vc_cache_if super diff --git a/lib/view_component/template_ast_builder.rb b/lib/view_component/template_ast_builder.rb index d04d567c6..0d8657543 100644 --- a/lib/view_component/template_ast_builder.rb +++ b/lib/view_component/template_ast_builder.rb @@ -1,52 +1,25 @@ # frozen_string_literal: true +require "action_view" + module ViewComponent class TemplateAstBuilder - def self.build(template_string, engine_name) - case engine_name.to_sym - when :erb - compile_erb(template_string) - else - compile_template_with_engine(template_string, engine_name) - end - end - - def self.compile_erb(template) - require "erb" - - ERB::Compiler.new("-").compile(template).first - rescue - nil - end - private_class_method :compile_erb - - def self.compile_template_with_engine(template_string, engine_name) - engine_class = load_template_engine(engine_name) - return nil unless engine_class - - engine_class.new.call(template_string) + def self.build(template_string, handler_name, identifier: nil) + handler = ActionView::Template.handler_for_extension(handler_name) + return nil unless handler + + identifier ||= "inline.#{handler_name}" + template = ActionView::Template.new( + template_string, + identifier, + handler, + locals: [], + virtual_path: identifier + ) + + handler.call(template, template_string) rescue nil end - private_class_method :compile_template_with_engine - - def self.load_template_engine(engine_name) - engine_class = template_engine_class(engine_name) - return engine_class if engine_class - - require engine_name.to_s - template_engine_class(engine_name) - rescue LoadError - nil - end - private_class_method :load_template_engine - - def self.template_engine_class(engine_name) - engine_module_name = engine_name.to_s.tr("-", "_").split("_").map!(&:capitalize).join - Object.const_get("#{engine_module_name}::Engine") - rescue NameError - nil - end - private_class_method :template_engine_class end end diff --git a/lib/view_component/template_dependency_extractor.rb b/lib/view_component/template_dependency_extractor.rb index 42eb7078b..402a1b34e 100644 --- a/lib/view_component/template_dependency_extractor.rb +++ b/lib/view_component/template_dependency_extractor.rb @@ -6,15 +6,16 @@ module ViewComponent class TemplateDependencyExtractor - def initialize(template_string, engine) + def initialize(template_string, engine, identifier: nil) @template_string = template_string @engine = engine + @identifier = identifier @dependencies = Set.new end def extract engine = @engine.to_sym - ruby_source = TemplateAstBuilder.build(@template_string, engine) + ruby_source = TemplateAstBuilder.build(@template_string, engine, identifier: @identifier) if ruby_source.nil? return extract_erb_fallback if engine == :erb diff --git a/lib/view_component/translatable.rb b/lib/view_component/translatable.rb index 815a1325e..b75d46a1c 100644 --- a/lib/view_component/translatable.rb +++ b/lib/view_component/translatable.rb @@ -30,7 +30,7 @@ def __vc_build_i18n_backend # can inherit translations from its parent and is able to overwrite them. translation_files = ancestors.reverse_each.with_object([]) do |ancestor, files| if ancestor.is_a?(Class) && ancestor < ViewComponent::Base - files.concat(ancestor.sidecar_files(TRANSLATION_EXTENSIONS)) + files.concat(ancestor.sidecar_translations) end end diff --git a/performance/cache_benchmark.rb b/performance/cache_benchmark.rb index d392260bb..21283c3f6 100644 --- a/performance/cache_benchmark.rb +++ b/performance/cache_benchmark.rb @@ -43,7 +43,7 @@ class BenchmarksController < ActionController::Base x.report("cacheable_miss") do cache_miss_counter += 1 - controller_view.render(Performance::CacheableBenchmarkComponent.new(name: "Fox Mulder #{cache_miss_counter}")) + controller_view.render(Performance::CacheableBenchmarkComponent.new(name: "Fox Mulder miss #{cache_miss_counter}x")) end x.report("cacheable_hit") do diff --git a/performance/components/cacheable_benchmark_component.rb b/performance/components/cacheable_benchmark_component.rb index eef1dcc90..9a6bfc2db 100644 --- a/performance/components/cacheable_benchmark_component.rb +++ b/performance/components/cacheable_benchmark_component.rb @@ -5,7 +5,9 @@ class CacheableBenchmarkComponent < ViewComponent::Base include ViewComponent::ExperimentallyCacheable cache_if :cache_worthy? - cache_on :name + cache do + [name] + end attr_reader :name diff --git a/performance/components/erb_cached_benchmark_component.html.erb b/performance/components/erb_cached_benchmark_component.html.erb new file mode 100644 index 000000000..3d3760b23 --- /dev/null +++ b/performance/components/erb_cached_benchmark_component.html.erb @@ -0,0 +1,15 @@ +
+
+

<%= name %> ERB cached

+

Total score: <%= number_with_delimiter(total_score) %>

+
+
    + <% report_rows.each do |row| %> +
  • + <%= row[:label] %> + <%= number_to_currency(row[:amount]) %> + <%= pluralize(row[:events], "event") %> +
  • + <% end %> +
+
diff --git a/performance/components/erb_cached_benchmark_component.rb b/performance/components/erb_cached_benchmark_component.rb new file mode 100644 index 000000000..54695bbfc --- /dev/null +++ b/performance/components/erb_cached_benchmark_component.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module Performance + class ErbCachedBenchmarkComponent < HandlerBenchmarkComponent + include ViewComponent::ExperimentallyCacheable + + cache do + [name] + end + end +end diff --git a/performance/components/erb_uncached_benchmark_component.html.erb b/performance/components/erb_uncached_benchmark_component.html.erb new file mode 100644 index 000000000..53e1f9107 --- /dev/null +++ b/performance/components/erb_uncached_benchmark_component.html.erb @@ -0,0 +1,15 @@ +
+
+

<%= name %> ERB uncached

+

Total score: <%= number_with_delimiter(total_score) %>

+
+
    + <% report_rows.each do |row| %> +
  • + <%= row[:label] %> + <%= number_to_currency(row[:amount]) %> + <%= pluralize(row[:events], "event") %> +
  • + <% end %> +
+
diff --git a/performance/components/erb_uncached_benchmark_component.rb b/performance/components/erb_uncached_benchmark_component.rb new file mode 100644 index 000000000..630ef057e --- /dev/null +++ b/performance/components/erb_uncached_benchmark_component.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +module Performance + class ErbUncachedBenchmarkComponent < HandlerBenchmarkComponent + end +end diff --git a/performance/components/haml_cached_benchmark_component.html.haml b/performance/components/haml_cached_benchmark_component.html.haml new file mode 100644 index 000000000..ecf9c1dfd --- /dev/null +++ b/performance/components/haml_cached_benchmark_component.html.haml @@ -0,0 +1,10 @@ +%section.handler-benchmark.haml.cached{data: {time: Time.zone.now.to_f}} + %header + %h3= "#{name} Haml cached" + %p= "Total score: #{number_with_delimiter(total_score)}" + %ul + - report_rows.each do |row| + %li + %span= row[:label] + %span= number_to_currency(row[:amount]) + %span= pluralize(row[:events], "event") diff --git a/performance/components/haml_cached_benchmark_component.rb b/performance/components/haml_cached_benchmark_component.rb new file mode 100644 index 000000000..a022fb3b7 --- /dev/null +++ b/performance/components/haml_cached_benchmark_component.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module Performance + class HamlCachedBenchmarkComponent < HandlerBenchmarkComponent + include ViewComponent::ExperimentallyCacheable + + cache do + [name] + end + end +end diff --git a/performance/components/haml_uncached_benchmark_component.html.haml b/performance/components/haml_uncached_benchmark_component.html.haml new file mode 100644 index 000000000..69b6aa27d --- /dev/null +++ b/performance/components/haml_uncached_benchmark_component.html.haml @@ -0,0 +1,10 @@ +%section.handler-benchmark.haml.uncached{data: {time: Time.zone.now.to_f}} + %header + %h3= "#{name} Haml uncached" + %p= "Total score: #{number_with_delimiter(total_score)}" + %ul + - report_rows.each do |row| + %li + %span= row[:label] + %span= number_to_currency(row[:amount]) + %span= pluralize(row[:events], "event") diff --git a/performance/components/haml_uncached_benchmark_component.rb b/performance/components/haml_uncached_benchmark_component.rb new file mode 100644 index 000000000..a2edd8957 --- /dev/null +++ b/performance/components/haml_uncached_benchmark_component.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +module Performance + class HamlUncachedBenchmarkComponent < HandlerBenchmarkComponent + end +end diff --git a/performance/components/handler_benchmark_component.rb b/performance/components/handler_benchmark_component.rb new file mode 100644 index 000000000..f253440a6 --- /dev/null +++ b/performance/components/handler_benchmark_component.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +module Performance + class HandlerBenchmarkComponent < ViewComponent::Base + attr_reader :name + + def initialize(name:) + @name = name + end + + def report_rows + @report_rows ||= Array.new(36) do |index| + sequence = index + 1 + amount = ((sequence * 13.75) + (sequence % 4) * 2.125) + + { + label: "#{name} item #{sequence}", + amount: amount, + events: (sequence * 7) % 11 + 1, + score: ((sequence * 41) % 100) + 1, + slug: "#{name.downcase.tr(" ", "-")}-#{sequence}" + } + end + end + + def total_score + report_rows.sum { _1[:score] } + end + end +end diff --git a/performance/components/jbuilder_cached_benchmark_component.json.jbuilder b/performance/components/jbuilder_cached_benchmark_component.json.jbuilder new file mode 100644 index 000000000..90849bd63 --- /dev/null +++ b/performance/components/jbuilder_cached_benchmark_component.json.jbuilder @@ -0,0 +1,11 @@ +json.handler "jbuilder" +json.cached true +json.name name +json.generated_at Time.zone.now.to_f +json.total_score total_score +json.rows report_rows do |row| + json.label row[:label] + json.amount row[:amount] + json.events row[:events] + json.slug row[:slug] +end diff --git a/performance/components/jbuilder_cached_benchmark_component.rb b/performance/components/jbuilder_cached_benchmark_component.rb new file mode 100644 index 000000000..7053a035a --- /dev/null +++ b/performance/components/jbuilder_cached_benchmark_component.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module Performance + class JbuilderCachedBenchmarkComponent < HandlerBenchmarkComponent + include ViewComponent::ExperimentallyCacheable + + cache do + [name] + end + end +end diff --git a/performance/components/jbuilder_uncached_benchmark_component.json.jbuilder b/performance/components/jbuilder_uncached_benchmark_component.json.jbuilder new file mode 100644 index 000000000..f7dc16398 --- /dev/null +++ b/performance/components/jbuilder_uncached_benchmark_component.json.jbuilder @@ -0,0 +1,11 @@ +json.handler "jbuilder" +json.cached false +json.name name +json.generated_at Time.zone.now.to_f +json.total_score total_score +json.rows report_rows do |row| + json.label row[:label] + json.amount row[:amount] + json.events row[:events] + json.slug row[:slug] +end diff --git a/performance/components/jbuilder_uncached_benchmark_component.rb b/performance/components/jbuilder_uncached_benchmark_component.rb new file mode 100644 index 000000000..2339f456b --- /dev/null +++ b/performance/components/jbuilder_uncached_benchmark_component.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +module Performance + class JbuilderUncachedBenchmarkComponent < HandlerBenchmarkComponent + end +end diff --git a/performance/components/slim_cached_benchmark_component.html.slim b/performance/components/slim_cached_benchmark_component.html.slim new file mode 100644 index 000000000..4be213c84 --- /dev/null +++ b/performance/components/slim_cached_benchmark_component.html.slim @@ -0,0 +1,10 @@ +section.handler-benchmark.slim.cached data-time=Time.zone.now.to_f + header + h3 = "#{name} Slim cached" + p = "Total score: #{number_with_delimiter(total_score)}" + ul + - report_rows.each do |row| + li + span = row[:label] + span = number_to_currency(row[:amount]) + span = pluralize(row[:events], "event") diff --git a/performance/components/slim_cached_benchmark_component.rb b/performance/components/slim_cached_benchmark_component.rb new file mode 100644 index 000000000..40fb76794 --- /dev/null +++ b/performance/components/slim_cached_benchmark_component.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module Performance + class SlimCachedBenchmarkComponent < HandlerBenchmarkComponent + include ViewComponent::ExperimentallyCacheable + + cache do + [name] + end + end +end diff --git a/performance/components/slim_uncached_benchmark_component.html.slim b/performance/components/slim_uncached_benchmark_component.html.slim new file mode 100644 index 000000000..3945f97b7 --- /dev/null +++ b/performance/components/slim_uncached_benchmark_component.html.slim @@ -0,0 +1,10 @@ +section.handler-benchmark.slim.uncached data-time=Time.zone.now.to_f + header + h3 = "#{name} Slim uncached" + p = "Total score: #{number_with_delimiter(total_score)}" + ul + - report_rows.each do |row| + li + span = row[:label] + span = number_to_currency(row[:amount]) + span = pluralize(row[:events], "event") diff --git a/performance/components/slim_uncached_benchmark_component.rb b/performance/components/slim_uncached_benchmark_component.rb new file mode 100644 index 000000000..c84c56e14 --- /dev/null +++ b/performance/components/slim_uncached_benchmark_component.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +module Performance + class SlimUncachedBenchmarkComponent < HandlerBenchmarkComponent + end +end diff --git a/performance/template_handler_cache_benchmark.rb b/performance/template_handler_cache_benchmark.rb new file mode 100644 index 000000000..6bb26e5ff --- /dev/null +++ b/performance/template_handler_cache_benchmark.rb @@ -0,0 +1,92 @@ +# frozen_string_literal: true + +require "benchmark/ips" + +ENV["RAILS_ENV"] = "production" +require File.expand_path("../test/sandbox/config/environment.rb", __dir__) + +Rails.logger.level = 1 + +module Performance + require_relative "components/handler_benchmark_component" + require_relative "components/erb_cached_benchmark_component" + require_relative "components/erb_uncached_benchmark_component" + require_relative "components/slim_cached_benchmark_component" + require_relative "components/slim_uncached_benchmark_component" + require_relative "components/haml_cached_benchmark_component" + require_relative "components/haml_uncached_benchmark_component" + require_relative "components/jbuilder_cached_benchmark_component" + require_relative "components/jbuilder_uncached_benchmark_component" +end + +class HandlerBenchmarksController < ActionController::Base +end + +ActionController::Base.perform_caching = true +original_cache = Rails.cache +Rails.cache = ActiveSupport::Cache::MemoryStore.new(size: 64.megabytes) +Rails.cache.clear + +HandlerBenchmarksController.view_paths = [File.expand_path("./views", __dir__)] +html_view = HandlerBenchmarksController.new.view_context +json_view = HandlerBenchmarksController.new.view_context +json_view.lookup_context.formats = [:json] + +BENCHMARKS = { + erb: { + view: html_view, + cached: Performance::ErbCachedBenchmarkComponent, + uncached: Performance::ErbUncachedBenchmarkComponent + }, + slim: { + view: html_view, + cached: Performance::SlimCachedBenchmarkComponent, + uncached: Performance::SlimUncachedBenchmarkComponent + }, + haml: { + view: html_view, + cached: Performance::HamlCachedBenchmarkComponent, + uncached: Performance::HamlUncachedBenchmarkComponent + }, + jbuilder: { + view: json_view, + cached: Performance::JbuilderCachedBenchmarkComponent, + uncached: Performance::JbuilderUncachedBenchmarkComponent + } +}.freeze + +cache_miss_counters = Hash.new(0) + +BENCHMARKS.each do |handler, config| + config[:warm_component] = config[:cached].new(name: "#{handler} cached") + config[:uncached_component] = config[:uncached].new(name: "#{handler} uncached") + + config[:view].render(config[:warm_component]) + config[:view].render(config[:uncached_component]) +end + +begin + Benchmark.ips do |x| + x.time = ENV.fetch("BENCHMARK_TIME", "20").to_i + x.warmup = ENV.fetch("BENCHMARK_WARMUP", "5").to_i + + BENCHMARKS.each do |handler, config| + x.report("#{handler}_without_cache") do + config[:view].render(config[:uncached_component]) + end + + x.report("#{handler}_cache_miss") do + cache_miss_counters[handler] += 1 + config[:view].render(config[:cached].new(name: "#{handler} miss #{cache_miss_counters[handler]}x")) + end + + x.report("#{handler}_cache_hit") do + config[:view].render(config[:warm_component]) + end + end + + x.compare! + end +ensure + Rails.cache = original_cache +end diff --git a/test/sandbox/app/components/cache_component.rb b/test/sandbox/app/components/cache_component.rb index 9633eb35d..8a88cbcf3 100644 --- a/test/sandbox/app/components/cache_component.rb +++ b/test/sandbox/app/components/cache_component.rb @@ -3,7 +3,9 @@ class CacheComponent < ViewComponent::Base include ViewComponent::ExperimentallyCacheable - cache_on :foo, :bar + cache do + [foo, bar] + end attr_reader :foo, :bar diff --git a/test/sandbox/app/components/cache_condition_component.rb b/test/sandbox/app/components/cache_condition_component.rb index 5c229927e..bd57becd1 100644 --- a/test/sandbox/app/components/cache_condition_component.rb +++ b/test/sandbox/app/components/cache_condition_component.rb @@ -4,7 +4,9 @@ class CacheConditionComponent < ViewComponent::Base include ViewComponent::ExperimentallyCacheable cache_if :cache_enabled? - cache_on :foo + cache do + [foo] + end attr_reader :foo diff --git a/test/sandbox/app/components/cache_dependency_types_component.rb b/test/sandbox/app/components/cache_dependency_types_component.rb index 76f2ad1a1..5c52fc988 100644 --- a/test/sandbox/app/components/cache_dependency_types_component.rb +++ b/test/sandbox/app/components/cache_dependency_types_component.rb @@ -3,7 +3,9 @@ class CacheDependencyTypesComponent < ViewComponent::Base include ViewComponent::ExperimentallyCacheable - cache_on :record, :tags, :label, :private_token + cache do + [record, tags, label, private_token] + end attr_reader :record, :tags, :label diff --git a/test/sandbox/app/components/cache_digestor_haml_parent_component.html.haml b/test/sandbox/app/components/cache_digestor_haml_parent_component.html.haml new file mode 100644 index 000000000..820f20f6d --- /dev/null +++ b/test/sandbox/app/components/cache_digestor_haml_parent_component.html.haml @@ -0,0 +1,2 @@ +.haml-parent + = render CacheDigestorChildComponent.new diff --git a/test/sandbox/app/components/cache_digestor_haml_parent_component.rb b/test/sandbox/app/components/cache_digestor_haml_parent_component.rb new file mode 100644 index 000000000..634266ff2 --- /dev/null +++ b/test/sandbox/app/components/cache_digestor_haml_parent_component.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +class CacheDigestorHamlParentComponent < ViewComponent::Base + include ViewComponent::ExperimentallyCacheable + + attr_reader :foo + + cache do + [foo] + end + + def initialize(foo:) + @foo = foo + end +end diff --git a/test/sandbox/app/components/cache_digestor_jbuilder_parent_component.json.jbuilder b/test/sandbox/app/components/cache_digestor_jbuilder_parent_component.json.jbuilder new file mode 100644 index 000000000..e94181000 --- /dev/null +++ b/test/sandbox/app/components/cache_digestor_jbuilder_parent_component.json.jbuilder @@ -0,0 +1,2 @@ +json.parent "jbuilder" +json.child render(CacheDigestorChildComponent.new) diff --git a/test/sandbox/app/components/cache_digestor_jbuilder_parent_component.rb b/test/sandbox/app/components/cache_digestor_jbuilder_parent_component.rb new file mode 100644 index 000000000..d682bc7c2 --- /dev/null +++ b/test/sandbox/app/components/cache_digestor_jbuilder_parent_component.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +class CacheDigestorJbuilderParentComponent < ViewComponent::Base + include ViewComponent::ExperimentallyCacheable + + attr_reader :foo + + cache do + [foo] + end + + def initialize(foo:) + @foo = foo + end +end diff --git a/test/sandbox/app/components/cache_digestor_layout_parent_component.rb b/test/sandbox/app/components/cache_digestor_layout_parent_component.rb index ef11450a7..9b6728e5c 100644 --- a/test/sandbox/app/components/cache_digestor_layout_parent_component.rb +++ b/test/sandbox/app/components/cache_digestor_layout_parent_component.rb @@ -3,7 +3,9 @@ class CacheDigestorLayoutParentComponent < ViewComponent::Base include ViewComponent::ExperimentallyCacheable - cache_on :foo + cache do + [foo] + end attr_reader :foo diff --git a/test/sandbox/app/components/cache_digestor_nested_partial_parent_component.rb b/test/sandbox/app/components/cache_digestor_nested_partial_parent_component.rb index 2776c3462..bf7acb9de 100644 --- a/test/sandbox/app/components/cache_digestor_nested_partial_parent_component.rb +++ b/test/sandbox/app/components/cache_digestor_nested_partial_parent_component.rb @@ -3,7 +3,9 @@ class CacheDigestorNestedPartialParentComponent < ViewComponent::Base include ViewComponent::ExperimentallyCacheable - cache_on :foo + cache do + [foo] + end attr_reader :foo diff --git a/test/sandbox/app/components/cache_digestor_parent_component.rb b/test/sandbox/app/components/cache_digestor_parent_component.rb index cbab5752f..42d214223 100644 --- a/test/sandbox/app/components/cache_digestor_parent_component.rb +++ b/test/sandbox/app/components/cache_digestor_parent_component.rb @@ -3,7 +3,9 @@ class CacheDigestorParentComponent < ViewComponent::Base include ViewComponent::ExperimentallyCacheable - cache_on :foo + cache do + [foo] + end attr_reader :foo diff --git a/test/sandbox/app/components/cache_digestor_partial_parent_component.rb b/test/sandbox/app/components/cache_digestor_partial_parent_component.rb index ce49c318c..bbd60c3bf 100644 --- a/test/sandbox/app/components/cache_digestor_partial_parent_component.rb +++ b/test/sandbox/app/components/cache_digestor_partial_parent_component.rb @@ -3,7 +3,9 @@ class CacheDigestorPartialParentComponent < ViewComponent::Base include ViewComponent::ExperimentallyCacheable - cache_on :foo + cache do + [foo] + end attr_reader :foo diff --git a/test/sandbox/app/components/cache_digestor_slim_parent_component.html.slim b/test/sandbox/app/components/cache_digestor_slim_parent_component.html.slim new file mode 100644 index 000000000..14d74f7c9 --- /dev/null +++ b/test/sandbox/app/components/cache_digestor_slim_parent_component.html.slim @@ -0,0 +1,2 @@ +.slim-parent + = render CacheDigestorChildComponent.new diff --git a/test/sandbox/app/components/cache_digestor_slim_parent_component.rb b/test/sandbox/app/components/cache_digestor_slim_parent_component.rb new file mode 100644 index 000000000..b6a4b74f6 --- /dev/null +++ b/test/sandbox/app/components/cache_digestor_slim_parent_component.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +class CacheDigestorSlimParentComponent < ViewComponent::Base + include ViewComponent::ExperimentallyCacheable + + attr_reader :foo + + cache do + [foo] + end + + def initialize(foo:) + @foo = foo + end +end diff --git a/test/sandbox/app/components/inline_cache_component.rb b/test/sandbox/app/components/inline_cache_component.rb index dd6a84d66..e4e33b36a 100644 --- a/test/sandbox/app/components/inline_cache_component.rb +++ b/test/sandbox/app/components/inline_cache_component.rb @@ -3,7 +3,9 @@ class InlineCacheComponent < ViewComponent::Base include ViewComponent::ExperimentallyCacheable - cache_on :foo, :bar + cache do + [foo, bar] + end attr_reader :foo, :bar diff --git a/test/sandbox/test/rendering_test.rb b/test/sandbox/test/rendering_test.rb index 32508800d..6aa4d2f5b 100644 --- a/test/sandbox/test/rendering_test.rb +++ b/test/sandbox/test/rendering_test.rb @@ -1357,39 +1357,43 @@ def test_around_render end def test_inline_cache_component - component = InlineCacheComponent.new(foo: "foo", bar: "bar") - render_inline(component) + with_action_controller_caching do + component = InlineCacheComponent.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") + assert_selector(".cache-component__cache-key", text: component.view_cache_dependencies) + assert_selector(".cache-component__cache-message", text: "foo bar") - render_inline(InlineCacheComponent.new(foo: "foo", bar: "bar")) + render_inline(InlineCacheComponent.new(foo: "foo", bar: "bar")) - assert_selector(".cache-component__cache-key", text: component.view_cache_dependencies) + assert_selector(".cache-component__cache-key", text: component.view_cache_dependencies) - new_component = InlineCacheComponent.new(foo: "foo", bar: "baz") - render_inline(new_component) + new_component = InlineCacheComponent.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") + assert_selector(".cache-component__cache-key", text: new_component.view_cache_dependencies) + assert_selector(".cache-component__cache-message", text: "foo baz") + end end def test_cache_component - component = CacheComponent.new(foo: "foo", bar: "bar") - render_inline(component) + with_action_controller_caching do + 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") + 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")) + render_inline(CacheComponent.new(foo: "foo", bar: "bar")) - assert_selector(".cache-component__cache-key", text: component.view_cache_dependencies) + assert_selector(".cache-component__cache-key", text: component.view_cache_dependencies) - new_component = CacheComponent.new(foo: "foo", bar: "baz") - render_inline(new_component) + 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") + assert_selector(".cache-component__cache-key", text: new_component.view_cache_dependencies) + assert_selector(".cache-component__cache-message", text: "foo baz") + end end def test_no_cache_compoennt @@ -1398,6 +1402,7 @@ def test_no_cache_compoennt assert_selector(".cache-component__cache-key", text: component.view_cache_dependencies) assert_selector(".cache-component__cache-message", text: "foo bar") + assert_nil(component.view_cache_options) end def test_cache_if_false_skips_caching @@ -1412,7 +1417,72 @@ def test_cache_if_false_skips_caching refute_equal(first_time, second_time) end - def test_cache_on_expands_dependency_values_and_allows_private_methods + def test_cache_hits_are_recorded_when_instrumentation_is_enabled + component = CacheComponent.new(foo: "foo", bar: "bar") + renderer = Struct.new(:cache_hits).new({}) + template = Struct.new(:virtual_path).new("cache_component") + + component.instance_variable_set(:@view_renderer, renderer) + component.instance_variable_set(:@current_template, template) + + with_instrumentation_enabled_option(true) do + component.record_fragment_cache(:hit) + end + + assert_equal(:hit, renderer.cache_hits["cache_component"]) + end + + def test_cache_hits_are_not_recorded_when_instrumentation_is_disabled + component = CacheComponent.new(foo: "foo", bar: "bar") + renderer = Struct.new(:cache_hits).new({}) + template = Struct.new(:virtual_path).new("cache_component") + + component.instance_variable_set(:@view_renderer, renderer) + component.instance_variable_set(:@current_template, template) + + with_instrumentation_enabled_option(false) do + component.record_fragment_cache(:hit) + end + + assert_empty(renderer.cache_hits) + end + + def test_component_cache_uses_rails_fragment_cache_instrumentation + events = [] + subscriber = lambda do |*args| + events << ActiveSupport::Notifications::Event.new(*args) + end + + with_action_controller_caching do + Rails.cache.clear + + ActiveSupport::Notifications.subscribed(subscriber, /_fragment\.action_controller/) do + render_inline(CacheComponent.new(foo: "foo", bar: "bar")) + render_inline(CacheComponent.new(foo: "foo", bar: "bar")) + end + end + + assert_includes(events.map(&:name), "write_fragment.action_controller") + assert_includes(events.map(&:name), "read_fragment.action_controller") + assert(events.any? { |event| event.payload[:key].flatten.include?(:view_component) }) + ensure + Rails.cache.clear + end + + def test_cache_digest_is_precomputed_when_template_caching_is_enabled + CacheComponent.remove_instance_variable(:@__vc_component_digest) if CacheComponent.instance_variable_defined?(:@__vc_component_digest) + + with_template_caching do + CacheComponent.__vc_compile(force: true) + end + + assert_predicate(CacheComponent.instance_variable_get(:@__vc_component_digest), :present?) + ensure + CacheComponent.remove_instance_variable(:@__vc_component_digest) if CacheComponent.instance_variable_defined?(:@__vc_component_digest) + ViewComponent::CompileCache.invalidate! + end + + def test_cache_block_expands_dependency_values_and_allows_private_methods record = GlobalID.parse("gid://sandbox/CacheDependencyTypesRecord/42") component = CacheDependencyTypesComponent.new(record: record, tags: ["alpha", "beta"], label: "plain-string") @@ -1422,36 +1492,77 @@ def test_cache_on_expands_dependency_values_and_allows_private_methods assert_equal(expected_dependencies, component.view_cache_dependencies) expanded_dependencies = ActiveSupport::Cache.expand_cache_key(expected_dependencies) - cache_key = component.view_cache_options.last + cache_key = ActiveSupport::Cache.expand_cache_key(component.view_cache_options) assert_includes(cache_key, expanded_dependencies) assert_includes(expanded_dependencies, record.to_param) end + def with_action_controller_caching + old_perform_caching = ActionController::Base.perform_caching + ActionController::Base.perform_caching = true + + yield + ensure + ActionController::Base.perform_caching = old_perform_caching + end + def test_cache_key_changes_when_child_component_template_changes + with_action_controller_caching do + child_template_path = CacheDigestorChildComponent.sidecar_files(["erb"]).first + original_template = File.read(child_template_path) + + Rails.cache.clear + ViewComponent::CompileCache.invalidate! + + component_v1 = CacheDigestorParentComponent.new(foo: "x") + render_inline(component_v1) + assert_selector(".child", text: "v1") + time_v1 = page.find(".parent")["data-time"] + + render_inline(CacheDigestorParentComponent.new(foo: "x")) + assert_selector(".child", text: "v1") + assert_equal(time_v1, page.find(".parent")["data-time"]) + + File.write(child_template_path, original_template.sub("v1", "v2")) + ViewComponent::CompileCache.invalidate! + + component_v2 = CacheDigestorParentComponent.new(foo: "x") + render_inline(component_v2) + assert_selector(".child", text: "v2") + refute_equal(time_v1, page.find(".parent")["data-time"]) + ensure + Rails.cache.clear + ViewComponent::CompileCache.invalidate! + + if child_template_path && original_template + File.write(child_template_path, original_template) + end + end + end + + def test_cache_digest_tracks_child_component_dependencies_from_rails_template_handlers + [ + CacheDigestorSlimParentComponent, + CacheDigestorHamlParentComponent, + CacheDigestorJbuilderParentComponent + ].each do |component_class| + assert_cache_digest_changes_when_child_component_template_changes(component_class) + end + end + + def assert_cache_digest_changes_when_child_component_template_changes(component_class) child_template_path = CacheDigestorChildComponent.sidecar_files(["erb"]).first original_template = File.read(child_template_path) - Rails.cache.clear ViewComponent::CompileCache.invalidate! - - component_v1 = CacheDigestorParentComponent.new(foo: "x") - render_inline(component_v1) - assert_selector(".child", text: "v1") - time_v1 = page.find(".parent")["data-time"] - - render_inline(CacheDigestorParentComponent.new(foo: "x")) - assert_selector(".child", text: "v1") - assert_equal(time_v1, page.find(".parent")["data-time"]) + digest_v1 = ViewComponent::CacheDigestor.digest(component_class) File.write(child_template_path, original_template.sub("v1", "v2")) ViewComponent::CompileCache.invalidate! - component_v2 = CacheDigestorParentComponent.new(foo: "x") - render_inline(component_v2) - assert_selector(".child", text: "v2") - refute_equal(time_v1, page.find(".parent")["data-time"]) + digest_v2 = ViewComponent::CacheDigestor.digest(component_class) + refute_equal(digest_v1, digest_v2) ensure - Rails.cache.clear ViewComponent::CompileCache.invalidate! if child_template_path && original_template @@ -1460,81 +1571,87 @@ def test_cache_key_changes_when_child_component_template_changes end def test_cache_key_does_not_change_when_partial_string_dependency_changes - partial_path = Rails.root.join("app/views/shared/_cache_digestor_partial.html.erb") - original_partial = File.read(partial_path) + with_action_controller_caching do + partial_path = Rails.root.join("app/views/shared/_cache_digestor_partial.html.erb") + original_partial = File.read(partial_path) - Rails.cache.clear - ViewComponent::CompileCache.invalidate! + Rails.cache.clear + ViewComponent::CompileCache.invalidate! - component_v1 = CacheDigestorPartialParentComponent.new(foo: "x") - render_inline(component_v1) - assert_selector(".partial-child", text: "partial-v1") - time_v1 = page.find(".partial-parent")["data-time"] + component_v1 = CacheDigestorPartialParentComponent.new(foo: "x") + render_inline(component_v1) + assert_selector(".partial-child", text: "partial-v1") + time_v1 = page.find(".partial-parent")["data-time"] - File.write(partial_path, original_partial.sub("partial-v1", "partial-v2")) - ViewComponent::CompileCache.invalidate! + File.write(partial_path, original_partial.sub("partial-v1", "partial-v2")) + ViewComponent::CompileCache.invalidate! - render_inline(CacheDigestorPartialParentComponent.new(foo: "x")) + render_inline(CacheDigestorPartialParentComponent.new(foo: "x")) - assert_selector(".partial-child", text: "partial-v1") - assert_equal(time_v1, page.find(".partial-parent")["data-time"]) - ensure - Rails.cache.clear - ViewComponent::CompileCache.invalidate! + assert_selector(".partial-child", text: "partial-v1") + assert_equal(time_v1, page.find(".partial-parent")["data-time"]) + ensure + Rails.cache.clear + ViewComponent::CompileCache.invalidate! - File.write(partial_path, original_partial) if partial_path && original_partial + File.write(partial_path, original_partial) if partial_path && original_partial + end end def test_cache_key_does_not_change_when_child_component_partial_dependency_changes - partial_path = Rails.root.join("app/views/shared/_cache_digestor_nested_partial.html.erb") - original_partial = File.read(partial_path) + with_action_controller_caching do + partial_path = Rails.root.join("app/views/shared/_cache_digestor_nested_partial.html.erb") + original_partial = File.read(partial_path) - Rails.cache.clear - ViewComponent::CompileCache.invalidate! + Rails.cache.clear + ViewComponent::CompileCache.invalidate! - component_v1 = CacheDigestorNestedPartialParentComponent.new(foo: "x") - render_inline(component_v1) - assert_selector(".nested-partial-child", text: "nested-v1") - time_v1 = page.find(".nested-partial-parent")["data-time"] + component_v1 = CacheDigestorNestedPartialParentComponent.new(foo: "x") + render_inline(component_v1) + assert_selector(".nested-partial-child", text: "nested-v1") + time_v1 = page.find(".nested-partial-parent")["data-time"] - File.write(partial_path, original_partial.sub("nested-v1", "nested-v2")) - ViewComponent::CompileCache.invalidate! + File.write(partial_path, original_partial.sub("nested-v1", "nested-v2")) + ViewComponent::CompileCache.invalidate! - render_inline(CacheDigestorNestedPartialParentComponent.new(foo: "x")) + render_inline(CacheDigestorNestedPartialParentComponent.new(foo: "x")) - assert_selector(".nested-partial-child", text: "nested-v1") - assert_equal(time_v1, page.find(".nested-partial-parent")["data-time"]) - ensure - Rails.cache.clear - ViewComponent::CompileCache.invalidate! + assert_selector(".nested-partial-child", text: "nested-v1") + assert_equal(time_v1, page.find(".nested-partial-parent")["data-time"]) + ensure + Rails.cache.clear + ViewComponent::CompileCache.invalidate! - File.write(partial_path, original_partial) if partial_path && original_partial + File.write(partial_path, original_partial) if partial_path && original_partial + end end def test_cache_key_does_not_change_when_layout_string_dependency_changes - layout_path = Rails.root.join("app/views/shared/_cache_digestor_layout.html.erb") - original_layout = File.read(layout_path) + with_action_controller_caching do + layout_path = Rails.root.join("app/views/shared/_cache_digestor_layout.html.erb") + original_layout = File.read(layout_path) - Rails.cache.clear - ViewComponent::CompileCache.invalidate! + Rails.cache.clear + ViewComponent::CompileCache.invalidate! - component_v1 = CacheDigestorLayoutParentComponent.new(foo: "x") - render_inline(component_v1) - assert_selector(".layout-shell", text: "layout-v1") - time_v1 = page.find(".layout-parent")["data-time"] + component_v1 = CacheDigestorLayoutParentComponent.new(foo: "x") + render_inline(component_v1) + assert_selector(".layout-shell", text: "layout-v1") + time_v1 = page.find(".layout-parent")["data-time"] - File.write(layout_path, original_layout.sub("layout-v1", "layout-v2")) - ViewComponent::CompileCache.invalidate! + File.write(layout_path, original_layout.sub("layout-v1", "layout-v2")) + ViewComponent::CompileCache.invalidate! - render_inline(CacheDigestorLayoutParentComponent.new(foo: "x")) + render_inline(CacheDigestorLayoutParentComponent.new(foo: "x")) - assert_selector(".layout-shell", text: "layout-v1") - assert_equal(time_v1, page.find(".layout-parent")["data-time"]) - ensure - Rails.cache.clear - ViewComponent::CompileCache.invalidate! + assert_selector(".layout-shell", text: "layout-v1") + assert_equal(time_v1, page.find(".layout-parent")["data-time"]) + ensure + Rails.cache.clear + ViewComponent::CompileCache.invalidate! - File.write(layout_path, original_layout) if layout_path && original_layout + File.write(layout_path, original_layout) if layout_path && original_layout + end end def test_render_partial_with_yield From ee97af17e75257f75d396885adab954e3629f8b0 Mon Sep 17 00:00:00 2001 From: Reegan Date: Sun, 14 Jun 2026 16:04:59 +0200 Subject: [PATCH 106/122] Fix CI failures after merge --- Rakefile | 3 ++- gemfiles/rails_main_head.gemfile.lock | 4 ++++ lib/docs/docs_builder_component.rb | 4 ++-- lib/view_component/template.rb | 6 +++++- test/sandbox/app/models/coupon.rb | 6 +++++- test/sandbox/app/models/partial_model.rb | 6 +++++- test/sandbox/app/models/photo.rb | 6 +++++- test/sandbox/app/models/product.rb | 6 +++++- 8 files changed, 33 insertions(+), 8 deletions(-) diff --git a/Rakefile b/Rakefile index 631346cc3..20a698bdd 100644 --- a/Rakefile +++ b/Rakefile @@ -73,7 +73,8 @@ namespace :docs do method.path.include?("ViewComponent::Base") && method.visibility == :public && !method[:name].to_s.start_with?("_") # Ignore methods we mark as internal by prefixing with underscores - end.sort_by { |method| method[:name] } + end + .sort_by { |method| method[:name] } instance_methods_to_document = meths.select { |method| method.scope != :class } class_methods_to_document = meths.select { |method| method.scope == :class } diff --git a/gemfiles/rails_main_head.gemfile.lock b/gemfiles/rails_main_head.gemfile.lock index 92062f8d4..feb0741e6 100644 --- a/gemfiles/rails_main_head.gemfile.lock +++ b/gemfiles/rails_main_head.gemfile.lock @@ -112,6 +112,7 @@ PATH specs: view_component (4.12.0) actionview (>= 7.1.0) + actionview_precompiler (>= 0.4) activesupport (>= 7.1.0) concurrent-ruby (~> 1) @@ -120,6 +121,8 @@ GEM specs: action_text-trix (2.1.19) railties + actionview_precompiler (0.4.0) + actionview (>= 6.0.a) addressable (2.9.0) public_suffix (>= 2.0.2, < 8.0) allocation_stats (0.1.5) @@ -456,6 +459,7 @@ CHECKSUMS actionpack (8.2.0.alpha) actiontext (8.2.0.alpha) actionview (8.2.0.alpha) + actionview_precompiler (0.4.0) sha256=33b6bd6ec4c1b856e02fdf5f6512c9eb4a92ac1c0545e941b3e354b7d540ed1c activejob (8.2.0.alpha) activemodel (8.2.0.alpha) activerecord (8.2.0.alpha) diff --git a/lib/docs/docs_builder_component.rb b/lib/docs/docs_builder_component.rb index 8c4384a98..314c0a73c 100644 --- a/lib/docs/docs_builder_component.rb +++ b/lib/docs/docs_builder_component.rb @@ -2,11 +2,11 @@ module ViewComponent class DocsBuilderComponent < Base - class Section < Struct.new(:heading, :methods, :error_klasses, :show_types, keyword_init: true) + class Section < Struct.new(:heading, :methods, :error_klasses, :show_types) def initialize(heading: nil, methods: [], error_klasses: [], show_types: true) methods.sort_by! { |method| method[:name] } error_klasses.sort! - super + super(heading, methods, error_klasses, show_types) end end diff --git a/lib/view_component/template.rb b/lib/view_component/template.rb index 8cc330075..ae94143c7 100644 --- a/lib/view_component/template.rb +++ b/lib/view_component/template.rb @@ -5,7 +5,11 @@ class Template DEFAULT_FORMAT = :html private_constant :DEFAULT_FORMAT - DataWithSource = Struct.new(:format, :identifier, :short_identifier, :type, keyword_init: true) + class DataWithSource < Struct.new(:format, :identifier, :short_identifier, :type) + def initialize(format:, identifier:, short_identifier:, type:) + super(format, identifier, short_identifier, type) + end + end attr_reader :details, :path diff --git a/test/sandbox/app/models/coupon.rb b/test/sandbox/app/models/coupon.rb index a464c1ece..d26379405 100644 --- a/test/sandbox/app/models/coupon.rb +++ b/test/sandbox/app/models/coupon.rb @@ -1 +1,5 @@ -Coupon = Struct.new(:percent_off, keyword_init: true) +class Coupon < Struct.new(:percent_off) + def initialize(percent_off:) + super(percent_off) + end +end diff --git a/test/sandbox/app/models/partial_model.rb b/test/sandbox/app/models/partial_model.rb index d6ad26cd6..61f870390 100644 --- a/test/sandbox/app/models/partial_model.rb +++ b/test/sandbox/app/models/partial_model.rb @@ -1 +1,5 @@ -PartialModel = Struct.new(:to_partial_path, keyword_init: true) +class PartialModel < Struct.new(:to_partial_path) + def initialize(to_partial_path:) + super(to_partial_path) + end +end diff --git a/test/sandbox/app/models/photo.rb b/test/sandbox/app/models/photo.rb index 5a117d627..f84c74ce3 100644 --- a/test/sandbox/app/models/photo.rb +++ b/test/sandbox/app/models/photo.rb @@ -1 +1,5 @@ -Photo = Struct.new(:title, :caption, :url, keyword_init: true) +class Photo < Struct.new(:title, :caption, :url) + def initialize(title:, caption:, url:) + super(title, caption, url) + end +end diff --git a/test/sandbox/app/models/product.rb b/test/sandbox/app/models/product.rb index dd901d8a3..dc97daef1 100644 --- a/test/sandbox/app/models/product.rb +++ b/test/sandbox/app/models/product.rb @@ -1 +1,5 @@ -Product = Struct.new(:name, keyword_init: true) +class Product < Struct.new(:name) + def initialize(name:) + super(name) + end +end From 53d748781b3075dd9e758e11642f1f3e7df5e13f Mon Sep 17 00:00:00 2001 From: Reegan Date: Sun, 14 Jun 2026 16:07:09 +0200 Subject: [PATCH 107/122] Refactor lint-sensitive chains --- Rakefile | 31 ++++++++++++------------------- lib/view_component/compiler.rb | 27 ++++++++++++++------------- 2 files changed, 26 insertions(+), 32 deletions(-) diff --git a/Rakefile b/Rakefile index 20a698bdd..ecd5be42e 100644 --- a/Rakefile +++ b/Rakefile @@ -64,29 +64,22 @@ namespace :docs do registry = YARD::RegistryStore.new registry.load!(".yardoc") - meths = - registry - .get("ViewComponent::Base") - .meths - .select do |method| - !method.tag(:private) && - method.path.include?("ViewComponent::Base") && - method.visibility == :public && - !method[:name].to_s.start_with?("_") # Ignore methods we mark as internal by prefixing with underscores - end - .sort_by { |method| method[:name] } + base_methods = registry.get("ViewComponent::Base").meths + meths = base_methods.select do |method| + !method.tag(:private) && + method.path.include?("ViewComponent::Base") && + method.visibility == :public && + !method[:name].to_s.start_with?("_") # Ignore methods we mark as internal by prefixing with underscores + end.sort_by { |method| method[:name] } instance_methods_to_document = meths.select { |method| method.scope != :class } class_methods_to_document = meths.select { |method| method.scope == :class } configuration_methods_to_document = registry.get("ViewComponent::Config").meths.select(&:reader?) - test_helper_methods_to_document = registry - .get("ViewComponent::TestHelpers") - .meths - .sort_by { |method| method[:name] } - .select do |method| - !method.tag(:private) && - method.visibility == :public - end + test_helper_methods = registry.get("ViewComponent::TestHelpers").meths + test_helper_methods_to_document = test_helper_methods.select do |method| + !method.tag(:private) && + method.visibility == :public + end.sort_by { |method| method[:name] } require "rails" require "action_controller" diff --git a/lib/view_component/compiler.rb b/lib/view_component/compiler.rb index 50c009cdb..28e588624 100644 --- a/lib/view_component/compiler.rb +++ b/lib/view_component/compiler.rb @@ -110,16 +110,17 @@ def template_errors errors << "Couldn't find a template file or inline render method for #{@component}." if @templates.empty? - @templates + duplicate_template_details = @templates .map { |template| [template.variant, template.format] } .tally .select { |_, count| count > 1 } - .each do |tally| - variant, this_format = tally.first - variant_string = " for variant `#{variant}`" if variant.present? + duplicate_template_details.each do |details, _count| + variant, this_format = details - errors << "More than one #{this_format.upcase} template found#{variant_string} for #{@component}. " + variant_string = " for variant `#{variant}`" if variant.present? + + errors << "More than one #{this_format.upcase} template found#{variant_string} for #{@component}. " end default_template_types = @templates.each_with_object(Set.new) do |template, memo| @@ -182,17 +183,17 @@ def gather_templates end component_instance_methods_on_self = @component.instance_methods(false) - - ( + call_methods = ( @component.ancestors.take_while { |ancestor| ancestor != ViewComponent::Base } - @component.included_modules ).flat_map { |ancestor| ancestor.instance_methods(false).grep(/^call(_|$)/) } .uniq - .each do |method_name| - templates << Template::InlineCall.new( - component: @component, - method_name: method_name, - defined_on_self: component_instance_methods_on_self.include?(method_name) - ) + + call_methods.each do |method_name| + templates << Template::InlineCall.new( + component: @component, + method_name: method_name, + defined_on_self: component_instance_methods_on_self.include?(method_name) + ) end templates From 94ee67cb312ae3982b208f2fcf50bc2d44180d0c Mon Sep 17 00:00:00 2001 From: Reegan Date: Sun, 14 Jun 2026 16:09:52 +0200 Subject: [PATCH 108/122] Handle Ruby head ERB line offsets --- lib/view_component/template.rb | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/lib/view_component/template.rb b/lib/view_component/template.rb index ae94143c7..677c269f1 100644 --- a/lib/view_component/template.rb +++ b/lib/view_component/template.rb @@ -38,16 +38,18 @@ def initialize(component:, details:, path:) # Use -1 to compensate for correct line numbers in stack traces. # However, negative line numbers cause segfaults when Ruby's coverage # is enabled (bugs.ruby-lang.org/issues/19363). In that case, strip the - # annotation line from compiled source instead. + # annotation line from compiled source instead. Ruby 4.1-dev also has + # trouble evaluating ERB methods with non-positive line numbers, so use + # the closest positive line offsets there. lineno = if Rails::VERSION::MAJOR >= 8 && Rails::VERSION::MINOR > 0 && details.handler == :erb if coverage_running? # Can't use negative lineno with coverage (causes segfault on Linux). # Strip annotation line if enabled to preserve correct line numbers. @strip_annotation_line = ActionView::Base.annotate_rendered_view_with_filenames - 0 + ruby_head? ? 1 : 0 else - -1 + ruby_head? ? 0 : -1 end else 0 @@ -165,6 +167,10 @@ def coverage_running? defined?(Coverage) && Coverage.running? end + def ruby_head? + RUBY_DESCRIPTION.include?("dev") + end + def safe_method_name_call m = safe_method_name proc { send(m) } From 7d1ca481ec4e50e94ba3997424ebc6cc6f7721b9 Mon Sep 17 00:00:00 2001 From: Reegan Date: Sun, 14 Jun 2026 16:12:44 +0200 Subject: [PATCH 109/122] Keep CI fix scoped --- Gemfile | 2 +- Gemfile.lock | 2 +- Rakefile | 30 ++++++++++++++---------- lib/docs/docs_builder_component.rb | 4 ++-- lib/view_component/compiler.rb | 27 ++++++++++----------- lib/view_component/template.rb | 6 +---- test/sandbox/app/models/coupon.rb | 6 +---- test/sandbox/app/models/partial_model.rb | 6 +---- test/sandbox/app/models/photo.rb | 6 +---- test/sandbox/app/models/product.rb | 6 +---- 10 files changed, 40 insertions(+), 55 deletions(-) diff --git a/Gemfile b/Gemfile index d1aa87cf1..1ab60b9b0 100644 --- a/Gemfile +++ b/Gemfile @@ -41,7 +41,7 @@ group :development, :test do gem "simplecov", "< 1" gem "slim", "~> 5" gem "sprockets-rails", "~> 3" - gem "standard", "~> 1" + gem "standard", "~> 1.54.0" gem "tailwindcss-rails", "~> 4" gem "turbo-rails" gem "warning" diff --git a/Gemfile.lock b/Gemfile.lock index 3bd018c4e..71cf1524d 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -459,7 +459,7 @@ DEPENDENCIES simplecov-console (< 1) slim (~> 5) sprockets-rails (~> 3) - standard (~> 1) + standard (~> 1.54.0) tailwindcss-rails (~> 4) turbo-rails view_component! diff --git a/Rakefile b/Rakefile index ecd5be42e..631346cc3 100644 --- a/Rakefile +++ b/Rakefile @@ -64,22 +64,28 @@ namespace :docs do registry = YARD::RegistryStore.new registry.load!(".yardoc") - base_methods = registry.get("ViewComponent::Base").meths - meths = base_methods.select do |method| - !method.tag(:private) && - method.path.include?("ViewComponent::Base") && - method.visibility == :public && - !method[:name].to_s.start_with?("_") # Ignore methods we mark as internal by prefixing with underscores - end.sort_by { |method| method[:name] } + meths = + registry + .get("ViewComponent::Base") + .meths + .select do |method| + !method.tag(:private) && + method.path.include?("ViewComponent::Base") && + method.visibility == :public && + !method[:name].to_s.start_with?("_") # Ignore methods we mark as internal by prefixing with underscores + end.sort_by { |method| method[:name] } instance_methods_to_document = meths.select { |method| method.scope != :class } class_methods_to_document = meths.select { |method| method.scope == :class } configuration_methods_to_document = registry.get("ViewComponent::Config").meths.select(&:reader?) - test_helper_methods = registry.get("ViewComponent::TestHelpers").meths - test_helper_methods_to_document = test_helper_methods.select do |method| - !method.tag(:private) && - method.visibility == :public - end.sort_by { |method| method[:name] } + test_helper_methods_to_document = registry + .get("ViewComponent::TestHelpers") + .meths + .sort_by { |method| method[:name] } + .select do |method| + !method.tag(:private) && + method.visibility == :public + end require "rails" require "action_controller" diff --git a/lib/docs/docs_builder_component.rb b/lib/docs/docs_builder_component.rb index 314c0a73c..8c4384a98 100644 --- a/lib/docs/docs_builder_component.rb +++ b/lib/docs/docs_builder_component.rb @@ -2,11 +2,11 @@ module ViewComponent class DocsBuilderComponent < Base - class Section < Struct.new(:heading, :methods, :error_klasses, :show_types) + class Section < Struct.new(:heading, :methods, :error_klasses, :show_types, keyword_init: true) def initialize(heading: nil, methods: [], error_klasses: [], show_types: true) methods.sort_by! { |method| method[:name] } error_klasses.sort! - super(heading, methods, error_klasses, show_types) + super end end diff --git a/lib/view_component/compiler.rb b/lib/view_component/compiler.rb index 28e588624..50c009cdb 100644 --- a/lib/view_component/compiler.rb +++ b/lib/view_component/compiler.rb @@ -110,17 +110,16 @@ def template_errors errors << "Couldn't find a template file or inline render method for #{@component}." if @templates.empty? - duplicate_template_details = @templates + @templates .map { |template| [template.variant, template.format] } .tally .select { |_, count| count > 1 } + .each do |tally| + variant, this_format = tally.first - duplicate_template_details.each do |details, _count| - variant, this_format = details + variant_string = " for variant `#{variant}`" if variant.present? - variant_string = " for variant `#{variant}`" if variant.present? - - errors << "More than one #{this_format.upcase} template found#{variant_string} for #{@component}. " + errors << "More than one #{this_format.upcase} template found#{variant_string} for #{@component}. " end default_template_types = @templates.each_with_object(Set.new) do |template, memo| @@ -183,17 +182,17 @@ def gather_templates end component_instance_methods_on_self = @component.instance_methods(false) - call_methods = ( + + ( @component.ancestors.take_while { |ancestor| ancestor != ViewComponent::Base } - @component.included_modules ).flat_map { |ancestor| ancestor.instance_methods(false).grep(/^call(_|$)/) } .uniq - - call_methods.each do |method_name| - templates << Template::InlineCall.new( - component: @component, - method_name: method_name, - defined_on_self: component_instance_methods_on_self.include?(method_name) - ) + .each do |method_name| + templates << Template::InlineCall.new( + component: @component, + method_name: method_name, + defined_on_self: component_instance_methods_on_self.include?(method_name) + ) end templates diff --git a/lib/view_component/template.rb b/lib/view_component/template.rb index 677c269f1..38d320e44 100644 --- a/lib/view_component/template.rb +++ b/lib/view_component/template.rb @@ -5,11 +5,7 @@ class Template DEFAULT_FORMAT = :html private_constant :DEFAULT_FORMAT - class DataWithSource < Struct.new(:format, :identifier, :short_identifier, :type) - def initialize(format:, identifier:, short_identifier:, type:) - super(format, identifier, short_identifier, type) - end - end + DataWithSource = Struct.new(:format, :identifier, :short_identifier, :type, keyword_init: true) attr_reader :details, :path diff --git a/test/sandbox/app/models/coupon.rb b/test/sandbox/app/models/coupon.rb index d26379405..a464c1ece 100644 --- a/test/sandbox/app/models/coupon.rb +++ b/test/sandbox/app/models/coupon.rb @@ -1,5 +1 @@ -class Coupon < Struct.new(:percent_off) - def initialize(percent_off:) - super(percent_off) - end -end +Coupon = Struct.new(:percent_off, keyword_init: true) diff --git a/test/sandbox/app/models/partial_model.rb b/test/sandbox/app/models/partial_model.rb index 61f870390..d6ad26cd6 100644 --- a/test/sandbox/app/models/partial_model.rb +++ b/test/sandbox/app/models/partial_model.rb @@ -1,5 +1 @@ -class PartialModel < Struct.new(:to_partial_path) - def initialize(to_partial_path:) - super(to_partial_path) - end -end +PartialModel = Struct.new(:to_partial_path, keyword_init: true) diff --git a/test/sandbox/app/models/photo.rb b/test/sandbox/app/models/photo.rb index f84c74ce3..5a117d627 100644 --- a/test/sandbox/app/models/photo.rb +++ b/test/sandbox/app/models/photo.rb @@ -1,5 +1 @@ -class Photo < Struct.new(:title, :caption, :url) - def initialize(title:, caption:, url:) - super(title, caption, url) - end -end +Photo = Struct.new(:title, :caption, :url, keyword_init: true) diff --git a/test/sandbox/app/models/product.rb b/test/sandbox/app/models/product.rb index dc97daef1..dd901d8a3 100644 --- a/test/sandbox/app/models/product.rb +++ b/test/sandbox/app/models/product.rb @@ -1,5 +1 @@ -class Product < Struct.new(:name) - def initialize(name:) - super(name) - end -end +Product = Struct.new(:name, keyword_init: true) From df9b131e19bfd296ead85483cc7554c20e7c1403 Mon Sep 17 00:00:00 2001 From: Reegan Date: Sun, 14 Jun 2026 16:18:11 +0200 Subject: [PATCH 110/122] Keep component caching isolated --- docs/CHANGELOG.md | 10 +++--- lib/view_component/base.rb | 16 +-------- lib/view_component/cache_digestor.rb | 4 +-- lib/view_component/compiler.rb | 8 +++-- .../experimentally_cacheable.rb | 14 ++++++++ lib/view_component/system_test_helpers.rb | 36 ++++++------------- lib/view_component/template.rb | 12 ++----- lib/view_component/translatable.rb | 2 +- 8 files changed, 42 insertions(+), 60 deletions(-) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 590e0f71e..694508d36 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -640,7 +640,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 for an invite. +* Add docs about Slack channel in Ruby Central workspace. (Join us! #oss-view-component). Email joelhawksley@github.com for an invite. *Joel Hawksley @@ -2000,7 +2000,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 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 joelhawksley@github.com 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) @@ -2752,7 +2752,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 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 joelhawksley@github.com with any questions. *Joel Hawksley* @@ -2770,7 +2770,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. @@ -3218,7 +3218,7 @@ Run into an issue with this release? [Let us know](https://github.com/ViewCompon * 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. diff --git a/lib/view_component/base.rb b/lib/view_component/base.rb index 7b418d0b1..dfcab9894 100644 --- a/lib/view_component/base.rb +++ b/lib/view_component/base.rb @@ -50,6 +50,7 @@ def config include Rails.application.routes.url_helpers if defined?(Rails.application.routes) include ERB::Escape include ActiveSupport::CoreExt::ERBUtil + include ViewComponent::InlineTemplate include ViewComponent::Slotable include ViewComponent::Translatable @@ -220,11 +221,6 @@ def render_parent_to_string end end - # @private - def __vc_render_template(safe_call) - instance_exec(&safe_call) - end - # Optional content to be returned before the rendered template. # # @return [String] @@ -608,16 +604,6 @@ def sidecar_files(extensions) (sidecar_files - [identifier] + sidecar_directory_files + nested_component_files).uniq end - # @private - def sidecar_templates - sidecar_files(ActionView::Template.template_handler_extensions) - end - - # @private - def sidecar_translations - sidecar_files(%w[yml yaml]) - end - # Render a component for each element in a collection ([documentation](/guide/collections)): # # ```ruby diff --git a/lib/view_component/cache_digestor.rb b/lib/view_component/cache_digestor.rb index 8b2b5ca22..24d204443 100644 --- a/lib/view_component/cache_digestor.rb +++ b/lib/view_component/cache_digestor.rb @@ -47,13 +47,13 @@ def digest_for_component(component_class) update_template_dependency_digests(digest, inline_source, inline_template.language, component_class.identifier) end - component_class.sidecar_templates.sort.each do |path| + component_class.sidecar_files(ActionView::Template.template_handler_extensions).sort.each do |path| template_source = cached_file_contents(path) update_digest(digest, template_source) update_template_dependency_digests(digest, template_source, File.extname(path).delete_prefix("."), path) end - component_class.sidecar_translations.sort.each do |path| + component_class.sidecar_files(%w[yml yaml]).sort.each do |path| update_digest(digest, cached_file_contents(path)) end diff --git a/lib/view_component/compiler.rb b/lib/view_component/compiler.rb index 50c009cdb..1c3e65b2b 100644 --- a/lib/view_component/compiler.rb +++ b/lib/view_component/compiler.rb @@ -90,13 +90,13 @@ def define_render_template_for safe_call = template.safe_method_name_call @component.define_method(:render_template_for) do |_| @current_template = template - __vc_render_template(safe_call) + instance_exec(&safe_call) end else compiler = self @component.define_method(:render_template_for) do |details| if (@current_template = compiler.find_templates_for(details).first) - __vc_render_template(@current_template.safe_method_name_call) + instance_exec(&@current_template.safe_method_name_call) else raise MissingTemplateError.new(self.class.name, details) end @@ -176,7 +176,9 @@ def gather_templates )] else path_parser = ActionView::Resolver::PathParser.new - templates = @component.sidecar_templates.map do |path| + templates = @component.sidecar_files( + ActionView::Template.template_handler_extensions + ).map do |path| details = path_parser.parse(path).details Template::File.new(component: @component, path: path, details: details) end diff --git a/lib/view_component/experimentally_cacheable.rb b/lib/view_component/experimentally_cacheable.rb index e70b80e2a..16427b0f3 100644 --- a/lib/view_component/experimentally_cacheable.rb +++ b/lib/view_component/experimentally_cacheable.rb @@ -119,9 +119,23 @@ def __vc_cache_enabled? def after_compile super + __vc_define_cached_render_template_for __vc_precompute_component_digest if ActionView::Base.cache_template_loading end + def __vc_define_cached_render_template_for + compiler = __vc_compiler + silence_redefinition_of_method(:render_template_for) + + define_method(:render_template_for) do |details| + if (@current_template = compiler.find_templates_for(details).first) + __vc_render_template(@current_template.safe_method_name_call) + else + raise ViewComponent::MissingTemplateError.new(self.class.name, details) + end + end + end + def __vc_component_digest @__vc_component_digest ||= ViewComponent::CacheDigestor.digest(self) end diff --git a/lib/view_component/system_test_helpers.rb b/lib/view_component/system_test_helpers.rb index 5bf941daa..673cf9fd4 100644 --- a/lib/view_component/system_test_helpers.rb +++ b/lib/view_component/system_test_helpers.rb @@ -1,8 +1,5 @@ # frozen_string_literal: true -require "base64" -require "securerandom" - module ViewComponent module SystemTestHelpers include TestHelpers @@ -13,29 +10,18 @@ module SystemTestHelpers # # @return [Proc] A block that can be used to visit the path of the inline rendered component. def with_rendered_component_path(fragment, layout: false, &block) - rendered_html = vc_test_controller.render_to_string(html: fragment.to_html.html_safe, layout: layout) - - if use_inline_data_url?(layout) - yield("data:text/html;base64,#{Base64.strict_encode64(rendered_html)}") - return + file = Tempfile.new( + ["rendered_#{fragment.class.name}", ".html"], + ViewComponentsSystemTestController.temp_dir + ) + begin + file.write(vc_test_controller.render_to_string(html: fragment.to_html.html_safe, layout: layout)) + file.rewind + + yield("/_system_test_entrypoint?file=#{file.path.split("/").last}") + ensure + file.unlink end - - filename = "rendered_#{fragment.class.name.gsub("::", "")}_#{SecureRandom.hex(8)}.html" - path = File.join(ViewComponentsSystemTestController.temp_dir, filename) - - File.write(path, rendered_html) - - yield("/_system_test_entrypoint?file=#{filename}") - end - - private - - def use_inline_data_url?(layout) - return false if layout - return true if defined?(ActionDispatch::SystemTestCase) && is_a?(ActionDispatch::SystemTestCase) - return false unless defined?(Capybara) && Capybara.respond_to?(:current_driver) - - Capybara.current_driver != :rack_test end end end diff --git a/lib/view_component/template.rb b/lib/view_component/template.rb index 38d320e44..8cc330075 100644 --- a/lib/view_component/template.rb +++ b/lib/view_component/template.rb @@ -34,18 +34,16 @@ def initialize(component:, details:, path:) # Use -1 to compensate for correct line numbers in stack traces. # However, negative line numbers cause segfaults when Ruby's coverage # is enabled (bugs.ruby-lang.org/issues/19363). In that case, strip the - # annotation line from compiled source instead. Ruby 4.1-dev also has - # trouble evaluating ERB methods with non-positive line numbers, so use - # the closest positive line offsets there. + # annotation line from compiled source instead. lineno = if Rails::VERSION::MAJOR >= 8 && Rails::VERSION::MINOR > 0 && details.handler == :erb if coverage_running? # Can't use negative lineno with coverage (causes segfault on Linux). # Strip annotation line if enabled to preserve correct line numbers. @strip_annotation_line = ActionView::Base.annotate_rendered_view_with_filenames - ruby_head? ? 1 : 0 + 0 else - ruby_head? ? 0 : -1 + -1 end else 0 @@ -163,10 +161,6 @@ def coverage_running? defined?(Coverage) && Coverage.running? end - def ruby_head? - RUBY_DESCRIPTION.include?("dev") - end - def safe_method_name_call m = safe_method_name proc { send(m) } diff --git a/lib/view_component/translatable.rb b/lib/view_component/translatable.rb index b75d46a1c..815a1325e 100644 --- a/lib/view_component/translatable.rb +++ b/lib/view_component/translatable.rb @@ -30,7 +30,7 @@ def __vc_build_i18n_backend # can inherit translations from its parent and is able to overwrite them. translation_files = ancestors.reverse_each.with_object([]) do |ancestor, files| if ancestor.is_a?(Class) && ancestor < ViewComponent::Base - files.concat(ancestor.sidecar_translations) + files.concat(ancestor.sidecar_files(TRANSLATION_EXTENSIONS)) end end From 2b310196b227e7cf322c4c11f222bba4d5b61aa9 Mon Sep 17 00:00:00 2001 From: Reegan Date: Sun, 14 Jun 2026 16:22:01 +0200 Subject: [PATCH 111/122] Stabilize rendering allocation check --- test/sandbox/test/rendering_test.rb | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/test/sandbox/test/rendering_test.rb b/test/sandbox/test/rendering_test.rb index fb0e9827a..32faf2741 100644 --- a/test/sandbox/test/rendering_test.rb +++ b/test/sandbox/test/rendering_test.rb @@ -20,7 +20,7 @@ def test_render_inline_allocations MyComponent.__vc_ensure_compiled with_instrumentation_enabled_option(false) do - assert_allocations({"4.1" => 69..160, "4.0" => 69..161, "3.4" => 75..76, "3.3" => 77..79, "3.2" => 80..82}) do + assert_allocations({"4.1" => 69..160, "4.0" => 69..162, "3.4" => 75..76, "3.3" => 77..79, "3.2" => 80..82}) do render_inline(MyComponent.new) end end @@ -406,6 +406,8 @@ def test_template_changes_are_not_reflected_if_cache_is_not_cleared end render_inline(MyComponent.new) + ensure + ViewComponent::CompileCache.cache.delete(MyComponent) end def test_that_it_has_a_version_number From cbfb7a880c8401e074fa55083a51eb5e8d958835 Mon Sep 17 00:00:00 2001 From: Reegan Date: Thu, 2 Jul 2026 17:43:23 +0200 Subject: [PATCH 112/122] Align Standard pin with main --- Gemfile | 2 +- Gemfile.lock | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/Gemfile b/Gemfile index 94a77fd50..a3851bd33 100644 --- a/Gemfile +++ b/Gemfile @@ -41,7 +41,7 @@ group :development, :test do gem "simplecov", "< 1" gem "slim", "~> 5" gem "sprockets-rails", "~> 3" - gem "standard", "~> 1.54.0" + gem "standard", "~> 1.55.0" gem "tailwindcss-rails", "~> 4" gem "turbo-rails" gem "warning" diff --git a/Gemfile.lock b/Gemfile.lock index 6bc64b7bb..606a7ce63 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -168,7 +168,7 @@ GEM jbuilder (2.15.1) actionview (>= 7.0.0) activesupport (>= 7.0.0) - json (2.19.9) + json (2.20.0) language_server-protocol (3.17.0.5) lint_roller (1.1.0) logger (1.7.0) @@ -217,7 +217,7 @@ GEM racc (~> 1.4) nokogiri (1.19.4-x86_64-linux-musl) racc (~> 1.4) - parallel (1.28.0) + parallel (2.1.0) parser (3.3.11.1) ast (~> 2.4.1) racc @@ -459,7 +459,7 @@ DEPENDENCIES simplecov-console (< 1) slim (~> 5) sprockets-rails (~> 3) - standard (~> 1.54.0) + standard (~> 1.55.0) tailwindcss-rails (~> 4) turbo-rails view_component! @@ -523,7 +523,7 @@ CHECKSUMS io-console (0.8.2) sha256=d6e3ae7a7cc7574f4b8893b4fca2162e57a825b223a177b7afa236c5ef9814cc irb (1.18.0) sha256=de9454a0703a54704b9811a5ef31a60c86949fbf4013fcf244fabc7c775248e3 jbuilder (2.15.1) sha256=2430bec28fb0cebacb5875b1009cf9d8bc3c303ccb810c4c8b062a4b51457637 - json (2.19.9) sha256=9b9025b7cdddafa38d316eca0b2358488e42d417045c1b90d216a9fefe46b79a + json (2.20.0) sha256=9362bc6e55a952b056abf9167cf053358181c904cb70cd6eee0808ea830fc32b language_server-protocol (3.17.0.5) sha256=fd1e39a51a28bf3eec959379985a72e296e9f9acfce46f6a79d31ca8760803cc lint_roller (1.1.0) sha256=2c0c845b632a7d172cb849cc90c1bce937a28c5c8ccccb50dfd46a485003cc87 logger (1.7.0) sha256=196edec7cc44b66cfb40f9755ce11b392f21f7967696af15d274dde7edff0203 @@ -549,7 +549,7 @@ CHECKSUMS nokogiri (1.19.4-x86_64-darwin) sha256=7fd17057d3e1f00e9954a74b3cd76595d3d4a5ef233b7ed9599047c204f70551 nokogiri (1.19.4-x86_64-linux-gnu) sha256=379fae440b28915e3f19d752ce2dcf8465ed2b2fbefd2a7ca0dd497bc981a06a nokogiri (1.19.4-x86_64-linux-musl) sha256=17dfb7c1fa194ae02fbf7c51a7afc8d278045ab3fdacfd86f91d02d7b274470b - parallel (1.28.0) sha256=33e6de1484baf2524792d178b0913fc8eb94c628d6cfe45599ad4458c638c970 + parallel (2.1.0) sha256=b35258865c2e31134c5ecb708beaaf6772adf9d5efae28e93e99260877b09356 parser (3.3.11.1) sha256=d17ace7aabe3e72c3cc94043714be27cc6f852f104d81aa284c2281aecc65d54 pp (0.6.3) sha256=2951d514450b93ccfeb1df7d021cae0da16e0a7f95ee1e2273719669d0ab9df6 prettyprint (0.2.0) sha256=2bc9e15581a94742064a3cc8b0fb9d45aae3d03a1baa6ef80922627a0766f193 From 5d91df3e3292a4af503c49b4dc0442f8dc5e1bbc Mon Sep 17 00:00:00 2001 From: Tim Burgan <55899215+timburgan@users.noreply.github.com> Date: Mon, 20 Jul 2026 19:02:10 +1000 Subject: [PATCH 113/122] Centralize sidecar template/translation extension lists The cache digestor reached into ActionView::Template.template_handler_extensions and hardcoded %w[yml yaml], the latter duplicating Translatable's TRANSLATION_EXTENSIONS. Expose sidecar_templates (Base) and sidecar_translations (Translatable) so the handler-extension coupling lives in one auditable place and the translation list can't drift from i18n loading. Addresses review feedback on the PR (sidecar_templates/sidecar_translations). --- lib/view_component/base.rb | 12 ++++++++++++ lib/view_component/cache_digestor.rb | 4 ++-- lib/view_component/translatable.rb | 11 ++++++++++- 3 files changed, 24 insertions(+), 3 deletions(-) diff --git a/lib/view_component/base.rb b/lib/view_component/base.rb index dfcab9894..901643e3c 100644 --- a/lib/view_component/base.rb +++ b/lib/view_component/base.rb @@ -604,6 +604,18 @@ def sidecar_files(extensions) (sidecar_files - [identifier] + sidecar_directory_files + nested_component_files).uniq end + # Sidecar template files (ERB, Haml, Slim, etc.) for this component. + # + # Centralizes the template-handler extension list so cache digesting (and any + # future caller) shares a single source of truth for + # `ActionView::Template.template_handler_extensions` rather than each reaching + # into ActionView directly. Keeping this coupling in one place means a Rails + # change to the handler registry only needs to be audited here. + # @return [Array] Absolute paths of sidecar template files. + def sidecar_templates + sidecar_files(ActionView::Template.template_handler_extensions) + end + # Render a component for each element in a collection ([documentation](/guide/collections)): # # ```ruby diff --git a/lib/view_component/cache_digestor.rb b/lib/view_component/cache_digestor.rb index 24d204443..8b2b5ca22 100644 --- a/lib/view_component/cache_digestor.rb +++ b/lib/view_component/cache_digestor.rb @@ -47,13 +47,13 @@ def digest_for_component(component_class) update_template_dependency_digests(digest, inline_source, inline_template.language, component_class.identifier) end - component_class.sidecar_files(ActionView::Template.template_handler_extensions).sort.each do |path| + component_class.sidecar_templates.sort.each do |path| template_source = cached_file_contents(path) update_digest(digest, template_source) update_template_dependency_digests(digest, template_source, File.extname(path).delete_prefix("."), path) end - component_class.sidecar_files(%w[yml yaml]).sort.each do |path| + component_class.sidecar_translations.sort.each do |path| update_digest(digest, cached_file_contents(path)) end diff --git a/lib/view_component/translatable.rb b/lib/view_component/translatable.rb index 815a1325e..aeb28bbdb 100644 --- a/lib/view_component/translatable.rb +++ b/lib/view_component/translatable.rb @@ -23,6 +23,15 @@ def __vc_i18n_scope @__vc_i18n_scope ||= virtual_path.sub(%r{^/}, "").gsub(%r{/_?}, ".") end + # Sidecar translation files (i18n YAML) for this component. + # + # Shares `TRANSLATION_EXTENSIONS` with translation loading so the extension + # list can't fall out of sync between i18n and cache digesting. + # @return [Array] Absolute paths of sidecar translation files. + def sidecar_translations + sidecar_files(TRANSLATION_EXTENSIONS) + end + def __vc_build_i18n_backend return if __vc_compiled? @@ -30,7 +39,7 @@ def __vc_build_i18n_backend # can inherit translations from its parent and is able to overwrite them. translation_files = ancestors.reverse_each.with_object([]) do |ancestor, files| if ancestor.is_a?(Class) && ancestor < ViewComponent::Base - files.concat(ancestor.sidecar_files(TRANSLATION_EXTENSIONS)) + files.concat(ancestor.sidecar_translations) end end From e797545986fbf91d77018ce60443938e8213109d Mon Sep 17 00:00:00 2001 From: Tim Burgan <55899215+timburgan@users.noreply.github.com> Date: Mon, 20 Jul 2026 19:02:10 +1000 Subject: [PATCH 114/122] Feature-detect ActionView::Renderer#cache_hits (internal API) cache_hits is :nodoc: and only annotates the render log; feature-detect it with respond_to? so a future Rails change can't raise during caching. The supported hit/miss signal remains the read_fragment/write_fragment ActiveSupport notifications, which fire regardless. --- lib/view_component/experimentally_cacheable.rb | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/lib/view_component/experimentally_cacheable.rb b/lib/view_component/experimentally_cacheable.rb index 16427b0f3..f0e42e80c 100644 --- a/lib/view_component/experimentally_cacheable.rb +++ b/lib/view_component/experimentally_cacheable.rb @@ -67,6 +67,13 @@ def record_fragment_cache(status) return unless Rails.application.config.view_component.instrumentation_enabled.present? return unless defined?(@view_renderer) + # `ActionView::Renderer#cache_hits` is internal (`:nodoc:`) API used only to + # annotate the template render log with cache hit/miss. Feature-detect it so a + # future Rails change can never raise here; the canonical, supported hit/miss + # signal is the `read_fragment`/`write_fragment` ActiveSupport::Notifications + # events, which fire regardless of this annotation. + return unless @view_renderer.respond_to?(:cache_hits) + @view_renderer.cache_hits[@current_template&.virtual_path] = status end From d6873ed5cc6f63c5f1690dd3b4cc53875f0e4830 Mon Sep 17 00:00:00 2001 From: Tim Burgan <55899215+timburgan@users.noreply.github.com> Date: Mon, 20 Jul 2026 19:02:10 +1000 Subject: [PATCH 115/122] Surface template AST build failures instead of swallowing them The bare rescue turned any ActionView::Template/handler-call signature break into a silent nil, which degrades to empty dependencies and stale caches. Capture the error and emit template_ast_build_failed.view_component instrumentation, and add per-handler canary tests (erb/slim/haml) so an upgrade break fails loudly in CI. --- lib/view_component/template_ast_builder.rb | 13 ++++- .../sandbox/test/template_ast_builder_test.rb | 52 +++++++++++++++++++ 2 files changed, 64 insertions(+), 1 deletion(-) create mode 100644 test/sandbox/test/template_ast_builder_test.rb diff --git a/lib/view_component/template_ast_builder.rb b/lib/view_component/template_ast_builder.rb index 0d8657543..63e82da42 100644 --- a/lib/view_component/template_ast_builder.rb +++ b/lib/view_component/template_ast_builder.rb @@ -18,7 +18,18 @@ def self.build(template_string, handler_name, identifier: nil) ) handler.call(template, template_string) - rescue + rescue => error + # Compiling a template to Ruby couples us to the ActionView::Template + # constructor and the handler-call convention, both of which have changed + # across Rails versions. Degrade gracefully (return nil so the caller can fall + # back), but surface the failure through instrumentation so an upgrade break is + # observable instead of silently producing empty dependencies, which would + # leave stale caches. The per-handler tests act as the loud CI canary for this + # path across the supported Rails matrix. + ActiveSupport::Notifications.instrument( + "template_ast_build_failed.view_component", + handler: handler_name, identifier: identifier, error: error + ) nil end end diff --git a/test/sandbox/test/template_ast_builder_test.rb b/test/sandbox/test/template_ast_builder_test.rb new file mode 100644 index 000000000..07331ddf7 --- /dev/null +++ b/test/sandbox/test/template_ast_builder_test.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +require "test_helper" + +# Canary tests for the ActionView coupling in TemplateAstBuilder. +# +# `build` constructs an ActionView::Template and invokes the handler to compile a +# template string down to Ruby (which cache digesting then scans for render +# dependencies). Both the Template constructor and the handler-call convention are +# Rails internals that have changed across versions. If a future Rails upgrade +# breaks either one, `build` returns nil and these assertions fail loudly here, +# instead of silently degrading to empty dependencies and producing stale caches. +class TemplateAstBuilderTest < ViewComponent::TestCase + def test_build_compiles_erb_to_ruby_containing_render + ruby = ViewComponent::TemplateAstBuilder.build("<%= render CacheComponent.new %>", :erb) + + refute_nil(ruby, "ERB handler coupling broke: build returned nil") + assert_includes(ruby, "render") + end + + def test_build_compiles_slim_to_ruby_containing_render + ruby = ViewComponent::TemplateAstBuilder.build("= render CacheComponent.new", :slim) + + refute_nil(ruby, "Slim handler coupling broke: build returned nil") + assert_includes(ruby, "render") + end + + def test_build_compiles_haml_to_ruby_containing_render + ruby = ViewComponent::TemplateAstBuilder.build("= render CacheComponent.new", :haml) + + refute_nil(ruby, "Haml handler coupling broke: build returned nil") + assert_includes(ruby, "render") + end + + def test_build_emits_instrumentation_when_compilation_fails + events = [] + subscriber = ActiveSupport::Notifications.subscribe("template_ast_build_failed.view_component") do |*args| + events << ActiveSupport::Notifications::Event.new(*args) + end + + # Force handler.call to raise so we exercise the rescue path. + handler = ActionView::Template.handler_for_extension(:erb) + handler.stub(:call, ->(*) { raise "boom" }) do + assert_nil(ViewComponent::TemplateAstBuilder.build("<%= 1 %>", :erb)) + end + + assert_equal(1, events.size) + assert_equal("boom", events.first.payload[:error].message) + ensure + ActiveSupport::Notifications.unsubscribe(subscriber) + end +end From 0dbeb244ff282aaac018efadbb6b8cb07faedb92 Mon Sep 17 00:00:00 2001 From: Tim Burgan <55899215+timburgan@users.noreply.github.com> Date: Mon, 20 Jul 2026 19:02:10 +1000 Subject: [PATCH 116/122] Fix cache_if with private methods cache_if evaluated Symbol/String conditions with public_send, so the documented example (cache_if :cacheable? with a private cacheable?) crashed at render with NoMethodError. Use send, consistent with how the cache block itself is evaluated via instance_exec. The existing cache_if test now uses a private method, and a positive test covers a private condition that enables caching. --- .../experimentally_cacheable.rb | 2 +- .../components/cache_condition_component.rb | 2 ++ .../cache_if_private_component.html.erb | 1 + .../components/cache_if_private_component.rb | 22 +++++++++++++++++++ test/sandbox/test/rendering_test.rb | 16 ++++++++++++++ 5 files changed, 42 insertions(+), 1 deletion(-) create mode 100644 test/sandbox/app/components/cache_if_private_component.html.erb create mode 100644 test/sandbox/app/components/cache_if_private_component.rb diff --git a/lib/view_component/experimentally_cacheable.rb b/lib/view_component/experimentally_cacheable.rb index f0e42e80c..a5bdcc91d 100644 --- a/lib/view_component/experimentally_cacheable.rb +++ b/lib/view_component/experimentally_cacheable.rb @@ -113,7 +113,7 @@ def __vc_cache_enabled? case cache_if when Symbol, String - public_send(cache_if) + send(cache_if) when Proc instance_exec(&cache_if) else diff --git a/test/sandbox/app/components/cache_condition_component.rb b/test/sandbox/app/components/cache_condition_component.rb index bd57becd1..5c4abf14c 100644 --- a/test/sandbox/app/components/cache_condition_component.rb +++ b/test/sandbox/app/components/cache_condition_component.rb @@ -14,6 +14,8 @@ def initialize(foo:) @foo = foo end + private + def cache_enabled? false end diff --git a/test/sandbox/app/components/cache_if_private_component.html.erb b/test/sandbox/app/components/cache_if_private_component.html.erb new file mode 100644 index 000000000..db7f732ce --- /dev/null +++ b/test/sandbox/app/components/cache_if_private_component.html.erb @@ -0,0 +1 @@ +

<%= foo %>

diff --git a/test/sandbox/app/components/cache_if_private_component.rb b/test/sandbox/app/components/cache_if_private_component.rb new file mode 100644 index 000000000..6c7d18a2d --- /dev/null +++ b/test/sandbox/app/components/cache_if_private_component.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +class CacheIfPrivateComponent < ViewComponent::Base + include ViewComponent::ExperimentallyCacheable + + cache_if :cacheable? + cache do + [foo] + end + + attr_reader :foo + + def initialize(foo:) + @foo = foo + end + + private + + def cacheable? + true + end +end diff --git a/test/sandbox/test/rendering_test.rb b/test/sandbox/test/rendering_test.rb index 32faf2741..a06ef29c2 100644 --- a/test/sandbox/test/rendering_test.rb +++ b/test/sandbox/test/rendering_test.rb @@ -1440,6 +1440,22 @@ def test_cache_if_false_skips_caching refute_equal(first_time, second_time) end + def test_cache_if_private_method_enables_caching + with_action_controller_caching do + Rails.cache.clear + + render_inline(CacheIfPrivateComponent.new(foo: "foo")) + first_time = page.find(".cache-if-private__message")["data-time"] + + render_inline(CacheIfPrivateComponent.new(foo: "foo")) + second_time = page.find(".cache-if-private__message")["data-time"] + + assert_equal(first_time, second_time) + ensure + Rails.cache.clear + end + end + def test_cache_hits_are_recorded_when_instrumentation_is_enabled component = CacheComponent.new(foo: "foo", bar: "bar") renderer = Struct.new(:cache_hits).new({}) From f5f046b090ef2e15d4ff61a1687a87fe98b6a62e Mon Sep 17 00:00:00 2001 From: Tim Burgan <55899215+timburgan@users.noreply.github.com> Date: Mon, 20 Jul 2026 19:22:55 +1000 Subject: [PATCH 117/122] Include I18n.locale in the cache key The cache key was composed only of the component digest and the cache block's dependencies, so a component with sidecar translations served the cached fragment from whichever locale rendered first (e.g. English output for a French request). Add I18n.locale to the key and cover it with a translatable, cacheable component that must render the correct language per locale. --- lib/view_component/experimentally_cacheable.rb | 2 +- .../components/cache_locale_component.html.erb | 1 + .../app/components/cache_locale_component.rb | 9 +++++++++ .../app/components/cache_locale_component.yml | 4 ++++ test/sandbox/test/rendering_test.rb | 16 ++++++++++++++++ 5 files changed, 31 insertions(+), 1 deletion(-) create mode 100644 test/sandbox/app/components/cache_locale_component.html.erb create mode 100644 test/sandbox/app/components/cache_locale_component.rb create mode 100644 test/sandbox/app/components/cache_locale_component.yml diff --git a/lib/view_component/experimentally_cacheable.rb b/lib/view_component/experimentally_cacheable.rb index a5bdcc91d..ef2517190 100644 --- a/lib/view_component/experimentally_cacheable.rb +++ b/lib/view_component/experimentally_cacheable.rb @@ -28,7 +28,7 @@ def view_cache_options return @__vc_cache_options = nil unless template_key @__vc_cache_options = cache_fragment_name( - [:view_component, view_cache_dependencies], + [:view_component, I18n.locale, view_cache_dependencies], digest_path: __vc_component_digest_path(template_key) ) end diff --git a/test/sandbox/app/components/cache_locale_component.html.erb b/test/sandbox/app/components/cache_locale_component.html.erb new file mode 100644 index 000000000..08959fd06 --- /dev/null +++ b/test/sandbox/app/components/cache_locale_component.html.erb @@ -0,0 +1 @@ +<%= t(".greeting") %> diff --git a/test/sandbox/app/components/cache_locale_component.rb b/test/sandbox/app/components/cache_locale_component.rb new file mode 100644 index 000000000..37dbfa5e9 --- /dev/null +++ b/test/sandbox/app/components/cache_locale_component.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class CacheLocaleComponent < ViewComponent::Base + include ViewComponent::ExperimentallyCacheable + + cache do + [:cache_locale_component] + end +end diff --git a/test/sandbox/app/components/cache_locale_component.yml b/test/sandbox/app/components/cache_locale_component.yml new file mode 100644 index 000000000..8045897d4 --- /dev/null +++ b/test/sandbox/app/components/cache_locale_component.yml @@ -0,0 +1,4 @@ +en: + greeting: "Hello" +fr: + greeting: "Bonjour" diff --git a/test/sandbox/test/rendering_test.rb b/test/sandbox/test/rendering_test.rb index a06ef29c2..78c6812ca 100644 --- a/test/sandbox/test/rendering_test.rb +++ b/test/sandbox/test/rendering_test.rb @@ -1456,6 +1456,22 @@ def test_cache_if_private_method_enables_caching end end + def test_cache_key_includes_locale + with_action_controller_caching do + Rails.cache.clear + + I18n.with_locale(:en) { render_inline(CacheLocaleComponent.new) } + assert_selector(".cache-locale__greeting", text: "Hello") + + # Without the locale in the cache key, this render would serve the cached + # English fragment instead of the French translation. + I18n.with_locale(:fr) { render_inline(CacheLocaleComponent.new) } + assert_selector(".cache-locale__greeting", text: "Bonjour") + ensure + Rails.cache.clear + end + end + def test_cache_hits_are_recorded_when_instrumentation_is_enabled component = CacheComponent.new(foo: "foo", bar: "bar") renderer = Struct.new(:cache_hits).new({}) From c7490fb5067d46688734e579595a5ff34bc5a655 Mon Sep 17 00:00:00 2001 From: Tim Burgan <55899215+timburgan@users.noreply.github.com> Date: Mon, 20 Jul 2026 20:11:05 +1000 Subject: [PATCH 118/122] Invalidate cache on changes to indirectly-rendered child components Dependency extraction ran only over template source, so a child rendered from a Ruby method (e.g. a helper called from the template) was not a tracked dependency and changes to it did not bust the parent digest. Also scan the component's own Ruby source for component renders (render Foo.new) and recurse, so indirect children invalidate the parent like template-level renders do. --- lib/view_component/cache_digestor.rb | 11 ++++++++++- lib/view_component/template_dependency_extractor.rb | 11 +++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/lib/view_component/cache_digestor.rb b/lib/view_component/cache_digestor.rb index 8b2b5ca22..97afeed3d 100644 --- a/lib/view_component/cache_digestor.rb +++ b/lib/view_component/cache_digestor.rb @@ -38,7 +38,9 @@ def digest_for_component(component_class) digest = Digest::SHA1.new - update_digest(digest, cached_file_contents(component_class.identifier)) + ruby_source = cached_file_contents(component_class.identifier) + update_digest(digest, ruby_source) + update_ruby_dependency_digests(digest, ruby_source, component_class.identifier) inline_template = component_class.__vc_inline_template if inline_template @@ -67,6 +69,13 @@ def update_template_dependency_digests(digest, template_source, handler, identif update_dependency_digests(digest, dependencies) end + def update_ruby_dependency_digests(digest, ruby_source, identifier) + return unless ruby_source&.include?("render") + + dependencies = ViewComponent::TemplateDependencyExtractor.new(ruby_source, :rb, identifier: identifier).extract_component_renders + update_dependency_digests(digest, dependencies) + end + def update_dependency_digests(digest, dependencies) dependencies.each do |dep| next unless uppercase_constant?(dep) diff --git a/lib/view_component/template_dependency_extractor.rb b/lib/view_component/template_dependency_extractor.rb index 402a1b34e..1909a4eec 100644 --- a/lib/view_component/template_dependency_extractor.rb +++ b/lib/view_component/template_dependency_extractor.rb @@ -27,6 +27,17 @@ def extract @dependencies.to_a end + # Extract ViewComponent render dependencies from raw Ruby source (for example a + # component's own .rb file), where a child may be rendered from a method rather + # than directly in a template. Only component-class renders (`render Foo.new`) + # are detected; partial/string renders are intentionally out of scope, matching + # how template sources are handled. + def extract_component_renders + return [] unless @template_string&.include?("render") + + extract_component_class_renders(@template_string).uniq + end + private def extract_from_ruby(ruby_code) From af60b1ef0a37d342ce0f36d9dbaa15cb6fc7c7ab Mon Sep 17 00:00:00 2001 From: Tim Burgan <55899215+timburgan@users.noreply.github.com> Date: Mon, 20 Jul 2026 20:11:05 +1000 Subject: [PATCH 119/122] Remove redundant inherited hook that broke late cache declarations __vc_cache_key_block/__vc_cache_if are class_attributes, which already inherit. The extra inherited hook eagerly copied the parent's then-current value into each subclass, so declaring cache on a parent AFTER a subclass was defined left the subclass with a stale nil and it silently would not cache. Rely on class_attribute inheritance instead. --- lib/view_component/experimentally_cacheable.rb | 7 ------- 1 file changed, 7 deletions(-) diff --git a/lib/view_component/experimentally_cacheable.rb b/lib/view_component/experimentally_cacheable.rb index ef2517190..06e7eef58 100644 --- a/lib/view_component/experimentally_cacheable.rb +++ b/lib/view_component/experimentally_cacheable.rb @@ -158,12 +158,5 @@ def cache(&block) def cache_if(value = nil, &block) self.__vc_cache_if = block || value end - - def inherited(child) - child.__vc_cache_key_block = __vc_cache_key_block - child.__vc_cache_if = __vc_cache_if - - super - end end end From d5d11b1414b3f0acc59820cfff8ab0393688ca4d Mon Sep 17 00:00:00 2001 From: Tim Burgan <55899215+timburgan@users.noreply.github.com> Date: Mon, 20 Jul 2026 20:11:05 +1000 Subject: [PATCH 120/122] Add caching regression tests for edge cases and invalidation depth Cover def-call components, ActiveRecord-like models (hit + invalidate on update), child .rb changes, indirectly-rendered children, cyclic/self-referential digest termination, variant isolation, HTML-safety round-trip, and cache-block inheritance ordering. --- .../app/components/cache_call_component.rb | 16 +++ .../cache_cycle_a_component.html.erb | 1 + .../app/components/cache_cycle_a_component.rb | 7 ++ .../cache_cycle_b_component.html.erb | 1 + .../app/components/cache_cycle_b_component.rb | 4 + .../cache_html_safety_component.html.erb | 1 + .../components/cache_html_safety_component.rb | 7 ++ .../cache_indirect_parent_component.html.erb | 1 + .../cache_indirect_parent_component.rb | 11 ++ .../cache_record_component.html.erb | 1 + .../app/components/cache_record_component.rb | 12 ++ .../cache_self_referential_component.html.erb | 1 + .../cache_self_referential_component.rb | 7 ++ .../cache_variant_component.html+phone.erb | 1 + .../cache_variant_component.html.erb | 1 + .../app/components/cache_variant_component.rb | 7 ++ .../app/models/cacheable_test_record.rb | 24 ++++ test/sandbox/test/rendering_test.rb | 115 ++++++++++++++++++ 18 files changed, 218 insertions(+) create mode 100644 test/sandbox/app/components/cache_call_component.rb create mode 100644 test/sandbox/app/components/cache_cycle_a_component.html.erb create mode 100644 test/sandbox/app/components/cache_cycle_a_component.rb create mode 100644 test/sandbox/app/components/cache_cycle_b_component.html.erb create mode 100644 test/sandbox/app/components/cache_cycle_b_component.rb create mode 100644 test/sandbox/app/components/cache_html_safety_component.html.erb create mode 100644 test/sandbox/app/components/cache_html_safety_component.rb create mode 100644 test/sandbox/app/components/cache_indirect_parent_component.html.erb create mode 100644 test/sandbox/app/components/cache_indirect_parent_component.rb create mode 100644 test/sandbox/app/components/cache_record_component.html.erb create mode 100644 test/sandbox/app/components/cache_record_component.rb create mode 100644 test/sandbox/app/components/cache_self_referential_component.html.erb create mode 100644 test/sandbox/app/components/cache_self_referential_component.rb create mode 100644 test/sandbox/app/components/cache_variant_component.html+phone.erb create mode 100644 test/sandbox/app/components/cache_variant_component.html.erb create mode 100644 test/sandbox/app/components/cache_variant_component.rb create mode 100644 test/sandbox/app/models/cacheable_test_record.rb diff --git a/test/sandbox/app/components/cache_call_component.rb b/test/sandbox/app/components/cache_call_component.rb new file mode 100644 index 000000000..b2e57465b --- /dev/null +++ b/test/sandbox/app/components/cache_call_component.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +class CacheCallComponent < ViewComponent::Base + include ViewComponent::ExperimentallyCacheable + + cache { [foo] } + attr_reader :foo + + def initialize(foo:) + @foo = foo + end + + def call + tag.span(foo, class: "cache-call", data: {time: Time.zone.now.to_f}) + end +end diff --git a/test/sandbox/app/components/cache_cycle_a_component.html.erb b/test/sandbox/app/components/cache_cycle_a_component.html.erb new file mode 100644 index 000000000..06f6a8591 --- /dev/null +++ b/test/sandbox/app/components/cache_cycle_a_component.html.erb @@ -0,0 +1 @@ +
<%= render CacheCycleBComponent.new %>
diff --git a/test/sandbox/app/components/cache_cycle_a_component.rb b/test/sandbox/app/components/cache_cycle_a_component.rb new file mode 100644 index 000000000..fafb07047 --- /dev/null +++ b/test/sandbox/app/components/cache_cycle_a_component.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class CacheCycleAComponent < ViewComponent::Base + include ViewComponent::ExperimentallyCacheable + + cache { [:a] } +end diff --git a/test/sandbox/app/components/cache_cycle_b_component.html.erb b/test/sandbox/app/components/cache_cycle_b_component.html.erb new file mode 100644 index 000000000..494a64ad5 --- /dev/null +++ b/test/sandbox/app/components/cache_cycle_b_component.html.erb @@ -0,0 +1 @@ +
<%= render CacheCycleAComponent.new %>
diff --git a/test/sandbox/app/components/cache_cycle_b_component.rb b/test/sandbox/app/components/cache_cycle_b_component.rb new file mode 100644 index 000000000..909434a03 --- /dev/null +++ b/test/sandbox/app/components/cache_cycle_b_component.rb @@ -0,0 +1,4 @@ +# frozen_string_literal: true + +class CacheCycleBComponent < ViewComponent::Base +end diff --git a/test/sandbox/app/components/cache_html_safety_component.html.erb b/test/sandbox/app/components/cache_html_safety_component.html.erb new file mode 100644 index 000000000..8cad4d22d --- /dev/null +++ b/test/sandbox/app/components/cache_html_safety_component.html.erb @@ -0,0 +1 @@ +
bold<%= "