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
Introduce support for 3rd-party component frameworks #36388
Conversation
merge master
2¢ on Template architecture … Without having used this, my estimation is that it would be nice to have an # components
app/components/foo.rb
app/components/billing/total.rb
# corresponding views
app/views/components/_foo.html.erb
app/views/components/billing/_total.html.erb Then, a method we could overwrite to make it dynamic would be super cool class MyComponent < ActionView::Component::Base
def partial_name
super + "-#{object.status}"
end
end app/views/components/_my_component-pending.html.erb
app/views/components/_my_component-active.html.erb |
@compiled = true | ||
|
||
instance = new(*args) | ||
instance.content = view_context.capture(&block) if block_given? |
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'd like it if we could pass this in to new
rather than using a setter. I know it's to do with the superclass not matching signatures, but I wanted to comment with that anyway. Using a setter like this will complicate downstream uses because they'll need to know to call the setter. On the flip side, making it a required parameter to initialize
will teach people it's required because they won't be able to construct the object without it. Plus you don't even need to read the docs to figure that out! 😊
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.
Interesting. I'm not opposed to that approach, but I wonder how it might look for components that don't render content
.
Right now they just ignore the nil
content
accessor, but if we were always passing a content
argument wouldn't they need to explicitly no-op that argument? Like so:
def initialize(_content:); end
|
||
module ActionView | ||
class Component < ActionView::Base | ||
include ActiveModel::Validations |
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.
Do we want this by default? Validating all components will punish performance of components that have no validations. If most components require validation, it probably makes sense. I just don't have a good feeling of the requirement.
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.
Maybe one more layer called class ApplicationComponent < ActionView::Component
?
That will be the place to specify template handler as well as including ActiveModel::Validations
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.
My gut says it would be best to measure the impact of having validations enabled by default.
If it's not much overhead, I think this would be a nice default.
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.
@skyksandr I 💯agree about having an ApplicationComponent
. We already follow this pattern internally and have found it useful.
Speaking about variants: Inline templates and/or Sinatra stylePros: markup and methods close to each other If you're not familiar with Sinatra's inline templates: class OurComponent
def hello_message
"Hello, world!"
end
...
end
__END__
@@ template
<div class="title"><%= hello_message %></div> We can even drop class OurComponent; ...; end
__END__
<div class="title"><%= hello_message %></div> SidecarI like the sidecar approach, would only ask to consider placing files as close in the directory structure as possible, like:
or
|
I really like @skyksandr's proposal of:
Seems like it would make packaging and distribution easy. I imagine you could have the same directory structure inside a gem just inside a |
To me, the API that komponent implements feels a bit more Railsy: <%= component 'button', text: 'My button' %> Namely, using a method other than <%= component :section, "Some Label" do %>
Some content.
<% end %> |
Thanks for the feedback folks! Since posting this PR, we've found ourselves wanting the template to be in a separate file. Personally, I prefer @skyksandr's suggestion of:
As it feels a little heavy-handed to have one folder per component, especially if components can still define their templates inline in the same file.
One of the advantages to the current approach of passing a class name ( More broadly, whether we use the existing Do we view this new architecture as something that should be thought of as a part of the existing Rails rendering functionality, or something entirely new? I feel like integrating with the existing view rendering mental model would make this new architecture easier to reason about for other developers. |
@hayesr one of the optimizations the currently proposed architecture gives us is the ability to easily precompile the ERB ahead of time, something that is currently difficult to do with existing Rails templates due to how formats/locals/etc can vary. I'm curious what the use case would be for having separate templates for different Perhaps we could find a middle ground where we allowed dynamic template lookup, but pre-compiled all the templates that matched the name of the component ahead of time? @tenderlove discussed this issue, and how we currently work around it at GitHub by always referencing fully qualified template paths, in his RailsConf keynote this year: https://youtu.be/8Dld5kFWGCc?t=1857 |
I absolutely agree that the performance benefits are a major selling point. But I think we could retain those benefits with a component registry. It could be a simple Hash (or HWIA) that's populated on app startup. It might also be a good place to trigger template pre-compilation. |
@joelhawksley I would defer to the optimization. I was thinking about something I'm working on, a "status widget" similar to the GitHub issue badge, but with more potential for branching logic. And I prefer distinct templates to one template with a lot of —I don't think a middle ground is necessary. Probably my use case would be better served by switching components rather than templates. Now I've got new ideas 😁What happens when one Component inherits from another? Does the template path work like it does with Controllers? (I would vote yes please) For example one might expect that templates defined by To the question of 1-folder-per-component, would it kill the pre-compilation to make it possible to override the directory but not the template name? I agree that it's a little heavy to have a folder for every component, but I think that |
I also kinda like the idea of a flat structure in If components each had their own directories, would the class definition of the component look something like |
To go with the analogy of container/presentational components, how would the container-prefixed components handle fetching? Or is this left up to the controller? If so, wouldn't embedding components inside I mean this only in the sense that having a type of component that delegates behavior to the controller and is used to render presentational components seems to me very different than the React ecosystem where every component is effectively view and controller bundled together, all the way down. If, on the other hand, the container components are in fact views that fetch and manage their own state, wouldn't that be pushing the boundaries of what it means to be a view and perhaps need at least a directory promotion outside of "all components are views"? |
Very fond of the idea, it opens up a lot of power in Rails. One thing I'd be curious about, would it be possible to drop the <%= render(TestComponent, title: "my title" ) do %>
Hello, World!
<% end %> Some ideas: <!-- Class-based -->
<%= TestComponent.new(title: 'something') do %>
Hello World!
<% end %>
<!-- Class-based, array accessor style -->
<%= TestComponent[title: 'something'] do %>
Hello World!
<% end %> The reason I ask is because if they retain that style it may make it more intuitive to have nested components: <%= GridComponent[title: 'something'] do %>
<%= Tile[name: 'something', image: 'src'] %>
<% end %> Then in a file: def tiles
@tiles.map { |attrs| Tile[attrs] }
end
def self.template
<<~'erb'
<div><%= tiles %></div>
erb
end It could also potentially encapsulate some of the testing behavior from inferring the def test_render_component
assert_equal(
%(<span title="my title">Hello, World!</span>),
TestComponent[title: "my title"] { "Hello, World!" }.to_html
)
end Though stylistically this gets a bit towards more of what one would see in a PORO-oriented design, just a few ideas and musings. |
I like that
Given a component class such as We could also make it configurable whether the methods are mixed into the view context directly (which has a larger potential for naming collisions with other methods/locals) or onto a more granular scope where they could be called on an explicit receiver (which could come with a short alias), e.g. <%= c.pull_request_badge state: issue.pull_request.state.to_sym, is_draft: issue.pull_request.draft? %> This pseudo-custom-element syntax was appealing to our team, so we started using it a lot in our rails views. |
We were thinking about distribution, too. I could imagine we may also want to distribute CSS (or SCSS) with the component, and potentially even associated javascript (to progressively enhance the component, e.g. with an associated stimulus controller). With sprockets, gems can distribute CSS/JS in |
I'm really digging this PR so far. I want to echo @rmacklin 's comment about bundling assets—in particular, I could see a serious use case here where the template format is something that could be parsed client-side as well, in which case a bundled Stimulus controller could import the template via Webpack and update a component client-side with the same template the server-rendered component uses. (The "holy grail" as it were…) As it is, I often find myself building a "component" as either a server-rendered partial OR a Stimulus controller + HTML template on the client, and that approach doesn't feel as Rails-y as I would like. But even a basic first pass at this PR would be awesome. |
I was having the exact same thoughts/dreams (having previously used mustache for shared client/server templates), but worried it might be getting too far out there. Hearing someone else say it is at least a little reassuring, heh |
Components in Rails is the most desirable feature I'd like to see. Thank you very much for making that effort and bringing that topic to the surface, @joelhawksley. Rails still has its power, and server-rendered apps solve many use cases. I think, for 80% apps, using SPA is overkill and Rails has its place under the sun and will have it for a long time. Also, now, when there is Stimulus which is elegant, simple yet powerful, I'm excited to see that the community wants to move in that direction. If Rails has a right componentisation approach, it will make my experience much better. My Pain Points
Well, the point is one - low transparency. It could be the result of my poor abilities to control, but I think Rails can make this work for us. At the end of the day, Rails puts developers' experience first. How I'd like to see it solvedIn my ideal world, I'd like to see my components defined with related JS, CSS, images, other assets and template in one place. The ruby part of it seems to be unnecessary and should be optional. In my views, I will have templates related to controller actions, but the sole purpose of these will be to use existing components to build that particular page. Like that: # app/views/pages/home.slim
= c("layout/main--wide") do
= c("home/hero")
= c("home/cta", user: @current_user)
= c("home/how")
= c("home/facts") Ideally, it shouldn't have references any JS or css classes and only declares a page structure. In component, I will have my template, JS, CSS:
We can assume that everything inside the component directory is needed. And the really cool thing to do would be to add some magic in it. When we are building assets, we know router endpoints, and for each endpoint, we can find related view and generate components tree. Based on the tree and config, we can define rules to build our packs declaratively: pack path: /^admin/, name: :admin
pack path: /^public/, name: :public
pack path: /^internal/, name: :internal Then, during assets building, it will check all the routes starting with In runtime, it will serve a pack depending on the path. It's not impossible, is it? PS. I spent some time playing with the approach taken by https://evilmartians.com/chronicles/evil-front-part-1, and as I know, it was inspired by komponent gem. I got rid of the Sprocket pipeline altogether, have a template, css, js (stimulusjs controllers), images located together. It is working very well. The downside of it is that I need to manually provide all the dependencies for JS, CSS, and set up webpack properly (it was painful). I can make a public example of the approach if there is interest. |
I assume that we'd want to support a |
this is a great proposal as a starting point, what I would personally like to see in so
|
Just FYI regarding the presentational/container component approach by Dan Abramov, the linked blogpost starts with a disclaimer:
I see how this pattern could've been useful for naming components in your app. OTOH, I think it should be possible to write appropriate naming guidelines without directly mentioning the concept of presentational & container components so that people don't go overboard with them like they did in Redux. |
Yes please. Eg. HAML is quite popular. Napkin math: downloads since late 2017: haml / rails = 7M / 45M = 20% of community uses haml (+/- the usual concerns about download counts) |
actionview/CHANGELOG.md
Outdated
* Fix `select_tag` so that it doesn't change `options` when `include_blank` is present. | ||
|
||
*Younes SERRAJ* | ||
|
||
|
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 new line was intentional 😄
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.
Oops! Resolved!
…der_in` Co-authored-by: Natasha Umer <natashau@github.com> Co-authored-by: Aaron Patterson <tenderlove@github.com> Co-authored-by: Shawn Allen <shawnbot@github.com> Co-authored-by: Emily Plummer <emplums@github.com> Co-authored-by: Diana Mounter <broccolini@github.com> Co-authored-by: John Hawthorn <jhawthorn@github.com> Co-authored-by: Nathan Herald <myobie@github.com> Co-authored-by: Zaid Zawaideh <zawaideh@github.com> Co-authored-by: Zach Ahn <engineering@zachahn.com>
I have used This works very well with Vue to progressively enhance server-rendered components, or direct Vue templates. The main complication I've found is with regards to splitting assets across app/assets and components/assets, some even have app/javascripts where an SPA lives. Would be great to see some standardisation around this. I am still using Rails views but mostly to render the components. In the Rails view I might set title, etc, pass in data from instance variables in the controller into the component. I usually break it up into contexts (similar to DDD):
Regardless, this structure is infinitely better than the current mess of controller/views/partials which don't seem to offer very much by way of encapsulation. I want to be in Ruby doing OOP. Hope these components are officially integrated and supported soon! One of the biggest resistances I have with using |
Shouldn't we get inspired by https://hyperstack.org to write HTML in ruby? And for style, ruby supports the syntax to generate css. Inspiration from https://github.com/ebryn/ember-component-css def style
{
'.label': { 'text-align': 'center' }
}
end then the output Inspiration from https://github.com/ebryn/ember-component-css .my-component-32131321 .label{
...
} Also more integrations with stimulus would be nice! |
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 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!\"}" ``` [rails#45432]: rails#45432 [rails#36388]: rails#36388 [rails#37919]: rails#37919
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 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!\"}" ``` [rails#45432]: rails#45432 [rails#36388]: rails#36388 [rails#37919]: rails#37919
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 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!\"}" ``` [rails#45432]: rails#45432 [rails#36388]: rails#36388 [rails#37919]: rails#37919
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 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
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 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
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 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
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 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
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 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
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 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
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 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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
Note: This PR initially was titled:
Introduce support for ActionView::Component
. I've updated the content to better reflect the changes we ended up shipping, to use the new name of GitHub's library,ViewComponent
, and to remove mentions of validations, which we no longer use. - @joelhawksley, March 2020Introduce support for 3rd-party component frameworks
This PR introduces structural support for 3rd-party component frameworks, including ViewComponent, GitHub's framework for building view components.
Specifically, it modifies
ActionView::RenderingHelper#render
to support passing in an object torender
thatresponds_to
render_in
, enabling us to build view components as objects in Rails.We’ve been running a variant of this patch in production at GitHub since March, and now have about a dozen components used in over a hundred call sites.
The PR includes an example component (
TestComponent
) that closely resembles the base component we're using at GitHub.I spoke about our project at RailsConf, where we got lots of great feedback from the community. Several folks asked us to upstream it into Rails.
Why
In working on views in our Rails monolith at GitHub (which has over 4,000 templates), we have run into several key pain points:
Testing
Currently, Rails encourages testing views via integration or system tests. This discourages us from testing our views thoroughly, due to the costly overhead of exercising the routing/controller layer, instead of just the view. It also often leads to partials being tested for each view they are included in, cheapening the benefit of DRYing up our views.
Code Coverage
Many common Ruby code coverage tools cannot properly handle coverage of views, making it difficult to audit how thorough our tests are and leading to gaps in our test suite.
Data Flow
Unlike a method declaration on an object, views do not declare the values they are expected to receive, making it hard to figure out what context is necessary to render them. This often leads to subtle bugs when we reuse a view across different contexts.
Standards
Our views often fail even the most basic standards of code quality we expect out of our Ruby classes: long methods, deep conditional nesting, and mystery guests abound.
ViewComponent
ViewComponent
is an effort to address these pain points in a way that improves the Rails view layer.Building Components
Components are subclasses of
ViewComponent
and live inapp/components
.They include a sidecar template file with the same base name as the component.
Example
Given the component
app/components/test_component.rb
:And the template
app/components/test_component.html.erb
:We can render it in a view as:
Which returns:
Testing
Components are unit tested directly, based on their HTML output. The
render_inline
test helper enables the use of Capybara assertions:Benefits
Testing
ViewComponent
allows views to be unit-tested. Our unit tests run in around 25ms/test, vs. ~6s/test for integration tests.Code Coverage
ViewComponent
is at least partially compatible with code coverage tools. We’ve seen some success with SimpleCov.Data flow
By clearly defining the context necessary to render a component, we’ve found them to be easier to reuse than partials.
Existing implementations
ViewComponent
is far from a novel idea. Popular implementations of view components in Ruby include, but are not limited to:In action
I’ve created a demo repository pointing to this branch.
Co-authored-by
A cross-functional working group of engineers and members of our Design Systems team collaborated on this work, including by not limited to: @natashaU, @tenderlove, @shawnbot, @emplums, @broccolini, @jhawthorn, @myobie, and @zawaideh.
Additionally, numerous members of the community have shared their ideas for
ViewComponent
, including but not limited to: @cgriego, @xdmx, @skyksandr, @jcoyne, @dylanahsmith, @kennyadsl, @cquinones100, @erikaxel, @zachahn, and @trcarden.