From 86541425e6605f360f8b7069d343a90885e43925 Mon Sep 17 00:00:00 2001 From: Cameron Dutro Date: Thu, 13 Jul 2023 11:13:43 -0700 Subject: [PATCH 01/15] Fix render_parent when variants are involved --- lib/view_component/base.rb | 14 ++++++++++++-- ...ender_parent_wrapper_component.html+phone.erb | 3 +++ .../render_parent_wrapper_component.html.erb | 3 +++ .../render_parent_wrapper_component.rb | 4 ++++ .../components/super_component.html+variant.erb | 3 +++ test/sandbox/test/rendering_test.rb | 16 ++++++++++++++++ 6 files changed, 41 insertions(+), 2 deletions(-) create mode 100644 test/sandbox/app/components/render_parent_wrapper_component.html+phone.erb create mode 100644 test/sandbox/app/components/render_parent_wrapper_component.html.erb create mode 100644 test/sandbox/app/components/render_parent_wrapper_component.rb create mode 100644 test/sandbox/app/components/super_component.html+variant.erb diff --git a/lib/view_component/base.rb b/lib/view_component/base.rb index 0f2910b8b..f69f91b0d 100644 --- a/lib/view_component/base.rb +++ b/lib/view_component/base.rb @@ -125,8 +125,18 @@ def render_in(view_context, &block) # # Calls `super`, returning `nil` to avoid rendering the result twice. def render_parent - mtd = @__vc_variant ? "call_#{@__vc_variant}" : "call" - method(mtd).super_method.call + mtd = if @__vc_variant + mtd_variant_name = "call_#{@__vc_variant}" + + if respond_to?(mtd_variant_name) + super_mtd = method(mtd_variant_name).super_method + super_mtd ? super_mtd : nil + end + end + + mtd ||= method(:call).super_method + mtd.call + nil end diff --git a/test/sandbox/app/components/render_parent_wrapper_component.html+phone.erb b/test/sandbox/app/components/render_parent_wrapper_component.html+phone.erb new file mode 100644 index 000000000..a325f3986 --- /dev/null +++ b/test/sandbox/app/components/render_parent_wrapper_component.html+phone.erb @@ -0,0 +1,3 @@ +
+ <%= render(SuperComponent.new) %> +
diff --git a/test/sandbox/app/components/render_parent_wrapper_component.html.erb b/test/sandbox/app/components/render_parent_wrapper_component.html.erb new file mode 100644 index 000000000..d101e0653 --- /dev/null +++ b/test/sandbox/app/components/render_parent_wrapper_component.html.erb @@ -0,0 +1,3 @@ +
+ <%= render(SuperComponent.new) %> +
diff --git a/test/sandbox/app/components/render_parent_wrapper_component.rb b/test/sandbox/app/components/render_parent_wrapper_component.rb new file mode 100644 index 000000000..a6c0c3494 --- /dev/null +++ b/test/sandbox/app/components/render_parent_wrapper_component.rb @@ -0,0 +1,4 @@ +# frozen_string_literal: true + +class RenderParentWrapperComponent < ViewComponent::Base +end diff --git a/test/sandbox/app/components/super_component.html+variant.erb b/test/sandbox/app/components/super_component.html+variant.erb new file mode 100644 index 000000000..c42ebbfb5 --- /dev/null +++ b/test/sandbox/app/components/super_component.html+variant.erb @@ -0,0 +1,3 @@ +
+ <%= render_parent %> +
diff --git a/test/sandbox/test/rendering_test.rb b/test/sandbox/test/rendering_test.rb index 89008eddf..6b69bd0f4 100644 --- a/test/sandbox/test/rendering_test.rb +++ b/test/sandbox/test/rendering_test.rb @@ -969,6 +969,22 @@ def test_inherited_component_calls_super end end + def test_inherited_component_falls_back_to_call_method_when_rendering_variant + with_variant :phone do + render_inline(RenderParentWrapperComponent.new) + end + + assert_selector ".render-parent-wrapper.phone .derived-component .base-component" + end + + def test_inherited_component_falls_back_to_super_call_method_when_rendering_variant + with_variant :variant do + render_inline(SuperComponent.new) + end + + assert_selector ".derived-component.variant .base-component" + end + def test_component_renders_without_trailing_whitespace template = File.read(Rails.root.join("app/components/trailing_whitespace_component.html.erb")) assert template =~ /\s+\z/, "Template does not contain any trailing whitespace" From 9b81c19ce622d3fa5dbf4d3323833a143b409998 Mon Sep 17 00:00:00 2001 From: Cameron Dutro Date: Fri, 14 Jul 2023 11:02:06 -0700 Subject: [PATCH 02/15] Ok, yield seems to work --- Gemfile.lock | 3 +++ lib/view_component/base.rb | 25 +++++++++++------- lib/view_component/compiler.rb | 17 +++++++++++- test/sandbox/app/.DS_Store | Bin 0 -> 6148 bytes .../app/components/level1_component.html.erb | 1 + .../app/components/level1_component.rb | 4 +++ .../level2_component.html+variant.erb | 3 +++ .../app/components/level2_component.html.erb | 3 +++ .../app/components/level2_component.rb | 4 +++ .../level3_component.html+variant.erb | 3 +++ .../app/components/level3_component.html.erb | 3 +++ .../app/components/level3_component.rb | 4 +++ ...er_parent_wrapper_component.html+phone.erb | 3 --- .../render_parent_wrapper_component.html.erb | 3 --- .../render_parent_wrapper_component.rb | 4 --- .../components/super_base_component.html.erb | 1 - .../app/components/super_base_component.rb | 4 --- .../super_component.html+variant.erb | 3 --- .../app/components/super_component.html.erb | 3 --- .../sandbox/app/components/super_component.rb | 4 --- test/sandbox/test/rendering_test.rb | 25 ++++-------------- 21 files changed, 64 insertions(+), 56 deletions(-) create mode 100644 test/sandbox/app/.DS_Store create mode 100644 test/sandbox/app/components/level1_component.html.erb create mode 100644 test/sandbox/app/components/level1_component.rb create mode 100644 test/sandbox/app/components/level2_component.html+variant.erb create mode 100644 test/sandbox/app/components/level2_component.html.erb create mode 100644 test/sandbox/app/components/level2_component.rb create mode 100644 test/sandbox/app/components/level3_component.html+variant.erb create mode 100644 test/sandbox/app/components/level3_component.html.erb create mode 100644 test/sandbox/app/components/level3_component.rb delete mode 100644 test/sandbox/app/components/render_parent_wrapper_component.html+phone.erb delete mode 100644 test/sandbox/app/components/render_parent_wrapper_component.html.erb delete mode 100644 test/sandbox/app/components/render_parent_wrapper_component.rb delete mode 100644 test/sandbox/app/components/super_base_component.html.erb delete mode 100644 test/sandbox/app/components/super_base_component.rb delete mode 100644 test/sandbox/app/components/super_component.html+variant.erb delete mode 100644 test/sandbox/app/components/super_component.html.erb delete mode 100644 test/sandbox/app/components/super_component.rb diff --git a/Gemfile.lock b/Gemfile.lock index 82a702316..15b7abb05 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -329,6 +329,9 @@ DEPENDENCIES jbuilder (~> 2) m (~> 1) minitest (~> 5.18) + net-imap + net-pop + net-smtp pry (~> 0.13) puma (~> 6) rails (~> 7.0.0) diff --git a/lib/view_component/base.rb b/lib/view_component/base.rb index f69f91b0d..ea11b4fa3 100644 --- a/lib/view_component/base.rb +++ b/lib/view_component/base.rb @@ -125,18 +125,23 @@ def render_in(view_context, &block) # # Calls `super`, returning `nil` to avoid rendering the result twice. def render_parent - mtd = if @__vc_variant - mtd_variant_name = "call_#{@__vc_variant}" - - if respond_to?(mtd_variant_name) - super_mtd = method(mtd_variant_name).super_method - super_mtd ? super_mtd : nil - end - end + # There are four scenarios to consider: + # + # 1. Scenario: Self responds to the variant method and so does the parent. + # Behavior: Call the parent's variant method (i.e. call super). - mtd ||= method(:call).super_method - mtd.call + # 2. Scenario: Self responds to the variant method but the parent does not. + # Behavior: Call the parent's #call method. + # 3. Scenario: Self does not respond to the variant method but the parent does. + # Behavior: Call the child's variant method, which is also the parent's variant method + # by way of inheritance. + # + # 4. Scenario: Neither self nor the parent respond to the variant method. + # Behavior: Call the parent's #call method. + # + mtd = @__vc_variant ? "call_#{@__vc_variant}" : "call" + method(mtd).super_method.call nil end diff --git a/lib/view_component/compiler.rb b/lib/view_component/compiler.rb index cfac92617..d535cfa7e 100644 --- a/lib/view_component/compiler.rb +++ b/lib/view_component/compiler.rb @@ -68,14 +68,25 @@ def render_template_for(variant = nil) # Remove existing compiled template methods, # as Ruby warns when redefining a method. method_name = call_method_name(template[:variant]) + unique_method_name = "#{method_name}__#{methodize(component_class.name)}" redefinition_lock.synchronize do component_class.silence_redefinition_of_method(method_name) + component_class.silence_redefinition_of_method(unique_method_name) + # rubocop:disable Style/EvalWithLocation component_class.class_eval <<-RUBY, template[:path], 0 - def #{method_name} + private def #{unique_method_name} #{compiled_template(template[:path])} end + + def #{method_name} + #{unique_method_name} do |msg| + if msg == :parent + capture { super } + end + end + end RUBY # rubocop:enable Style/EvalWithLocation end @@ -93,6 +104,10 @@ def #{method_name} attr_reader :component_class, :redefinition_lock + def methodize(str) + str.gsub("::", "_").underscore + end + def define_render_template_for variant_elsifs = variants.compact.uniq.map do |variant| "elsif variant.to_sym == :'#{variant}'\n #{call_method_name(variant)}" diff --git a/test/sandbox/app/.DS_Store b/test/sandbox/app/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..48333a58e643197b27776a06abead9919b293f75 GIT binary patch literal 6148 zcmeHK%}N6?5T4YkX+`WoP;a?->!B?b^&l*@9=r)FdQj0_TXdmrN_T6~TG{8&H}VC1 z9cPlXAgw1sq|CtNo6Ju_zFqPI0HV=vRRKx>AW;c(1#D&r#Yv|m=RAbMjG-ZhHfjpV zR5m;QBLnp9if{`W9-xcu-802etBom_=TY3!gNE+Mt;CGu z+klh>S-3834TsfwtyWg`+Wx4lhWmT}1 zW_I)|lMceO$SpI#418x`!FEe@{vZAP{{LRYJ!XIzm@5WEzUem`xFmPBPA!hkT7`Op qNZX# literal 0 HcmV?d00001 diff --git a/test/sandbox/app/components/level1_component.html.erb b/test/sandbox/app/components/level1_component.html.erb new file mode 100644 index 000000000..4eaf6c9b1 --- /dev/null +++ b/test/sandbox/app/components/level1_component.html.erb @@ -0,0 +1 @@ +
diff --git a/test/sandbox/app/components/level1_component.rb b/test/sandbox/app/components/level1_component.rb new file mode 100644 index 000000000..a47e7850c --- /dev/null +++ b/test/sandbox/app/components/level1_component.rb @@ -0,0 +1,4 @@ +# frozen_string_literal: true + +class Level1Component < ViewComponent::Base +end diff --git a/test/sandbox/app/components/level2_component.html+variant.erb b/test/sandbox/app/components/level2_component.html+variant.erb new file mode 100644 index 000000000..e70d22fa6 --- /dev/null +++ b/test/sandbox/app/components/level2_component.html+variant.erb @@ -0,0 +1,3 @@ +
+ <%= yield :parent %> +
diff --git a/test/sandbox/app/components/level2_component.html.erb b/test/sandbox/app/components/level2_component.html.erb new file mode 100644 index 000000000..86af3fe9a --- /dev/null +++ b/test/sandbox/app/components/level2_component.html.erb @@ -0,0 +1,3 @@ +
+ <%= yield :parent %> +
diff --git a/test/sandbox/app/components/level2_component.rb b/test/sandbox/app/components/level2_component.rb new file mode 100644 index 000000000..f8d7973ab --- /dev/null +++ b/test/sandbox/app/components/level2_component.rb @@ -0,0 +1,4 @@ +# frozen_string_literal: true + +class Level2Component < Level1Component +end diff --git a/test/sandbox/app/components/level3_component.html+variant.erb b/test/sandbox/app/components/level3_component.html+variant.erb new file mode 100644 index 000000000..882e19869 --- /dev/null +++ b/test/sandbox/app/components/level3_component.html+variant.erb @@ -0,0 +1,3 @@ +
+ <%= yield :parent %> +
diff --git a/test/sandbox/app/components/level3_component.html.erb b/test/sandbox/app/components/level3_component.html.erb new file mode 100644 index 000000000..90c9c8d29 --- /dev/null +++ b/test/sandbox/app/components/level3_component.html.erb @@ -0,0 +1,3 @@ +
+ <%= yield :parent %> +
diff --git a/test/sandbox/app/components/level3_component.rb b/test/sandbox/app/components/level3_component.rb new file mode 100644 index 000000000..c34c13327 --- /dev/null +++ b/test/sandbox/app/components/level3_component.rb @@ -0,0 +1,4 @@ +# frozen_string_literal: true + +class Level3Component < Level2Component +end diff --git a/test/sandbox/app/components/render_parent_wrapper_component.html+phone.erb b/test/sandbox/app/components/render_parent_wrapper_component.html+phone.erb deleted file mode 100644 index a325f3986..000000000 --- a/test/sandbox/app/components/render_parent_wrapper_component.html+phone.erb +++ /dev/null @@ -1,3 +0,0 @@ -
- <%= render(SuperComponent.new) %> -
diff --git a/test/sandbox/app/components/render_parent_wrapper_component.html.erb b/test/sandbox/app/components/render_parent_wrapper_component.html.erb deleted file mode 100644 index d101e0653..000000000 --- a/test/sandbox/app/components/render_parent_wrapper_component.html.erb +++ /dev/null @@ -1,3 +0,0 @@ -
- <%= render(SuperComponent.new) %> -
diff --git a/test/sandbox/app/components/render_parent_wrapper_component.rb b/test/sandbox/app/components/render_parent_wrapper_component.rb deleted file mode 100644 index a6c0c3494..000000000 --- a/test/sandbox/app/components/render_parent_wrapper_component.rb +++ /dev/null @@ -1,4 +0,0 @@ -# frozen_string_literal: true - -class RenderParentWrapperComponent < ViewComponent::Base -end diff --git a/test/sandbox/app/components/super_base_component.html.erb b/test/sandbox/app/components/super_base_component.html.erb deleted file mode 100644 index 95dc2504d..000000000 --- a/test/sandbox/app/components/super_base_component.html.erb +++ /dev/null @@ -1 +0,0 @@ -
diff --git a/test/sandbox/app/components/super_base_component.rb b/test/sandbox/app/components/super_base_component.rb deleted file mode 100644 index 62ab304c3..000000000 --- a/test/sandbox/app/components/super_base_component.rb +++ /dev/null @@ -1,4 +0,0 @@ -# frozen_string_literal: true - -class SuperBaseComponent < ViewComponent::Base -end diff --git a/test/sandbox/app/components/super_component.html+variant.erb b/test/sandbox/app/components/super_component.html+variant.erb deleted file mode 100644 index c42ebbfb5..000000000 --- a/test/sandbox/app/components/super_component.html+variant.erb +++ /dev/null @@ -1,3 +0,0 @@ -
- <%= render_parent %> -
diff --git a/test/sandbox/app/components/super_component.html.erb b/test/sandbox/app/components/super_component.html.erb deleted file mode 100644 index b40543223..000000000 --- a/test/sandbox/app/components/super_component.html.erb +++ /dev/null @@ -1,3 +0,0 @@ -
- <%= render_parent %> -
diff --git a/test/sandbox/app/components/super_component.rb b/test/sandbox/app/components/super_component.rb deleted file mode 100644 index e92084a17..000000000 --- a/test/sandbox/app/components/super_component.rb +++ /dev/null @@ -1,4 +0,0 @@ -# frozen_string_literal: true - -class SuperComponent < SuperBaseComponent -end diff --git a/test/sandbox/test/rendering_test.rb b/test/sandbox/test/rendering_test.rb index 6b69bd0f4..bb2a29731 100644 --- a/test/sandbox/test/rendering_test.rb +++ b/test/sandbox/test/rendering_test.rb @@ -961,28 +961,13 @@ def test_inherited_component_renders_when_lazy_loading end def test_inherited_component_calls_super - render_inline(SuperComponent.new) + render_inline(Level3Component.new) - assert_selector(".base-component", count: 1) - assert_selector(".derived-component", count: 1) do - assert_selector(".base-component", count: 1) - end - end - - def test_inherited_component_falls_back_to_call_method_when_rendering_variant - with_variant :phone do - render_inline(RenderParentWrapperComponent.new) - end - - assert_selector ".render-parent-wrapper.phone .derived-component .base-component" - end - - def test_inherited_component_falls_back_to_super_call_method_when_rendering_variant - with_variant :variant do - render_inline(SuperComponent.new) + assert_selector(".level3-component", count: 1) do |level3| + level3.assert_selector(".level2-component", count: 1) do |level2| + level2.assert_selector(".level1-component", count: 1) + end end - - assert_selector ".derived-component.variant .base-component" end def test_component_renders_without_trailing_whitespace From 7991f70208447caadd8ec1457b754f4cf8b93885 Mon Sep 17 00:00:00 2001 From: Cameron Dutro Date: Fri, 14 Jul 2023 13:44:07 -0700 Subject: [PATCH 03/15] Got it working for variants and inline templates --- lib/view_component/compiler.rb | 59 ++++++++++++++++--- lib/view_component/errors.rb | 8 +++ .../bad_yield_value_component.html.erb | 1 + .../components/bad_yield_value_component.rb | 4 ++ .../app/components/level2_component.html.erb | 2 +- .../app/components/level3_component.html.erb | 2 +- test/sandbox/test/inline_template_test.rb | 39 ++++++++++++ test/sandbox/test/rendering_test.rb | 30 ++++++++-- 8 files changed, 129 insertions(+), 16 deletions(-) create mode 100644 test/sandbox/app/components/bad_yield_value_component.html.erb create mode 100644 test/sandbox/app/components/bad_yield_value_component.rb diff --git a/lib/view_component/compiler.rb b/lib/view_component/compiler.rb index d535cfa7e..ffdf9fd7a 100644 --- a/lib/view_component/compiler.rb +++ b/lib/view_component/compiler.rb @@ -43,15 +43,41 @@ def compile(raise_errors: false, force: false) component_class.validate_collection_parameter! end + unique_superclass_name = methodize(component_class.superclass.name) + if has_inline_template? template = component_class.inline_template + unique_method_name = "call__#{methodize(component_class.name)}" redefinition_lock.synchronize do component_class.silence_redefinition_of_method("call") # rubocop:disable Style/EvalWithLocation - component_class.class_eval <<-RUBY, template.path, template.lineno + component_class.class_eval <<-RUBY, template.path, template.lineno - 1 + private def #{unique_method_name} + if block_given? + #{compiled_inline_template(template)} + else + #{unique_method_name} do |msg| + case msg + when :parent + super_method_name = if @__vc_variant + super_variant_method_name = :"call_\#{@__vc_variant}__#{unique_superclass_name}" + respond_to?(super_variant_method_name, true) ? super_variant_method_name : nil + end + + super_method_name ||= :call__#{unique_superclass_name} + send(super_method_name) + + nil + else + raise UnexpectedTemplateYield.new(msg) + end + end + end + end + def call - #{compiled_inline_template(template)} + #{unique_method_name} end RUBY # rubocop:enable Style/EvalWithLocation @@ -75,17 +101,32 @@ def render_template_for(variant = nil) component_class.silence_redefinition_of_method(unique_method_name) # rubocop:disable Style/EvalWithLocation - component_class.class_eval <<-RUBY, template[:path], 0 + component_class.class_eval <<-RUBY, template[:path], -1 private def #{unique_method_name} - #{compiled_template(template[:path])} + if block_given? + #{compiled_template(template[:path])} + else + #{unique_method_name} do |msg| + case msg + when :parent + super_method_name = if @__vc_variant + super_variant_method_name = :"call_\#{@__vc_variant}__#{unique_superclass_name}" + respond_to?(super_variant_method_name, true) ? super_variant_method_name : nil + end + + super_method_name ||= :call__#{unique_superclass_name} + send(super_method_name) + + nil + else + raise UnexpectedTemplateYield.new(msg) + end + end + end end def #{method_name} - #{unique_method_name} do |msg| - if msg == :parent - capture { super } - end - end + #{unique_method_name} end RUBY # rubocop:enable Style/EvalWithLocation diff --git a/lib/view_component/errors.rb b/lib/view_component/errors.rb index 36f9ee570..49575c930 100644 --- a/lib/view_component/errors.rb +++ b/lib/view_component/errors.rb @@ -220,4 +220,12 @@ def initialize(setter_method_name, setter_name) super(MESSAGE.gsub("SETTER_METHOD_NAME", setter_method_name.to_s).gsub("SETTER_NAME", setter_name.to_s)) end end + + class UnexpectedTemplateYield < StandardError + MESSAGE = "An unexpected value 'YIELDED_VALUE' was yielded inside a component template. Only :parent is allowed." + + def initialize(yielded_value) + super(MESSAGE.gsub("YIELDED_VALUE", yielded_value.inspect)) + end + end end diff --git a/test/sandbox/app/components/bad_yield_value_component.html.erb b/test/sandbox/app/components/bad_yield_value_component.html.erb new file mode 100644 index 000000000..fdeb28d3a --- /dev/null +++ b/test/sandbox/app/components/bad_yield_value_component.html.erb @@ -0,0 +1 @@ +<%= yield :foo %> diff --git a/test/sandbox/app/components/bad_yield_value_component.rb b/test/sandbox/app/components/bad_yield_value_component.rb new file mode 100644 index 000000000..fe2144221 --- /dev/null +++ b/test/sandbox/app/components/bad_yield_value_component.rb @@ -0,0 +1,4 @@ +# frozen_string_literal: true + +class BadYieldValueComponent < ViewComponent::Base +end diff --git a/test/sandbox/app/components/level2_component.html.erb b/test/sandbox/app/components/level2_component.html.erb index 86af3fe9a..2a7a4c490 100644 --- a/test/sandbox/app/components/level2_component.html.erb +++ b/test/sandbox/app/components/level2_component.html.erb @@ -1,3 +1,3 @@ -
+
<%= yield :parent %>
diff --git a/test/sandbox/app/components/level3_component.html.erb b/test/sandbox/app/components/level3_component.html.erb index 90c9c8d29..695db1105 100644 --- a/test/sandbox/app/components/level3_component.html.erb +++ b/test/sandbox/app/components/level3_component.html.erb @@ -1,3 +1,3 @@ -
+
<%= yield :parent %>
diff --git a/test/sandbox/test/inline_template_test.rb b/test/sandbox/test/inline_template_test.rb index 76ce0877b..53366411b 100644 --- a/test/sandbox/test/inline_template_test.rb +++ b/test/sandbox/test/inline_template_test.rb @@ -30,6 +30,9 @@ def initialize(name) class InlineErbSubclassComponent < InlineErbComponent erb_template <<~ERB

