Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Refactor compiler to use ActionView's partial sorting algorithm #2158

Merged
merged 1 commit into from
Jan 16, 2025
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions docs/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -28,6 +28,17 @@

*Stephen Nelson*

* BREAKING: Use ActionView's `lookup_context` for picking templates instead of the request format.
sfnelson marked this conversation as resolved.
Show resolved Hide resolved

3.15 added support for using templates that match the request format, i.e. if `/resource.csv` is requested then

Check failure on line 33 in docs/CHANGELOG.md

GitHub Actions / prose

[vale] reported by reviewdog 🐶 [Microsoft.Foreign] Use 'that is' instead of 'i.e.'. Raw Output: {"message": "[Microsoft.Foreign] Use 'that is' instead of 'i.e.'.", "location": {"path": "docs/CHANGELOG.md", "range": {"start": {"line": 33, "column": 73}}}, "severity": "ERROR"}
ViewComponents would pick `_component.csv.erb` over `_component.html.erb`.

With this release, the request format is no longer considered and instead ViewComponent will use the Rails logic
for picking the most appropriate template type, i.e. the csv template will be used if it matches the `Accept` header

Check failure on line 37 in docs/CHANGELOG.md

GitHub Actions / prose

[vale] reported by reviewdog 🐶 [Microsoft.Foreign] Use 'that is' instead of 'i.e.'. Raw Output: {"message": "[Microsoft.Foreign] Use 'that is' instead of 'i.e.'.", "location": {"path": "docs/CHANGELOG.md", "range": {"start": {"line": 37, "column": 51}}}, "severity": "ERROR"}
or because the controller uses a `respond_to` block to pick the response format.

*Stephen Nelson*

Comment on lines +31 to +41
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
* BREAKING: Use ActionView's `lookup_context` for picking templates instead of the request format.
3.15 added support for using templates that match the request format, i.e. if `/resource.csv` is requested then
ViewComponents would pick `_component.csv.erb` over `_component.html.erb`.
With this release, the request format is no longer considered and instead ViewComponent will use the Rails logic
for picking the most appropriate template type, i.e. the csv template will be used if it matches the `Accept` header
or because the controller uses a `respond_to` block to pick the response format.
*Stephen Nelson*
* BREAKING: Use ActionView's `lookup_context` for picking templates instead of the request format.
`3.15` added support for using templates that match the request format: when`/resource.csv` is requested then ViewComponent would pick `_component.csv.erb` over `_component.html.erb`.
With this release, the request format is no longer considered and instead ViewComponent will use the Rails logic for picking the most appropriate template type. For example, the `csv` template will be used if it matches the `Accept` header or because the controller uses a `respond_to` block to pick the response format.
*Stephen Nelson*

* Ensure HTML output safety wrapper is used for all inline templates.

*Joel Hawksley*
25 changes: 13 additions & 12 deletions lib/view_component/base.rb
Original file line number Diff line number Diff line change
@@ -9,6 +9,7 @@
require "view_component/errors"
require "view_component/inline_template"
require "view_component/preview"
require "view_component/request_details"
require "view_component/slotable"
require "view_component/slotable_default"
require "view_component/template"
@@ -63,6 +64,8 @@ def set_original_view_context(view_context)
self.__vc_original_view_context = view_context
end

using RequestDetails

# Entrypoint for rendering components.
#
# - `view_context`: ActionView context from calling view
@@ -90,13 +93,12 @@ def render_in(view_context, &block)
# For i18n
@virtual_path ||= virtual_path

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

# For caching, such as #cache_if
@current_template = nil unless defined?(@current_template)
old_current_template = @current_template
@current_template = self

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

if render?
rendered_template = render_template_for(@__vc_variant, __vc_request&.format&.to_sym).to_s
rendered_template = render_template_for(@__vc_requested_details).to_s

# Avoid allocating new string when output_preamble and output_postamble are blank
if output_preamble.blank? && output_postamble.blank?
@@ -156,7 +158,7 @@ def render_parent_to_string
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)
target_render.bind_call(self, @__vc_requested_details)
ensure
@__vc_parent_render_level -= 1
end
@@ -267,11 +269,10 @@ def view_cache_dependencies
[]
end

# For caching, such as #cache_if
#
# @private
# Rails expects us to define `format` on all renderables,
# but we do not know the `format` of a ViewComponent until runtime.
def format
@__vc_variant if defined?(@__vc_variant)
nil
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've spent some time investigating why this code returned the variant and I still don't really understand why. It was added during 1.x and it's been refactored numerous times. I don't think this has any meaningful contribution to caching anymore as Rails cache already uses the LookupContext@details ivar as an entry point to caching. Any cache generated from a ViewComponent render would already be keyed by the specific LookupContext details including locale, variants, formats, and handlers.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If that's the case, then let's just remove this method entirely. It's optional anyways: https://github.com/rails/rails/blob/main/actionview/lib/action_view/template/renderable.rb#L26

