Open cpjmcquillan opened 2 years ago
If this is something we feel is worth implementing I would love to hear people's ideas for how it could work, and I love to pair on the feature.
Right now, I actually need this feature for something I'm working on @raisedevs. I'm going to be working on a cleaner solution at some point in the future, but right now I can recommend using partials as a compatibility layer here. Either render what you want to directly in the partial, or render a component inside the partial. The latter should make it much easier to switch to the solution once it arises.
I've taken some time to battle with this one, and here are my findings:
Turbo::StreamsChannel.broadcast_append_to(record_or_channel_name, target: dom_target_id, html: ExampleComponent.new.render_in(view_context))
We can use any of the methods from off Turbo::StreamsChannel
, which is what turbo-rails
uses internally, to broadcast to the same model channels Turbo would be using. This is what that looks like in the context of a controller, and it wouldn't take much to adapt that to a component.
I think some of their Broadcastable concern is applicable to ViewComponent, but some of it is pointed at self
(usually a record), which makes it less applicable. Maybe the two could be broken apart within Turbo and we could use the helpers that we need.
I think what's particularly notable about the above is that a view context is necessary. That makes it less than optimal for using in Active Record callbacks, which (while not my personal preference) is the way they introduce you to it in the Turbo docs, so some Turbo folks would most likely expect this usage to integrate with ViewComponent.
I've been leaning on objects like this for now.
module Broadcast
class Message
def self.append(message:, view_context:)
new(message, view_context).append
end
def initialize(message, view_context)
@message = message
@view_context = view_context
end
def append
Turbo::StreamsChannel.broadcast_append_later_to(
:messages,
target: "messages",
html: rendered_component
)
end
private
attr_reader :message, :view_context
def rendered_component
MessageComponent.new(message: message).render_in(view_context)
end
end
end
I think what I'd like to be able to do is something like this - which would mean we don't rely on a view_context
to broadcast components.
class Message < ApplicationRecord
belongs_to :user
after_create_commit :update_message_count
private
def update_message_count
broadcast_update_to(
user,
:messages,
target: "message-count",
html: CountComponent.new(count: user.messages.count).render_to_html
)
end
end
I suppose there's still the wider question of components that inherently rely on a view context - say, for example, you're using current_user
, form_with
, or other notable helpers - and how they'd interact with this.
At one point there was talk of making a new instance of ActionView::Base
the default value for the view_context
arg to render_in
, but that was scrapped. I've had luck using ActionController::Base
instead, though. I'll revive discussion on #201 so we can come to a decision about it, because it's a prerequisite for this.
Been dwelling on this a bit and I think this requires very little in the way of code at all. I think what it comes down to is folks:
render_in
to render the component to a string and passing that as the html
arg to either Turbo's Broadcastable
methods or direct usage of Turbo::StreamsChannel
broadcast_target_default
to reflect that if the solution calls for itOne thing we probably don't want to do is create methods on component instances to broadcast them directly, e.g. ExampleComponent.new.broadcast_to(stream, insert_by: :append)
. Personally I don't think it's a component's responsibility to render itself and then broadcast that output elsewhere.
We'll definitely want to make it easier to render components to strings in order to enable this, and document the approach too.
The only thing I can think of that we might want to do is take the top-level broadcast helpers a bit further by allowing folks to pass the html
option, so that they can render a component instead of going with the default partial. But whether that's even our concern is another question - perhaps it needs to be done over on turbo-rails
.
@boardfish What about adding a section about Turbo on the Compatibility page?
That sounds right, but I think if the aim is for it to be saying to folks "ViewComponent works with X!", we might need to break it down a bit, since a lot of what's there feels like it's saying "ViewComponent works with X, but you have to pull these strings to do it".
Rendering to a string is currently in the FAQs, so I'll probably link to that from there.
@boardfish Apologies if I'm missing something simple here.
I spent some time today working on broadcasting view components from models, based on your work here and in #201. I started with this:
# app/models/post.rb
broadcast_append_to(
'posts',
html: PostComponent.new(post: self).render_in(ActionController::Base.new.view_context)
)
This worked up until the point that I needed to access url helpers in the component, like this:
<%= link_to "Show this post", @post %>
At that point, the model broadcast failed with In order to use #url_for, you must include routing helpers explicitly. For instance, include Rails.application.routes.url_helpers.
Adding include Rails.application.routes.url_helpers
to the component didn't resolve the issue and I kind of ran into a wall with getting it working with render_in
when my component needed access to url helpers.
Eventually I switched to using ApplicationController.render
instead, which seemed to Just Work™, but I'm not nearly comfortable enough with view_component to know if I'm missing something here:
broadcast_append_to(
'posts',
html: ApplicationController.render(
PostComponent.new(post: self)
)
)
Is there a reason not to use ApplicationController.render
to render the component in a stream broadcast when you don't have access to the normal view context?
Is there a reason not to use ApplicationController.render to render the component in a stream broadcast when you don't have access to the normal view context?
I don't think there is. The docs currently specify that if you want to render to a string from inside a controller action, you would do so through render_in
, which is why I suggested it in the first instance. But if this works, it's better.
The difference in behavior between these two methods interests me - again, it's got me thinking about the trend of calling helpers without chaining off helpers
and the inconsistent behavior that seems to have.
I wonder if using ActionController::Base.render
is also our solution to #201, but I also don't know where that leaves render_in
. It's part of a component's public API, but if its behavior seemingly isn't equivalent to ActionController::Base.render
, that's odd.
currently all of these work for me without any additional magic
turbo_stream.update('inboxes-pagination', render_to_string(PaginationComponent.new(results: @results))),
turbo_stream.update("inboxes-pagination", view_context.render(PaginationComponent.new(results: @results))),
turbo_stream.update("inboxes-pagination", PaginationComponent.new(results: @results).render_in(view_context))
more details: https://blog.corsego.com/turbo-stream-view-components
update: sorry for the confusion: this works for me in a controller. did not try in model broadcast
I wonder if using
ActionController::Base.render
is also our solution to #201, but I also don't know where that leaves render_in. It's part of a component's public API, but if its behavior seemingly isn't equivalent toActionController::Base.render
.
I came across the helper issues while exploring this yesterday, some interesting challenges here. One thing to note is that ActionController::Base.render
also fails when the component calls url helpers, while ApplicationController.render
works fine. This seems odd to me, but I suppose this means that ActionController::Base doesn't have access to helper methods out of the box.
I suppose this means that
ActionController::Base
doesn't have access to helper methods out of the box.
@DavidColby I was wondering the same. That would imply that descendants of ActionController::Base
are functionally different from their parent.
Helpers are include
d on ActionController::Base
, so it should get them, I'd assume.
currently all of these work for me without any additional magic
@yshmarov Thanks for highlighting this - it's interesting to note that there are three different ways to render to a string. Looks like our FAQs recommend render_in as the one to use, most likely because the other two are provided by Rails and may be subject to change. render_in
is something we define ourselves which makes rendering components compatible with Rails' render
helpers natively.
Looks like our FAQs recommend render_in as the one to use, most likely because the other two are provided by Rails and may be subject to change.
render_in
is something we define ourselves which makes rendering components compatible with Rails'render
helpers natively.
I wrote this FAQ. It recommends against render_to_string
from within a controller action that renders HTML. But it's probably fine when rendering a turbo_stream.
I've found something new linked to using Turbo - raising a separate issue.
(deleted my previous comment when I noticed ApplicationController.render
/ActionController::Renderer
was in fact already mentioned here.)
If ApplicationController.render
(https://api.rubyonrails.org/classes/ActionController/Renderer.html) is Rails' attempt to answer this question generally, which seems to work with ViewComponent... is there any reason to come up with an alternate solution instead? It seems like that's what Rails is saying to use... and it seems to work? Does it have problems for these use cases, specific to ViewComponent or otherwise? Is there a reason to be discussing anything but ApplicationController.render/ActionController::Renderer, and do they already Just Work?
The FAQ linked above is no longer there at that URL, there appears to no longer be a "FAQ" section of docs? Not sure if that means the advice about render in a controller is no longer given.
I've been doing some more work in this corner. So far, I'm feeling the following:
authenticity_token: false
on all forms you broadcast) isn't a permanent one, which is good.self.class.render(component, layout: false)
. Maybe we should make a less verbose helper that does this and recommend that in all cases. render turbo_stream: turbo_stream.update(frame_id_for(:index)) {
self.class.render(ExampleComponent.new(**args), layout: false)
} + turbo_stream.update(frame_id_for(:new)) {
self.class.render(ExampleComponent.new(**args), layout: false)
}
The FAQ linked above is no longer there at that URL, there appears to no longer be a "FAQ" section of docs? Not sure if that means the advice about render in a controller is no longer given.
Sorry about that, it has been moved to https://viewcomponent.org/guide/getting-started.html#rendering-viewcomponents-to-strings-inside-controller-actions
👋 FYI, I just opened a PR in turbo-rails
related to this as it affects Phlex too. https://github.com/hotwired/turbo-rails/pull/433
The API I proposed is this:
turbo_stream.append "notifications", NotificationComponent.new
From ERB, you can also pass in content
<%= turbo_stream.append "notifications", NotificationComponent.new do %>
<h1>Hello World!</h1>
<% end %>
Update: This does not actually work.
~Note, one option I haven’t seen mentioned in the comments here that already works is this:~
turbo_stream.append "notifications" do
render NotificationComponent.new
end
If that's the case, it's probably worth me updating #1227 accordingly and perhaps also adding some docs.
It seems like there are a lot of potential ways to go about this, so comprehensive coverage of what works and what doesn't should help.
@boardfish sorry, I got it wrong. The technique I mentioned doesn't actually work but my PR will provide a way to do this once it's merged in turbo-rails.
The PR got merged, so I assume support will ship in the next release. https://github.com/hotwired/turbo-rails/pull/433
It looks like this shipped in 1.4 a few days ago. https://github.com/hotwired/turbo-rails/releases/tag/v1.4.0
Shall we close this issue? Should we do anything in the documentation first?
I think we should update the docs. @boardfish or @joeldrapper (or anyone else here) want to draft a PR? I'm happy to review.
something broke for me, but still trying to figure out what exactly. All my vanilla GET link_to
s are throwing in the browser
The response (200) did not contain the expected
and will be ignored. To perform a full page visit instead, set turbo-visit-control to reload.
Just basic resources index -> show navigation. Probably user error, but timing is suspicious. Error sounds suspicious too, it shouldn't be expecting a turbo_frame_tag :users
from the default advance to GET /users/:id
. I'll report back once i figure out what I did.
OK, for sure related to release, but maybe I have something fundamentally confused and it has only been working on accident.
Reverting and locking to gem "turbo-rails", '1.3.3'
, everything works again.
For reference i use importmaps
pin "application", preload: true
pin "@hotwired/turbo-rails", to: "turbo.min.js", preload: true
pin "@hotwired/stimulus", to: "stimulus.min.js", preload: true
pin "@hotwired/stimulus-loading", to: "stimulus-loading.js", preload: true
@rromanchuk This doesn't seem related to ViewComponent or the renderable changes. You may want to open an issue on the turbo-rails repository.
@joeldrapper yup, i know. This is just where i naturally ended up while debugging, and the links + discussion here gave the clues i needed. Since the links were being rendered by viewcomponent, I initially thought it might be related. Just wanted to update since future debuggers may (definitely will) end up here too.
@rromanchuk it about this PR https://github.com/hotwired/turbo/commit/1e78f3b1ef4e5263c4d6fd2003ae8298e27b3b3c
Would it be appropriate to consider broadcast streams scenarios for view_components as well, a la https://github.com/hotwired/turbo-rails/issues/270?
Feature request
Provide an interface for rendering view components outside the context of a request-response cycle (e.g without depending on a controller or view context).
Motivation
Rails 7 ships with Hotwire by default. Turbo Streams are a component of Hotwire that (among other things) allow applications to broadcast HTML "over the wire" based on model changes.
Like Rails, Turbo Streams lean heavily on partials - although you can now broadcast HTML from the model. Since ViewComponents replace partials in lots of scenarios, I think it would be beneficial for the library to integrate seamlessly with Turbo Streams in the same way partials do.
Currently there are few ways to integrate ViewComponents with Turbo Streams, but I think it would be nice to offer an "approved" approach.
#render_in(view_context)
to broadcast HTML from a controller.#call
method we can invoke it to return HTML and broadcast from a model (or any other class).Context
Turbo broadcasting discussion Hotwire by default in Rails 7