Skip to content

Commit b9efa46

Browse files
joelhawksleyCopilot
andcommitted
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
1 parent ff23301 commit b9efa46

7 files changed

Lines changed: 111 additions & 42 deletions

File tree

‎Rakefile‎

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,13 @@ RSpec::Core::RakeTask.new(:spec)
1414
Rake::TestTask.new(:test) do |t|
1515
t.libs << "test"
1616
t.libs << "lib"
17-
t.test_files = FileList["test/**/*_test.rb"]
17+
t.test_files = FileList["test/**/*_test.rb"].exclude("test/isolated/**/*")
18+
end
19+
20+
# Each test boots its own application, so these can't share a process with the sandbox.
21+
Rake::TestTask.new(:isolated_test) do |t|
22+
t.libs << "lib"
23+
t.test_files = FileList["test/isolated/**/*_test.rb"]
1824
end
1925

2026
Rake::TestTask.new(:engine_test) do |t|
@@ -176,6 +182,10 @@ task :all_tests do
176182
Rake::Task["test"].invoke
177183
puts
178184
puts
185+
puts "Running isolated Minitests"
186+
Rake::Task["isolated_test"].invoke
187+
puts
188+
puts
179189
puts "Running Minitests for Rails Engine compatibility"
180190
Rake::Task["engine_test"].invoke
181191
puts

‎lib/view_component/compile_cache.rb‎

Lines changed: 0 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -34,20 +34,5 @@ def template_watcher(file_watcher)
3434

3535
file_watcher.new([], directories) { invalidate! }
3636
end
37-
38-
# Like Rails' own template cache expiry, only watches templates when they
39-
# aren't cached at boot. With template caching on, as in production, no
40-
# hook runs per request, so requests never read ActionView's caching
41-
# class variable, which non-main Ractors can't access.
42-
def watch_templates(app)
43-
return if ActionView::Base.cache_template_loading
44-
45-
watcher = nil
46-
47-
app.executor.to_run :before do
48-
watcher ||= CompileCache.template_watcher(app.config.file_watcher)
49-
watcher.execute_if_updated
50-
end
51-
end
5237
end
5338
end

‎lib/view_component/engine.rb‎

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -132,7 +132,20 @@ class Engine < Rails::Engine # :nodoc:
132132
end
133133
end
134134

135-
ActiveSupport.on_load(:action_view) { CompileCache.watch_templates(app) }
135+
# Like Rails' own template cache expiry, only watch templates when they
136+
# aren't cached at boot. With template caching on, as in production, no
137+
# hook runs per request, so requests never read ActionView's caching
138+
# class variable, which non-main Ractors can't access.
139+
ActiveSupport.on_load(:action_view) do
140+
next if ActionView::Base.cache_template_loading
141+
142+
template_watcher = nil
143+
144+
app.executor.to_run :before do
145+
template_watcher ||= CompileCache.template_watcher(app.config.file_watcher)
146+
template_watcher.execute_if_updated
147+
end
148+
end
136149

137150
app.reloader.before_class_unload { CompileCache.invalidate! }
138151
end
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
# frozen_string_literal: true
2+
3+
require "bundler/setup"
4+
require "minitest/autorun"
5+
require "minitest/mock"
6+
require "rails"
7+
require "action_controller/railtie"
8+
require "active_support/testing/isolation"
9+
require "view_component"
10+
11+
# Boots a fresh application per test in a forked process, so tests can choose
12+
# their own environment. Runs separately from the sandbox, which boots in test.
13+
class TemplateCachingTest < ActiveSupport::TestCase
14+
include ActiveSupport::Testing::Isolation
15+
16+
def test_executor_checks_for_template_changes_when_templates_are_not_cached
17+
app = boot_app("development")
18+
refute ActionView::Base.cache_template_loading
19+
20+
watcher = Minitest::Mock.new
21+
watcher.expect(:execute_if_updated, nil)
22+
ViewComponent::CompileCache.stub(:template_watcher, watcher) { app.executor.wrap {} }
23+
24+
watcher.verify
25+
end
26+
27+
# Non-main Ractors can't read ActionView::Resolver's class variable on Ruby 4.0,
28+
# so the executor must not read it on every request when templates are cached.
29+
def test_executor_does_nothing_when_templates_are_cached
30+
app = boot_app("production")
31+
assert ActionView::Base.cache_template_loading
32+
33+
reads = 0
34+
ActionView::Resolver.singleton_class.prepend(Module.new do
35+
define_method(:caching?) do
36+
reads += 1
37+
super()
38+
end
39+
end)
40+
ViewComponent::CompileCache.stub(:template_watcher, ->(*) { flunk "templates were watched" }) { app.executor.wrap {} }
41+
42+
assert_equal 0, reads
43+
end
44+
45+
private
46+
47+
def boot_app(env)
48+
Rails.env = env
49+
50+
Class.new(Rails::Application) do
51+
config.root = __dir__
52+
config.eager_load = env == "production"
53+
config.enable_reloading = env == "development"
54+
config.secret_key_base = "test"
55+
config.logger = Logger.new(nil)
56+
end.tap(&:initialize!)
57+
end
58+
end