end

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

def maybe_escape_html(text)
return text if __vc_request && !__vc_request.format.html?
return text if @current_template && !@current_template.html?
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note that current_template is set by render_template_for now, so this logic takes into account the format picked by the renderer (e.g. if the chosen template is json then the before/after strings won't be HTML sanitised).

return text if text.blank?

if text.html_safe?
@@ -517,12 +518,12 @@ def inherited(child)
# meaning it will not be called for any children and thus not compile their templates.
if !child.instance_methods(false).include?(:render_template_for) && !child.compiled?
child.class_eval <<~RUBY, __FILE__, __LINE__ + 1
def render_template_for(variant = nil, format = nil)
def render_template_for(requested_details)
# Force compilation here so the compiler always redefines render_template_for.
# This is mostly a safeguard to prevent infinite recursion.
self.class.compile(raise_errors: true, force: true)
# .compile replaces this method; call the new one
render_template_for(variant, format)
render_template_for(requested_details)
end
RUBY
end
86 changes: 42 additions & 44 deletions lib/view_component/compiler.rb
Original file line number Diff line number Diff line change
@@ -55,6 +55,23 @@ def compile(raise_errors: false, force: false)
end
end

# @return all matching compiled templates, in priority order based on the requested details from LookupContext
#
# @param [ActionView::TemplateDetails::Requested] requested_details i.e. locales, formats, variants
def find_templates_for(requested_details)
filtered_templates = @templates.select do |template|
template.details.matches?(requested_details)
end

if filtered_templates.count > 1
filtered_templates.sort_by! do |template|
template.details.sort_key_for(requested_details)
end
end

filtered_templates
end

private

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

method_body =
if @templates.one?
@templates.first.safe_method_name_call
elsif (template = @templates.find(&:inline?))
template.safe_method_name_call
else
branches = []

@templates.each do |template|
conditional =
if template.inline_call?
"variant&.to_sym == #{template.variant.inspect}"
else
[
template.default_format? ? "(format == #{ViewComponent::Base::VC_INTERNAL_DEFAULT_FORMAT.inspect} || format.nil?)" : "format == #{template.format.inspect}",
template.variant.nil? ? "variant.nil?" : "variant&.to_sym == #{template.variant.inspect}"
].join(" && ")
end

branches << [conditional, template.safe_method_name_call]
end
@component.silence_redefinition_of_method(:render_template_for)

out = branches.each_with_object(+"") do |(conditional, branch_body), memo|
memo << "#{(!memo.present?) ? "if" : "elsif"} #{conditional}\n #{branch_body}\n"
if @templates.one?
template = @templates.first
safe_call = template.safe_method_name_call
@component.define_method(:render_template_for) do |_|
@current_template = template
instance_exec(&safe_call)
end
else
compiler = self
@component.define_method(:render_template_for) do |details|
if (@current_template = compiler.find_templates_for(details).first)
instance_exec(&@current_template.safe_method_name_call)
else
raise MissingTemplateError.new(self.class.name, details)
end
out << "else\n #{templates.find { _1.variant.nil? && _1.default_format? }.safe_method_name_call}\nend"
end

@component.silence_redefinition_of_method(:render_template_for)
@component.class_eval <<-RUBY, __FILE__, __LINE__ + 1
def render_template_for(variant = nil, format = nil)
#{method_body}
end
RUBY
end

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

def gather_templates
@templates ||=
begin
if @component.inline_template.present?
[Template::Inline.new(
component: @component,
inline_template: @component.inline_template
)]
else
path_parser = ActionView::Resolver::PathParser.new
templates = @component.sidecar_files(
ActionView::Template.template_handler_extensions
).map do |path|
details = path_parser.parse(path).details
out = Template::File.new(component: @component, path: path, details: details)

out
Template::File.new(component: @component, path: path, details: details)
end

component_instance_methods_on_self = @component.instance_methods(false)
@@ -186,17 +191,10 @@ def gather_templates
).flat_map { |ancestor| ancestor.instance_methods(false).grep(/^call(_|$)/) }
.uniq
.each do |method_name|
templates << Template::InlineCall.new(
component: @component,
method_name: method_name,
defined_on_self: component_instance_methods_on_self.include?(method_name)
)
end

