From 9a438f5dc8f1253e06f46c6ae8df599381f33aee Mon Sep 17 00:00:00 2001 From: mhenrixon Date: Sat, 17 Oct 2026 13:03:57 +0200 Subject: [PATCH 1/4] Only register the compile cache executor hook without template caching The executor hook that clears the compile cache before each request did nothing when Action View caches templates, as in production, but it still read `ActionView::Base.cache_template_loading` on every request. That reads `ActionView::Resolver`'s `@@caching` class variable, which non-main Ractors can't read on Ruby 4.0, so under Rails' experimental `Rails.application.ractorize!` every request failed once view_component was loaded. Decide once, when Action View loads, whether to register the hook. Rails' Action View railtie likewise registers its view reloader only when template caching is off. The hook still checks the flag on each run, so toggling it at runtime, with caching off at boot, works as before. --- docs/CHANGELOG.md | 4 +++ lib/view_component/engine.rb | 10 +++++-- test/sandbox/test/initializers/engine_test.rb | 29 +++++++++++++++++++ 3 files changed, 41 insertions(+), 2 deletions(-) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 7cf29eb89..95908bead 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -10,6 +10,10 @@ nav_order: 6 ## main +* Only register the executor hook that clears the compile cache when Action View doesn't cache templates. With template caching on, as in production, the hook did nothing but still read `ActionView::Base.cache_template_loading` on every request. That value lives in a class variable, which non-main Ractors can't read on Ruby 4.0, so under Rails' experimental `Rails.application.ractorize!` every request failed. + + *Mikael Henriksson* + ## 4.16.0 * Fix subclasses defined after their parent renders a collection using the parent's default collection parameter name instead of their own. Fix recompiling a component not refreshing its collection parameter, counter, and iteration detection. Compute that detection when a component compiles, and store the results frozen so they're shareable across Ractors. diff --git a/lib/view_component/engine.rb b/lib/view_component/engine.rb index c6d31b13c..945e41810 100644 --- a/lib/view_component/engine.rb +++ b/lib/view_component/engine.rb @@ -132,8 +132,14 @@ class Engine < Rails::Engine # :nodoc: end end - app.executor.to_run :before do - CompileCache.invalidate! unless ActionView::Base.cache_template_loading + # Only when templates can change: with template caching on, as in + # production, the hook would do nothing on every request. + ActiveSupport.on_load(:action_view) do + unless ActionView::Base.cache_template_loading + app.executor.to_run :before do + CompileCache.invalidate! unless ActionView::Base.cache_template_loading + end + end end end end diff --git a/test/sandbox/test/initializers/engine_test.rb b/test/sandbox/test/initializers/engine_test.rb index ce030c3f7..24efc7321 100644 --- a/test/sandbox/test/initializers/engine_test.rb +++ b/test/sandbox/test/initializers/engine_test.rb @@ -2,9 +2,38 @@ require "test_helper" +require "open3" + class EngineTest < Minitest::Test def test_set_no_duplicate_autoload_paths test_path = "#{Rails.root}/my/components/previews" # set in sandbox/config/application.rb assert_equal 1, ActiveSupport::Dependencies.autoload_paths.count(test_path) end + + # Non-main Ractors can't read ActionView::Resolver's class variable on Ruby 4.0, + # so the executor must not read it on every request when templates are cached. + def test_executor_does_not_read_cache_template_loading_when_templates_are_cached + reads, errors, status = Open3.capture3({"RAILS_ENV" => "production"}, RbConfig.ruby, "-rbundler/setup", "-e", <<~RUBY) + require "rails" + require "action_controller/railtie" + require "view_component" + + class CachedTemplatesApp < Rails::Application + config.eager_load = true + config.enable_reloading = false + config.secret_key_base = "test" + config.logger = Logger.new(nil) + end + + CachedTemplatesApp.initialize! + + reads = 0 + ActionView::Resolver.singleton_class.prepend(Module.new { define_method(:caching?) { reads += 1; super() } }) + CachedTemplatesApp.executor.wrap {} + print reads + RUBY + + assert status.success?, errors + assert_equal "0", reads + end end From ff23301013c8b6c0ec1dddd11635f12499e60645 Mon Sep 17 00:00:00 2001 From: Joel Hawksley Date: Mon, 19 Oct 2026 12:56:42 -0600 Subject: [PATCH 2/4] Restore cached-template integration test Since template caching is now decided at boot, with_template_caching also skips the template watcher hook the sandbox registered. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: f2e356ff-16cd-4517-8e63-a8600747d3fa --- test/sandbox/test/integration_test.rb | 31 ++++++++++----------------- test/test_helper.rb | 9 ++++++++ 2 files changed, 20 insertions(+), 20 deletions(-) diff --git a/test/sandbox/test/integration_test.rb b/test/sandbox/test/integration_test.rb index 628dc92d7..d04744c4e 100644 --- a/test/sandbox/test/integration_test.rb +++ b/test/sandbox/test/integration_test.rb @@ -50,15 +50,15 @@ def test_rendering_component_in_a_controller_with_block assert_response :success end - def test_template_changes_are_reflected_on_new_request_when_cache_template_loading_is_false - with_new_cache do + def test_template_changes_are_not_reflected_on_new_request_when_cache_template_loading_is_true + with_template_caching do get "/controller_inline" assert_select("div", "bar") assert_response :success modify_file "app/components/controller_inline_component.html.erb", "
Goodbye world!
" do get "/controller_inline" - assert_select("div", "Goodbye world!") + assert_select("div", "bar") assert_response :success end @@ -68,31 +68,22 @@ def test_template_changes_are_reflected_on_new_request_when_cache_template_loadi end end - def test_components_are_not_recompiled_on_new_request_when_no_template_changed + def test_template_changes_are_reflected_on_new_request_when_cache_template_loading_is_false with_new_cache do get "/controller_inline" - MyComponent.__vc_compile - - get "/controller_inline" + assert_select("div", "bar") assert_response :success - assert MyComponent.__vc_compiled? - end - end - - def test_components_are_recompiled_on_new_request_when_a_template_is_added - new_template = Rails.root.join("app/components/added_template_component.html.erb") - with_new_cache do - get "/controller_inline" - MyComponent.__vc_compile + modify_file "app/components/controller_inline_component.html.erb", "
Goodbye world!
" do + get "/controller_inline" + assert_select("div", "Goodbye world!") + assert_response :success + end - File.write(new_template, "
Added
") get "/controller_inline" + assert_select("div", "bar") assert_response :success - refute MyComponent.__vc_compiled? end - ensure - FileUtils.rm_f(new_template) end def test_inherited_template_changes_are_reflected_on_new_request_when_cache_template_loading_is_false diff --git a/test/test_helper.rb b/test/test_helper.rb index 3c7d69ba7..ff2543af9 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -128,13 +128,22 @@ def with_generate_sidecar(enabled, &block) with_generate_option(:sidecar, enabled, &block) end +# Template caching is decided at boot, so also skip the template watcher that +# the sandbox registered because it boots without template caching. def with_template_caching + executor = Rails.application.executor + watcher_hook = executor._run_callbacks.find do |callback| + callback.filter.is_a?(Proc) && callback.filter.source_location&.first&.end_with?("view_component/compile_cache.rb") + end&.filter + executor.skip_callback(:run, :before, watcher_hook) if watcher_hook + old_cache_template_loading = ActionView::Base.cache_template_loading ActionView::Base.cache_template_loading = true yield ensure ActionView::Base.cache_template_loading = old_cache_template_loading + executor.set_callback(:run, :before, watcher_hook) if watcher_hook end def with_new_cache From b9efa4692be08466980a121c2e56aaf2076816af Mon Sep 17 00:00:00 2001 From: Joel Hawksley Date: Mon, 19 Oct 2026 13:09:51 -0600 Subject: [PATCH 3/4] Test template caching by booting apps with ActiveSupport::Testing::Isolation Boot a fresh development or production app per test in a forked process, the way railties tests do, instead of an Open3 subprocess or an extracted method. These tests run in their own rake task since the sandbox is already booted in the test process. Inline the executor hook back into the engine, and restore #2730's integration tests that a previous commit dropped by mistake. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: f2e356ff-16cd-4517-8e63-a8600747d3fa --- Rakefile | 12 +++- lib/view_component/compile_cache.rb | 15 ----- lib/view_component/engine.rb | 15 ++++- test/isolated/template_caching_test.rb | 58 +++++++++++++++++++ test/sandbox/test/initializers/engine_test.rb | 24 -------- test/sandbox/test/integration_test.rb | 27 +++++++++ test/test_helper.rb | 2 +- 7 files changed, 111 insertions(+), 42 deletions(-) create mode 100644 test/isolated/template_caching_test.rb diff --git a/Rakefile b/Rakefile index ef45ecd55..19e2df166 100644 --- a/Rakefile +++ b/Rakefile @@ -14,7 +14,13 @@ RSpec::Core::RakeTask.new(:spec) Rake::TestTask.new(:test) do |t| t.libs << "test" t.libs << "lib" - t.test_files = FileList["test/**/*_test.rb"] + t.test_files = FileList["test/**/*_test.rb"].exclude("test/isolated/**/*") +end + +# Each test boots its own application, so these can't share a process with the sandbox. +Rake::TestTask.new(:isolated_test) do |t| + t.libs << "lib" + t.test_files = FileList["test/isolated/**/*_test.rb"] end Rake::TestTask.new(:engine_test) do |t| @@ -176,6 +182,10 @@ task :all_tests do Rake::Task["test"].invoke puts puts + puts "Running isolated Minitests" + Rake::Task["isolated_test"].invoke + puts + puts puts "Running Minitests for Rails Engine compatibility" Rake::Task["engine_test"].invoke puts diff --git a/lib/view_component/compile_cache.rb b/lib/view_component/compile_cache.rb index fff25d931..649282563 100644 --- a/lib/view_component/compile_cache.rb +++ b/lib/view_component/compile_cache.rb @@ -34,20 +34,5 @@ def template_watcher(file_watcher) file_watcher.new([], directories) { invalidate! } end - - # Like Rails' own template cache expiry, only watches templates when they - # aren't cached at boot. With template caching on, as in production, no - # hook runs per request, so requests never read ActionView's caching - # class variable, which non-main Ractors can't access. - def watch_templates(app) - return if ActionView::Base.cache_template_loading - - watcher = nil - - app.executor.to_run :before do - watcher ||= CompileCache.template_watcher(app.config.file_watcher) - watcher.execute_if_updated - end - end end end diff --git a/lib/view_component/engine.rb b/lib/view_component/engine.rb index b72a55a9b..d2ea1bea3 100644 --- a/lib/view_component/engine.rb +++ b/lib/view_component/engine.rb @@ -132,7 +132,20 @@ class Engine < Rails::Engine # :nodoc: end end - ActiveSupport.on_load(:action_view) { CompileCache.watch_templates(app) } + # Like Rails' own template cache expiry, only watch templates when they + # aren't cached at boot. With template caching on, as in production, no + # hook runs per request, so requests never read ActionView's caching + # class variable, which non-main Ractors can't access. + ActiveSupport.on_load(:action_view) do + next if ActionView::Base.cache_template_loading + + template_watcher = nil + + app.executor.to_run :before do + template_watcher ||= CompileCache.template_watcher(app.config.file_watcher) + template_watcher.execute_if_updated + end + end app.reloader.before_class_unload { CompileCache.invalidate! } end diff --git a/test/isolated/template_caching_test.rb b/test/isolated/template_caching_test.rb new file mode 100644 index 000000000..52eca74cb --- /dev/null +++ b/test/isolated/template_caching_test.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true + +require "bundler/setup" +require "minitest/autorun" +require "minitest/mock" +require "rails" +require "action_controller/railtie" +require "active_support/testing/isolation" +require "view_component" + +# Boots a fresh application per test in a forked process, so tests can choose +# their own environment. Runs separately from the sandbox, which boots in test. +class TemplateCachingTest < ActiveSupport::TestCase + include ActiveSupport::Testing::Isolation + + def test_executor_checks_for_template_changes_when_templates_are_not_cached + app = boot_app("development") + refute ActionView::Base.cache_template_loading + + watcher = Minitest::Mock.new + watcher.expect(:execute_if_updated, nil) + ViewComponent::CompileCache.stub(:template_watcher, watcher) { app.executor.wrap {} } + + watcher.verify + end + + # Non-main Ractors can't read ActionView::Resolver's class variable on Ruby 4.0, + # so the executor must not read it on every request when templates are cached. + def test_executor_does_nothing_when_templates_are_cached + app = boot_app("production") + assert ActionView::Base.cache_template_loading + + reads = 0 + ActionView::Resolver.singleton_class.prepend(Module.new do + define_method(:caching?) do + reads += 1 + super() + end + end) + ViewComponent::CompileCache.stub(:template_watcher, ->(*) { flunk "templates were watched" }) { app.executor.wrap {} } + + assert_equal 0, reads + end + + private + + def boot_app(env) + Rails.env = env + + Class.new(Rails::Application) do + config.root = __dir__ + config.eager_load = env == "production" + config.enable_reloading = env == "development" + config.secret_key_base = "test" + config.logger = Logger.new(nil) + end.tap(&:initialize!) + end +end diff --git a/test/sandbox/test/initializers/engine_test.rb b/test/sandbox/test/initializers/engine_test.rb index df55f8701..ce030c3f7 100644 --- a/test/sandbox/test/initializers/engine_test.rb +++ b/test/sandbox/test/initializers/engine_test.rb @@ -7,28 +7,4 @@ def test_set_no_duplicate_autoload_paths test_path = "#{Rails.root}/my/components/previews" # set in sandbox/config/application.rb assert_equal 1, ActiveSupport::Dependencies.autoload_paths.count(test_path) end - - # Non-main Ractors can't read ActionView::Resolver's class variable on Ruby 4.0, - # so no per-request hook may read it when templates are cached. - def test_watch_templates_registers_no_executor_hook_when_templates_are_cached - app = app_with_new_executor - - with_template_caching { ViewComponent::CompileCache.watch_templates(app) } - - assert_empty app.executor._run_callbacks - end - - def test_watch_templates_registers_an_executor_hook_when_templates_are_not_cached - app = app_with_new_executor - - with_new_cache { ViewComponent::CompileCache.watch_templates(app) } - - assert_equal 1, app.executor._run_callbacks.count - end - - private - - def app_with_new_executor - Struct.new(:executor, :config).new(Class.new(ActiveSupport::Executor), Rails.application.config) - end end diff --git a/test/sandbox/test/integration_test.rb b/test/sandbox/test/integration_test.rb index d04744c4e..014297a18 100644 --- a/test/sandbox/test/integration_test.rb +++ b/test/sandbox/test/integration_test.rb @@ -86,6 +86,33 @@ def test_template_changes_are_reflected_on_new_request_when_cache_template_loadi end end + def test_components_are_not_recompiled_on_new_request_when_no_template_changed + with_new_cache do + get "/controller_inline" + MyComponent.__vc_compile + + get "/controller_inline" + assert_response :success + assert MyComponent.__vc_compiled? + end + end + + def test_components_are_recompiled_on_new_request_when_a_template_is_added + new_template = Rails.root.join("app/components/added_template_component.html.erb") + + with_new_cache do + get "/controller_inline" + MyComponent.__vc_compile + + File.write(new_template, "
Added
") + get "/controller_inline" + assert_response :success + refute MyComponent.__vc_compiled? + end + ensure + FileUtils.rm_f(new_template) + end + def test_inherited_template_changes_are_reflected_on_new_request_when_cache_template_loading_is_false with_new_cache do get "/inherited_sidecar" diff --git a/test/test_helper.rb b/test/test_helper.rb index ff2543af9..5c0eff413 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -133,7 +133,7 @@ def with_generate_sidecar(enabled, &block) def with_template_caching executor = Rails.application.executor watcher_hook = executor._run_callbacks.find do |callback| - callback.filter.is_a?(Proc) && callback.filter.source_location&.first&.end_with?("view_component/compile_cache.rb") + callback.filter.is_a?(Proc) && callback.filter.source_location&.first&.end_with?("view_component/engine.rb") end&.filter executor.skip_callback(:run, :before, watcher_hook) if watcher_hook From 3d8e7b2e87d3bba805652d41faaa89f5b219afe6 Mon Sep 17 00:00:00 2001 From: Joel Hawksley Date: Mon, 19 Oct 2026 13:18:38 -0600 Subject: [PATCH 4/4] Move cached-template integration test to isolated tests Booting a production app tests template caching as decided at boot, so the sandbox no longer needs to fake it by skipping the watcher hook. Remove the now-unused with_template_caching helper. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: f2e356ff-16cd-4517-8e63-a8600747d3fa --- test/isolated/template_caching_test.rb | 29 ++++++++++++++++++++++++-- test/sandbox/test/integration_test.rb | 18 ---------------- test/test_helper.rb | 18 ---------------- 3 files changed, 27 insertions(+), 38 deletions(-) diff --git a/test/isolated/template_caching_test.rb b/test/isolated/template_caching_test.rb index 52eca74cb..7db61f540 100644 --- a/test/isolated/template_caching_test.rb +++ b/test/isolated/template_caching_test.rb @@ -3,6 +3,7 @@ require "bundler/setup" require "minitest/autorun" require "minitest/mock" +require "tmpdir" require "rails" require "action_controller/railtie" require "active_support/testing/isolation" @@ -42,17 +43,41 @@ def test_executor_does_nothing_when_templates_are_cached assert_equal 0, reads end + def test_template_changes_are_not_reflected_on_new_request_when_templates_are_cached + Dir.mktmpdir do |root| + write_file(root, "app/components/greeting_component.rb", "class GreetingComponent < ViewComponent::Base; end") + write_file(root, "app/components/greeting_component.html.erb", "
Hello
") + + app = boot_app("production", root: root) + app.routes.draw do + get "/", to: ->(_env) { [200, {"content-type" => "text/html"}, [ActionController::Base.render(GreetingComponent.new)]] } + end + request = Rack::MockRequest.new(app) + + assert_equal "
Hello
", request.get("/").body.strip + + write_file(root, "app/components/greeting_component.html.erb", "
Goodbye
") + + assert_equal "
Hello
", request.get("/").body.strip + end + end + private - def boot_app(env) + def boot_app(env, root: __dir__) Rails.env = env Class.new(Rails::Application) do - config.root = __dir__ + config.root = root config.eager_load = env == "production" config.enable_reloading = env == "development" config.secret_key_base = "test" config.logger = Logger.new(nil) end.tap(&:initialize!) end + + def write_file(root, path, content) + FileUtils.mkdir_p(File.dirname(File.join(root, path))) + File.write(File.join(root, path), content) + end end diff --git a/test/sandbox/test/integration_test.rb b/test/sandbox/test/integration_test.rb index 014297a18..628dc92d7 100644 --- a/test/sandbox/test/integration_test.rb +++ b/test/sandbox/test/integration_test.rb @@ -50,24 +50,6 @@ def test_rendering_component_in_a_controller_with_block assert_response :success end - def test_template_changes_are_not_reflected_on_new_request_when_cache_template_loading_is_true - with_template_caching do - get "/controller_inline" - assert_select("div", "bar") - assert_response :success - - modify_file "app/components/controller_inline_component.html.erb", "
Goodbye world!
" do - get "/controller_inline" - assert_select("div", "bar") - assert_response :success - end - - get "/controller_inline" - assert_select("div", "bar") - assert_response :success - end - end - def test_template_changes_are_reflected_on_new_request_when_cache_template_loading_is_false with_new_cache do get "/controller_inline" diff --git a/test/test_helper.rb b/test/test_helper.rb index 5c0eff413..490d2fcc2 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -128,24 +128,6 @@ def with_generate_sidecar(enabled, &block) with_generate_option(:sidecar, enabled, &block) end -# Template caching is decided at boot, so also skip the template watcher that -# the sandbox registered because it boots without template caching. -def with_template_caching - executor = Rails.application.executor - watcher_hook = executor._run_callbacks.find do |callback| - callback.filter.is_a?(Proc) && callback.filter.source_location&.first&.end_with?("view_component/engine.rb") - end&.filter - executor.skip_callback(:run, :before, watcher_hook) if watcher_hook - - old_cache_template_loading = ActionView::Base.cache_template_loading - ActionView::Base.cache_template_loading = true - - yield -ensure - ActionView::Base.cache_template_loading = old_cache_template_loading - executor.set_callback(:run, :before, watcher_hook) if watcher_hook -end - def with_new_cache old_cache = ViewComponent::CompileCache.cache ViewComponent::CompileCache.cache = Set.new