Skip to content

Commit ddc2447

Browse files
Fix duplicate template error bug (#2121)
* Fix duplicate template error bug This change fixes the bug reported where a component will report that it has both a template and a `call` method defined despite only having an HTML template. I was able to reproduce this bug by pulling in Avo (like reported in the initial bug report) and running `vegeta` (to trigger parallel requests since I had a hunch this was a race condition): ``` echo "GET http://localhost:3000/avo/resources/cars/1" | vegeta attack -duration=2s ``` This reliably reproduced the error: ``` ActionView::Template::Error (Template file and inline render method found for Avo::CoverPhotoComponent. There can only be a template file or inline render method per component. Template file and inline render method found for variant '' in Avo::CoverPhotoComponent. There can only be a template file or inline render method per variant. Templates: ``` I added _a lot_ of debug logs to understand the race condition that I thought was occurring, and realized that multiple threads are calling `gather_templates` _and_ mutating the `@templates` array at the same.When looking at the old compiler code and realized that this likely isn't new behavior. This led the investigation towards how we collect and surface errors or otherwise might modify templates. It turns out there's a difference in the new and old compiler code after the refactor: ```ruby \# old def template_errors @__vc_template_errors ||= \# new def gather_template_errors(raise_errors) errors = [] ``` _We're not memoizing the errors like we used to_. This is more correct behavior, but explains how a race condition would make this error case much more difficult to occur in older versions of the compiler. This change brings us back to the old behavior by memoizing the errors we collect in `gather_template_errors` but the `@templates` ivar is still being mutated. I don't want to change _too much_ in this PR, but a subsequent change might be wrapping the entire `compile` method in the `redefinition_lock` (and renaming it to `compile_lock`) to avoid similar issues in the future. I did not include a test because it's dfficult to reproduce this race condition reliably in the test environment. I believe this _should_ close out #2114 * Fix tests, raise errors for compiled components with errors * There isn't always a default template when there are errors. * Make standard happy ugh * Remove debug code * Fail compilation when errors present * Revert default template change * Add informative comment * Add changelog --------- Co-authored-by: Joel Hawksley <[email protected]>
1 parent f4b5e18 commit ddc2447

File tree

4 files changed

+62
-64
lines changed

4 files changed

+62
-64
lines changed

docs/CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,10 @@ nav_order: 5
2828

2929
## 3.16.0
3030

31+
* Fix development mode race condition that caused an invalid duplicate template error.
32+
33+
*Blake Williams*
34+
3135
* Add template information to multiple template error messages.
3236

3337
*Joel Hawksley*

lib/view_component/compiler.rb

Lines changed: 58 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,12 @@ def compile(raise_errors: false, force: false)
3030
@component.superclass.compile(raise_errors: raise_errors)
3131
end
3232

33-
return if gather_template_errors(raise_errors).any?
33+
if template_errors.present?
34+
raise TemplateError.new(template_errors) if raise_errors
35+
36+
# this return is load bearing, and prevents the component from being considered "compiled?"
37+
return false
38+
end
3439

3540
if raise_errors
3641
@component.validate_initialization_parameters!
@@ -98,70 +103,70 @@ def render_template_for(variant = nil, format = nil)
98103
end
99104
end
100105

101-
def gather_template_errors(raise_errors)
102-
errors = []
106+
def template_errors
107+
@_template_errors ||= begin
108+
errors = []
103109

104-
errors << "Couldn't find a template file or inline render method for #{@component}." if @templates.empty?
110+
errors << "Couldn't find a template file or inline render method for #{@component}." if @templates.empty?
105111

106-
# We currently allow components to have both an inline call method and a template for a variant, with the
107-
# inline call method overriding the template. We should aim to change this in v4 to instead
108-
# raise an error.
109-
@templates.reject(&:inline_call?)
110-
.map { |template| [template.variant, template.format] }
111-
.tally
112-
.select { |_, count| count > 1 }
113-
.each do |tally|
114-
variant, this_format = tally.first
112+
# We currently allow components to have both an inline call method and a template for a variant, with the
113+
# inline call method overriding the template. We should aim to change this in v4 to instead
114+
# raise an error.
115+
@templates.reject(&:inline_call?)
116+
.map { |template| [template.variant, template.format] }
117+
.tally
118+
.select { |_, count| count > 1 }
119+
.each do |tally|
120+
variant, this_format = tally.first
115121

116-
variant_string = " for variant `#{variant}`" if variant.present?
117-
118-
errors << "More than one #{this_format.upcase} template found#{variant_string} for #{@component}. "
119-
end
122+
variant_string = " for variant `#{variant}`" if variant.present?
120123

121-
default_template_types = @templates.each_with_object(Set.new) do |template, memo|
122-
next if template.variant
124+
errors << "More than one #{this_format.upcase} template found#{variant_string} for #{@component}. "
125+
end
123126

124-
memo << :template_file if !template.inline_call?
125-
memo << :inline_render if template.inline_call? && template.defined_on_self?
127+
default_template_types = @templates.each_with_object(Set.new) do |template, memo|
128+
next if template.variant
126129

127-
memo
128-
end
130+
memo << :template_file if !template.inline_call?
131+
memo << :inline_render if template.inline_call? && template.defined_on_self?
129132

130-
if default_template_types.length > 1
131-
errors <<
132-
"Template file and inline render method found for #{@component}. " \
133-
"There can only be a template file or inline render method per component."
134-
end
133+
memo
134+
end
135135

136-
# If a template has inline calls, they can conflict with template files the component may use
137-
# to render. This attempts to catch and raise that issue before run time. For example,
138-
# `def render_mobile` would conflict with a sidecar template of `component.html+mobile.erb`
139-
duplicate_template_file_and_inline_call_variants =
140-
@templates.reject(&:inline_call?).map(&:variant) &
141-
@templates.select { _1.inline_call? && _1.defined_on_self? }.map(&:variant)
142-
143-
unless duplicate_template_file_and_inline_call_variants.empty?
144-
count = duplicate_template_file_and_inline_call_variants.count
145-
146-
errors <<
147-
"Template #{"file".pluralize(count)} and inline render #{"method".pluralize(count)} " \
148-
"found for #{"variant".pluralize(count)} " \
149-
"#{duplicate_template_file_and_inline_call_variants.map { |v| "'#{v}'" }.to_sentence} " \
150-
"in #{@component}. There can only be a template file or inline render method per variant."
151-
end
136+
if default_template_types.length > 1
137+
errors <<
138+
"Template file and inline render method found for #{@component}. " \
139+
"There can only be a template file or inline render method per component."
140+
end
152141

153-
@templates.select(&:variant).each_with_object(Hash.new { |h, k| h[k] = Set.new }) do |template, memo|
154-
memo[template.normalized_variant_name] << template.variant
155-
memo
156-
end.each do |_, variant_names|
157-
next unless variant_names.length > 1
142+
# If a template has inline calls, they can conflict with template files the component may use
143+
# to render. This attempts to catch and raise that issue before run time. For example,
144+
# `def render_mobile` would conflict with a sidecar template of `component.html+mobile.erb`
145+
duplicate_template_file_and_inline_call_variants =
146+
@templates.reject(&:inline_call?).map(&:variant) &
147+
@templates.select { _1.inline_call? && _1.defined_on_self? }.map(&:variant)
148+
149+
unless duplicate_template_file_and_inline_call_variants.empty?
150+
count = duplicate_template_file_and_inline_call_variants.count
151+
152+
errors <<
153+
"Template #{"file".pluralize(count)} and inline render #{"method".pluralize(count)} " \
154+
"found for #{"variant".pluralize(count)} " \
155+
"#{duplicate_template_file_and_inline_call_variants.map { |v| "'#{v}'" }.to_sentence} " \
156+
"in #{@component}. There can only be a template file or inline render method per variant."
157+
end
158158

159-
errors << "Colliding templates #{variant_names.sort.map { |v| "'#{v}'" }.to_sentence} found in #{@component}."
160-
end
159+
@templates.select(&:variant).each_with_object(Hash.new { |h, k| h[k] = Set.new }) do |template, memo|
160+
memo[template.normalized_variant_name] << template.variant
161+
memo
162+
end.each do |_, variant_names|
163+
next unless variant_names.length > 1
161164

162-
raise TemplateError.new(errors, @templates) if errors.any? && raise_errors
165+
errors << "Colliding templates #{variant_names.sort.map { |v| "'#{v}'" }.to_sentence} found in #{@component}."
166+
end
163167

164-
errors
168+
errors
169+
end
165170
end
166171

167172
def gather_templates

lib/view_component/errors.rb

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -20,12 +20,6 @@ class TemplateError < StandardError
2020
def initialize(errors, templates = nil)
2121
message = errors.join("\n")
2222

23-
if templates
24-
message << "\n"
25-
message << "Templates:\n"
26-
message << templates.map(&:inspect).join("\n")
27-
end
28-
2923
super(message)
3024
end
3125
end

test/sandbox/test/rendering_test.rb

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -512,11 +512,6 @@ def test_raise_error_when_variant_template_file_and_inline_variant_call_exist
512512
end
513513
end
514514

515-
assert_includes(
516-
error.message,
517-
"ViewComponent::Template:"
518-
)
519-
520515
assert_includes(
521516
error.message,
522517
"Template file and inline render method found for variant 'phone' in " \

0 commit comments

Comments
 (0)