Skip to content

Commit 67bb1b6

Browse files
committed
Define TagBuilder methods for void Elements
The `ActionView::Helpers::TagHelper::TagBuilder` class renders HTML elements based on the methods invoked on it. For example, `tag.input` will render an `<input>` element, while `tag.turbo_frame` will render a `<turbo-frame>` element. The magic of the class is rooted in its definition of `#method_missing`. The current implementation bakes-in special treatment of void HTML elements (for example: `<input>`) and self-closing SVG elements (for example: `<use />`). Despite its ahead-of-time knowledge of these element names, calls to corresponding methods `tag.input` and `tag.use` still rely on `#method_missing`. This has performance implications. This commit defines a new `TagBuilder.define_void_element` class method to dynamically define methods for each known element. Then, the class invokes the method for each element name in `HTML_VOID_ELEMENTS` and `SVG_SELF_CLOSING_ELEMENTS`. Calls to `.define_void_element` and `.define_self_closing_element` makr `HTML_VOID_ELEMENTS` and `SVG_SELF_CLOSING_ELEMENTS` unnecessary, so this commit removes those constant definitions. Additionally, this call removes calls to `TagHelper.ensure_valid_html5_tag_name` from the `TagBuilder` defined element methods, since they're known ahead of time to be valid HTML. Instead, only call `ensure_valid_html5_tag_name` from within the `method_missing` calls, since those are determined at runtime and are uncontrolled. By cutting out the reliance on method missing, calls to `TagBuilder` methods (like `tag.input`) become comparable to calls to the `tag` view helper with positional arguments (like `tag(:input)`). ``` ❯ ruby bench.rb Warming up -------------------------------------- tag 73.438k i/100ms tag_builder 79.910k i/100ms Calculating ------------------------------------- tag 732.467k (± 0.9%) i/s - 3.672M in 5.013504s tag_builder 810.981k (± 0.8%) i/s - 4.075M in 5.025632s Comparison: tag_builder: 810981.5 i/s tag: 732467.4 i/s - 1.11x (± 0.00) slower ``` The results were from the following benchmark rendering void (`<input>`) elements: ```ruby # frozen_string_literal: true require "bundler/setup" require "action_view" require "minitest/autorun" require "benchmark/ips" class Foo include ActionView::Helpers end helpers = Foo.new Benchmark.ips do |x| x.report("tag") { helpers.tag("input", value: "foo") } x.report("tag_builder") { helpers.tag.input(value: "foo") } x.compare! end ``` Similar to `tag.input` (a void HTML element), calls to `tag.div` are become somewhat comparable to `tag("div")`: ``` ❯ ruby bench.rb Warming up -------------------------------------- content_tag 59.548k i/100ms tag_builder 51.215k i/100ms Calculating ------------------------------------- content_tag 595.067k (± 0.5%) i/s - 2.977M in 5.003570s tag_builder 505.553k (± 2.2%) i/s - 2.561M in 5.067704s Comparison: content_tag: 595067.5 i/s tag_builder: 505552.6 i/s - 1.18x (± 0.00) slower ``` The following benchmarks were collected to compare `tag("turbo-frame")` (an unknown custom HTML element) and `tag.turbo_frame`: ``` ❯ ruby bench.rb Warming up -------------------------------------- content_tag 56.152k i/100ms tag_builder 49.207k i/100ms Calculating ------------------------------------- content_tag 561.134k (± 0.5%) i/s - 2.808M in 5.003567s tag_builder 491.178k (± 0.3%) i/s - 2.460M in 5.009140s Comparison: content_tag: 561133.8 i/s tag_builder: 491177.7 i/s - 1.14x (± 0.00) slower ```
1 parent 2d271a4 commit 67bb1b6

File tree

2 files changed

+191
-14
lines changed

2 files changed

+191
-14
lines changed

actionview/lib/action_view/helpers/tag_helper.rb

Lines changed: 175 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
# frozen_string_literal: true
22

3+
require "active_support/code_generator"
34
require "active_support/core_ext/enumerable"
45
require "active_support/core_ext/string/output_safety"
6+
require "active_support/core_ext/string/inflections"
57
require "set"
68
require "action_view/helpers/capture_helper"
79
require "action_view/helpers/output_safety_helper"
@@ -46,8 +48,166 @@ class TagBuilder # :nodoc:
4648
include CaptureHelper
4749
include OutputSafetyHelper
4850