Hey, <%= name %>!

+
+ <%= yield :parent %> +
ERB end @@ -76,6 +79,20 @@ def initialize(name) end end + class InlineBadYieldComponent < ViewComponent::Base + erb_template <<~ERB + <%= yield :foo %> + ERB + end + + class InlineComponentDerivedFromComponentSupportingVariants < Level2Component + erb_template <<~ERB +
+ <%= yield :parent %> +
+ ERB + end + test "renders inline templates" do render_inline(InlineErbComponent.new("Fox Mulder")) @@ -112,6 +129,28 @@ def initialize(name) assert_selector("h1", text: "Hey, Fox Mulder!") end + test "child components can render their parent" do + render_inline(InlineErbSubclassComponent.new("Fox Mulder")) + + assert_selector(".parent h1", text: "Hello, Fox Mulder!") + end + + test "inline child component propagates variant to parent" do + with_variant :variant do + render_inline(InlineComponentDerivedFromComponentSupportingVariants.new) + end + + assert_selector ".inline-template .level2-component.variant .level1-component" + end + + test "yielding unexpected value raises error" do + error = assert_raises(ViewComponent::UnexpectedTemplateYield) do + render_inline(InlineBadYieldComponent.new) + end + + assert_equal "An unexpected value ':foo' was yielded inside a component template. Only :parent is allowed.", error.message + end + test "calling template methods multiple times raises an exception" do error = assert_raises ViewComponent::MultipleInlineTemplatesError do Class.new(InlineErbComponent) do diff --git a/test/sandbox/test/rendering_test.rb b/test/sandbox/test/rendering_test.rb index bb2a29731..96bdb5b02 100644 --- a/test/sandbox/test/rendering_test.rb +++ b/test/sandbox/test/rendering_test.rb @@ -960,14 +960,34 @@ def test_inherited_component_renders_when_lazy_loading assert_selector("div", text: "hello, my own template") end - def test_inherited_component_calls_super + def test_child_components_can_render_parent render_inline(Level3Component.new) - assert_selector(".level3-component", count: 1) do |level3| - level3.assert_selector(".level2-component", count: 1) do |level2| - level2.assert_selector(".level1-component", count: 1) - end + assert_selector(".level3-component.base .level2-component.base .level1-component") + end + + def test_variant_propagates_to_parent + with_variant :variant do + render_inline(Level3Component.new) + end + + assert_selector ".level3-component.variant .level2-component.variant .level1-component" + end + + def test_child_components_fall_back_to_default_variant + with_variant :non_existent_variant do + render_inline(Level3Component.new) + end + + assert_selector ".level3-component.base .level2-component.base .level1-component" + end + + def test_yielding_unexpected_value_raises_error + error = assert_raises(ViewComponent::UnexpectedTemplateYield) do + render_inline(BadYieldValueComponent.new) end + + assert_equal "An unexpected value ':foo' was yielded inside a component template. Only :parent is allowed.", error.message end def test_component_renders_without_trailing_whitespace From 0bdd60c3dae4cc6d8ad228b48de40ba58342be0f Mon Sep 17 00:00:00 2001 From: Cameron Dutro Date: Fri, 14 Jul 2023 15:02:00 -0700 Subject: [PATCH 04/15] Refactor a bit --- docs/api.md | 2 + docs/guide/templates.md | 44 +++++++++++ lib/view_component/base.rb | 19 ++--- lib/view_component/compiler.rb | 137 +++++++++++++++------------------ 4 files changed, 111 insertions(+), 91 deletions(-) diff --git a/docs/api.md b/docs/api.md index b2b3f3304..1dda2b510 100644 --- a/docs/api.md +++ b/docs/api.md @@ -102,6 +102,8 @@ Returns HTML that has been escaped by the respective template handler. ### `#render_parent` +DEPRECATED + Subclass components that call `super` inside their template code will cause a double render if they emit the result: diff --git a/docs/guide/templates.md b/docs/guide/templates.md index 073191308..35bcb08e4 100644 --- a/docs/guide/templates.md +++ b/docs/guide/templates.md @@ -117,9 +117,53 @@ end ### Rendering parent templates +Since 3.5.0 +{: .label } + +To render a parent component's template from a subclass' template, use `yield :parent`: + +```erb +<%# my_link_component.html.erb %> +
+ <% yield :parent %> +
+``` + +If the parent supports the current variant, the variant will automatically be rendered. `yield :parent` replaces the deprecated `#render_parent` method, which does not respect variants or multiple levels of inheritance. + +`yield :parent` also works with inline templates: + +```ruby +class MyComponent < ViewComponent::Base + erb_template <<~ERB +
+ <% yield :parent %> +
+ ERB +end +``` + +To render a parent component's template from a `#call` method, call `super`. + +```ruby +class MyComponent < ViewComponent::Base + # "phone" variant + def call_phone + "
#{super}
" + end +end +``` + +`super` will attempt to call the `#call_phone` method on the parent class. If the parent class does not support the "phone" variant, Ruby will raise a `NoMethodError`. Consider using a template and `render :parent` to handle superclass variants automatically. + +### render_parent + Since 2.55.0 {: .label } +Deprecated +{: .label .label-red } + To render a parent component's template from a subclass, call `render_parent`: ```erb diff --git a/lib/view_component/base.rb b/lib/view_component/base.rb index ea11b4fa3..950b43bf1 100644 --- a/lib/view_component/base.rb +++ b/lib/view_component/base.rb @@ -115,6 +115,8 @@ def render_in(view_context, &block) @current_template = old_current_template end + # DEPRECATED + # # Subclass components that call `super` inside their template code will cause a # double render if they emit the result: # @@ -125,21 +127,10 @@ def render_in(view_context, &block) # # Calls `super`, returning `nil` to avoid rendering the result twice. def render_parent - # There are four scenarios to consider: - # - # 1. Scenario: Self responds to the variant method and so does the parent. - # Behavior: Call the parent's variant method (i.e. call super). - - # 2. Scenario: Self responds to the variant method but the parent does not. - # Behavior: Call the parent's #call method. + ViewComponent::Deprecation.deprecation_warning( + "render_parent", "Use `yield :parent` instead." + ) - # 3. Scenario: Self does not respond to the variant method but the parent does. - # Behavior: Call the child's variant method, which is also the parent's variant method - # by way of inheritance. - # - # 4. Scenario: Neither self nor the parent respond to the variant method. - # Behavior: Call the parent's #call method. - # mtd = @__vc_variant ? "call_#{@__vc_variant}" : "call" method(mtd).super_method.call nil diff --git a/lib/view_component/compiler.rb b/lib/view_component/compiler.rb index ffdf9fd7a..2afe8f257 100644 --- a/lib/view_component/compiler.rb +++ b/lib/view_component/compiler.rb @@ -43,45 +43,18 @@ def compile(raise_errors: false, force: false) component_class.validate_collection_parameter! end - unique_superclass_name = methodize(component_class.superclass.name) - if has_inline_template? template = component_class.inline_template - unique_method_name = "call__#{methodize(component_class.name)}" - redefinition_lock.synchronize do - component_class.silence_redefinition_of_method("call") - # rubocop:disable Style/EvalWithLocation - component_class.class_eval <<-RUBY, template.path, template.lineno - 1 - private def #{unique_method_name} - if block_given? - #{compiled_inline_template(template)} - else - #{unique_method_name} do |msg| - case msg - when :parent - super_method_name = if @__vc_variant - super_variant_method_name = :"call_\#{@__vc_variant}__#{unique_superclass_name}" - respond_to?(super_variant_method_name, true) ? super_variant_method_name : nil - end - - super_method_name ||= :call__#{unique_superclass_name} - send(super_method_name) - - nil - else - raise UnexpectedTemplateYield.new(msg) - end - end - end - end + template_info = { + path: template.path, + lineno: template.lineno - 1, + body: compiled_inline_template(template) + } - def call - #{unique_method_name} - end - RUBY - # rubocop:enable Style/EvalWithLocation + define_compiled_template_methods("call", template_info) + redefinition_lock.synchronize do component_class.silence_redefinition_of_method("render_template_for") component_class.class_eval <<-RUBY, __FILE__, __LINE__ + 1 def render_template_for(variant = nil) @@ -91,46 +64,14 @@ def render_template_for(variant = nil) end else templates.each do |template| - # Remove existing compiled template methods, - # as Ruby warns when redefining a method. method_name = call_method_name(template[:variant]) - unique_method_name = "#{method_name}__#{methodize(component_class.name)}" + template_info = { + path: template[:path], + lineno: -1, + body: compiled_template(template[:path]) + } - redefinition_lock.synchronize do - component_class.silence_redefinition_of_method(method_name) - component_class.silence_redefinition_of_method(unique_method_name) - - # rubocop:disable Style/EvalWithLocation - component_class.class_eval <<-RUBY, template[:path], -1 - private def #{unique_method_name} - if block_given? - #{compiled_template(template[:path])} - else - #{unique_method_name} do |msg| - case msg - when :parent - super_method_name = if @__vc_variant - super_variant_method_name = :"call_\#{@__vc_variant}__#{unique_superclass_name}" - respond_to?(super_variant_method_name, true) ? super_variant_method_name : nil - end - - super_method_name ||= :call__#{unique_superclass_name} - send(super_method_name) - - nil - else - raise UnexpectedTemplateYield.new(msg) - end - end - end - end - - def #{method_name} - #{unique_method_name} - end - RUBY - # rubocop:enable Style/EvalWithLocation - end + define_compiled_template_methods(method_name, template_info) end define_render_template_for @@ -145,6 +86,47 @@ def #{method_name} attr_reader :component_class, :redefinition_lock + def define_compiled_template_methods(method_name, template_info) + unique_method_name = "#{method_name}__#{methodize(component_class.name)}" + unique_superclass_name = methodize(component_class.superclass.name) + + redefinition_lock.synchronize do + # Remove existing compiled template methods, + # as Ruby warns when redefining a method. + component_class.silence_redefinition_of_method(method_name) + component_class.silence_redefinition_of_method(unique_method_name) + + component_class.class_eval <<-RUBY, template_info[:path], template_info[:lineno] + private def #{unique_method_name} + if block_given? + #{template_info[:body]} + else + #{unique_method_name} do |msg| + case msg + when :parent + super_method_name = if @__vc_variant + super_variant_method_name = :"call_\#{@__vc_variant}__#{unique_superclass_name}" + respond_to?(super_variant_method_name, true) ? super_variant_method_name : nil + end + + super_method_name ||= :call__#{unique_superclass_name} + send(super_method_name) + + nil + else + raise UnexpectedTemplateYield.new(msg) + end + end + end + end + + def #{method_name} + #{unique_method_name} + end + RUBY + end + end + def methodize(str) str.gsub("::", "_").underscore end @@ -333,11 +315,12 @@ def normalized_variant_name(variant) end def should_compile_superclass? - development? && templates.empty? && !has_inline_template? && - !( - component_class.instance_methods(false).include?(:call) || - component_class.private_instance_methods(false).include?(:call) - ) + development? && templates.empty? && !has_inline_template? && !call_defined? + end + + def call_defined? + component_class.instance_methods(false).include?(:call) || + component_class.private_instance_methods(false).include?(:call) end end end From 4439760e8631950598daa46600ff991abc8885b2 Mon Sep 17 00:00:00 2001 From: Cameron Dutro Date: Mon, 17 Jul 2023 10:53:55 -0700 Subject: [PATCH 05/15] Fix Vale grammar issues; add #render_parent test back in; fix linting issues --- docs/CHANGELOG.md | 4 ++++ docs/guide/templates.md | 4 ++-- lib/view_component/compiler.rb | 2 ++ .../sandbox/app/components/super_base_component.html.erb | 1 + test/sandbox/app/components/super_base_component.rb | 4 ++++ test/sandbox/app/components/super_component.html.erb | 3 +++ test/sandbox/app/components/super_component.rb | 4 ++++ test/sandbox/test/rendering_test.rb | 9 +++++++++ 8 files changed, 29 insertions(+), 2 deletions(-) create mode 100644 test/sandbox/app/components/super_base_component.html.erb create mode 100644 test/sandbox/app/components/super_base_component.rb create mode 100644 test/sandbox/app/components/super_component.html.erb create mode 100644 test/sandbox/app/components/super_component.rb diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index d56b25214..ac9cee1dd 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -14,6 +14,10 @@ nav_order: 5 *Chris Nitsas* +* Introduce `yield :parent` as a replacement for `#render_parent`, which respects variants and deep inheritance hierarchies. + + *Cameron Dutro* + ## 3.4.0 * Avoid including Rails `url_helpers` into `Preview` class when they're not defined. diff --git a/docs/guide/templates.md b/docs/guide/templates.md index 35bcb08e4..6c1f9253d 100644 --- a/docs/guide/templates.md +++ b/docs/guide/templates.md @@ -129,7 +129,7 @@ To render a parent component's template from a subclass' template, use `yield :p
``` -If the parent supports the current variant, the variant will automatically be rendered. `yield :parent` replaces the deprecated `#render_parent` method, which does not respect variants or multiple levels of inheritance. +If the parent supports the current variant, the variant will automatically be rendered. `yield :parent` replaces the deprecated `#render_parent` method, which doesn't respect variants or multiple levels of inheritance. `yield :parent` also works with inline templates: @@ -154,7 +154,7 @@ class MyComponent < ViewComponent::Base end ``` -`super` will attempt to call the `#call_phone` method on the parent class. If the parent class does not support the "phone" variant, Ruby will raise a `NoMethodError`. Consider using a template and `render :parent` to handle superclass variants automatically. +`super` will attempt to call the `#call_phone` method on the parent class. If the parent class doesn't support the "phone" variant, Ruby will raise a `NoMethodError`. Consider using a template and `render :parent` to handle superclass variants automatically. ### render_parent diff --git a/lib/view_component/compiler.rb b/lib/view_component/compiler.rb index 2afe8f257..92592279c 100644 --- a/lib/view_component/compiler.rb +++ b/lib/view_component/compiler.rb @@ -96,6 +96,7 @@ def define_compiled_template_methods(method_name, template_info) component_class.silence_redefinition_of_method(method_name) component_class.silence_redefinition_of_method(unique_method_name) + # rubocop:disable Style/EvalWithLocation component_class.class_eval <<-RUBY, template_info[:path], template_info[:lineno] private def #{unique_method_name} if block_given? @@ -124,6 +125,7 @@ def #{method_name} #{unique_method_name} end RUBY + # rubocop:enable Style/EvalWithLocation end end diff --git a/test/sandbox/app/components/super_base_component.html.erb b/test/sandbox/app/components/super_base_component.html.erb new file mode 100644 index 000000000..95dc2504d --- /dev/null +++ b/test/sandbox/app/components/super_base_component.html.erb @@ -0,0 +1 @@ +
diff --git a/test/sandbox/app/components/super_base_component.rb b/test/sandbox/app/components/super_base_component.rb new file mode 100644 index 000000000..62ab304c3 --- /dev/null +++ b/test/sandbox/app/components/super_base_component.rb @@ -0,0 +1,4 @@ +# frozen_string_literal: true + +class SuperBaseComponent < ViewComponent::Base +end diff --git a/test/sandbox/app/components/super_component.html.erb b/test/sandbox/app/components/super_component.html.erb new file mode 100644 index 000000000..b40543223 --- /dev/null +++ b/test/sandbox/app/components/super_component.html.erb @@ -0,0 +1,3 @@ +
+ <%= render_parent %> +
diff --git a/test/sandbox/app/components/super_component.rb b/test/sandbox/app/components/super_component.rb new file mode 100644 index 000000000..e92084a17 --- /dev/null +++ b/test/sandbox/app/components/super_component.rb @@ -0,0 +1,4 @@ +# frozen_string_literal: true + +class SuperComponent < SuperBaseComponent +end diff --git a/test/sandbox/test/rendering_test.rb b/test/sandbox/test/rendering_test.rb index 96bdb5b02..bff3ce522 100644 --- a/test/sandbox/test/rendering_test.rb +++ b/test/sandbox/test/rendering_test.rb @@ -960,6 +960,15 @@ def test_inherited_component_renders_when_lazy_loading assert_selector("div", text: "hello, my own template") end + def test_render_parent + render_inline(SuperComponent.new) + + assert_selector(".base-component", count: 1) + assert_selector(".derived-component", count: 1) do |derived| + derived.assert_selector(".base-component", count: 1) + end + end + def test_child_components_can_render_parent render_inline(Level3Component.new) From fa6e6725c3693cb658dc03033407fc19418a96f5 Mon Sep 17 00:00:00 2001 From: Cameron Dutro Date: Mon, 17 Jul 2023 13:42:30 -0700 Subject: [PATCH 06/15] Merge functionality instead --- docs/guide/templates.md | 31 ++++----------- lib/view_component/base.rb | 16 +++----- lib/view_component/compiler.rb | 39 ++++++++++--------- lib/view_component/errors.rb | 8 ---- .../bad_yield_value_component.html.erb | 1 - .../components/bad_yield_value_component.rb | 4 -- .../level2_component.html+variant.erb | 2 +- .../app/components/level2_component.html.erb | 2 +- .../level3_component.html+variant.erb | 2 +- .../app/components/level3_component.html.erb | 2 +- test/sandbox/test/inline_template_test.rb | 16 +------- test/sandbox/test/rendering_test.rb | 8 ---- 12 files changed, 37 insertions(+), 94 deletions(-) delete mode 100644 test/sandbox/app/components/bad_yield_value_component.html.erb delete mode 100644 test/sandbox/app/components/bad_yield_value_component.rb diff --git a/docs/guide/templates.md b/docs/guide/templates.md index 6c1f9253d..264fa17ae 100644 --- a/docs/guide/templates.md +++ b/docs/guide/templates.md @@ -117,27 +117,27 @@ end ### Rendering parent templates -Since 3.5.0 +Since 2.55.0 {: .label } -To render a parent component's template from a subclass' template, use `yield :parent`: +To render a parent component's template from a subclass' template, use `#render_parent`: ```erb <%# my_link_component.html.erb %>
- <% yield :parent %> + <%= render_parent %>
``` -If the parent supports the current variant, the variant will automatically be rendered. `yield :parent` replaces the deprecated `#render_parent` method, which doesn't respect variants or multiple levels of inheritance. +If the parent supports the current variant, the variant will automatically be rendered. -`yield :parent` also works with inline templates: +`#render_parent` also works with inline templates: ```ruby class MyComponent < ViewComponent::Base erb_template <<~ERB
- <% yield :parent %> + <%= render_parent %>
ERB end @@ -154,24 +154,7 @@ class MyComponent < ViewComponent::Base end ``` -`super` will attempt to call the `#call_phone` method on the parent class. If the parent class doesn't support the "phone" variant, Ruby will raise a `NoMethodError`. Consider using a template and `render :parent` to handle superclass variants automatically. - -### render_parent - -Since 2.55.0 -{: .label } - -Deprecated -{: .label .label-red } - -To render a parent component's template from a subclass, call `render_parent`: - -```erb -<%# my_link_component.html.erb %> -
- <% render_parent %> -
-``` +`super` will attempt to call the `#call_phone` method on the parent class. If the parent class doesn't support the "phone" variant, Ruby will raise a `NoMethodError`. Consider using a template and `#render_parent` to handle superclass variants automatically. ## Trailing whitespace diff --git a/lib/view_component/base.rb b/lib/view_component/base.rb index 950b43bf1..1a7f1e96b 100644 --- a/lib/view_component/base.rb +++ b/lib/view_component/base.rb @@ -115,25 +115,19 @@ def render_in(view_context, &block) @current_template = old_current_template end - # DEPRECATED - # # Subclass components that call `super` inside their template code will cause a - # double render if they emit the result: + # double render if they emit the result. # # ```erb # <%= super %> # double-renders # <% super %> # does not double-render # ``` # - # Calls `super`, returning `nil` to avoid rendering the result twice. + # `super` also does not consider the current variant. `render_parent` penders the + # parent template considering the current variant and emits the result without + # double-rendering. def render_parent - ViewComponent::Deprecation.deprecation_warning( - "render_parent", "Use `yield :parent` instead." - ) - - mtd = @__vc_variant ? "call_#{@__vc_variant}" : "call" - method(mtd).super_method.call - nil + @__vc_parent_call_block&.call end # Optional content to be returned after the rendered template. diff --git a/lib/view_component/compiler.rb b/lib/view_component/compiler.rb index 92592279c..51db120ec 100644 --- a/lib/view_component/compiler.rb +++ b/lib/view_component/compiler.rb @@ -48,7 +48,7 @@ def compile(raise_errors: false, force: false) template_info = { path: template.path, - lineno: template.lineno - 1, + lineno: template.lineno - 4, body: compiled_inline_template(template) } @@ -67,7 +67,7 @@ def render_template_for(variant = nil) method_name = call_method_name(template[:variant]) template_info = { path: template[:path], - lineno: -1, + lineno: -4, body: compiled_template(template[:path]) } @@ -98,25 +98,26 @@ def define_compiled_template_methods(method_name, template_info) # rubocop:disable Style/EvalWithLocation component_class.class_eval <<-RUBY, template_info[:path], template_info[:lineno] - private def #{unique_method_name} - if block_given? - #{template_info[:body]} + private def #{unique_method_name}(&block) + if block + @__vc_parent_call_block = block + + begin + #{template_info[:body]} + end.tap do + @__vc_parent_call_block = nil + end else - #{unique_method_name} do |msg| - case msg - when :parent - super_method_name = if @__vc_variant - super_variant_method_name = :"call_\#{@__vc_variant}__#{unique_superclass_name}" - respond_to?(super_variant_method_name, true) ? super_variant_method_name : nil - end - - super_method_name ||= :call__#{unique_superclass_name} - send(super_method_name) - - nil - else - raise UnexpectedTemplateYield.new(msg) + #{unique_method_name} do + super_method_name = if @__vc_variant + super_variant_method_name = :"call_\#{@__vc_variant}__#{unique_superclass_name}" + respond_to?(super_variant_method_name, true) ? super_variant_method_name : nil end + + super_method_name ||= :call__#{unique_superclass_name} + send(super_method_name) + + nil end end end diff --git a/lib/view_component/errors.rb b/lib/view_component/errors.rb index 49575c930..36f9ee570 100644 --- a/lib/view_component/errors.rb +++ b/lib/view_component/errors.rb @@ -220,12 +220,4 @@ def initialize(setter_method_name, setter_name) super(MESSAGE.gsub("SETTER_METHOD_NAME", setter_method_name.to_s).gsub("SETTER_NAME", setter_name.to_s)) end end - - class UnexpectedTemplateYield < StandardError - MESSAGE = "An unexpected value 'YIELDED_VALUE' was yielded inside a component template. Only :parent is allowed." - - def initialize(yielded_value) - super(MESSAGE.gsub("YIELDED_VALUE", yielded_value.inspect)) - end - end end diff --git a/test/sandbox/app/components/bad_yield_value_component.html.erb b/test/sandbox/app/components/bad_yield_value_component.html.erb deleted file mode 100644 index fdeb28d3a..000000000 --- a/test/sandbox/app/components/bad_yield_value_component.html.erb +++ /dev/null @@ -1 +0,0 @@ -<%= yield :foo %> diff --git a/test/sandbox/app/components/bad_yield_value_component.rb b/test/sandbox/app/components/bad_yield_value_component.rb deleted file mode 100644 index fe2144221..000000000 --- a/test/sandbox/app/components/bad_yield_value_component.rb +++ /dev/null @@ -1,4 +0,0 @@ -# frozen_string_literal: true - -class BadYieldValueComponent < ViewComponent::Base -end diff --git a/test/sandbox/app/components/level2_component.html+variant.erb b/test/sandbox/app/components/level2_component.html+variant.erb index e70d22fa6..ca3e8fe7b 100644 --- a/test/sandbox/app/components/level2_component.html+variant.erb +++ b/test/sandbox/app/components/level2_component.html+variant.erb @@ -1,3 +1,3 @@
- <%= yield :parent %> + <%= render_parent %>
diff --git a/test/sandbox/app/components/level2_component.html.erb b/test/sandbox/app/components/level2_component.html.erb index 2a7a4c490..ec3815262 100644 --- a/test/sandbox/app/components/level2_component.html.erb +++ b/test/sandbox/app/components/level2_component.html.erb @@ -1,3 +1,3 @@
- <%= yield :parent %> + <%= render_parent %>
diff --git a/test/sandbox/app/components/level3_component.html+variant.erb b/test/sandbox/app/components/level3_component.html+variant.erb index 882e19869..dce97454a 100644 --- a/test/sandbox/app/components/level3_component.html+variant.erb +++ b/test/sandbox/app/components/level3_component.html+variant.erb @@ -1,3 +1,3 @@
- <%= yield :parent %> + <%= render_parent %>
diff --git a/test/sandbox/app/components/level3_component.html.erb b/test/sandbox/app/components/level3_component.html.erb index 695db1105..e9621744f 100644 --- a/test/sandbox/app/components/level3_component.html.erb +++ b/test/sandbox/app/components/level3_component.html.erb @@ -1,3 +1,3 @@
- <%= yield :parent %> + <%= render_parent %>
diff --git a/test/sandbox/test/inline_template_test.rb b/test/sandbox/test/inline_template_test.rb index 53366411b..1990ca9f6 100644 --- a/test/sandbox/test/inline_template_test.rb +++ b/test/sandbox/test/inline_template_test.rb @@ -79,16 +79,10 @@ def initialize(name) end end - class InlineBadYieldComponent < ViewComponent::Base - erb_template <<~ERB - <%= yield :foo %> - ERB - end - class InlineComponentDerivedFromComponentSupportingVariants < Level2Component erb_template <<~ERB
- <%= yield :parent %> + <%= render_parent %>
ERB end @@ -143,14 +137,6 @@ class InlineComponentDerivedFromComponentSupportingVariants < Level2Component assert_selector ".inline-template .level2-component.variant .level1-component" end - test "yielding unexpected value raises error" do - error = assert_raises(ViewComponent::UnexpectedTemplateYield) do - render_inline(InlineBadYieldComponent.new) - end - - assert_equal "An unexpected value ':foo' was yielded inside a component template. Only :parent is allowed.", error.message - end - test "calling template methods multiple times raises an exception" do error = assert_raises ViewComponent::MultipleInlineTemplatesError do Class.new(InlineErbComponent) do diff --git a/test/sandbox/test/rendering_test.rb b/test/sandbox/test/rendering_test.rb index bff3ce522..db68850ed 100644 --- a/test/sandbox/test/rendering_test.rb +++ b/test/sandbox/test/rendering_test.rb @@ -991,14 +991,6 @@ def test_child_components_fall_back_to_default_variant assert_selector ".level3-component.base .level2-component.base .level1-component" end - def test_yielding_unexpected_value_raises_error - error = assert_raises(ViewComponent::UnexpectedTemplateYield) do - render_inline(BadYieldValueComponent.new) - end - - assert_equal "An unexpected value ':foo' was yielded inside a component template. Only :parent is allowed.", error.message - end - def test_component_renders_without_trailing_whitespace template = File.read(Rails.root.join("app/components/trailing_whitespace_component.html.erb")) assert template =~ /\s+\z/, "Template does not contain any trailing whitespace" From d384cfee884f4e034398feaa8f70c0264f834669 Mon Sep 17 00:00:00 2001 From: Blake Williams Date: Tue, 18 Jul 2023 19:47:01 -0400 Subject: [PATCH 07/15] wip --- Gemfile.lock | 3 --- lib/view_component/base.rb | 20 +++++++++++++++++++- lib/view_component/compiler.rb | 29 ++++++++++++++++++++--------- 3 files changed, 39 insertions(+), 13 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index 15b7abb05..82a702316 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -329,9 +329,6 @@ DEPENDENCIES jbuilder (~> 2) m (~> 1) minitest (~> 5.18) - net-imap - net-pop - net-smtp pry (~> 0.13) puma (~> 6) rails (~> 7.0.0) diff --git a/lib/view_component/base.rb b/lib/view_component/base.rb index 1a7f1e96b..e9290fa84 100644 --- a/lib/view_component/base.rb +++ b/lib/view_component/base.rb @@ -127,7 +127,18 @@ def render_in(view_context, &block) # parent template considering the current variant and emits the result without # double-rendering. def render_parent - @__vc_parent_call_block&.call + @__vc_parent_render_level ||= 0 # ensure a good starting value + + begin + target_render = self.class.instance_variable_get(:@__vc_ancestor_calls).reverse[@__vc_parent_render_level] + @__vc_parent_render_level += 1 + + target_render.bind_call(self) + ensure + @__vc_parent_render_level -= 1 + end + + # @__vc_parent_call_block&.call end # Optional content to be returned after the rendered template. @@ -459,6 +470,13 @@ def render_template_for(variant = nil) # Set collection parameter to the extended component child.with_collection_parameter provided_collection_parameter + if instance_methods(false).include?(:render_template_for) + __vc_ancestor_calls = defined?(@__vc_ancestor_calls) ? @__vc_ancestor_calls.dup : [] + + __vc_ancestor_calls.push(instance_method(:render_template_for)) + child.instance_variable_set(:@__vc_ancestor_calls, __vc_ancestor_calls) + end + super end diff --git a/lib/view_component/compiler.rb b/lib/view_component/compiler.rb index 51db120ec..872c2c986 100644 --- a/lib/view_component/compiler.rb +++ b/lib/view_component/compiler.rb @@ -65,13 +65,19 @@ def render_template_for(variant = nil) else templates.each do |template| method_name = call_method_name(template[:variant]) - template_info = { - path: template[:path], - lineno: -4, - body: compiled_template(template[:path]) - } + + redefinition_lock.synchronize do + component_class.silence_redefinition_of_method(method_name) + # rubocop:disable Style/EvalWithLocation + component_class.class_eval <<-RUBY, template[:path], 0 + def #{method_name} + #{compiled_template(template[:path])} + end + RUBY + # rubocop:enable Style/EvalWithLocation + end - define_compiled_template_methods(method_name, template_info) + # define_compiled_template_methods(method_name, template_info) end define_render_template_for @@ -136,15 +142,20 @@ def methodize(str) def define_render_template_for variant_elsifs = variants.compact.uniq.map do |variant| - "elsif variant.to_sym == :'#{variant}'\n #{call_method_name(variant)}" + safe_name = "_call_variant_#{normalized_variant_name(variant)}_#{component_class.name.underscore}" + component_class.define_method("_call_variant_#{normalized_variant_name(variant)}_#{component_class.name.underscore}", component_class.instance_method(call_method_name(variant))) + + "elsif variant.to_sym == :'#{variant}'\n #{safe_name}" end.join("\n") + component_class.define_method("_call_#{component_class.name.underscore.gsub("/", "__")}", component_class.instance_method(:call)) + body = <<-RUBY if variant.nil? - call + _call_#{component_class.name.underscore.gsub("/", "__")} #{variant_elsifs} else - call + _call_#{component_class.name.underscore.gsub("/", "__")} end RUBY From ecffaab7613c074b3c2120326a222c8f04369cd0 Mon Sep 17 00:00:00 2001 From: Cameron Dutro Date: Thu, 20 Jul 2023 14:22:02 -0700 Subject: [PATCH 08/15] Fix tests --- lib/view_component/base.rb | 3 ++- lib/view_component/compiler.rb | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/lib/view_component/base.rb b/lib/view_component/base.rb index e9290fa84..421fb6f9a 100644 --- a/lib/view_component/base.rb +++ b/lib/view_component/base.rb @@ -133,7 +133,8 @@ def render_parent target_render = self.class.instance_variable_get(:@__vc_ancestor_calls).reverse[@__vc_parent_render_level] @__vc_parent_render_level += 1 - target_render.bind_call(self) + target_render.bind_call(self, @__vc_variant) + nil ensure @__vc_parent_render_level -= 1 end diff --git a/lib/view_component/compiler.rb b/lib/view_component/compiler.rb index 872c2c986..d24a0b739 100644 --- a/lib/view_component/compiler.rb +++ b/lib/view_component/compiler.rb @@ -65,7 +65,7 @@ def render_template_for(variant = nil) else templates.each do |template| method_name = call_method_name(template[:variant]) - + redefinition_lock.synchronize do component_class.silence_redefinition_of_method(method_name) # rubocop:disable Style/EvalWithLocation From 314f75de77491c9fe096bcceab9632f67145040c Mon Sep 17 00:00:00 2001 From: Cameron Dutro Date: Fri, 21 Jul 2023 14:54:52 -0700 Subject: [PATCH 09/15] Clean up --- lib/view_component/base.rb | 6 +- lib/view_component/compiler.rb | 83 ++++++----------------- test/sandbox/test/inline_template_test.rb | 2 +- 3 files changed, 23 insertions(+), 68 deletions(-) diff --git a/lib/view_component/base.rb b/lib/view_component/base.rb index 421fb6f9a..98f770720 100644 --- a/lib/view_component/base.rb +++ b/lib/view_component/base.rb @@ -130,7 +130,7 @@ def render_parent @__vc_parent_render_level ||= 0 # ensure a good starting value begin - target_render = self.class.instance_variable_get(:@__vc_ancestor_calls).reverse[@__vc_parent_render_level] + target_render = self.class.instance_variable_get(:@__vc_ancestor_calls)[@__vc_parent_render_level] @__vc_parent_render_level += 1 target_render.bind_call(self, @__vc_variant) @@ -138,8 +138,6 @@ def render_parent ensure @__vc_parent_render_level -= 1 end - - # @__vc_parent_call_block&.call end # Optional content to be returned after the rendered template. @@ -474,7 +472,7 @@ def render_template_for(variant = nil) if instance_methods(false).include?(:render_template_for) __vc_ancestor_calls = defined?(@__vc_ancestor_calls) ? @__vc_ancestor_calls.dup : [] - __vc_ancestor_calls.push(instance_method(:render_template_for)) + __vc_ancestor_calls.unshift(instance_method(:render_template_for)) child.instance_variable_set(:@__vc_ancestor_calls, __vc_ancestor_calls) end diff --git a/lib/view_component/compiler.rb b/lib/view_component/compiler.rb index d24a0b739..c827c0144 100644 --- a/lib/view_component/compiler.rb +++ b/lib/view_component/compiler.rb @@ -46,19 +46,22 @@ def compile(raise_errors: false, force: false) if has_inline_template? template = component_class.inline_template - template_info = { - path: template.path, - lineno: template.lineno - 4, - body: compiled_inline_template(template) - } + redefinition_lock.synchronize do + component_class.silence_redefinition_of_method("call") + # rubocop:disable Style/EvalWithLocation + component_class.class_eval <<-RUBY, template.path, template.lineno + def call + #{compiled_inline_template(template)} + end + RUBY + # rubocop:enable Style/EvalWithLocation - define_compiled_template_methods("call", template_info) + component_class.define_method("_call_#{safe_class_name}", component_class.instance_method(:call)) - redefinition_lock.synchronize do component_class.silence_redefinition_of_method("render_template_for") component_class.class_eval <<-RUBY, __FILE__, __LINE__ + 1 def render_template_for(variant = nil) - call + _call_#{safe_class_name} end RUBY end @@ -76,8 +79,6 @@ def #{method_name} RUBY # rubocop:enable Style/EvalWithLocation end - - # define_compiled_template_methods(method_name, template_info) end define_render_template_for @@ -92,70 +93,22 @@ def #{method_name} attr_reader :component_class, :redefinition_lock - def define_compiled_template_methods(method_name, template_info) - unique_method_name = "#{method_name}__#{methodize(component_class.name)}" - unique_superclass_name = methodize(component_class.superclass.name) - - redefinition_lock.synchronize do - # Remove existing compiled template methods, - # as Ruby warns when redefining a method. - component_class.silence_redefinition_of_method(method_name) - component_class.silence_redefinition_of_method(unique_method_name) - - # rubocop:disable Style/EvalWithLocation - component_class.class_eval <<-RUBY, template_info[:path], template_info[:lineno] - private def #{unique_method_name}(&block) - if block - @__vc_parent_call_block = block - - begin - #{template_info[:body]} - end.tap do - @__vc_parent_call_block = nil - end - else - #{unique_method_name} do - super_method_name = if @__vc_variant - super_variant_method_name = :"call_\#{@__vc_variant}__#{unique_superclass_name}" - respond_to?(super_variant_method_name, true) ? super_variant_method_name : nil - end - - super_method_name ||= :call__#{unique_superclass_name} - send(super_method_name) - - nil - end - end - end - - def #{method_name} - #{unique_method_name} - end - RUBY - # rubocop:enable Style/EvalWithLocation - end - end - - def methodize(str) - str.gsub("::", "_").underscore - end - def define_render_template_for variant_elsifs = variants.compact.uniq.map do |variant| - safe_name = "_call_variant_#{normalized_variant_name(variant)}_#{component_class.name.underscore}" - component_class.define_method("_call_variant_#{normalized_variant_name(variant)}_#{component_class.name.underscore}", component_class.instance_method(call_method_name(variant))) + safe_name = "_call_variant_#{normalized_variant_name(variant)}_#{safe_class_name}" + component_class.define_method(safe_name, component_class.instance_method(call_method_name(variant))) "elsif variant.to_sym == :'#{variant}'\n #{safe_name}" end.join("\n") - component_class.define_method("_call_#{component_class.name.underscore.gsub("/", "__")}", component_class.instance_method(:call)) + component_class.define_method("_call_#{safe_class_name}", component_class.instance_method(:call)) body = <<-RUBY if variant.nil? - _call_#{component_class.name.underscore.gsub("/", "__")} + _call_#{safe_class_name} #{variant_elsifs} else - _call_#{component_class.name.underscore.gsub("/", "__")} + _call_#{safe_class_name} end RUBY @@ -328,6 +281,10 @@ def normalized_variant_name(variant) variant.to_s.gsub("-", "__").gsub(".", "___") end + def safe_class_name + @safe_class_name ||= component_class.name.gsub("::", "_").underscore + end + def should_compile_superclass? development? && templates.empty? && !has_inline_template? && !call_defined? end diff --git a/test/sandbox/test/inline_template_test.rb b/test/sandbox/test/inline_template_test.rb index 1990ca9f6..986db8b6b 100644 --- a/test/sandbox/test/inline_template_test.rb +++ b/test/sandbox/test/inline_template_test.rb @@ -31,7 +31,7 @@ class InlineErbSubclassComponent < InlineErbComponent erb_template <<~ERB

