-
Notifications
You must be signed in to change notification settings - Fork 443
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
Render collections #274
Render collections #274
Conversation
Co-Authored-By: Joel Hawksley <[email protected]>
lib/view_component/base.rb
Outdated
as = as_variable(@options) || :item | ||
args = @options.except(:items, :as) | ||
|
||
@options[:items].map do |item| |
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 that is the optimal way to render large collections. I suggest creating a string buffer and concatenate each rendered component instead of creating an array, then joining it later. Look for some inspiration on ActionView::OutputBuffer
class and see how it works under the hood. :)
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.
Looks like ActionView::OutputBuffer < ActiveSupport::SafeBuffer < String
(see buffers.rb
, and output_safety.rb
) and ActionView::RenderedCollection
does map/join
as well. We can definitely make this use a buffer, but is that optimal? Do we want to optimize for time? If so, a naive benchmark tells me that join
is going to be faster.
require "benchmark"
def test_benchmark
n = 1000000
Benchmark.bmbm do |x|
foo = [1] * n
buffer = ActionView::OutputBuffer.new
x.report("using ActionView::OutputBuffer") do
foo.each do
buffer << "foobar"
end
string = buffer.html_safe
end
x.report("using map and join") do
string = foo.map do
"foobar"
end.join
end
end
end
user system total real
using ActionView::OutputBuffer 0.449913 0.004264 0.454177 ( 0.455215)
using map and join 0.085798 0.002497 0.088295 ( 0.088758)
I'm new to rails internals, is there another builder like object besides OutputBuffer
? I was also a little surprised that render_in
had to return anything as I was expecting some internal buffer to be getting appended to as things are rendered. Is it possible that some of this is just for inline test rendering?
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.
Your benchmark isn't really apples to apples because you're doing html_safe
in one and not the other. I think we'd want to do a benchmark of a real case using the rendering of the view component instead of test strings. My gut says that Rails must be doing html_safe etc. inside the loop.
Imagine that with rendering components it looks more like this - note the new html_safe in the join case:
require "benchmark"
def test_benchmark
n = 1000000
Benchmark.bmbm do |x|
foo = [1] * n
buffer = ActionView::OutputBuffer.new
x.report("using ActionView::OutputBuffer") do
foo.each do
buffer << "foobar"
end
string = buffer.html_safe
end
x.report("using map and join") do
string = foo.map do
"foobar".html_safe
end.join
end
end
end
then
user system total real
using ActionView::OutputBuffer 0.373818 0.002911 0.376729 ( 0.377495)
using map and join 0.821182 0.017310 0.838492 ( 0.840472)
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.
Yup, you're right, both cases need to call html_safe
(which is what's happening at the end of render_in
), but I think they both need to call it inside the loop. The benchmark you pasted has the OutputBuffer
case doing html_safe
once at the end and the map/join
case doing html_safe
on each iteration. Let's make them the same.
I think we'd want to do a benchmark of a real case using the rendering of the view component instead of test strings.
Love this suggestion! Here's an updated benchmark that actually calls render_inline
:
require "benchmark"
def test_benchmark
n = 10000
@product = OpenStruct.new(title: "Bye")
Benchmark.bmbm do |x|
foo = [1] * n
buffer = ActionView::OutputBuffer.new
x.report("using ActionView::OutputBuffer") do
foo.each do
buffer << render_inline(ProductComponent.new(product: @product, extra: "abc"))
end
end
x.report("using map and join") do
foo.map do
render_inline(ProductComponent.new(product: @product, extra: "abc"))
end.join
end
end
end
user system total real
using ActionView::OutputBuffer 1.427219 0.023809 1.451028 ( 1.461946)
using map and join 1.152875 0.026003 1.178878 ( 1.204991)
I didn't dig too far, but array joins in ruby are implemented in c and appear to be specialized for strings.
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.
Re-running benchmarks with the knowledge that map/join will require a final html_safe
. The OutputBuffer
version doesn't need this because it's already a safe buffer.
map/join still wins by a little.
require "benchmark"
def test_benchmark
n = 10000
@product = OpenStruct.new(name: "Soap")
Benchmark.bmbm do |x|
foo = [1] * n
buffer = ActionView::OutputBuffer.new
x.report("using ActionView::OutputBuffer") do
foo.each do
buffer << render_inline(ProductComponent.new(product: @product, notice: "abc"))
end
end
x.report("using map and join") do
foo.map do
render_inline(ProductComponent.new(product: @product, notice: "abc"))
end.join.html_safe
end
end
end
user system total real
using ActionView::OutputBuffer 1.543178 0.028958 1.572136 ( 1.573645)
using map and join 1.285421 0.025860 1.311281 ( 1.312286)
I didn't like the |
@rainerborene, thank you so much for the review!
👍 I made another attempt here, curious to hear what you think about the naming. API looks like this now: # Verbose version
render(ProductComponent.all(collection: @products, as: :product, foo: foo))
# Does the same thing
render(ProductComponent.all(@products, foo: foo)) |
Naming is hard. One pattern that I see emerging from Rails codebase is |
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.
Great work. Love the direction this is going and looks like you've broken the back of making this work.
A few comments/suggestions about the API that we're exposing.
assert_selector("h1", count: 2) | ||
end | ||
|
||
def test_render_collection_minimal_specify_as |
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.
What's the case your guarding against with this test?
lib/view_component/base.rb
Outdated
@@ -14,6 +15,11 @@ class Base < ActionView::Base | |||
class_attribute :content_areas, default: [] | |||
self.content_areas = [] # default doesn't work until Rails 5.2 | |||
|
|||
# Render a component collection. | |||
def self.all(*args) |
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.
Would it be better to call this collection
?
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.
It was: #274 (comment)
I'm fine with either, but have a slight preference for all
as I think:
.all(collection:
reads better than .collection(collection:
.
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 argument would be we shouldn't need a collection:
argument. My proposal is
MyComponent.with_collection(@products, .....)
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'm into the idea of with_collection
and requiring the first argument to be the collection (something that can to_ary
) and then dropping all the other special kwargs (like as:
and collection:
).
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.
And I think we could take or leave the with_
prefix:
MyComponent.collection(@products, extra: foo)
MyComponent.with_collection(@products, extra: foo)
Naming: how about |
@jonspalmer If we want this library to be merged on Rails in future, we need to stay as close as possible to Rails internals. |
Sure that's a good guiding principal however we should step back and understand the reasoning for the rails pattern. Passing locals to a partial is something like this <h1>New zone</h1>
<%= render partial: "form", locals: {zone: @zone} %> The value of In our case I'm proposing that we make def self.with_collection(collection, **component_args) There is no confusion with that API. It's also nice because it makes the calling mostly "symmetric" when using a collection or not. i.e. <%= render ProductComponent.new(product: @product, foo: 1, bar: 2) %>
<%= render ProductComponent.with_collection(@products, foo: 1, bar: 2) %> |
Can we add an 'integration' test of this actually running in the test app? |
Must pass collection object directly, no more special keyword args
Alright, I made another pass at the API and addressed a bunch of specific feedback. Thanks for the review and some great ideas @jonspalmer! I did add a single integration level test too which was a good idea because that demonstrated the need to use Here's the current state of the API: ProductComponent.with_collection(@products, foo: foo, …) And instead of having any special keyword args, if a component needs to have a different parameter name you can do that like so (otherwise in this case the name would be class ProductCouponComponent < ViewComponent::Base
with_collection_parameter :item
def initialize(item:)
@item = item
end
end This also means that all additional hash/keyword args are passed directly to the component's constructor which I think is quite nice. |
CHANGELOG.md
Outdated
@@ -1,5 +1,11 @@ | |||
# master | |||
|
|||
# v2.1.0 | |||
|
|||
* Support rendering collection (e.g., `render(MyComponent.all(@items))`). |
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.
Just dropping a note here to make sure we update this to reflect whatever API we land on ❤️
Great work @tclem. Make sure to rebase before merging this PR. 👊 |
lib/view_component/version.rb
Outdated
@@ -3,7 +3,7 @@ | |||
module ViewComponent | |||
module VERSION | |||
MAJOR = 2 | |||
MINOR = 0 | |||
MINOR = 1 |
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.
@joelhawksley would you prefer to do version bump and release in a separate PR? Just read the contributing guidelines...
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 would, if you don't mind removing this change 😄
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.
Wonderful work here ❤️
Thanks for all the thoughtful feedback @jonspalmer @rainerborene
Co-Authored-By: Joel Hawksley <[email protected]>
This reverts commit 4364c69.
Fixes #21.
Introduces collection rendering for view components with the following API:
Worked with @joelhawksley on the initial sketch of this with the goal of having this API feel very similar to the standard Rails partial rendering of collections. Opening early for feedback, there are still a few open TODOs.
TODO
:as