49-
HTML_VOID_ELEMENTS = %i(area base br col embed hr img input keygen link meta param source track wbr).to_set
50-
SVG_SELF_CLOSING_ELEMENTS = %i(animate animateMotion animateTransform circle ellipse line path polygon polyline rect set stop use view).to_set
51+
def self.define_element(name, code_generator:, method_name: name.to_s.underscore)
52+
code_generator.define_cached_method(method_name, namespace: :tag_builder) do |batch|
53+
batch.push(<<~RUBY) unless instance_methods.include?(method_name.to_sym)
54+
def #{method_name}(content = nil, escape: true, **options, &block)
55+
tag_string(#{name.inspect}, content, escape: escape, **options, &block)
56+
end
57+
RUBY
58+
end
59+
end
60+
61+
def self.define_void_element(name, code_generator:, method_name: name.to_s.underscore, self_closing: false)
62+
code_generator.define_cached_method(method_name, namespace: :tag_builder) do |batch|
63+
batch.push(<<~RUBY)
64+
def #{method_name}(content = nil, escape: true, **options, &block)
65+
if content || block
66+
tag_string(#{name.inspect}, content, escape: escape, **options, &block)
67+
else
68+
void_tag_string(#{name.inspect}, options, escape, #{self_closing})
69+
end
70+
end
71+
RUBY
72+
end
73+
end
74+
75+
def self.define_self_closing_element(name, **options)
76+
define_void_element(name, self_closing: true, **options)
77+
end
78+
79+
ActiveSupport::CodeGenerator.batch(self, __FILE__, __LINE__) do |code_generator|
80+
define_void_element :area, code_generator: code_generator
81+
define_void_element :base, code_generator: code_generator
82+
define_void_element :br, code_generator: code_generator
83+
define_void_element :col, code_generator: code_generator
84+
define_void_element :embed, code_generator: code_generator
85+
define_void_element :hr, code_generator: code_generator
86+
define_void_element :img, code_generator: code_generator
87+
define_void_element :input, code_generator: code_generator
88+
define_void_element :keygen, code_generator: code_generator
89+
define_void_element :link, code_generator: code_generator
90+
define_void_element :meta, code_generator: code_generator
91+
define_void_element :source, code_generator: code_generator
92+
define_void_element :track, code_generator: code_generator
93+
define_void_element :wbr, code_generator: code_generator
94+
95+
define_self_closing_element :animate, code_generator: code_generator
96+
define_self_closing_element :animateMotion, code_generator: code_generator
97+
define_self_closing_element :animateTransform, code_generator: code_generator
98+
define_self_closing_element :circle, code_generator: code_generator
99+
define_self_closing_element :ellipse, code_generator: code_generator
100+
define_self_closing_element :line, code_generator: code_generator
101+
define_self_closing_element :path, code_generator: code_generator
102+
define_self_closing_element :polygon, code_generator: code_generator
103+
define_self_closing_element :polyline, code_generator: code_generator
104+
define_self_closing_element :rect, code_generator: code_generator
105+
define_self_closing_element :set, code_generator: code_generator
106+
define_self_closing_element :stop, code_generator: code_generator
107+
define_self_closing_element :use, code_generator: code_generator
108+
define_self_closing_element :view, code_generator: code_generator
109+
110+
define_element :a, code_generator: code_generator
111+
define_element :abbr, code_generator: code_generator
112+
define_element :address, code_generator: code_generator
113+
define_element :article, code_generator: code_generator
114+
define_element :aside, code_generator: code_generator
115+
define_element :audio, code_generator: code_generator
116+
define_element :b, code_generator: code_generator
117+
define_element :bdi, code_generator: code_generator
118+
define_element :bdo, code_generator: code_generator
119+
define_element :blockquote, code_generator: code_generator
120+
define_element :body, code_generator: code_generator
121+
define_element :button, code_generator: code_generator
122+
define_element :canvas, code_generator: code_generator
123+
define_element :caption, code_generator: code_generator
124+
define_element :cite, code_generator: code_generator
125+
define_element :code, code_generator: code_generator
126+
define_element :colgroup, code_generator: code_generator
127+
define_element :data, code_generator: code_generator
128+
define_element :datalist, code_generator: code_generator
129+
define_element :dd, code_generator: code_generator
130+
define_element :del, code_generator: code_generator
131+
define_element :details, code_generator: code_generator
132+
define_element :dfn, code_generator: code_generator
133+
define_element :dialog, code_generator: code_generator
134+
define_element :div, code_generator: code_generator
135+
define_element :dl, code_generator: code_generator
136+
define_element :dt, code_generator: code_generator
137+
define_element :em, code_generator: code_generator
138+
define_element :fieldset, code_generator: code_generator
139+
define_element :figcaption, code_generator: code_generator
140+
define_element :figure, code_generator: code_generator
141+
define_element :footer, code_generator: code_generator
142+
define_element :form, code_generator: code_generator
143+
define_element :h1, code_generator: code_generator
144+
define_element :h2, code_generator: code_generator
145+
define_element :h3, code_generator: code_generator
146+
define_element :h4, code_generator: code_generator
147+
define_element :h5, code_generator: code_generator
148+
define_element :h6, code_generator: code_generator
149+
define_element :head, code_generator: code_generator
150+
define_element :header, code_generator: code_generator
151+
define_element :hgroup, code_generator: code_generator
152+
define_element :html, code_generator: code_generator
153+
define_element :i, code_generator: code_generator
154+
define_element :iframe, code_generator: code_generator
155+
define_element :ins, code_generator: code_generator
156+
define_element :kbd, code_generator: code_generator
157+
define_element :label, code_generator: code_generator
158+
define_element :legend, code_generator: code_generator
159+
define_element :li, code_generator: code_generator
160+
define_element :main, code_generator: code_generator
161+
define_element :map, code_generator: code_generator
162+
define_element :mark, code_generator: code_generator
163+
define_element :menu, code_generator: code_generator
164+
define_element :meter, code_generator: code_generator
165+
define_element :nav, code_generator: code_generator
166+
define_element :noscript, code_generator: code_generator
167+
define_element :object, code_generator: code_generator
168+
define_element :ol, code_generator: code_generator
169+
define_element :optgroup, code_generator: code_generator
170+
define_element :option, code_generator: code_generator
171+
define_element :output, code_generator: code_generator
172+
define_element :p, code_generator: code_generator
173+
define_element :picture, code_generator: code_generator
174+
define_element :portal, code_generator: code_generator
175+
define_element :pre, code_generator: code_generator
176+
define_element :progress, code_generator: code_generator
177+
define_element :q, code_generator: code_generator
178+
define_element :rp, code_generator: code_generator
179+
define_element :rt, code_generator: code_generator
180+
define_element :ruby, code_generator: code_generator
181+
define_element :s, code_generator: code_generator
182+
define_element :samp, code_generator: code_generator
183+
define_element :script, code_generator: code_generator
184+
define_element :search, code_generator: code_generator
185+
define_element :section, code_generator: code_generator
186+
define_element :select, code_generator: code_generator
187+
define_element :slot, code_generator: code_generator
188+
define_element :small, code_generator: code_generator
189+
define_element :span, code_generator: code_generator
190+
define_element :strong, code_generator: code_generator
191+
define_element :style, code_generator: code_generator
192+
define_element :sub, code_generator: code_generator
193+
define_element :summary, code_generator: code_generator
194+
define_element :sup, code_generator: code_generator
195+
define_element :table, code_generator: code_generator
196+
define_element :tbody, code_generator: code_generator
197+
define_element :td, code_generator: code_generator
198+
define_element :template, code_generator: code_generator
199+
define_element :textarea, code_generator: code_generator
200+
define_element :tfoot, code_generator: code_generator
201+
define_element :th, code_generator: code_generator
202+
define_element :thead, code_generator: code_generator
203+
define_element :time, code_generator: code_generator
204+
define_element :title, code_generator: code_generator
205+
define_element :tr, code_generator: code_generator
206+
define_element :u, code_generator: code_generator
207+
define_element :ul, code_generator: code_generator
208+
define_element :var, code_generator: code_generator
209+
define_element :video, code_generator: code_generator
210+
end
51211

52212
def initialize(view_context)
53213
@view_context = view_context
@@ -62,24 +222,19 @@ def attributes(attributes)
62222
tag_options(attributes.to_h).to_s.strip.html_safe
63223
end
64224

65-
def p(*arguments, **options, &block)
66-
tag_string(:p, *arguments, **options, &block)
225+
def tag_string(name, content = nil, escape: true, **options, &block)
226+
content = @view_context.capture(self, &block) if block
227+
228+
content_tag_string(name, content, options, escape)
67229
end
68230

69-
def tag_string(name, content = nil, escape: true, **options, &block)
70-
content = @view_context.capture(self, &block) if block_given?
71-
self_closing = SVG_SELF_CLOSING_ELEMENTS.include?(name)
72-
if (HTML_VOID_ELEMENTS.include?(name) || self_closing) && content.nil?
73-
"<#{name.to_s.dasherize}#{tag_options(options, escape)}#{self_closing ? " />" : ">"}".html_safe
74-
else
75-
content_tag_string(name.to_s.dasherize, content || "", options, escape)
76-
end
231+
def void_tag_string(name, options, escape = true, self_closing = false)
232+
"<#{name}#{tag_options(options, escape)}#{self_closing ? " />" : ">"}".html_safe
77233
end
78234

79235
def content_tag_string(name, content, options, escape = true)
80236
tag_options = tag_options(options, escape) if options
81237

82-
TagHelper.ensure_valid_html5_tag_name(name)
83238
if escape
84239
content = ERB::Util.unwrapped_html_escape(content)
85240
end
@@ -163,7 +318,11 @@ def respond_to_missing?(*args)
163318
end
164319

165320
def method_missing(called, *args, **options, &block)
166-
tag_string(called, *args, **options, &block)
321+
name = called.to_s.dasherize
322+
323+
TagHelper.ensure_valid_html5_tag_name(name)
324+
325+
tag_string(name, *args, **options, &block)
167326
end
168327
end
169328

@@ -343,6 +502,8 @@ def tag(name = nil, options = nil, open = false, escape = true)
343502
# <% end -%>
344503
# # => <div class="strong">Hello world!</div>
345504
def content_tag(name, content_or_options_with_block = nil, options = nil, escape = true, &block)
505+
ensure_valid_html5_tag_name(name)
506+
346507
if block_given?
347508
options = content_or_options_with_block if content_or_options_with_block.is_a?(Hash)
348509
tag_builder.content_tag_string(name, capture(&block), options, escape)

actionview/test/template/tag_helper_test.rb

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,15 +30,31 @@ def test_tag_builder_void_tag_with_forced_content
3030
assert_equal "<br>some content</br>", tag.br("some content")
3131
end
3232

33+
def test_tag_builder_void_tag_with_empty_content
34+
assert_equal "<br></br>", tag.br("")
35+
end
36+
3337
def test_tag_builder_self_closing_tag
3438
assert_equal "<svg><use href=\"#cool-icon\" /></svg>", tag.svg { tag.use("href" => "#cool-icon") }
3539
assert_equal "<svg><circle cx=\"5\" cy=\"5\" r=\"5\" /></svg>", tag.svg { tag.circle(cx: "5", cy: "5", r: "5") }
40+
assert_equal "<animateMotion dur=\"10s\" repeatCount=\"indefinite\" />", tag.animate_motion(dur: "10s", repeatCount: "indefinite")
3641
end
3742

3843
def test_tag_builder_self_closing_tag_with_content
3944
assert_equal "<svg><circle><desc>A circle</desc></circle></svg>", tag.svg { tag.circle { tag.desc "A circle" } }
4045
end
4146

47+
def test_tag_builder_defines_methods_to_build_html_elements
48+
assert_respond_to tag, :div
49+
assert_includes tag.public_methods, :div
50+
end
51+
52+
def test_tag_builder_renders_unknown_html_elements
53+
assert_respond_to tag, :turbo_frame
54+
55+
assert_equal "<turbo-frame id=\"rendered\">Rendered</turbo-frame>", tag.turbo_frame("Rendered", id: "rendered")
56+
end
57+
4258
def test_tag_builder_is_singleton
4359
assert_equal tag, tag
4460
end

0 commit comments

Comments
 (0)