Hey, <%= name %>!

- <%= yield :parent %> + <%= render_parent %>
ERB end From 1ff7b2dedfdff603509000002dfb443f3f6fa2ae Mon Sep 17 00:00:00 2001 From: Cameron Dutro Date: Fri, 21 Jul 2023 14:58:36 -0700 Subject: [PATCH 10/15] Small fixes --- docs/CHANGELOG.md | 2 +- docs/api.md | 8 ++++---- lib/view_component/base.rb | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index ac9cee1dd..1faccbf1a 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -14,7 +14,7 @@ nav_order: 5 *Chris Nitsas* -* Introduce `yield :parent` as a replacement for `#render_parent`, which respects variants and deep inheritance hierarchies. +* Improve implementation of `#render_parent` so it respects variants and deep inheritance hierarchies. *Cameron Dutro* diff --git a/docs/api.md b/docs/api.md index 1dda2b510..846c9f8c2 100644 --- a/docs/api.md +++ b/docs/api.md @@ -102,17 +102,17 @@ Returns HTML that has been escaped by the respective template handler. ### `#render_parent` -DEPRECATED - Subclass components that call `super` inside their template code will cause a -double render if they emit the result: +double render if they emit the result. ```erb <%= super %> # double-renders <% super %> # does not double-render ``` -Calls `super`, returning `nil` to avoid rendering the result twice. +`super` also does not consider the current variant. `render_parent` penders the +parent template considering the current variant and emits the result without +double-rendering. ### `#request` → [ActionDispatch::Request] diff --git a/lib/view_component/base.rb b/lib/view_component/base.rb index 98f770720..01821dc3b 100644 --- a/lib/view_component/base.rb +++ b/lib/view_component/base.rb @@ -123,7 +123,7 @@ def render_in(view_context, &block) # <% super %> # does not double-render # ``` # - # `super` also does not consider the current variant. `render_parent` penders the + # `super` also does not consider the current variant. `render_parent` renders the # parent template considering the current variant and emits the result without # double-rendering. def render_parent From d39e32fa42dc11e54a78004e5df4322ab891c47e Mon Sep 17 00:00:00 2001 From: Cameron Dutro Date: Fri, 21 Jul 2023 15:55:48 -0700 Subject: [PATCH 11/15] Update docs/api.md Co-authored-by: Blake Williams --- docs/api.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/api.md b/docs/api.md index 846c9f8c2..c81629455 100644 --- a/docs/api.md +++ b/docs/api.md @@ -110,7 +110,7 @@ double render if they emit the result. <% super %> # does not double-render ``` -`super` also does not consider the current variant. `render_parent` penders the +`super` also does not consider the current variant. `render_parent` renders the parent template considering the current variant and emits the result without double-rendering. From a701df5925d213c4a0db35e58f22760c93059b4d Mon Sep 17 00:00:00 2001 From: Cameron Dutro Date: Fri, 21 Jul 2023 15:58:13 -0700 Subject: [PATCH 12/15] Fix linting issues --- lib/view_component/base.rb | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/lib/view_component/base.rb b/lib/view_component/base.rb index 01821dc3b..adbe67d95 100644 --- a/lib/view_component/base.rb +++ b/lib/view_component/base.rb @@ -120,10 +120,10 @@ def render_in(view_context, &block) # # ```erb # <%= super %> # double-renders - # <% super %> # does not double-render + # <% super %> # doesn't double-render # ``` # - # `super` also does not consider the current variant. `render_parent` renders the + # `super` also doesn't consider the current variant. `render_parent` renders the # parent template considering the current variant and emits the result without # double-rendering. def render_parent @@ -470,10 +470,10 @@ def render_template_for(variant = nil) child.with_collection_parameter provided_collection_parameter if instance_methods(false).include?(:render_template_for) - __vc_ancestor_calls = defined?(@__vc_ancestor_calls) ? @__vc_ancestor_calls.dup : [] + vc_ancestor_calls = defined?(@__vc_ancestor_calls) ? @__vc_ancestor_calls.dup : [] - __vc_ancestor_calls.unshift(instance_method(:render_template_for)) - child.instance_variable_set(:@__vc_ancestor_calls, __vc_ancestor_calls) + vc_ancestor_calls.unshift(instance_method(:render_template_for)) + child.instance_variable_set(:@__vc_ancestor_calls, vc_ancestor_calls) end super From 04f45c5bd46ebd7ea3478184d1b147dc1e487938 Mon Sep 17 00:00:00 2001 From: Cameron Dutro Date: Fri, 21 Jul 2023 16:01:33 -0700 Subject: [PATCH 13/15] Regen docs --- docs/api.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/api.md b/docs/api.md index c81629455..e40b9b2cc 100644 --- a/docs/api.md +++ b/docs/api.md @@ -107,10 +107,10 @@ double render if they emit the result. ```erb <%= super %> # double-renders -<% super %> # does not double-render +<% super %> # doesn't double-render ``` -`super` also does not consider the current variant. `render_parent` renders the +`super` also doesn't consider the current variant. `render_parent` renders the parent template considering the current variant and emits the result without double-rendering. From 5f2309e2f31b2288d2e13baff3caadc8580d41f1 Mon Sep 17 00:00:00 2001 From: Cameron Dutro Date: Mon, 24 Jul 2023 10:18:09 -0700 Subject: [PATCH 14/15] Update lib/view_component/compiler.rb Co-authored-by: Blake Williams --- lib/view_component/compiler.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/view_component/compiler.rb b/lib/view_component/compiler.rb index c827c0144..72c7367c4 100644 --- a/lib/view_component/compiler.rb +++ b/lib/view_component/compiler.rb @@ -282,7 +282,7 @@ def normalized_variant_name(variant) end def safe_class_name - @safe_class_name ||= component_class.name.gsub("::", "_").underscore + @safe_class_name ||= component_class.name.underscore.gsub("/", "__") end def should_compile_superclass? From 11fdbcfdb2461dde3e063cf29be6ac255e2b4fb1 Mon Sep 17 00:00:00 2001 From: Cameron Dutro Date: Mon, 24 Jul 2023 10:41:34 -0700 Subject: [PATCH 15/15] Add tests for rendering parents from inline templates (i.e. #call methods); add helpful #render_parent_to_string method --- docs/api.md | 13 +++++++++++ docs/guide/templates.md | 20 +++++++++++++---- lib/view_component/base.rb | 14 ++++++++++++ .../app/components/inline_level1_component.rb | 7 ++++++ .../app/components/inline_level2_component.rb | 11 ++++++++++ .../app/components/inline_level3_component.rb | 15 +++++++++++++ test/sandbox/test/rendering_test.rb | 22 +++++++++++++++++++ 7 files changed, 98 insertions(+), 4 deletions(-) create mode 100644 test/sandbox/app/components/inline_level1_component.rb create mode 100644 test/sandbox/app/components/inline_level2_component.rb create mode 100644 test/sandbox/app/components/inline_level3_component.rb diff --git a/docs/api.md b/docs/api.md index e40b9b2cc..a4a820abe 100644 --- a/docs/api.md +++ b/docs/api.md @@ -114,6 +114,19 @@ double render if they emit the result. parent template considering the current variant and emits the result without double-rendering. +### `#render_parent_to_string` + +Renders the parent component to a string and returns it. This method is meant +to be used inside custom #call methods when a string result is desired, eg. + +```ruby +def call + "
#{render_parent_to_string}
" +end +``` + +When rendering the parent inside an .erb template, use `#render_parent` instead. + ### `#request` → [ActionDispatch::Request] The current request. Use sparingly as doing so introduces coupling that diff --git a/docs/guide/templates.md b/docs/guide/templates.md index 264fa17ae..312d192cf 100644 --- a/docs/guide/templates.md +++ b/docs/guide/templates.md @@ -143,19 +143,31 @@ class MyComponent < ViewComponent::Base end ``` -To render a parent component's template from a `#call` method, call `super`. +Finally, `#render_parent` also works inside `#call` methods: + +```ruby +class MyComponent < ViewComponent::Base + def call + content_tag("div") do + render_parent + end + end +end +``` + +When composing `#call` methods, keep in mind that `#render_parent` does not return a string. If a string is desired, call `#render_parent_to_string` instead. For example: ```ruby class MyComponent < ViewComponent::Base # "phone" variant def call_phone - "
#{super}
" + content_tag("div") do + "
#{render_parent_to_string}
" + end end end ``` -`super` will attempt to call the `#call_phone` method on the parent class. If the parent class doesn't support the "phone" variant, Ruby will raise a `NoMethodError`. Consider using a template and `#render_parent` to handle superclass variants automatically. - ## Trailing whitespace Code editors commonly add a trailing newline character to source files in keeping with the Unix standard. Including trailing whitespace in component templates can result in unwanted whitespace in the HTML, eg. if the component is rendered before the period at the end of a sentence. diff --git a/lib/view_component/base.rb b/lib/view_component/base.rb index adbe67d95..ac3256a3c 100644 --- a/lib/view_component/base.rb +++ b/lib/view_component/base.rb @@ -140,6 +140,20 @@ def render_parent end end + # Renders the parent component to a string and returns it. This method is meant + # to be used inside custom #call methods when a string result is desired, eg. + # + # ```ruby + # def call + # "
#{render_parent_to_string}
" + # end + # ``` + # + # When rendering the parent inside an .erb template, use `#render_parent` instead. + def render_parent_to_string + capture { render_parent } + end + # Optional content to be returned after the rendered template. # # @return [String] diff --git a/test/sandbox/app/components/inline_level1_component.rb b/test/sandbox/app/components/inline_level1_component.rb new file mode 100644 index 000000000..bbc996d85 --- /dev/null +++ b/test/sandbox/app/components/inline_level1_component.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class InlineLevel1Component < ViewComponent::Base + def call + content_tag(:div, class: "level1-component") + end +end diff --git a/test/sandbox/app/components/inline_level2_component.rb b/test/sandbox/app/components/inline_level2_component.rb new file mode 100644 index 000000000..bb6378589 --- /dev/null +++ b/test/sandbox/app/components/inline_level2_component.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +class InlineLevel2Component < Level2Component + def call + "
#{render_parent_to_string}
" + end + + def call_variant + "
#{render_parent_to_string}
" + end +end diff --git a/test/sandbox/app/components/inline_level3_component.rb b/test/sandbox/app/components/inline_level3_component.rb new file mode 100644 index 000000000..99e0c4aff --- /dev/null +++ b/test/sandbox/app/components/inline_level3_component.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +class InlineLevel3Component < Level2Component + def call + content_tag(:div, class: "level3-component base") do + render_parent + end + end + + def call_variant + content_tag(:div, class: "level3-component variant") do + render_parent + end + end +end diff --git a/test/sandbox/test/rendering_test.rb b/test/sandbox/test/rendering_test.rb index db68850ed..f9bac0557 100644 --- a/test/sandbox/test/rendering_test.rb +++ b/test/sandbox/test/rendering_test.rb @@ -991,6 +991,28 @@ def test_child_components_fall_back_to_default_variant assert_selector ".level3-component.base .level2-component.base .level1-component" end + def test_child_components_can_render_parent_with_inline_templates + render_inline(InlineLevel3Component.new) + + assert_selector(".level3-component.base .level2-component.base .level1-component") + end + + def test_variant_propagates_to_parent_with_inline_templates + with_variant :variant do + render_inline(InlineLevel3Component.new) + end + + assert_selector ".level3-component.variant .level2-component.variant .level1-component" + end + + def test_child_components_fall_back_to_default_variant_with_inline_templates + with_variant :non_existent_variant do + render_inline(InlineLevel3Component.new) + end + + assert_selector ".level3-component.base .level2-component.base .level1-component" + end + def test_component_renders_without_trailing_whitespace template = File.read(Rails.root.join("app/components/trailing_whitespace_component.html.erb")) assert template =~ /\s+\z/, "Template does not contain any trailing whitespace"