diff --git a/.audition-baseline.json b/.audition-baseline.json index 46c266441..8317cd107 100644 --- a/.audition-baseline.json +++ b/.audition-baseline.json @@ -75,8 +75,9 @@ "runtime-class-variable|/Users/joelhawksley/.local/share/mise/installs/ruby/4.0.5/lib/ruby/gems/4.0.0/gems/rails-html-sanitizer-1.7.0/lib/rails-html-sanitizer.rb": 1, "runtime-class-variable|/Users/joelhawksley/github/view_component/lib/view_component/base.rb": 1, "runtime-class-variable|/Users/joelhawksley/github/view_component/lib/view_component/compile_cache.rb": 1, - "runtime-require|lib/view_component.rb": 14, + "runtime-require|lib/view_component.rb": 15, "runtime-require|lib/view_component/preview.rb": 1, + "runtime-require|lib/view_component/template_dependency_extractor.rb": 1, "runtime-require|lib/view_component/test_helpers.rb": 1, "runtime-unshareable-constant|/Users/joelhawksley/.local/share/mise/installs/ruby/4.0.5/lib/ruby/4.0.0/cgi/escape.rb": 1, "runtime-unshareable-constant|/Users/joelhawksley/.local/share/mise/installs/ruby/4.0.5/lib/ruby/gems/4.0.0/gems/actionpack-8.1.3/lib/action_dispatch/http/mime_type.rb": 7, @@ -129,6 +130,7 @@ "unsafe-calls|lib/view_component/compile_cache.rb": 1, "unsafe-calls|lib/view_component/compiler.rb": 3, "unsafe-calls|lib/view_component/config.rb": 1, + "unsafe-calls|lib/view_component/experimentally_cacheable.rb": 3, "unsafe-calls|lib/view_component/slotable.rb": 14, "unsafe-calls|lib/view_component/translatable.rb": 1 } diff --git a/Gemfile.lock b/Gemfile.lock index fafa59d92..dba6587a2 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -3,6 +3,7 @@ PATH specs: view_component (4.12.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.3) activesupport (= 8.1.3) globalid (>= 0.3.6) @@ -489,6 +492,7 @@ CHECKSUMS actionpack (8.1.3) sha256=af998cae4d47c5d581a2cc363b5c77eb718b7c4b45748d81b1887b25621c29a3 actiontext (8.1.3) sha256=d291019c00e1ea9e6463011fa214f6081a56d7b9a1d224e7d3f6384c1dafc7d2 actionview (8.1.3) sha256=1347c88c7f3edb38100c5ce0e9fb5e62d7755f3edc1b61cce2eb0b2c6ea2fd5d + actionview_precompiler (0.4.0) sha256=33b6bd6ec4c1b856e02fdf5f6512c9eb4a92ac1c0545e941b3e354b7d540ed1c activejob (8.1.3) sha256=a149b1766aa8204c3c3da7309e4becd40fcd5529c348cffbf6c9b16b565fe8d3 activemodel (8.1.3) sha256=90c05cbe4cef3649b8f79f13016191ea94c4525ce4a5c0fb7ef909c4b91c8219 activerecord (8.1.3) sha256=8003be7b2466ba0a2a670e603eeb0a61dd66058fccecfc49901e775260ac70ab diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index b619b2c88..d8a8b7cee 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -10,6 +10,10 @@ nav_order: 6 ## main +* Add experimental support for caching by including `ViewComponent::ExperimentallyCacheable`. + + *Reegan Viljoen* + * Add [audition](https://github.com/yaroslav/audition) Ractor-readiness checks to CI. Applied safe `.freeze` auto-fixes to string constants in `ViewComponent::Errors` and baselined existing findings so the gate fails only on new Ractor-isolation violations. *Joel Hawksley* diff --git a/docs/guide/caching.md b/docs/guide/caching.md new file mode 100644 index 000000000..6fef85f58 --- /dev/null +++ b/docs/guide/caching.md @@ -0,0 +1,95 @@ +--- +layout: default +title: Caching +parent: How-to guide +--- + +# Caching + +Experimental +{: .label } + +Caching is experimental. To cache a component, include `ViewComponent::ExperimentallyCacheable` and declare cache dependencies using `cache`: + +```ruby +class CacheComponent < ViewComponent::Base + include ViewComponent::ExperimentallyCacheable + + attr_reader :foo, :bar + + cache do + [foo, bar] + end + + def initialize(foo:, bar:) + @foo = foo + @bar = bar + end +end +``` + +```erb +

