diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index d15ec34f9..c2237a233 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -10,6 +10,10 @@ nav_order: 6 ## main +* Add experimental support for caching. + + *Reegan Viljoen* + * Automatically merge dependabot PRs. *Joel Hawksley* @@ -532,7 +536,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 @@ -1892,7 +1896,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) @@ -2644,7 +2648,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* @@ -2662,7 +2666,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. @@ -3110,7 +3114,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. diff --git a/docs/guide/caching.md b/docs/guide/caching.md new file mode 100644 index 000000000..61addb82c --- /dev/null +++ b/docs/guide/caching.md @@ -0,0 +1,52 @@ +--- +layout: default +title: Caching +parent: How-to guide +--- + +# Caching + +Experimental +{: .label } + +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 + 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

+``` 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 3f1e0eb61..95fc34ff5 100644 --- a/lib/view_component/base.rb +++ b/lib/view_component/base.rb @@ -49,12 +49,13 @@ 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 include ViewComponent::WithContentHelper + 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 @@ -68,7 +69,6 @@ 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 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 new file mode 100644 index 000000000..d2c534db6 --- /dev/null +++ b/lib/view_component/cache_digestor.rb @@ -0,0 +1,87 @@ +# 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 + 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/cache_registry.rb b/lib/view_component/cache_registry.rb new file mode 100644 index 000000000..f90535c96 --- /dev/null +++ b/lib/view_component/cache_registry.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +module ViewComponent + module CachingRegistry + extend self + + def caching? + ActiveSupport::IsolatedExecutionState[:view_component_caching] ||= false + end + + def track_caching + caching_was = ActiveSupport::IsolatedExecutionState[:view_component_caching] + ActiveSupport::IsolatedExecutionState[:action_view_caching] = true + + yield + ensure + ActiveSupport::IsolatedExecutionState[:view_component_caching] = caching_was + end + end +end diff --git a/lib/view_component/cacheable.rb b/lib/view_component/cacheable.rb new file mode 100644 index 000000000..90bbe7f39 --- /dev/null +++ b/lib/view_component/cacheable.rb @@ -0,0 +1,95 @@ +# frozen_string_literal: true + +require "view_component/cache_registry" +require "view_component/cache_digestor" + +module ViewComponent::Cacheable + extend ActiveSupport::Concern + + included do + class_attribute :__vc_cache_dependencies, default: Set.new + + # For caching, such as #cache_if + # + # @private + def view_cache_dependencies + self.class.__vc_cache_dependencies.map { |dep| public_send(dep) } + end + + def view_cache_options + return if self.class.__vc_cache_dependencies.blank? + + 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 view_cache_options + ViewComponent::CachingRegistry.track_caching do + template_fragment(safe_call) + end + else + instance_exec(&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) + content + else + @view_renderer.cache_hits[@current_template&.virtual_path] = :miss if defined?(@view_renderer) + write_fragment(safe_call) + end + end + + def read_fragment + Rails.cache.fetch(view_cache_options) + end + + def write_fragment(safe_call) + content = instance_exec(&safe_call) + Rails.cache.fetch(view_cache_options) do + content + end + content + end + + def combined_fragment_cache_key(key) + cache_key = [:view_component, ENV["RAILS_CACHE_ID"] || ENV["RAILS_APP_VERSION"], key] + cache_key.flatten!(1) + cache_key.compact! + cache_key + end + + def component_digest + ViewComponent::CacheDigestor.new(component: self).digest + end + end + + class_methods do + # For caching the component + def cache_on(*args) + __vc_cache_dependencies.merge(args) + end + + def inherited(child) + child.__vc_cache_dependencies = __vc_cache_dependencies.dup + + super + end + end +end diff --git a/lib/view_component/compiler.rb b/lib/view_component/compiler.rb index 69b555046..ee9c83e4a 100644 --- a/lib/view_component/compiler.rb +++ b/lib/view_component/compiler.rb @@ -90,13 +90,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 is_a?(ViewComponent::Cacheable) + __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 is_a?(ViewComponent::Cacheable) + __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/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/lib/view_component/prism_render_dependency_extractor.rb b/lib/view_component/prism_render_dependency_extractor.rb new file mode 100644 index 000000000..318b503e6 --- /dev/null +++ b/lib/view_component/prism_render_dependency_extractor.rb @@ -0,0 +1,69 @@ +# frozen_string_literal: true + +require "prism" + +module ViewComponent + class PrismRenderDependencyExtractor + def initialize(code) + @code = code + @dependencies = [] + end + + def extract + result = Prism.parse(@code) + walk(result.value) + @dependencies + end + + private + + def walk(node) + return unless node.respond_to?(:child_nodes) + + if node.is_a?(Prism::CallNode) && render_call?(node) + extract_render_target(node) + end + + node.child_nodes.each { |child| walk(child) if child } + end + + def render_call?(node) + node.receiver.nil? && node.name == :render + end + + def extract_render_target(node) + args = node.arguments&.arguments + return unless args && !args.empty? + + first_arg = args.first + + if first_arg.is_a?(Prism::CallNode) && + first_arg.name == :new && + first_arg.receiver.is_a?(Prism::ConstantPathNode) || first_arg.receiver.is_a?(Prism::ConstantReadNode) + + const = extract_constant_path(first_arg.receiver) + @dependencies << const if const + end + end + + def extract_constant_path(const_node) + parts = [] + current = const_node + + while current + case current + when Prism::ConstantPathNode + parts.unshift(current.child.name) + current = current.parent + when Prism::ConstantReadNode + parts.unshift(current.name) + break + else + break + end + end + + parts.join("::") + end + end +end diff --git a/lib/view_component/template_ast_builder.rb b/lib/view_component/template_ast_builder.rb new file mode 100644 index 000000000..c84a56f3d --- /dev/null +++ b/lib/view_component/template_ast_builder.rb @@ -0,0 +1,57 @@ +# 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 + end + end + + if defined?(Temple) + class ErbTempleWrapper < Temple::Engine + def call(template) + Temple::ERB::Engine.new.call(template) + end + end + end + + 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 + return nil + end.call + + engine.call(template_string) + end + end +end diff --git a/lib/view_component/template_dependency_extractor.rb b/lib/view_component/template_dependency_extractor.rb new file mode 100644 index 000000000..24b1a53b2 --- /dev/null +++ b/lib/view_component/template_dependency_extractor.rb @@ -0,0 +1,58 @@ +# 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) + return extract_erb_fallback if ast.blank? && @engine.to_sym == :erb + return @dependencies unless ast.present? + 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 + + 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 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..1ba99c998 --- /dev/null +++ b/test/sandbox/app/components/cache_component.html.erb @@ -0,0 +1,4 @@ +

<%= view_cache_dependencies %>

+

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

+ +<%= render(ButtonToComponent.new) %> diff --git a/test/sandbox/app/components/cache_component.rb b/test/sandbox/app/components/cache_component.rb new file mode 100644 index 000000000..0236f0b47 --- /dev/null +++ b/test/sandbox/app/components/cache_component.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +class CacheComponent < ViewComponent::Base + include ViewComponent::Cacheable + + cache_on :foo, :bar + + attr_reader :foo, :bar + + def initialize(foo:, bar:) + @foo = foo + @bar = bar + end +end 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..f0145826b --- /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/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 new file mode 100644 index 000000000..c1de347a1 --- /dev/null +++ b/test/sandbox/app/components/inherited_cache_component.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class InheritedCacheComponent < CacheComponent + def initialize(foo:, bar:) + super + end +end 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/app/components/no_cache_component.html.erb b/test/sandbox/app/components/no_cache_component.html.erb new file mode 100644 index 000000000..fccbe87a4 --- /dev/null +++ b/test/sandbox/app/components/no_cache_component.html.erb @@ -0,0 +1,3 @@ +

<%= view_cache_dependencies %>

+ +

"><%= "#{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/app/controllers/integration_examples_controller.rb b/test/sandbox/app/controllers/integration_examples_controller.rb index 125fa43ed..5dcba6cca 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: foo, bar: 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 2d8fa470b..b98e881e7 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" diff --git a/test/sandbox/test/rendering_test.rb b/test/sandbox/test/rendering_test.rb index 44265ee77..e311f7a04 100644 --- a/test/sandbox/test/rendering_test.rb +++ b/test/sandbox/test/rendering_test.rb @@ -1339,6 +1339,90 @@ 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" + + component = CacheComponent.new(foo: "foo", bar: "bar") + render_inline(component) + + assert_selector(".cache-component__cache-key", text: component.view_cache_dependencies) + assert_selector(".cache-component__cache-message", text: "foo bar") + + render_inline(CacheComponent.new(foo: "foo", bar: "bar")) + + assert_selector(".cache-component__cache-key", text: component.view_cache_dependencies) + + new_component = CacheComponent.new(foo: "foo", bar: "baz") + render_inline(new_component) + + assert_selector(".cache-component__cache-key", text: new_component.view_cache_dependencies) + assert_selector(".cache-component__cache-message", text: "foo baz") + end + + def test_no_cache_compoennt + return if Rails.version < "7.0" + + 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 + + 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! + + 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 + def test_render_partial_with_yield render_inline(PartialWithYieldComponent.new) assert_text "hello world", exact: true, normalize_ws: true