diff --git a/Gemfile.lock b/Gemfile.lock index cc41ae84f..6e8ff584d 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -4,6 +4,9 @@ PATH view_component (4.0.0.rc2) activesupport (>= 7.1.0, < 8.1) concurrent-ruby (~> 1) + haml (~> 6) + slim (~> 5) + temple (~> 0.10) GEM remote: https://rubygems.org/ diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 3ce5fc2c2..66d8e8505 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -20,6 +20,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. @@ -323,7 +327,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 @@ -1779,7 +1783,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) @@ -2543,7 +2547,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* @@ -2561,7 +2565,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. @@ -3009,7 +3013,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..365536131 --- /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

+``` diff --git a/lib/view_component/base.rb b/lib/view_component/base.rb index 51bc9619f..bce609be9 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" @@ -50,11 +51,11 @@ def config include Rails.application.routes.url_helpers if defined?(Rails) && Rails.application include ERB::Escape include ActiveSupport::CoreExt::ERBUtil - include ViewComponent::InlineTemplate include ViewComponent::Slotable include ViewComponent::Translatable include ViewComponent::WithContentHelper + include ViewComponent::Cacheable # For CSRF authenticity tokens in forms delegate :form_authenticity_token, :protect_against_forgery?, :config, to: :helpers @@ -69,10 +70,12 @@ def config delegate :content_security_policy_nonce, to: :helpers # Config option that strips trailing whitespace in templates before compiling them. - class_attribute :__vc_strip_trailing_whitespace, instance_accessor: false, instance_predicate: false, default: false class_attribute :__vc_response_format, instance_accessor: false, instance_predicate: false, default: nil + class_attribute :__vc_strip_trailing_whitespace, instance_accessor: false, instance_predicate: false + self.__vc_strip_trailing_whitespace = false # class_attribute:default doesn't work until Rails 5.2 + attr_accessor :__vc_original_view_context attr_reader :current_template @@ -312,12 +315,6 @@ def virtual_path self.class.virtual_path end - # For caching, such as #cache_if - # @private - def view_cache_dependencies - [] - end - # The current request. Use sparingly as doing so introduces coupling that # inhibits encapsulation & reuse, often making testing difficult. # diff --git a/lib/view_component/cache_digestor.rb b/lib/view_component/cache_digestor.rb new file mode 100644 index 000000000..bf98bcaee --- /dev/null +++ b/lib/view_component/cache_digestor.rb @@ -0,0 +1,21 @@ +# # frozen_string_literal: true + +require "view_component/template_dependency_extractor" + +module ViewComponent + class CacheDigestor + def initialize(component:) + @component = component + end + + def digest + template = @component.current_template + if template.nil? && template == :inline_call + [] + else + template_string = template.source + ViewComponent::TemplateDependencyExtractor.new(template_string, template.details.handler).extract + end + end + end +end 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..e0c73925c --- /dev/null +++ b/lib/view_component/cacheable.rb @@ -0,0 +1,86 @@ +# frozen_string_literal: true + +require "view_component/cache_registry" +require "view_component/cache_digestor" + +module ViewComponent::Cacheable + extend ActiveSupport::Concern + + included do + class_attribute :__vc_cache_options, default: Set[:identifier] + class_attribute :__vc_cache_dependencies, default: Set.new + + # For caching, such as #cache_if + # + # @private + def view_cache_dependencies + self.class.__vc_cache_dependencies.map { |dep| public_send(dep) } + end + + def view_cache_options + return if __vc_cache_options.blank? + + computed_view_cache_options = __vc_cache_options.map { |opt| if respond_to?(opt) then public_send(opt) end } + combined_fragment_cache_key(ActiveSupport::Cache.expand_cache_key(computed_view_cache_options + component_digest)) + end + + # Render component from cache if possible + # + # @private + def __vc_render_cacheable(safe_call) + if (__vc_cache_options - [:identifier]).any? + ViewComponent::CachingRegistry.track_caching do + template_fragment(safe_call) + end + else + instance_exec(&safe_call) + end + end + + def template_fragment + if (content = read_fragment) + @view_renderer.cache_hits[@current_template&.virtual_path] = :hit if defined?(@view_renderer) + content + else + @view_renderer.cache_hits[@current_template&.virtual_path] = :miss if defined?(@view_renderer) + write_fragment + end + end + + def read_fragment + Rails.cache.fetch(view_cache_options) + end + + def write_fragment + content = instance_exec(&safe_call) + Rails.cache.fetch(view_cache_options) do + content + end + content + end + + def combined_fragment_cache_key(key) + cache_key = [:view_component, ENV["RAILS_CACHE_ID"] || ENV["RAILS_APP_VERSION"], key] + cache_key.flatten!(1) + cache_key.compact! + cache_key + end + + def component_digest + ViewComponent::CacheDigestor.new(component: self).digest + end + end + + class_methods do + # For caching the component + def cache_on(*args) + __vc_cache_options.merge(args) + end + + def inherited(child) + child.__vc_cache_options = __vc_cache_options.dup + + super + end + 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/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..dc40dcaeb --- /dev/null +++ b/lib/view_component/template_ast_builder.rb @@ -0,0 +1,38 @@ +# 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 + 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..46dc0a460 --- /dev/null +++ b/lib/view_component/template_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) + 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 + 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/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 686328846..1aefaed49 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 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" diff --git a/test/sandbox/test/rendering_test.rb b/test/sandbox/test/rendering_test.rb index 061ec6571..02764bf8f 100644 --- a/test/sandbox/test/rendering_test.rb +++ b/test/sandbox/test/rendering_test.rb @@ -1321,4 +1321,54 @@ 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 end diff --git a/view_component.gemspec b/view_component.gemspec index 8154e9f65..a98232d29 100644 --- a/view_component.gemspec +++ b/view_component.gemspec @@ -34,4 +34,7 @@ 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