-
Notifications
You must be signed in to change notification settings - Fork 21.4k
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
Pass render options and block to calls to #render_in
#50623
base: main
Are you sure you want to change the base?
Conversation
The support for blocks was somewhat incidental here. My main goal was to support passing @joelhawksley (and @BlakeWilliams, since you've authored #45432) does this change support behavior that's desirable to ViewComponents? |
Thanks for adding block support! I lost that one in my TODO's a while back since I've been pretty busy lately. I'm not positive the locals changes as-is would help us much since we expect all data to be passed in to components via the constructor, but also with the new API it's no longer clear to me when to pass something as a local vs constructor argument. e.g. render(Greeting.new(name: "Fox Mulder"), name: "Dana Scully")) I think the API changes make that valid code, but it's not obvious to me which argument would take precedence or how Greeting should respond to locals from a ViewComponent perspective (we use Coming at this with a relatively heavy ViewComponent bias, I could see an API that delegates to the constructor making sense, maybe like: render(Greeting.new(name: "local"))
render(Greeting, name: "local") # Equivalent to the above since Greeting has a `render_in` instance_method. `name:` is passed to the constructor All of that has a heavy ViewComponent bias of course, but I'm curious if you had any particular use-cases for the locals changes in mind, ViewComponent or otherwise? |
I can relate to that! Since ViewComponent was the pilot tool for driving out
With access to the options, that'd be possible as a class-method: class ViewComponent::Base
def self.render_in(view_context, locals: {}, **options, &block)
view_context.render renderable: new(locals, &block), **options
end
def render_in(view_context, **, &)
# render without :locals or &block
end
end
class Greeting < ViewComponent::Base
# ...
end
render Greeting, name: "local" do
"from a block"
end
# the abbreviated form above is equivalent to the long-form below
render renderable: Greeting, locals: { name: "local" } do
"from a block"
end Since
At the moment, the main motivating use case is internal to Rails. I'm in the process of exploring an Action Text branch that adds support for editors other than Trix. Incidentally, one part of the code I'm exploring re-structuring involves the ActionText::ContentHelper helper module, namely the rails/actiontext/app/helpers/action_text/content_helper.rb Lines 43 to 55 in b294a25
It's a
The implementation is fairly concerned with checking whether or not the I'm imagining a world where each editor adapter (ckeditor5, tinymce, etc) have an opportunity to subclass class ActionText::Attachment
def to_attachable_partial_path
# to be overridden
to_partial_path
end
def render_in(view_context, **options)
view_context.render options.with_defaults(
partial: to_attachable_partial_path,
object: self,
as: model_name.element
)
end
end That's a circumstance where the constructor arguments to the object and the values passed into the Outside of that use case, I'm imagining other scenarios where an application might want to build renderable objects (maybe even ViewComponents) prior to their render. I don't have a concrete example handy, but I'm imagining a component that yields itself into another component without knowledge of how the receiving component would want to treat it. |
@BlakeWilliams if this change is accepted, I'm also exploring adding a default The idea there would be that any Active Model or Active Record instance would provide a seam for applications to take over control of rendering (for example, mapping a |
e55d1bc
to
0ba4642
Compare
def render(context, *args) | ||
@renderable.render_in(context) | ||
def render(context, locals) | ||
@renderable.render_in(context, formats: @formats, locals: locals, &@block) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is a breaking change for those objets. We probably need to deprecate the old signature and tell people to upgrade their objects
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@rafaelfranca I could also see Rails checking the arity?
Regardless, it'd be a huge bummer to have a breaking change like this. I'd really hope we could avoid going down that route.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yeah, I was thinking on doing the arity check to show the deprecation.
Why would it be a bummer if we deprecated the old signature? The old code would work, showing a deprecation, and if the library changed to the new one, as the arguments are optional, things would just work in old versions of Rails. In fact you can release a new version of a library right now with the new signature.
end | ||
|
||
def format | ||
@renderable.format | ||
@renderable.try(:format) || Array(@formats).first |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Why are we trying to call the format now?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@rafaelfranca this is related to #50622.
When #render_in
was introduced, there wasn't any public documentation citing that in addition to #render_in
, objects would also need to define #format
.
The TestRenderable
in the test suite defines it (and, for example, I think ViewComponent::Base
also defines it). It's called internal to Action View, so it's part of the public API despite the fact that it wasn't part of any of the published documentation.
https://github.com/rails/rails/pull/50622/files#diff-70344644d049724d12c9a0c2c387dd1ea31663fbfad4c6a1acdd49efc3cb9512R20 is actually the source of the Object#try
change. The motivation behind that change was that since it was newly documented, prior implementers of #render_in
could get by without defining #format
. The absence of the method means that TemplateRenderable#format
returns nil
, and somewhere down the chain the content gets treated as :html
by default.
Since this change makes more of the options available to the #render_in
call, it could be useful for the caller to decide how to render itself (as HTML, JSON, etc) based on that newly available information.
If the Object#try
doesn't feel correct, would we be able to change this interface to accept the :formats
available from the render
call as arguments?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@rafaelfranca based on this discussion, I've made two changes:
- omit support for
render formats: ...
from this PR - deprecation warning for
@renderable
objects that don't respond to#format
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
deprecation warning for @Renderable objects that don't respond to #format
Why we need that?
e6a401d
to
6276610
Compare
case @renderable.method(:render_in).arity | ||
when 1 | ||
ActionView.deprecator.warn <<~WARN | ||
#render_in signatures without options are deprecated. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@rafaelfranca @joelhawksley I've added a deprecation here along with test coverage. I struggled to convey a more detailed deprecation message. If you're able, please share any suggestions you have.
6276610
to
8a1754a
Compare
cb1b21b
to
b554fd3
Compare
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think these examples could be improved, but I'm not entirely sure how.
In my mind, there's a few points worth highlighting:
#render_in
and the:renderable
key as an alternative to:partial
is novel in its own right.- The object that defines
#render_in
can decide on its own how to mix and match its own internal state with the state provided at therender
call site through the:locals
options - The object that defines
#render_in
can also decide how to mix the block passed torender
- The object can decide which format to render into
Prior to this commit, 1. is only point mentioned at all.
With the proposed changes, 2. poses an interesting opportunity. I consider it similar to React's split between State and Props. Maybe there's an Avatar
object that manages its own internal state like the URL and alt text, but accepts overrides from :locals
like :size
:
class Avatar
include ActiveModel::Model
include ActiveModel::Attributes
attribute :url, :string
attribute :alt, :string
attribute :size, :string, default: "medium"
def render_in(view_context, **options)
# ...
end
end
avatar = Avatar.new(url: "https://example.com/avatar.jpg", alt: "A smiling person")
render avatar
# => <img src="https://example.com/avatar.jpg" alt="A smiling person" class="avatar avatar--medium">
render avatar, size: "small"
# => <img src="https://example.com/avatar.jpg" alt="A smiling person" class="avatar avatar--small">
Block-centric rendering objects like ViewComponent::Base
could benefit from 3. by forwarding the render
call's block to its own constructor. Prior to this commit, calls like this wouldn't behave like you'd expect (as highlighted by #45432):
# ignores the block
render MyComponent.new do
"Content"
end
# ignores the block
render(MyComponent.new) do
"Content"
end
# captures the block
render(MyComponent.new do
"Content"
end)
Support for determining the templating format (4.) is potentially desirable for objects that want to support multiple formats (like HTML, JSON, etc.). Unfortunately, that support might require more broad changes, and need to be incorporated in a subsequent PR. At the moment, it isn't clear the precedent between:
view_context.lookup_context.formats
- the
:formats
option passed torender
- the
#format
method defined by the same object as#render_in
. At this point in time, the#format
call would lack access to both theview_context
and the renderoptions
There's even potential for applications (or Rails itself) to extend models to know how to render themselves, if they want more control than what Action View provides out of the box:
class ApplicationRecord < ActiveRecord::Base
# Renders the object into an Action View context.
#
# # app/models/person.rb
# class Person < ApplicationRecord
# end
#
# # app/views/people/_person.html.erb
# <p><%= person.name %></p>
#
# person = Person.new name: "Ralph"
#
# render(person) # => "<p>Ralph</p>
# render(renderable: person) # => "<p>Ralph</p>
def render_in(view_context, **options, &block)
view_context.render(partial: to_partial_path, object: self, **options, &block)
end
end
class Person < ApplicationRecord
end
render Person.new(name: "Renders as a Partial")
class Article < ApplicationRecord
def render_in(view_context, ...)
view_context.render ArticleComponent.new(name:)
end
end
render Article.new(name: "Renders as a ViewComponent")
159b549
to
1ba6b9f
Compare
f58cedd
to
03f89f2
Compare
@renderable.format | ||
else | ||
ActionView.deprecator.warn <<~WARN | ||
#{@renderable.inspect} must respond to `#format` to be renderable |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Documentation in a bunch of places on this PR says format
is optional, here we are telling it is required. Which is the right requirement? If we need format, why?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I agree with you, the documentation should be specific about the fact that #format
must be implemented. I'll make those changes now.
I've added this deprecation because the requirement for #format
was never mentioned prior to https://github.com/rails/rails/pull/50622/files.
Is requiring that #format
be implemented considered a breaking change, since there might be apps that have #render_in
objects that don't define #format
?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
On the other hand, the code does not current require that #format
be implemented thanks to https://github.com/rails/rails/pull/50622/files#r1445897182.
We could continue to treat #format
as optional, in which case the deprecation would not be necessary, and the code could remain @renderable.try(:format)
.
For what it's worth, implementers of #render_in
could also define #format
and return nil
, and the current implementation would still work.
Is that preferable over a deprecation?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@rafaelfranca thank you for clarifying.
Since #50622 made that optional, I will change the documentation to reinforce that it is optional, then remove the deprecation.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
👍🏽
57f3ce8
to
f8e3912
Compare
def test_render_renderable_does_not_mask_nomethoderror_from_within_render_in | ||
def test_render_renderable_does_not_mask_nameerror_from_within_render_in | ||
renderable = Object.new | ||
renderable.define_singleton_method(:render_in) { |*| nil.foo } | ||
renderable.define_singleton_method(:render_in) { |*| nil.method(:foo) } | ||
|
||
assert_raises NoMethodError, match: "undefined method `foo' for nil" do | ||
assert_raises NameError, match: "undefined method `foo' for class `NilClass'" do |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I don't think this should change. Presumably, we will remove the arity check when we remove the deprecated code, and go back to rescuing NoMethodError
. If we forget to also change this test back, we will lose test coverage.
Conversely, raising NoMethodError
in this test can exercise both rescue NameError
and rescue NoMethodError
.
f8e3912
to
83d3205
Compare
7dac0ab
to
8f14f10
Compare
8f14f10
to
39af464
Compare
I wouldn't want us to take that stance since that creates a pretty awkward API for consumers since there's two supported paths but only one is correct. I chatted with the rest of the ViewComponent team and I think we're in agreement that this API change is fine, but it will require us to make changes in ViewComponent to properly support it (which I'm somewhat excited about). If this change is approved we'll need to update the library to support both paths: render(MyComponent, name: "Fox Mulder")
render(MyComponent.new(name: "Fox Mulder")) I'm excited because the first example above could be used to enable component authors to omit an So tl;dr is that this change seems fine (if not good) from a ViewComponent perspective. 👍 |
39af464
to
1d390d1
Compare
1d390d1
to
4161fe4
Compare
def render(context, locals) | ||
options = | ||
if @renderable.method(:render_in).arity == 1 | ||
ActionView.deprecator.warn <<~WARN | ||
Action View support for #render_in without options is deprecated. | ||
|
||
Change #render_in to accept keyword arguments. | ||
WARN | ||
|
||
{} | ||
else | ||
{ locals: locals } | ||
end | ||
|
||
@renderable.render_in(context, **options, &@block) | ||
rescue NameError |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@jonathanhefner since you helped me workshop the NameError
handling below specific to NameError
instances that stem from #render_in
and only #render_in
, I'm curious if a similar approach would be safe for ArgumentError
handling:
def render(context, locals) | |
options = | |
if @renderable.method(:render_in).arity == 1 | |
ActionView.deprecator.warn <<~WARN | |
Action View support for #render_in without options is deprecated. | |
Change #render_in to accept keyword arguments. | |
WARN | |
{} | |
else | |
{ locals: locals } | |
end | |
@renderable.render_in(context, **options, &@block) | |
rescue NameError | |
def render(context, locals) | |
@renderable.render_in(context, locals: locals, &@block) | |
rescue ArgumentError | |
if @renderable.method(:render_in).arity == 1 | |
ActionView.deprecator.warn <<~WARN | |
Action View support for #render_in without options is deprecated. | |
Change #render_in to accept keyword arguments. | |
WARN | |
@renderable.render_in(context, &@block) | |
else | |
raise | |
end | |
rescue NameError |
The motivation here is to optimize adherents to the interface to avoid slowing the happy path down with conditionals. Does that feel like a good trade-off?
4161fe4
to
94926db
Compare
465e84a
to
98f1705
Compare
98f1705
to
56a1e49
Compare
56a1e49
to
97a7a97
Compare
77867f2
to
cd57c45
Compare
cd57c45
to
3b2546e
Compare
Closes [rails#45432][] Support for objects that respond to `#render_in` was introduced in [rails#36388][] and [rails#37919][]. Those implementations assume that the instance will all the context it needs to render itself. That assumption doesn't account for call-site arguments like `locals: { ... }` or a block. This commit expands support for rendering with a `:renderable` option to incorporate locals and blocks. For example: ```ruby class Greeting def render_in(view_context, **options, &block) if block view_context.render plain: block.call else case Array(options[:formats]).first when :json view_context.render json: { greeting: "Hello, World!" } else view_context.render **options, inline: "<%= Hello, <%= name %>!" end end end def format :html end end render(Greeting.new) # => "Hello, World!" render(Greeting.new, name: "Local") # => "Hello, Local!" render(Greeting.new) { "Hello, Block!" } # => "Hello, Block!" render(renderable: Greeting.new, formats: :json) # => "{\"greeting\":\"Hello, World!\"}" ``` Since existing tools depend on the `#render_in(view_context)` signature without options, this commit deprecates that signature in favor of one that accepts options and a block. [rails#45432]: rails#45432 [rails#36388]: rails#36388 [rails#37919]: rails#37919
3b2546e
to
7f2d39e
Compare
Motivation / Background
Closes #45432
Support for objects that respond to
#render_in
was introduced in #36388 and #37919. Those implementations assume that the instance will all the context it needs to render itself. That assumption doesn't account for call-site arguments likelocals: { ... }
or a block.Detail
This commit expands support for rendering with a
:renderable
option to incorporate locals and blocks. For example:Since existing tools depend on the
#render_in(view_context)
signaturewithout options, this commit deprecates that signature in favor of one
that accepts options and a block.
Checklist
Before submitting the PR make sure the following are checked:
[Fix #issue-number]