if @component.inline_template.present?
templates << Template::Inline.new(
templates << Template::InlineCall.new(
component: @component,
inline_template: @component.inline_template
method_name: method_name,
defined_on_self: component_instance_methods_on_self.include?(method_name)
)
end

16 changes: 16 additions & 0 deletions lib/view_component/errors.rb
Original file line number Diff line number Diff line change
@@ -38,6 +38,22 @@ def initialize(example)
end
end

class MissingTemplateError < StandardError
MESSAGE =
"No templates for COMPONENT match the request DETAIL.\n\n" \
"To fix this issue, provide a suitable template."

def initialize(component, request_detail)
detail = {
locale: request_detail.locale,
formats: request_detail.formats,
variants: request_detail.variants,
handlers: request_detail.handlers
}
super(MESSAGE.gsub("COMPONENT", component).gsub("DETAIL", detail.inspect))
end
end

class DuplicateContentError < StandardError
MESSAGE =
"It looks like a block was provided after calling `with_content` on COMPONENT, " \
30 changes: 30 additions & 0 deletions lib/view_component/request_details.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
# frozen_string_literal: true

module ViewComponent
# LookupContext computes and encapsulates @details for each request
# so that it doesn't need to be recomputed on each partial render.
# This data is wrapped in ActionView::TemplateDetails::Requested and
# used by instances of ActionView::Resolver to choose which template
# best matches the request.
#
# ActionView considers this logic internal to template/partial resolution.
# We're exposing it to the compiler via `refine` so that ViewComponent
# can match Rails' template picking logic.
module RequestDetails
refine ActionView::LookupContext do
# Return an abstraction for matching and sorting available templates
# based on the current lookup context details.
#
# @return ActionView::TemplateDetails::Requested
# @see ActionView::LookupContext#detail_args_for
# @see ActionView::FileSystemResolver#_find_all
def vc_requested_details(user_details = {})
# The hash `user_details` would normally be the standard arguments that
# `render` accepts, but there's currently no mechanism for users to
# provide these when calling render on a ViewComponent.
details, cached = detail_args_for(user_details)
cached || ActionView::TemplateDetails::Requested.new(**details)
end
end
end
end
71 changes: 31 additions & 40 deletions lib/view_component/template.rb
Original file line number Diff line number Diff line change
@@ -7,30 +7,14 @@ class Template

attr_reader :details

delegate :format, :variant, to: :details

def initialize(
component:,
details:,
lineno: nil,
path: nil,
method_name: nil
)
delegate :virtual_path, to: :@component
delegate :format, :variant, to: :@details

def initialize(component:, details:, lineno: nil, path: nil)
@component = component
@details = details
@lineno = lineno
@path = path
@method_name = method_name

@call_method_name =
if @method_name
@method_name
else
out = +"call"
out << "_#{normalized_variant_name}" if variant.present?
out << "_#{format}" if format.present? && format != ViewComponent::Base::VC_INTERNAL_DEFAULT_FORMAT
out
end
end

class File < Template
@@ -79,12 +63,9 @@ def initialize(component:, method_name:, defined_on_self:)
variant = method_name.to_s.include?("call_") ? method_name.to_s.sub("call_", "").to_sym : nil
details = ActionView::TemplateDetails.new(nil, nil, nil, variant)

super(
component: component,
details: details,
method_name: method_name
)
super(component: component, details: details)

@call_method_name = method_name
@defined_on_self = defined_on_self
end

@@ -96,31 +77,38 @@ def compile_to_component
@component.define_method(safe_method_name, @component.instance_method(@call_method_name))
end

def safe_method_name_call
m = safe_method_name
proc do
maybe_escape_html(send(m)) do
Kernel.warn("WARNING: The #{self.class} component rendered HTML-unsafe output. " \
"The output will be automatically escaped, but you may want to investigate.")
end
end
end

def defined_on_self?
@defined_on_self
end
end

def compile_to_component
@component.silence_redefinition_of_method(@call_method_name)
@component.silence_redefinition_of_method(call_method_name)

# rubocop:disable Style/EvalWithLocation
@component.class_eval <<-RUBY, @path, @lineno
def #{@call_method_name}
#{compiled_source}
end
@component.class_eval <<~RUBY, @path, @lineno
def #{call_method_name}
#{compiled_source}
end
RUBY
# rubocop:enable Style/EvalWithLocation

@component.define_method(safe_method_name, @component.instance_method(@call_method_name))
end

def safe_method_name_call
return safe_method_name unless inline_call?

"maybe_escape_html(#{safe_method_name}) " \
"{ Kernel.warn('WARNING: The #{@component} component rendered HTML-unsafe output. " \
"The output will be automatically escaped, but you may want to investigate.') } "
m = safe_method_name
proc { send(m) }
end

def requires_compiled_superclass?
@@ -131,16 +119,19 @@ def inline_call?
type == :inline_call
end

def inline?
type == :inline
end

def default_format?
format.nil? || format == ViewComponent::Base::VC_INTERNAL_DEFAULT_FORMAT
end
alias_method :html?, :default_format?

def call_method_name
@call_method_name ||=
["call", (normalized_variant_name if variant.present?), (format unless default_format?)]
.compact.join("_").to_sym
end

def safe_method_name
"_#{@call_method_name}_#{@component.name.underscore.gsub("/", "__")}"
"_#{call_method_name}_#{@component.name.underscore.gsub("/", "__")}"
end

def normalized_variant_name
20 changes: 12 additions & 8 deletions lib/view_component/test_helpers.rb
Original file line number Diff line number Diff line change
@@ -127,11 +127,11 @@ def render_in_view_context(*args, &block)
# end
# ```
#
# @param variant [Symbol] The variant to be set for the provided block.
def with_variant(variant)
# @param variants [Symbol[]] The variants to be set for the provided block.
def with_variant(*variants)
old_variants = vc_test_controller.view_context.lookup_context.variants

vc_test_controller.view_context.lookup_context.variants = variant
vc_test_controller.view_context.lookup_context.variants += variants
yield
ensure
vc_test_controller.view_context.lookup_context.variants = old_variants
@@ -164,9 +164,14 @@ def with_controller_class(klass)
# end
# ```
#
# @param format [Symbol] The format to be set for the provided block.
def with_format(format)
with_request_url("/", format: format) { yield }
# @param formats [Symbol[]] The format(s) to be set for the provided block.
def with_format(*formats)
old_formats = vc_test_controller.view_context.lookup_context.formats

vc_test_controller.view_context.lookup_context.formats = formats
yield
ensure
vc_test_controller.view_context.lookup_context.formats = old_formats
end

# Set the URL of the current request (such as when using request-dependent path helpers):
@@ -196,7 +201,7 @@ def with_format(format)
# @param full_path [String] The path to set for the current request.
# @param host [String] The host to set for the current request.
# @param method [String] The request method to set for the current request.
def with_request_url(full_path, host: nil, method: nil, format: ViewComponent::Base::VC_INTERNAL_DEFAULT_FORMAT)
def with_request_url(full_path, host: nil, method: nil)
old_request_host = vc_test_request.host
old_request_method = vc_test_request.request_method
old_request_path_info = vc_test_request.path_info
@@ -216,7 +221,6 @@ def with_request_url(full_path, host: nil, method: nil, format: ViewComponent::B
vc_test_request.set_header("action_dispatch.request.query_parameters",
Rack::Utils.parse_nested_query(query).with_indifferent_access)
vc_test_request.set_header(Rack::QUERY_STRING, query)
vc_test_request.format = format
yield
ensure
vc_test_request.host = old_request_host
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Hi turbo stream custom!
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Hi turbo stream!
4 changes: 4 additions & 0 deletions test/sandbox/app/components/turbo_stream_format_component.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
# frozen_string_literal: true

class TurboStreamFormatComponent < ViewComponent::Base
end
34 changes: 33 additions & 1 deletion test/sandbox/test/rendering_test.rb
Original file line number Diff line number Diff line change
@@ -15,7 +15,7 @@ def test_render_inline_allocations
ViewComponent::CompileCache.cache.delete(MyComponent)
MyComponent.ensure_compiled

assert_allocations("3.4.0" => 109, "3.3.6" => 115, "3.3.0" => 124, "3.2.6" => 114) do
assert_allocations("3.5.0" => 104, "3.4.1" => 107, "3.3.6" => 107, "3.2.6" => 105) do
render_inline(MyComponent.new)
end

@@ -190,6 +190,14 @@ def test_renders_component_with_variant
end
end

def test_renders_component_with_multiple_variants
with_variant :app, :phone do
render_inline(VariantsComponent.new)

assert_text("Phone")
end
end

def test_renders_component_with_variant_containing_a_dash
with_variant :"mini-watch" do
render_inline(VariantsComponent.new)
@@ -1198,6 +1206,20 @@ def test_with_format
end
end

def test_with_format_missing
with_format(:xml) do
exception =
assert_raises ViewComponent::MissingTemplateError do
render_inline(MultipleFormatsComponent.new)
end

assert_includes(
exception.message,
"No templates for MultipleFormatsComponent match the request"
)
end
end

def test_localised_component
render_inline(LocalisedComponent.new)

@@ -1209,4 +1231,14 @@ def test_request_param

assert_text("foo")
end

def test_turbo_stream_format_custom_variant
with_format(:turbo_stream, :html) do
with_variant(:custom) do
render_inline(TurboStreamFormatComponent.new)

assert_text("Hi turbo stream custom!")
end
end
end
end