Skip to content

Commit ad6a494

Browse files
authored
Refactor compiler and template to use requested details (#2158)
1 parent 86b0149 commit ad6a494

File tree

11 files changed

+194
-105
lines changed

11 files changed

+194
-105
lines changed

docs/CHANGELOG.md

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

2929
*Stephen Nelson*
3030

31+
* BREAKING: Use ActionView's `lookup_context` for picking templates instead of the request format.
32+
33+
3.15 added support for using templates that match the request format, i.e. if `/resource.csv` is requested then
34+
ViewComponents would pick `_component.csv.erb` over `_component.html.erb`.
35+
36+
With this release, the request format is no longer considered and instead ViewComponent will use the Rails logic
37+
for picking the most appropriate template type, i.e. the csv template will be used if it matches the `Accept` header
38+
or because the controller uses a `respond_to` block to pick the response format.
39+
40+
*Stephen Nelson*
41+
3142
* Ensure HTML output safety wrapper is used for all inline templates.
3243

3344
*Joel Hawksley*

lib/view_component/base.rb

Lines changed: 13 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
require "view_component/errors"
1010
require "view_component/inline_template"
1111
require "view_component/preview"
12+
require "view_component/request_details"
1213
require "view_component/slotable"
1314
require "view_component/slotable_default"
1415
require "view_component/template"
@@ -63,6 +64,8 @@ def set_original_view_context(view_context)
6364
self.__vc_original_view_context = view_context
6465
end
6566

67+
using RequestDetails
68+
6669
# Entrypoint for rendering components.
6770
#
6871
# - `view_context`: ActionView context from calling view
@@ -90,13 +93,12 @@ def render_in(view_context, &block)
9093
# For i18n
9194
@virtual_path ||= virtual_path
9295

93-
# For template variants (+phone, +desktop, etc.)
94-
@__vc_variant ||= @lookup_context.variants.first
96+
# Describes the inferred request constraints (locales, formats, variants)
97+
@__vc_requested_details ||= @lookup_context.vc_requested_details
9598

9699
# For caching, such as #cache_if
97100
@current_template = nil unless defined?(@current_template)
98101
old_current_template = @current_template
99-
@current_template = self
100102

101103
if block && defined?(@__vc_content_set_by_with_content)
102104
raise DuplicateContentError.new(self.class.name)
@@ -108,7 +110,7 @@ def render_in(view_context, &block)
108110
before_render
109111

110112
if render?
111-
rendered_template = render_template_for(@__vc_variant, __vc_request&.format&.to_sym).to_s
113+
rendered_template = render_template_for(@__vc_requested_details).to_s
112114

113115
# Avoid allocating new string when output_preamble and output_postamble are blank
114116
if output_preamble.blank? && output_postamble.blank?
@@ -156,7 +158,7 @@ def render_parent_to_string
156158
target_render = self.class.instance_variable_get(:@__vc_ancestor_calls)[@__vc_parent_render_level]
157159
@__vc_parent_render_level += 1
158160

159-
target_render.bind_call(self, @__vc_variant)
161+
target_render.bind_call(self, @__vc_requested_details)
160162
ensure
161163
@__vc_parent_render_level -= 1
162164
end
@@ -267,11 +269,10 @@ def view_cache_dependencies
267269
[]
268270
end
269271

270-
# For caching, such as #cache_if
271-
#
272-
# @private
272+
# Rails expects us to define `format` on all renderables,
273+
# but we do not know the `format` of a ViewComponent until runtime.
273274
def format
274-
@__vc_variant if defined?(@__vc_variant)
275+
nil
275276
end
276277

277278
# The current request. Use sparingly as doing so introduces coupling that
@@ -328,7 +329,7 @@ def content_evaluated?
328329
end
329330

330331
def maybe_escape_html(text)
331-
return text if __vc_request && !__vc_request.format.html?
332+
return text if @current_template && !@current_template.html?
332333
return text if text.blank?
333334

334335
if text.html_safe?
@@ -517,12 +518,12 @@ def inherited(child)
517518
# meaning it will not be called for any children and thus not compile their templates.
518519
if !child.instance_methods(false).include?(:render_template_for) && !child.compiled?
519520
child.class_eval <<~RUBY, __FILE__, __LINE__ + 1
520-
def render_template_for(variant = nil, format = nil)
521+
def render_template_for(requested_details)
521522
# Force compilation here so the compiler always redefines render_template_for.
522523
# This is mostly a safeguard to prevent infinite recursion.
523524
self.class.compile(raise_errors: true, force: true)
524525
# .compile replaces this method; call the new one
525-
render_template_for(variant, format)
526+
render_template_for(requested_details)
526527
end
527528
RUBY
528529
end

lib/view_component/compiler.rb

Lines changed: 42 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,23 @@ def compile(raise_errors: false, force: false)
5555
end
5656
end
5757

58+
# @return all matching compiled templates, in priority order based on the requested details from LookupContext
59+
#
60+
# @param [ActionView::TemplateDetails::Requested] requested_details i.e. locales, formats, variants
61+
def find_templates_for(requested_details)
62+
filtered_templates = @templates.select do |template|
63+
template.details.matches?(requested_details)
64+
end
65+
66+
if filtered_templates.count > 1
67+
filtered_templates.sort_by! do |template|
68+
template.details.sort_key_for(requested_details)
69+
end
70+
end
71+
72+
filtered_templates
73+
end
74+
5875
private
5976

6077
attr_reader :templates
@@ -64,40 +81,25 @@ def define_render_template_for
6481
template.compile_to_component
6582
end
6683

67-
method_body =
68-
if @templates.one?
69-
@templates.first.safe_method_name_call
70-
elsif (template = @templates.find(&:inline?))
71-
template.safe_method_name_call
72-
else
73-
branches = []
74-
75-
@templates.each do |template|
76-
conditional =
77-
if template.inline_call?
78-
"variant&.to_sym == #{template.variant.inspect}"
79-
else
80-
[
81-
template.default_format? ? "(format == #{ViewComponent::Base::VC_INTERNAL_DEFAULT_FORMAT.inspect} || format.nil?)" : "format == #{template.format.inspect}",
82-
template.variant.nil? ? "variant.nil?" : "variant&.to_sym == #{template.variant.inspect}"
83-
].join(" && ")
84-
end
85-
86-
branches << [conditional, template.safe_method_name_call]
87-
end
84+
@component.silence_redefinition_of_method(:render_template_for)
8885

89-
out = branches.each_with_object(+"") do |(conditional, branch_body), memo|
90-
memo << "#{(!memo.present?) ? "if" : "elsif"} #{conditional}\n #{branch_body}\n"
86+
if @templates.one?
87+
template = @templates.first
88+
safe_call = template.safe_method_name_call
89+
@component.define_method(:render_template_for) do |_|
90+
@current_template = template
91+
instance_exec(&safe_call)
92+
end
93+
else
94+
compiler = self
95+
@component.define_method(:render_template_for) do |details|
96+
if (@current_template = compiler.find_templates_for(details).first)
97+
instance_exec(&@current_template.safe_method_name_call)
98+
else
99+
raise MissingTemplateError.new(self.class.name, details)
91100
end
92-
out << "else\n #{templates.find { _1.variant.nil? && _1.default_format? }.safe_method_name_call}\nend"
93101
end
94-
95-
@component.silence_redefinition_of_method(:render_template_for)
96-
@component.class_eval <<-RUBY, __FILE__, __LINE__ + 1
97-
def render_template_for(variant = nil, format = nil)
98-
#{method_body}
99102
end
100-
RUBY
101103
end
102104

103105
def template_errors
@@ -168,15 +170,18 @@ def template_errors
168170

169171
def gather_templates
170172
@templates ||=
171-
begin
173+
if @component.inline_template.present?
174+
[Template::Inline.new(
175+
component: @component,
176+
inline_template: @component.inline_template
177+
)]
178+
else
172179
path_parser = ActionView::Resolver::PathParser.new
173180
templates = @component.sidecar_files(
174181
ActionView::Template.template_handler_extensions
175182
).map do |path|
176183
details = path_parser.parse(path).details
177-
out = Template::File.new(component: @component, path: path, details: details)
178-
179-
out
184+
Template::File.new(component: @component, path: path, details: details)
180185
end
181186

182187
component_instance_methods_on_self = @component.instance_methods(false)
@@ -186,17 +191,10 @@ def gather_templates
186191
).flat_map { |ancestor| ancestor.instance_methods(false).grep(/^call(_|$)/) }
187192
.uniq
188193
.each do |method_name|
189-
templates << Template::InlineCall.new(
190-
component: @component,
191-
method_name: method_name,
192-
defined_on_self: component_instance_methods_on_self.include?(method_name)
193-
)
194-
end
195-
196-
if @component.inline_template.present?
197-
templates << Template::Inline.new(
194+
templates << Template::InlineCall.new(
198195
component: @component,
199-
inline_template: @component.inline_template
196+
method_name: method_name,
197+
defined_on_self: component_instance_methods_on_self.include?(method_name)
200198
)
201199
end
202200

lib/view_component/errors.rb

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,22 @@ def initialize(example)
3838
end
3939
end
4040

41+
class MissingTemplateError < StandardError
42+
MESSAGE =
43+
"No templates for COMPONENT match the request DETAIL.\n\n" \
44+
"To fix this issue, provide a suitable template."
45+
46+
def initialize(component, request_detail)
47+
detail = {
48+
locale: request_detail.locale,
49+
formats: request_detail.formats,
50+
variants: request_detail.variants,
51+
handlers: request_detail.handlers
52+
}
53+
super(MESSAGE.gsub("COMPONENT", component).gsub("DETAIL", detail.inspect))
54+
end
55+
end
56+
4157
class DuplicateContentError < StandardError
4258
MESSAGE =
4359
"It looks like a block was provided after calling `with_content` on COMPONENT, " \
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
# frozen_string_literal: true
2+
3+
module ViewComponent
4+
# LookupContext computes and encapsulates @details for each request
5+
# so that it doesn't need to be recomputed on each partial render.
6+
# This data is wrapped in ActionView::TemplateDetails::Requested and
7+
# used by instances of ActionView::Resolver to choose which template
8+
# best matches the request.
9+
#
10+
# ActionView considers this logic internal to template/partial resolution.
11+
# We're exposing it to the compiler via `refine` so that ViewComponent
12+
# can match Rails' template picking logic.
13+
module RequestDetails
14+
refine ActionView::LookupContext do
15+
# Return an abstraction for matching and sorting available templates
16+
# based on the current lookup context details.
17+
#
18+
# @return ActionView::TemplateDetails::Requested
19+
# @see ActionView::LookupContext#detail_args_for
20+
# @see ActionView::FileSystemResolver#_find_all
21+
def vc_requested_details(user_details = {})
22+
# The hash `user_details` would normally be the standard arguments that
23+
# `render` accepts, but there's currently no mechanism for users to
24+
# provide these when calling render on a ViewComponent.
25+
details, cached = detail_args_for(user_details)
26+
cached || ActionView::TemplateDetails::Requested.new(**details)
27+
end
28+
end
29+
end
30+
end

0 commit comments

Comments
 (0)