‎test/sandbox/test/initializers/engine_test.rb‎

Lines changed: 0 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -7,28 +7,4 @@ def test_set_no_duplicate_autoload_paths
77
test_path = "#{Rails.root}/my/components/previews" # set in sandbox/config/application.rb
88
assert_equal 1, ActiveSupport::Dependencies.autoload_paths.count(test_path)
99
end
10-
11-
# Non-main Ractors can't read ActionView::Resolver's class variable on Ruby 4.0,
12-
# so no per-request hook may read it when templates are cached.
13-
def test_watch_templates_registers_no_executor_hook_when_templates_are_cached
14-
app = app_with_new_executor
15-
16-
with_template_caching { ViewComponent::CompileCache.watch_templates(app) }
17-
18-
assert_empty app.executor._run_callbacks
19-
end
20-
21-
def test_watch_templates_registers_an_executor_hook_when_templates_are_not_cached
22-
app = app_with_new_executor
23-
24-
with_new_cache { ViewComponent::CompileCache.watch_templates(app) }
25-
26-
assert_equal 1, app.executor._run_callbacks.count
27-
end
28-
29-
private
30-
31-
def app_with_new_executor
32-
Struct.new(:executor, :config).new(Class.new(ActiveSupport::Executor), Rails.application.config)
33-
end
3410
end

‎test/sandbox/test/integration_test.rb‎

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,33 @@ def test_template_changes_are_reflected_on_new_request_when_cache_template_loadi
8686
end
8787
end
8888

89+
def test_components_are_not_recompiled_on_new_request_when_no_template_changed
90+
with_new_cache do
91+
get "/controller_inline"
92+
MyComponent.__vc_compile
93+
94+
get "/controller_inline"
95+
assert_response :success
96+
assert MyComponent.__vc_compiled?
97+
end
98+
end
99+
100+
def test_components_are_recompiled_on_new_request_when_a_template_is_added
101+
new_template = Rails.root.join("app/components/added_template_component.html.erb")
102+
103+
with_new_cache do
104+
get "/controller_inline"
105+
MyComponent.__vc_compile
106+
107+
File.write(new_template, "<div>Added</div>")
108+
get "/controller_inline"
109+
assert_response :success
110+
refute MyComponent.__vc_compiled?
111+
end
112+
ensure
113+
FileUtils.rm_f(new_template)
114+
end
115+
89116
def test_inherited_template_changes_are_reflected_on_new_request_when_cache_template_loading_is_false
90117
with_new_cache do
91118
get "/inherited_sidecar"

‎test/test_helper.rb‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -133,7 +133,7 @@ def with_generate_sidecar(enabled, &block)
133133
def with_template_caching
134134
executor = Rails.application.executor
135135
watcher_hook = executor._run_callbacks.find do |callback|
136-
callback.filter.is_a?(Proc) && callback.filter.source_location&.first&.end_with?("view_component/compile_cache.rb")
136+
callback.filter.is_a?(Proc) && callback.filter.source_location&.first&.end_with?("view_component/engine.rb")
137137
end&.filter
138138
executor.skip_callback(:run, :before, watcher_hook) if watcher_hook
139139

0 commit comments

Comments
 (0)