diff --git a/.tool-versions b/.tool-versions index 6e03b218f..0f80fd9a0 100644 --- a/.tool-versions +++ b/.tool-versions @@ -1 +1,2 @@ ruby 4.0.1 + diff --git a/Gemfile.lock b/Gemfile.lock index 18cf88dec..98a9b8499 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -3,6 +3,7 @@ PATH specs: view_component (4.4.0) actionview (>= 7.1.0) + actionview_precompiler (>= 0.4) activesupport (>= 7.1.0) concurrent-ruby (~> 1) @@ -55,6 +56,8 @@ GEM erubi (~> 1.11) rails-dom-testing (~> 2.2) rails-html-sanitizer (~> 1.6) + actionview_precompiler (0.4.0) + actionview (>= 6.0.a) activejob (8.1.2) activesupport (= 8.1.2) globalid (>= 0.3.6) @@ -145,6 +148,7 @@ GEM temple (>= 0.8.2) thor tilt + herb (0.8.9) herb (0.8.9-aarch64-linux-gnu) herb (0.8.9-aarch64-linux-musl) herb (0.8.9-arm-linux-gnu) @@ -182,6 +186,7 @@ GEM matrix (0.4.3) method_source (1.1.0) mini_mime (1.1.5) + mini_portile2 (2.8.9) minitest (6.0.1) prism (~> 1.5) minitest-mock (5.27.0) @@ -195,6 +200,9 @@ GEM net-smtp (0.5.1) net-protocol nio4r (2.7.5) + nokogiri (1.19.0) + mini_portile2 (~> 2.8.2) + racc (~> 1.4) nokogiri (1.19.0-aarch64-linux-gnu) racc (~> 1.4) nokogiri (1.19.0-aarch64-linux-musl) @@ -466,6 +474,7 @@ CHECKSUMS actionpack (8.1.2) sha256=ced74147a1f0daafaa4bab7f677513fd4d3add574c7839958f7b4f1de44f8423 actiontext (8.1.2) sha256=0bf57da22a9c19d970779c3ce24a56be31b51c7640f2763ec64aa72e358d2d2d actionview (8.1.2) sha256=80455b2588911c9b72cec22d240edacb7c150e800ef2234821269b2b2c3e2e5b + actionview_precompiler (0.4.0) sha256=33b6bd6ec4c1b856e02fdf5f6512c9eb4a92ac1c0545e941b3e354b7d540ed1c activejob (8.1.2) sha256=908dab3713b101859536375819f4156b07bdf4c232cc645e7538adb9e302f825 activemodel (8.1.2) sha256=e21358c11ce68aed3f9838b7e464977bc007b4446c6e4059781e1d5c03bcf33e activerecord (8.1.2) sha256=acfbe0cadfcc50fa208011fe6f4eb01cae682ebae0ef57145ba45380c74bcc44 @@ -498,6 +507,7 @@ CHECKSUMS ferrum (0.17.1) sha256=51d591120fc593e5a13b5d9d6474389f5145bb92a91e36eab147b5d096c8cbe7 globalid (1.3.0) sha256=05c639ad6eb4594522a0b07983022f04aa7254626ab69445a0e493aa3786ff11 haml (7.2.0) sha256=87fd2b71f7feab1724337b090a7d767f5ab2d42f08c974f3ead673f18cfcd55a + herb (0.8.9) sha256=8617e7eba753877cef231e3269a7e00788a9b67a91c3b79d1210ae20321b60f7 herb (0.8.9-aarch64-linux-gnu) sha256=9214cc24953c355f9ae785a95bb9362cdfc9fcd2ae4db2a32bc26c0b88f98c7f herb (0.8.9-aarch64-linux-musl) sha256=4ee883eed0935cfe2e508d0843b015d689ef9d13f00e9ec4a81a35acd061dc13 herb (0.8.9-arm-linux-gnu) sha256=9f83c76899fecd5e3429bbd66728226e7b75539cc59283829041479381a6ceea @@ -521,6 +531,7 @@ CHECKSUMS matrix (0.4.3) sha256=a0d5ab7ddcc1973ff690ab361b67f359acbb16958d1dc072b8b956a286564c5b method_source (1.1.0) sha256=181301c9c45b731b4769bc81e8860e72f9161ad7d66dd99103c9ab84f560f5c5 mini_mime (1.1.5) sha256=8681b7e2e4215f2a159f9400b5816d85e9d8c6c6b491e96a12797e798f8bccef + mini_portile2 (2.8.9) sha256=0cd7c7f824e010c072e33f68bc02d85a00aeb6fce05bb4819c03dfd3c140c289 minitest (6.0.1) sha256=7854c74f48e2e975969062833adc4013f249a4b212f5e7b9d5c040bf838d54bb minitest-mock (5.27.0) sha256=7040ed7185417a966920987eaa6eaf1be4ea1fc5b25bb03ff4703f98564a55b0 net-imap (0.6.2) sha256=08caacad486853c61676cca0c0c47df93db02abc4a8239a8b67eb0981428acc6 @@ -528,6 +539,7 @@ CHECKSUMS net-protocol (0.2.2) sha256=aa73e0cba6a125369de9837b8d8ef82a61849360eba0521900e2c3713aa162a8 net-smtp (0.5.1) sha256=ed96a0af63c524fceb4b29b0d352195c30d82dd916a42f03c62a3a70e5b70736 nio4r (2.7.5) sha256=6c90168e48fb5f8e768419c93abb94ba2b892a1d0602cb06eef16d8b7df1dca1 + nokogiri (1.19.0) sha256=e304d21865f62518e04f2bf59f93bd3a97ca7b07e7f03952946d8e1c05f45695 nokogiri (1.19.0-aarch64-linux-gnu) sha256=11a97ecc3c0e7e5edcf395720b10860ef493b768f6aa80c539573530bc933767 nokogiri (1.19.0-aarch64-linux-musl) sha256=eb70507f5e01bc23dad9b8dbec2b36ad0e61d227b42d292835020ff754fb7ba9 nokogiri (1.19.0-arm-linux-gnu) sha256=572a259026b2c8b7c161fdb6469fa2d0edd2b61cd599db4bbda93289abefbfe5 diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 29565c5c5..5663d6ee4 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* + ## 4.4.0 * Fix segfaults when Ruby coverage is enabled. @@ -552,7 +556,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 @@ -1912,7 +1916,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) @@ -2664,7 +2668,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* @@ -2682,7 +2686,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. @@ -3130,7 +3134,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..469900c5f --- /dev/null +++ b/docs/guide/caching.md @@ -0,0 +1,45 @@ +--- +layout: default +title: Caching +parent: How-to guide +--- + +# Caching + +Experimental +{: .label } + +Caching is experimental. + +To enable caching, include `ViewComponent::ExperimentallyCacheable`. + +Components implement caching by marking dependencies using `cache_on`: + +```ruby +class CacheComponent < ViewComponent::Base + include ViewComponent::ExperimentallyCacheable + + cache_on :foo, :bar + attr_reader :foo, :bar + + def initialize(foo:, bar:) + @foo = foo + @bar = bar + end +end +``` + +```erb +