<%= Time.zone.now %>

+

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

+``` + +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. + +Caches are invalidated when the component source, sidecar templates, sidecar translations, or rendered child ViewComponents change. + +## Limitations + +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/gemfiles/rails_7.1.gemfile.lock b/gemfiles/rails_7.1.gemfile.lock index bc243144d..17ede5cfb 100644 --- a/gemfiles/rails_7.1.gemfile.lock +++ b/gemfiles/rails_7.1.gemfile.lock @@ -3,6 +3,7 @@ PATH specs: view_component (4.12.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 bc79ffa12..1e43c54f8 100644 --- a/gemfiles/rails_7.2.gemfile.lock +++ b/gemfiles/rails_7.2.gemfile.lock @@ -3,6 +3,7 @@ PATH specs: view_component (4.12.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 e00da7091..118a58d41 100644 --- a/gemfiles/rails_8.0.gemfile.lock +++ b/gemfiles/rails_8.0.gemfile.lock @@ -3,6 +3,7 @@ PATH specs: view_component (4.12.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 77edfc7fa..8ddee480b 100644 --- a/gemfiles/rails_8.1.gemfile.lock +++ b/gemfiles/rails_8.1.gemfile.lock @@ -3,6 +3,7 @@ PATH specs: view_component (4.12.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/gemfiles/rails_main.gemfile.lock b/gemfiles/rails_main.gemfile.lock index 06761af86..1a5435786 100644 --- a/gemfiles/rails_main.gemfile.lock +++ b/gemfiles/rails_main.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) @@ -486,6 +489,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/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/view_component.rb b/lib/view_component.rb index ac1102ed9..0a54a9859 100644 --- a/lib/view_component.rb +++ b/lib/view_component.rb @@ -12,6 +12,7 @@ module ViewComponent 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 dfcab9894..8ff9b1d60 100644 --- a/lib/view_component/base.rb +++ b/lib/view_component/base.rb @@ -471,6 +471,9 @@ def __vc_safe_around_render_output(output) # slot setters or `with_content`). def __vc_reset_render_state! %i[ + @__vc_cache_dependencies + @__vc_cache_options + @__vc_component_digest @__vc_controller @__vc_helpers @__vc_request @@ -604,6 +607,17 @@ 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. Wraps + # `ActionView::Template::Handlers.extensions` so callers don't reach into + # Action View directly. + # + # @private + # + # @return [Array] Absolute paths of sidecar template files. + def sidecar_templates + sidecar_files(ActionView::Template::Handlers.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 new file mode 100644 index 000000000..97afeed3d --- /dev/null +++ b/lib/view_component/cache_digestor.rb @@ -0,0 +1,118 @@ +# frozen_string_literal: true + +require "digest" +require "view_component/template_dependency_extractor" + +module ViewComponent + class CacheDigestor + def self.digest(component) + new(component: component).digest + end + + def initialize(component:) + @component_class = component.is_a?(Class) ? component : component.class + @digests = {} + @file_cache = {} + @constant_cache = {} + end + + def digest + 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 || component_class.object_id + + cached_digest = @digests[name] + return "" if cached_digest == IN_PROGRESS + return cached_digest if cached_digest + + @digests[name] = IN_PROGRESS + + digest = Digest::SHA1.new + + 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 + inline_source = inline_template.source + update_digest(digest, inline_source) + update_template_dependency_digests(digest, inline_source, inline_template.language, component_class.identifier) + end + + 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_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, identifier) + return unless template_source&.include?("render") + + dependencies = ViewComponent::TemplateDependencyExtractor.new(template_source, handler, identifier: identifier).extract + 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) + + klass = cached_constantize(dep) + next unless 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 cached_constantize(constant_name) + @constant_cache.fetch(constant_name) do + @constant_cache[constant_name] = constant_name.safe_constantize + end + end + + def cached_file_contents(path) + return nil if path.nil? + + @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/cache_registry.rb b/lib/view_component/cache_registry.rb new file mode 100644 index 000000000..1b2dc71a2 --- /dev/null +++ b/lib/view_component/cache_registry.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +module ViewComponent + module CachingRegistry + extend self + + def track_caching + caching_was = ActiveSupport::IsolatedExecutionState[:view_component_caching] + ActiveSupport::IsolatedExecutionState[:view_component_caching] = true + + yield + ensure + ActiveSupport::IsolatedExecutionState[:view_component_caching] = caching_was + end + end +end diff --git a/lib/view_component/compiler.rb b/lib/view_component/compiler.rb index 3cfbdef5d..b8e715ffd 100644 --- a/lib/view_component/compiler.rb +++ b/lib/view_component/compiler.rb @@ -176,9 +176,7 @@ def gather_templates )] else path_parser = ActionView::Resolver::PathParser.new - templates = @component.sidecar_files( - ActionView::Template::Handlers.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 new file mode 100644 index 000000000..57cb9a566 --- /dev/null +++ b/lib/view_component/experimentally_cacheable.rb @@ -0,0 +1,159 @@ +# frozen_string_literal: true + +require "view_component/cache_registry" +require "view_component/cache_digestor" + +module ViewComponent::ExperimentallyCacheable + extend ActiveSupport::Concern + + included do + 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 + 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) + + 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 + + @__vc_cache_options = cache_fragment_name( + [:view_component, I18n.locale, view_cache_dependencies], + digest_path: __vc_component_digest_path(template_key) + ) + end + + # Render component from cache if possible + # + # @private + 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 + 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(cache_key, safe_call) + if (content = read_fragment(cache_key)) + record_fragment_cache(:hit) + content + else + 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) + + # `cache_hits` is internal (`:nodoc:`) Rails API used only to annotate the + # render log; feature-detect so a Rails change can't raise here. + return unless @view_renderer.respond_to?(:cache_hits) + + @view_renderer.cache_hits[@current_template&.virtual_path] = status + end + + def read_fragment(cache_key) + controller.read_fragment(cache_key) + end + + def write_fragment(cache_key, safe_call) + content = instance_exec(&safe_call) + controller.write_fragment(cache_key, content) + content + end + + def component_digest + return @__vc_component_digest ||= __vc_compute_component_digest unless ActionView::Base.cache_template_loading + + self.class.__vc_component_digest + end + + def __vc_compute_component_digest + ViewComponent::CacheDigestor.digest(self) + end + + def __vc_component_digest_path(template_key) + digest = component_digest + call_method_name, template_virtual_path = template_key + [self.class.virtual_path, call_method_name, template_virtual_path, digest].compact.join(":") + end + + def __vc_controller_perform_caching? + controller.respond_to?(:perform_caching) && controller.perform_caching + end + + def __vc_cache_enabled? + cache_if = self.class.__vc_cache_if + return true if cache_if.nil? + + case cache_if + when Symbol, String + send(cache_if) + when Proc + instance_exec(&cache_if) + else + !!cache_if + end + end + end + + class_methods do + 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 + + def __vc_precompute_component_digest + @__vc_component_digest = ViewComponent::CacheDigestor.digest(self) + end + + def cache(&block) + self.__vc_cache_key_block = block + end + + def cache_if(value = nil, &block) + self.__vc_cache_if = block || value + end + end +end diff --git a/lib/view_component/template.rb b/lib/view_component/template.rb index e91334c85..afc459511 100644 --- a/lib/view_component/template.rb +++ b/lib/view_component/template.rb @@ -28,32 +28,11 @@ def initialize(component:, details:, path:) details = ActionView::TemplateDetails.new(details.locale, details.handler, DEFAULT_FORMAT, details.variant) end - @strip_annotation_line = false - - # Rails 8.1 added a newline to compiled ERB output (rails/rails#53731). - # 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. - 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 - else - -1 - end - else - 0 - end - super( component: component, details: details, path: path, - lineno: lineno + lineno: 0 ) end @@ -68,11 +47,19 @@ def source private + def compile_lineno + return super unless Rails::VERSION::MAJOR >= 8 && Rails::VERSION::MINOR > 0 && details.handler == :erb + + coverage_running? ? 0 : -1 + end + def compiled_source result = super # Strip the annotation line to maintain correct line numbers when coverage # is running (avoids segfault from negative lineno) - result = result.partition(";").last if @strip_annotation_line + if coverage_running? && ActionView::Base.annotate_rendered_view_with_filenames + result = result.partition(";").last + end result end end @@ -147,7 +134,7 @@ def compile_to_component @component.silence_redefinition_of_method(call_method_name) # rubocop:disable Style/EvalWithLocation - @component.class_eval <<~RUBY, @path, @lineno + @component.class_eval <<~RUBY, @path, compile_lineno def #{call_method_name} #{compiled_source} end @@ -195,6 +182,10 @@ def normalized_variant_name private + def compile_lineno + @lineno + end + def compiled_source handler = details.handler_class this_source = source diff --git a/lib/view_component/template_ast_builder.rb b/lib/view_component/template_ast_builder.rb new file mode 100644 index 000000000..0c365a7d7 --- /dev/null +++ b/lib/view_component/template_ast_builder.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +require "action_view" + +module ViewComponent + class TemplateAstBuilder + 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 => error + # Instrument rather than silently returning nil: a swallowed compile failure + # produces empty dependencies and stale caches. Per-handler tests guard this. + ActiveSupport::Notifications.instrument( + "template_ast_build_failed.view_component", + handler: handler_name, identifier: identifier, error: error + ) + nil + 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..2ffccc72f --- /dev/null +++ b/lib/view_component/template_dependency_extractor.rb @@ -0,0 +1,82 @@ +# frozen_string_literal: true + +require "actionview_precompiler" + +require_relative "template_ast_builder" + +module ViewComponent + class TemplateDependencyExtractor + 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, identifier: @identifier) + + if ruby_source.nil? + return extract_erb_fallback if engine == :erb + + return [] + end + + extract_from_ruby(ruby_source) + @dependencies.to_a + end + + # Scan raw Ruby source (e.g. a component's own .rb) for `render Foo.new` so a + # child rendered from a method still busts the parent digest. Regex-based, so a + # match inside a comment or string only ever over-invalidates, never serves stale. + 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) + return unless ruby_code.include?("render") + + 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 + + COMPONENT_RENDER = /(?:render|render_to_string)\s*\(?\s*([A-Z]\w*(?:::[A-Z]\w*)*)\.new\b/ + private_constant :COMPONENT_RENDER + + def extract_component_class_renders(ruby_code) + ruby_code.scan(COMPONENT_RENDER).flatten + end + + 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" + + ActionviewPrecompiler::RenderParser.new(ruby_code, parser: ActionviewPrecompiler::RipperASTParser).render_calls.map do |call| + call.respond_to?(:virtual_path) ? call.virtual_path : call + end + end + + ERB_RUBY_TAG = /<%(=|-|#)?(.*?)%>/m + private_constant :ERB_RUBY_TAG + + def extract_erb_fallback + @template_string.scan(ERB_RUBY_TAG) do |(_, tag_ruby)| + extract_from_ruby(tag_ruby) + end + + @dependencies.to_a + end + end +end diff --git a/lib/view_component/translatable.rb b/lib/view_component/translatable.rb index 815a1325e..f7623005b 100644 --- a/lib/view_component/translatable.rb +++ b/lib/view_component/translatable.rb @@ -23,6 +23,17 @@ 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. + # + # @private + # + # @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 +41,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 new file mode 100644 index 000000000..21283c3f6 --- /dev/null +++ b/performance/cache_benchmark.rb @@ -0,0 +1,57 @@ +# 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 +original_cache = Rails.cache +Rails.cache = ActiveSupport::Cache::MemoryStore.new(size: 64.megabytes) +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) + +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("cacheable_miss") do + cache_miss_counter += 1 + controller_view.render(Performance::CacheableBenchmarkComponent.new(name: "Fox Mulder miss #{cache_miss_counter}x")) + end + + x.report("cacheable_hit") do + controller_view.render(cacheable_warm_component) + end + + x.compare! + end +ensure + Rails.cache = original_cache +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..0351f7a61 --- /dev/null +++ b/performance/components/cacheable_benchmark_component.html.erb @@ -0,0 +1,18 @@ +
+
+

<%= 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 new file mode 100644 index 000000000..9a6bfc2db --- /dev/null +++ b/performance/components/cacheable_benchmark_component.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +module Performance + class CacheableBenchmarkComponent < ViewComponent::Base + include ViewComponent::ExperimentallyCacheable + + cache_if :cache_worthy? + cache do + [name] + end + + attr_reader :name + + def initialize(name:) + @name = name + end + + 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/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/non_cacheable_benchmark_component.html.erb b/performance/components/non_cacheable_benchmark_component.html.erb new file mode 100644 index 000000000..d1340b9c2 --- /dev/null +++ b/performance/components/non_cacheable_benchmark_component.html.erb @@ -0,0 +1,18 @@ +
+
+

<%= 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 new file mode 100644 index 000000000..0d139ff7d --- /dev/null +++ b/performance/components/non_cacheable_benchmark_component.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +module Performance + class NonCacheableBenchmarkComponent < 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/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_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_component.html.erb b/test/sandbox/app/components/cache_component.html.erb new file mode 100644 index 000000000..4b968b659 --- /dev/null +++ b/test/sandbox/app/components/cache_component.html.erb @@ -0,0 +1,3 @@ +

<%= 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..8a88cbcf3 --- /dev/null +++ b/test/sandbox/app/components/cache_component.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +class CacheComponent < ViewComponent::Base + include ViewComponent::ExperimentallyCacheable + + cache do + [foo, bar] + end + + attr_reader :foo, :bar + + def initialize(foo:, bar:) + @foo = foo + @bar = bar + 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..5c4abf14c --- /dev/null +++ b/test/sandbox/app/components/cache_condition_component.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +class CacheConditionComponent < ViewComponent::Base + include ViewComponent::ExperimentallyCacheable + + cache_if :cache_enabled? + cache do + [foo] + end + + attr_reader :foo + + def initialize(foo:) + @foo = foo + end + + private + + def cache_enabled? + false + 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_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..5c52fc988 --- /dev/null +++ b/test/sandbox/app/components/cache_dependency_types_component.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +class CacheDependencyTypesComponent < ViewComponent::Base + include ViewComponent::ExperimentallyCacheable + + cache do + [record, tags, label, private_token] + end + + attr_reader :record, :tags, :label + + def initialize(record:, tags:, label:) + @record = record + @tags = tags + @label = label + end + + private + + def private_token + "private-token" + 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_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_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.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..9b6728e5c --- /dev/null +++ b/test/sandbox/app/components/cache_digestor_layout_parent_component.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +class CacheDigestorLayoutParentComponent < ViewComponent::Base + include ViewComponent::ExperimentallyCacheable + + cache do + [foo] + end + + attr_reader :foo + + def initialize(foo:) + @foo = foo + end +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..bf7acb9de --- /dev/null +++ b/test/sandbox/app/components/cache_digestor_nested_partial_parent_component.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +class CacheDigestorNestedPartialParentComponent < ViewComponent::Base + include ViewComponent::ExperimentallyCacheable + + cache do + [foo] + end + + attr_reader :foo + + def initialize(foo:) + @foo = foo + end +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..42d214223 --- /dev/null +++ b/test/sandbox/app/components/cache_digestor_parent_component.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +class CacheDigestorParentComponent < ViewComponent::Base + include ViewComponent::ExperimentallyCacheable + + cache do + [foo] + end + + attr_reader :foo + + def initialize(foo:) + @foo = foo + end +end 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..bbd60c3bf --- /dev/null +++ b/test/sandbox/app/components/cache_digestor_partial_parent_component.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +class CacheDigestorPartialParentComponent < ViewComponent::Base + include ViewComponent::ExperimentallyCacheable + + cache do + [foo] + end + + attr_reader :foo + + def initialize(foo:) + @foo = foo + end +end 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/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<%= "