<%= view_cache_dependencies.inspect %>

+ +

<%= Time.zone.now %>

+

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

+``` + +`cache_on` accepts method names. Returned values are expanded via `ActiveSupport::Cache.expand_cache_key`, so Active Record models, `GlobalID`, arrays, and plain strings work as expected. + +Methods listed in `cache_on` may be private. + +The cache key includes a digest of component source (Ruby + templates + i18n sidecars) and rendered child ViewComponents. + +Partial/layout string dependencies aren't currently included in the digest, to invalidate the cache on deploy modify `RAILS_CACHE_ID`/`RAILS_APP_VERSION`. diff --git a/gemfiles/rails_7.1.gemfile.lock b/gemfiles/rails_7.1.gemfile.lock index 61e55b34e..8a3f5f02b 100644 --- a/gemfiles/rails_7.1.gemfile.lock +++ b/gemfiles/rails_7.1.gemfile.lock @@ -3,6 +3,7 @@ PATH specs: view_component (4.4.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 0212c0f67..948139abd 100644 --- a/gemfiles/rails_7.2.gemfile.lock +++ b/gemfiles/rails_7.2.gemfile.lock @@ -3,6 +3,7 @@ PATH specs: view_component (4.4.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 2b2a50352..539c7c5d8 100644 --- a/gemfiles/rails_8.0.gemfile.lock +++ b/gemfiles/rails_8.0.gemfile.lock @@ -3,6 +3,7 @@ PATH specs: view_component (4.4.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 57e4a80dd..0a89f618c 100644 --- a/gemfiles/rails_8.1.gemfile.lock +++ b/gemfiles/rails_8.1.gemfile.lock @@ -3,6 +3,7 @@ PATH specs: view_component (4.4.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 33cadee59..f532e6a6d 100644 --- a/gemfiles/rails_main.gemfile.lock +++ b/gemfiles/rails_main.gemfile.lock @@ -7,7 +7,7 @@ GIT GIT remote: https://github.com/rails/rails.git - revision: 990198b7b5f4a3a3d0d59bc1bcac58efa405f527 + revision: 58c94cbd8081ddefadf8e1824685051f52234791 branch: main specs: actioncable (8.2.0.alpha) @@ -41,7 +41,7 @@ GIT rails-html-sanitizer (~> 1.6) useragent (~> 0.16) actiontext (8.2.0.alpha) - action_text-trix (~> 2.1.15) + action_text-trix (~> 2.1.16) actionpack (= 8.2.0.alpha) activerecord (= 8.2.0.alpha) activestorage (= 8.2.0.alpha) @@ -79,6 +79,7 @@ GIT json logger (>= 1.4.2) minitest (>= 5.1) + psych (>= 4) securerandom (>= 0.3) tzinfo (~> 2.0, >= 2.0.5) uri (>= 0.13.1) @@ -111,14 +112,17 @@ PATH specs: view_component (4.4.0) actionview (>= 7.1.0) + actionview_precompiler (>= 0.4) activesupport (>= 7.1.0) concurrent-ruby (~> 1) GEM remote: https://rubygems.org/ specs: - action_text-trix (2.1.15) + action_text-trix (2.1.16) railties + actionview_precompiler (0.4.0) + actionview (>= 6.0.a) addressable (2.8.7) public_suffix (>= 2.0.2, < 7.0) allocation_stats (0.1.5) @@ -138,7 +142,7 @@ GEM erubi (~> 1.4) parser (>= 2.4) smart_properties - bigdecimal (3.3.1) + bigdecimal (4.0.1) builder (3.3.0) capybara (3.40.0) addressable @@ -149,18 +153,18 @@ GEM rack-test (>= 0.6.3) regexp_parser (>= 1.5, < 3.0) xpath (~> 3.2) - concurrent-ruby (1.3.5) - connection_pool (2.5.4) + concurrent-ruby (1.3.6) + connection_pool (3.0.2) crass (1.0.6) cuprite (0.17) capybara (~> 3.0) ferrum (~> 0.17.0) - date (3.5.0) + date (3.5.1) diff-lcs (1.6.2) docile (1.4.1) drb (2.2.3) dry-initializer (3.2.0) - erb (6.0.0) + erb (6.0.1) erb_lint (0.9.0) activesupport better_html (>= 2.0.1) @@ -182,21 +186,22 @@ GEM thor tilt herb (0.8.2) - i18n (1.14.7) + i18n (1.14.8) concurrent-ruby (~> 1.0) - io-console (0.8.1) - irb (1.15.3) + io-console (0.8.2) + irb (1.17.0) pp (>= 0.6.0) + prism (>= 1.3.0) rdoc (>= 4.0.0) reline (>= 0.4.2) jbuilder (2.14.1) actionview (>= 7.0.0) activesupport (>= 7.0.0) - json (2.16.0) + json (2.18.1) language_server-protocol (3.17.0.5) lint_roller (1.1.0) logger (1.7.0) - loofah (2.24.1) + loofah (2.25.0) crass (~> 1.0.2) nokogiri (>= 1.12.0) m (1.6.2) @@ -213,8 +218,8 @@ GEM method_source (1.1.0) mini_mime (1.1.5) mini_portile2 (2.8.9) - minitest (5.26.2) - net-imap (0.5.12) + minitest (5.27.0) + net-imap (0.6.2) date net-protocol net-pop (0.1.2) @@ -224,25 +229,9 @@ GEM net-smtp (0.5.1) net-protocol nio4r (2.7.5) - nokogiri (1.18.10) + nokogiri (1.19.0) mini_portile2 (~> 2.8.2) racc (~> 1.4) - nokogiri (1.18.10-aarch64-linux-gnu) - racc (~> 1.4) - nokogiri (1.18.10-aarch64-linux-musl) - racc (~> 1.4) - nokogiri (1.18.10-arm-linux-gnu) - racc (~> 1.4) - nokogiri (1.18.10-arm-linux-musl) - racc (~> 1.4) - nokogiri (1.18.10-arm64-darwin) - racc (~> 1.4) - nokogiri (1.18.10-x86_64-darwin) - racc (~> 1.4) - nokogiri (1.18.10-x86_64-linux-gnu) - racc (~> 1.4) - nokogiri (1.18.10-x86_64-linux-musl) - racc (~> 1.4) parallel (1.27.0) parser (3.3.10.0) ast (~> 2.4.1) @@ -255,7 +244,7 @@ GEM actionpack (>= 7.0.0) activesupport (>= 7.0.0) rack - psych (5.2.6) + psych (5.3.1) date stringio public_suffix (6.0.2) @@ -267,7 +256,7 @@ GEM rack (>= 3.0.0) rack-test (2.2.0) rack (>= 1.3) - rackup (2.2.1) + rackup (2.3.1) rack (>= 3) rails-dom-testing (2.3.0) activesupport (>= 5.0.0) @@ -278,7 +267,7 @@ GEM nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0) rainbow (3.1.1) rake (13.3.1) - rdoc (6.15.1) + rdoc (7.2.0) erb psych (>= 4.0.0) tsort @@ -374,7 +363,7 @@ GEM standard-performance (1.8.0) lint_roller (~> 1.1) rubocop-performance (~> 1.25.0) - stringio (3.1.8) + stringio (3.2.0) tailwindcss-rails (4.4.0) railties (>= 7.0.0) tailwindcss-ruby (~> 4.0) @@ -388,9 +377,9 @@ GEM temple (0.10.4) terminal-table (4.0.0) unicode-display_width (>= 1.1.1, < 4) - thor (1.4.0) + thor (1.5.0) tilt (2.6.1) - timeout (0.4.4) + timeout (0.6.0) tsort (0.2.0) turbo-rails (2.0.20) actionpack (>= 7.1.0) @@ -414,7 +403,7 @@ GEM yard (0.9.37) yard-activesupport-concern (0.0.1) yard (>= 0.8) - zeitwerk (2.7.3) + zeitwerk (2.7.4) PLATFORMS aarch64-linux-gnu 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 5fee45ea1..bd50641d3 100644 --- a/lib/view_component/base.rb +++ b/lib/view_component/base.rb @@ -50,7 +50,6 @@ 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 diff --git a/lib/view_component/cache_digestor.rb b/lib/view_component/cache_digestor.rb new file mode 100644 index 000000000..bded33a17 --- /dev/null +++ b/lib/view_component/cache_digestor.rb @@ -0,0 +1,107 @@ +# frozen_string_literal: true + +require "digest" +require "view_component/template_dependency_extractor" + +module ViewComponent + class CacheDigestor + def initialize(component:) + @component = component + @digests = {} + @file_cache = {} + @constant_cache = {} + end + + def digest + digest_for_component(@component.class) + end + + private + + IN_PROGRESS = :__vc_in_progress + private_constant :IN_PROGRESS + + def digest_for_component(component_class) + return "" unless component_class <= ViewComponent::Base + name = component_class.name + return "" unless name + + cached_digest = @digests[name] + return "" if cached_digest == IN_PROGRESS + return cached_digest if cached_digest + + @digests[name] = IN_PROGRESS + + digest = Digest::SHA1.new + + update_digest(digest, file_contents(component_class.identifier)) + + inline_template = component_class.__vc_inline_template + if inline_template + inline_source = inline_template.source + update_digest(digest, inline_source) + update_template_dependency_digests(digest, inline_source, inline_template.language) + end + + template_paths = component_class.sidecar_files(ActionView::Template.template_handler_extensions).sort + template_paths.each do |path| + template_source = file_contents(path) + update_digest(digest, template_source) + update_template_dependency_digests(digest, template_source, File.extname(path).delete_prefix(".")) + end + + i18n_paths = component_class.sidecar_files(%w[yml yaml]).sort + i18n_paths.each do |path| + update_digest(digest, file_contents(path)) + end + + @digests[name] = digest.hexdigest + end + + def update_template_dependency_digests(digest, template_source, handler) + return unless template_source&.include?("render") + + dependencies = ViewComponent::TemplateDependencyExtractor.new(template_source, handler).extract + update_dependency_digests(digest, dependencies) + end + + def update_dependency_digests(digest, dependencies) + dependencies.each do |dep| + next unless uppercase_constant?(dep) + + klass = 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 constantize(constant_name) + @constant_cache.fetch(constant_name) do + @constant_cache[constant_name] = constant_name.safe_constantize + end + end + + def file_contents(path) + return nil if path.nil? + + @file_cache.fetch(path) do + @file_cache[path] = File.file?(path) ? File.read(path) : nil + end + 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..8a7f74b53 --- /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[: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 69b555046..4cff288b3 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::ExperimentallyCacheable) + __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::ExperimentallyCacheable) + __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/experimentally_cacheable.rb b/lib/view_component/experimentally_cacheable.rb new file mode 100644 index 000000000..4d67f3871 --- /dev/null +++ b/lib/view_component/experimentally_cacheable.rb @@ -0,0 +1,138 @@ +# 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_dependencies, default: Set.new + class_attribute :__vc_cache_if, default: nil + + # For caching, such as #cache_if + # + # @private + def view_cache_dependencies + @__vc_cache_dependencies ||= self.class.__vc_cache_dependencies.map { |dep| send(dep) } + end + + def view_cache_options + return @__vc_cache_options if instance_variable_defined?(:@__vc_cache_options) + + dependencies = self.class.__vc_cache_dependencies + return @__vc_cache_options = nil if dependencies.empty? + + template_key = __vc_cache_template_key + return @__vc_cache_options = nil unless template_key + + expanded_key = ActiveSupport::Cache.expand_cache_key([__vc_static_cache_key_parts(template_key), view_cache_dependencies]) + @__vc_cache_options = combined_fragment_cache_key(expanded_key) + end + + # Render component from cache if possible + # + # @private + def __vc_render_cacheable(safe_call) + if __vc_cache_enabled? && (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)) + @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(cache_key, safe_call) + end + end + + def read_fragment(cache_key) + Rails.cache.read(cache_key) + end + + def write_fragment(cache_key, safe_call) + content = instance_exec(&safe_call) + Rails.cache.write(cache_key, content) + content + end + + def combined_fragment_cache_key(key) + cache_key = [:view_component, ENV["RAILS_CACHE_ID"] || ENV["RAILS_APP_VERSION"], key] + cache_key.flatten!(1) + cache_key.compact! + cache_key + end + + def component_digest + return @__vc_component_digest ||= __vc_compute_component_digest unless ActionView::Base.cache_template_loading + + klass = self.class + digest = klass.instance_variable_get(:@__vc_component_digest) + return digest if digest + + klass.instance_variable_set(:@__vc_component_digest, __vc_compute_component_digest) + end + + def __vc_compute_component_digest + ViewComponent::CacheDigestor.new(component: self).digest + end + + def __vc_static_cache_key_parts(template_key) + klass = self.class + digest = component_digest + call_method_name, template_virtual_path = template_key + cache_key = [call_method_name, template_virtual_path, digest] + + static_key_cache = klass.instance_variable_get(:@__vc_static_cache_key_parts) || + klass.instance_variable_set(:@__vc_static_cache_key_parts, {}) + + static_key_cache[cache_key] ||= [klass.name, klass.virtual_path, [call_method_name, template_virtual_path].freeze, digest].freeze + end + + def __vc_cache_enabled? + cache_if = self.class.__vc_cache_if + return true if cache_if.nil? + + case cache_if + when Symbol, String + public_send(cache_if) + when Proc + instance_exec(&cache_if) + else + !!cache_if + end + end + end + + class_methods do + def cache_if(value = nil, &block) + self.__vc_cache_if = block || value + end + + # For caching the component + def cache_on(*args) + __vc_cache_dependencies.merge(args) + end + + def inherited(child) + child.__vc_cache_dependencies = __vc_cache_dependencies.dup + child.__vc_cache_if = __vc_cache_if + + super + end + end +end diff --git a/lib/view_component/system_test_helpers.rb b/lib/view_component/system_test_helpers.rb index c5677e63b..070d0612c 100644 --- a/lib/view_component/system_test_helpers.rb +++ b/lib/view_component/system_test_helpers.rb @@ -1,5 +1,8 @@ # frozen_string_literal: true +require "base64" +require "securerandom" + module ViewComponent module SystemTestHelpers include TestHelpers @@ -9,18 +12,29 @@ module SystemTestHelpers # @param layout [String] The (optional) layout to use. # @return [Proc] A block that can be used to visit the path of the inline rendered component. def with_rendered_component_path(fragment, layout: false, &block) - file = Tempfile.new( - ["rendered_#{fragment.class.name}", ".html"], - ViewComponentsSystemTestController.temp_dir - ) - begin - file.write(vc_test_controller.render_to_string(html: fragment.to_html.html_safe, layout: layout)) - file.rewind - - yield("/_system_test_entrypoint?file=#{file.path.split("/").last}") - ensure - file.unlink + rendered_html = vc_test_controller.render_to_string(html: fragment.to_html.html_safe, layout: layout) + + if use_inline_data_url?(layout) + yield("data:text/html;base64,#{Base64.strict_encode64(rendered_html)}") + return end + + filename = "rendered_#{fragment.class.name.gsub("::", "")}_#{SecureRandom.hex(8)}.html" + path = File.join(ViewComponentsSystemTestController.temp_dir, filename) + + File.write(path, rendered_html) + + yield("/_system_test_entrypoint?file=#{filename}") + end + + private + + def use_inline_data_url?(layout) + return false if layout + return true if defined?(ActionDispatch::SystemTestCase) && is_a?(ActionDispatch::SystemTestCase) + return false unless defined?(Capybara) && Capybara.respond_to?(:current_driver) + + Capybara.current_driver != :rack_test end end end diff --git a/lib/view_component/template_ast_builder.rb b/lib/view_component/template_ast_builder.rb new file mode 100644 index 000000000..d04d567c6 --- /dev/null +++ b/lib/view_component/template_ast_builder.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +module ViewComponent + class TemplateAstBuilder + def self.build(template_string, engine_name) + case engine_name.to_sym + when :erb + compile_erb(template_string) + else + compile_template_with_engine(template_string, engine_name) + end + end + + def self.compile_erb(template) + require "erb" + + ERB::Compiler.new("-").compile(template).first + rescue + nil + end + private_class_method :compile_erb + + def self.compile_template_with_engine(template_string, engine_name) + engine_class = load_template_engine(engine_name) + return nil unless engine_class + + engine_class.new.call(template_string) + rescue + nil + end + private_class_method :compile_template_with_engine + + def self.load_template_engine(engine_name) + engine_class = template_engine_class(engine_name) + return engine_class if engine_class + + require engine_name.to_s + template_engine_class(engine_name) + rescue LoadError + nil + end + private_class_method :load_template_engine + + def self.template_engine_class(engine_name) + engine_module_name = engine_name.to_s.tr("-", "_").split("_").map!(&:capitalize).join + Object.const_get("#{engine_module_name}::Engine") + rescue NameError + nil + end + private_class_method :template_engine_class + end +end diff --git a/lib/view_component/template_dependency_extractor.rb b/lib/view_component/template_dependency_extractor.rb new file mode 100644 index 000000000..42eb7078b --- /dev/null +++ b/lib/view_component/template_dependency_extractor.rb @@ -0,0 +1,72 @@ +# frozen_string_literal: true + +require "actionview_precompiler" + +require_relative "template_ast_builder" + +module ViewComponent + class TemplateDependencyExtractor + def initialize(template_string, engine) + @template_string = template_string + @engine = engine + @dependencies = Set.new + end + + def extract + engine = @engine.to_sym + ruby_source = TemplateAstBuilder.build(@template_string, engine) + + if ruby_source.nil? + return extract_erb_fallback if engine == :erb + + return [] + end + + extract_from_ruby(ruby_source) + @dependencies.to_a + 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/performance/cache_benchmark.rb b/performance/cache_benchmark.rb new file mode 100644 index 000000000..d392260bb --- /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 #{cache_miss_counter}")) + 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..eef1dcc90 --- /dev/null +++ b/performance/components/cacheable_benchmark_component.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +module Performance + class CacheableBenchmarkComponent < ViewComponent::Base + include ViewComponent::ExperimentallyCacheable + + cache_if :cache_worthy? + cache_on :name + + 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/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/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..9633eb35d --- /dev/null +++ b/test/sandbox/app/components/cache_component.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +class CacheComponent < ViewComponent::Base + include ViewComponent::ExperimentallyCacheable + + 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_condition_component.html.erb b/test/sandbox/app/components/cache_condition_component.html.erb new file mode 100644 index 000000000..61035c27e --- /dev/null +++ b/test/sandbox/app/components/cache_condition_component.html.erb @@ -0,0 +1 @@ +

<%= foo %>

diff --git a/test/sandbox/app/components/cache_condition_component.rb b/test/sandbox/app/components/cache_condition_component.rb new file mode 100644 index 000000000..5c229927e --- /dev/null +++ b/test/sandbox/app/components/cache_condition_component.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +class CacheConditionComponent < ViewComponent::Base + include ViewComponent::ExperimentallyCacheable + + cache_if :cache_enabled? + cache_on :foo + + attr_reader :foo + + def initialize(foo:) + @foo = foo + end + + def cache_enabled? + false + end +end diff --git a/test/sandbox/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..76f2ad1a1 --- /dev/null +++ b/test/sandbox/app/components/cache_dependency_types_component.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +class CacheDependencyTypesComponent < ViewComponent::Base + include ViewComponent::ExperimentallyCacheable + + cache_on :record, :tags, :label, :private_token + + 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_layout_parent_component.html.erb b/test/sandbox/app/components/cache_digestor_layout_parent_component.html.erb new file mode 100644 index 000000000..ff7f52a4d --- /dev/null +++ b/test/sandbox/app/components/cache_digestor_layout_parent_component.html.erb @@ -0,0 +1,5 @@ +
+ <%= render layout: "shared/cache_digestor_layout" do %> + layout-body + <% end %> +
diff --git a/test/sandbox/app/components/cache_digestor_layout_parent_component.rb b/test/sandbox/app/components/cache_digestor_layout_parent_component.rb new file mode 100644 index 000000000..ef11450a7 --- /dev/null +++ b/test/sandbox/app/components/cache_digestor_layout_parent_component.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +class CacheDigestorLayoutParentComponent < ViewComponent::Base + include ViewComponent::ExperimentallyCacheable + + cache_on :foo + + attr_reader :foo + + def initialize(foo:) + @foo = foo + end +end diff --git a/test/sandbox/app/components/cache_digestor_nested_partial_parent_component.html.erb b/test/sandbox/app/components/cache_digestor_nested_partial_parent_component.html.erb new file mode 100644 index 000000000..cce2a9786 --- /dev/null +++ b/test/sandbox/app/components/cache_digestor_nested_partial_parent_component.html.erb @@ -0,0 +1,3 @@ +
+ <%= render CacheDigestorChildPartialComponent.new %> +
diff --git a/test/sandbox/app/components/cache_digestor_nested_partial_parent_component.rb b/test/sandbox/app/components/cache_digestor_nested_partial_parent_component.rb new file mode 100644 index 000000000..2776c3462 --- /dev/null +++ b/test/sandbox/app/components/cache_digestor_nested_partial_parent_component.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +class CacheDigestorNestedPartialParentComponent < ViewComponent::Base + include ViewComponent::ExperimentallyCacheable + + cache_on :foo + + attr_reader :foo + + def initialize(foo:) + @foo = foo + end +end diff --git a/test/sandbox/app/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..cbab5752f --- /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::ExperimentallyCacheable + + cache_on :foo + + 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..ce49c318c --- /dev/null +++ b/test/sandbox/app/components/cache_digestor_partial_parent_component.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +class CacheDigestorPartialParentComponent < ViewComponent::Base + include ViewComponent::ExperimentallyCacheable + + cache_on :foo + + attr_reader :foo + + def initialize(foo:) + @foo = foo + end +end diff --git a/test/sandbox/app/components/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..dd6a84d66 --- /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::ExperimentallyCacheable + + 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..2635739cc --- /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::ExperimentallyCacheable + + 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/app/views/shared/_cache_digestor_layout.html.erb b/test/sandbox/app/views/shared/_cache_digestor_layout.html.erb new file mode 100644 index 000000000..ffdc53d01 --- /dev/null +++ b/test/sandbox/app/views/shared/_cache_digestor_layout.html.erb @@ -0,0 +1 @@ +
layout-v1 <%= yield %>
diff --git a/test/sandbox/app/views/shared/_cache_digestor_nested_partial.html.erb b/test/sandbox/app/views/shared/_cache_digestor_nested_partial.html.erb new file mode 100644 index 000000000..adc577b1c --- /dev/null +++ b/test/sandbox/app/views/shared/_cache_digestor_nested_partial.html.erb @@ -0,0 +1 @@ +nested-v1 diff --git a/test/sandbox/app/views/shared/_cache_digestor_partial.html.erb b/test/sandbox/app/views/shared/_cache_digestor_partial.html.erb new file mode 100644 index 000000000..8fca5d1fc --- /dev/null +++ b/test/sandbox/app/views/shared/_cache_digestor_partial.html.erb @@ -0,0 +1 @@ +partial-v1 diff --git a/test/sandbox/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/integration_test.rb b/test/sandbox/test/integration_test.rb index 0de660347..69093540f 100644 --- a/test/sandbox/test/integration_test.rb +++ b/test/sandbox/test/integration_test.rb @@ -273,6 +273,33 @@ def test_rendering_component_with_caching Rails.cache.clear end + def test_rendering_cacheable_component_in_controller + Rails.cache.clear + ActionController::Base.perform_caching = true + + get "/controller_inline_cached?foo=foo&bar=bar" + assert_response :success + assert_select ".cache-component__cache-message", text: "foo bar" + first_time = css_select(".cache-component__cache-message").first["data-time"] + refute_nil first_time + + get "/controller_inline_cached?foo=foo&bar=bar" + assert_response :success + assert_select ".cache-component__cache-message", text: "foo bar" + second_time = css_select(".cache-component__cache-message").first["data-time"] + assert_equal first_time, second_time + + get "/controller_inline_cached?foo=foo&bar=baz" + assert_response :success + assert_select ".cache-component__cache-message", text: "foo baz" + third_time = css_select(".cache-component__cache-message").first["data-time"] + refute_nil third_time + refute_equal first_time, third_time + ensure + ActionController::Base.perform_caching = false + Rails.cache.clear + end + def test_optional_rendering_component_depending_on_request_context get "/render_check" assert_response :success diff --git a/test/sandbox/test/rendering_test.rb b/test/sandbox/test/rendering_test.rb index c57bf6ee7..32508800d 100644 --- a/test/sandbox/test/rendering_test.rb +++ b/test/sandbox/test/rendering_test.rb @@ -20,7 +20,7 @@ def test_render_inline_allocations MyComponent.__vc_ensure_compiled with_instrumentation_enabled_option(false) do - assert_allocations({"4.1" => 67..68, "4.0" => 67, "3.4" => 72..74, "3.3" => 75, "3.2" => 78..79}) do + assert_allocations({"4.1" => 67..68, "4.0" => 67, "3.4" => 72..76, "3.3" => 75, "3.2" => 78..79}) do render_inline(MyComponent.new) end end @@ -1356,6 +1356,187 @@ def test_around_render assert_text("Hi!") end + def test_inline_cache_component + 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 + 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 + 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_if_false_skips_caching + component = CacheConditionComponent.new(foo: "foo") + + render_inline(component) + first_time = page.find(".cache-condition-component__message")["data-time"] + + render_inline(component) + second_time = page.find(".cache-condition-component__message")["data-time"] + + refute_equal(first_time, second_time) + end + + def test_cache_on_expands_dependency_values_and_allows_private_methods + record = GlobalID.parse("gid://sandbox/CacheDependencyTypesRecord/42") + component = CacheDependencyTypesComponent.new(record: record, tags: ["alpha", "beta"], label: "plain-string") + + render_inline(component) + + expected_dependencies = [record, ["alpha", "beta"], "plain-string", "private-token"] + assert_equal(expected_dependencies, component.view_cache_dependencies) + + expanded_dependencies = ActiveSupport::Cache.expand_cache_key(expected_dependencies) + cache_key = component.view_cache_options.last + assert_includes(cache_key, expanded_dependencies) + assert_includes(expanded_dependencies, record.to_param) + end + + def test_cache_key_changes_when_child_component_template_changes + child_template_path = CacheDigestorChildComponent.sidecar_files(["erb"]).first + original_template = File.read(child_template_path) + + 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_cache_key_does_not_change_when_partial_string_dependency_changes + partial_path = Rails.root.join("app/views/shared/_cache_digestor_partial.html.erb") + original_partial = File.read(partial_path) + + Rails.cache.clear + ViewComponent::CompileCache.invalidate! + + component_v1 = CacheDigestorPartialParentComponent.new(foo: "x") + render_inline(component_v1) + assert_selector(".partial-child", text: "partial-v1") + time_v1 = page.find(".partial-parent")["data-time"] + + File.write(partial_path, original_partial.sub("partial-v1", "partial-v2")) + ViewComponent::CompileCache.invalidate! + + render_inline(CacheDigestorPartialParentComponent.new(foo: "x")) + + assert_selector(".partial-child", text: "partial-v1") + assert_equal(time_v1, page.find(".partial-parent")["data-time"]) + ensure + Rails.cache.clear + ViewComponent::CompileCache.invalidate! + + File.write(partial_path, original_partial) if partial_path && original_partial + end + + def test_cache_key_does_not_change_when_child_component_partial_dependency_changes + partial_path = Rails.root.join("app/views/shared/_cache_digestor_nested_partial.html.erb") + original_partial = File.read(partial_path) + + Rails.cache.clear + ViewComponent::CompileCache.invalidate! + + component_v1 = CacheDigestorNestedPartialParentComponent.new(foo: "x") + render_inline(component_v1) + assert_selector(".nested-partial-child", text: "nested-v1") + time_v1 = page.find(".nested-partial-parent")["data-time"] + + File.write(partial_path, original_partial.sub("nested-v1", "nested-v2")) + ViewComponent::CompileCache.invalidate! + + render_inline(CacheDigestorNestedPartialParentComponent.new(foo: "x")) + + assert_selector(".nested-partial-child", text: "nested-v1") + assert_equal(time_v1, page.find(".nested-partial-parent")["data-time"]) + ensure + Rails.cache.clear + ViewComponent::CompileCache.invalidate! + + File.write(partial_path, original_partial) if partial_path && original_partial + end + + def test_cache_key_does_not_change_when_layout_string_dependency_changes + layout_path = Rails.root.join("app/views/shared/_cache_digestor_layout.html.erb") + original_layout = File.read(layout_path) + + Rails.cache.clear + ViewComponent::CompileCache.invalidate! + + component_v1 = CacheDigestorLayoutParentComponent.new(foo: "x") + render_inline(component_v1) + assert_selector(".layout-shell", text: "layout-v1") + time_v1 = page.find(".layout-parent")["data-time"] + + File.write(layout_path, original_layout.sub("layout-v1", "layout-v2")) + ViewComponent::CompileCache.invalidate! + + render_inline(CacheDigestorLayoutParentComponent.new(foo: "x")) + + assert_selector(".layout-shell", text: "layout-v1") + assert_equal(time_v1, page.find(".layout-parent")["data-time"]) + ensure + Rails.cache.clear + ViewComponent::CompileCache.invalidate! + + File.write(layout_path, original_layout) if layout_path && original_layout + end + def test_render_partial_with_yield render_inline(PartialWithYieldComponent.new) assert_text "hello world", exact: true, normalize_ws: true diff --git a/view_component.gemspec b/view_component.gemspec index 6b8d75f0f..233d6c022 100644 --- a/view_component.gemspec +++ b/view_component.gemspec @@ -35,5 +35,6 @@ Gem::Specification.new do |spec| supported_rails_version = [">= 7.1.0"] spec.add_runtime_dependency "activesupport", supported_rails_version spec.add_runtime_dependency "actionview", supported_rails_version + spec.add_runtime_dependency "actionview_precompiler", ">= 0.4" spec.add_runtime_dependency "concurrent-ruby", "~> 1" end