Skip to content
New issue

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

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

Already on GitHub? Sign in to your account

Turbo-Streaming ViewComponents #1106

Closed
cpjmcquillan opened this issue Oct 24, 2021 · 35 comments
Closed

Turbo-Streaming ViewComponents #1106

cpjmcquillan opened this issue Oct 24, 2021 · 35 comments
Labels

Comments

@cpjmcquillan
Copy link

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.

  • We can use #render_in(view_context) to broadcast HTML from a controller.
  • If a component implements a #call method we can invoke it to return HTML and broadcast from a model (or any other class).
  • We can render the component inside a view partial and broadcast from a model (or any other class).

Context

Turbo broadcasting discussion
Hotwire by default in Rails 7

@cpjmcquillan
Copy link
Author

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.

@boardfish
Copy link
Collaborator

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.

@boardfish
Copy link
Collaborator

boardfish commented Nov 2, 2021

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.

@boardfish
Copy link
Collaborator

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.

@cpjmcquillan
Copy link
Author

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

@boardfish
Copy link
Collaborator

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.

@boardfish
Copy link
Collaborator

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:

One 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.

@boardfish
Copy link
Collaborator

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.

@Spone
Copy link
Collaborator

Spone commented Nov 3, 2021

@boardfish What about adding a section about Turbo on the Compatibility page?

@boardfish
Copy link
Collaborator

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.

@DavidColby
Copy link

DavidColby commented Nov 3, 2021

@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?

@boardfish
Copy link
Collaborator

boardfish commented Nov 4, 2021

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.

@yshmarov
Copy link
Contributor

yshmarov commented Nov 4, 2021

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

@DavidColby
Copy link

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.

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.

@boardfish
Copy link
Collaborator

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 included 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.

@Spone
Copy link
Collaborator

Spone commented Nov 5, 2021

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.

@boardfish
Copy link
Collaborator

I've found something new linked to using Turbo - raising a separate issue.

@boardfish
Copy link
Collaborator

#1137 is linked to this if your live-updating features get too complex for Turbo's own broadcasting features to handle

@jrochkind
Copy link

jrochkind commented Nov 24, 2021

(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.

@boardfish
Copy link
Collaborator

boardfish commented Nov 29, 2021

I've been doing some more work in this corner. So far, I'm feeling the following:

  • Folks might come up against Turbo stream partial with button_to tag throws ActionView::Template::Error hotwired/turbo-rails#243 if they're working with forms. Seems like the @hotwired folks want to make sure the current fix for that (setting authenticity_token: false on all forms you broadcast) isn't a permanent one, which is good.
  • Rendering components to a string from controllers is best done with self.class.render(component, layout: false). Maybe we should make a less verbose helper that does this and recommend that in all cases.
  • Sending back multiple Turbo Streams responses seems intuitive enough, but that helper would probably benefit the cleanliness of the code.
        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)
        }

@Spone
Copy link
Collaborator

Spone commented Nov 30, 2021

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

@joeldrapper
Copy link

joeldrapper commented Feb 12, 2023

👋 FYI, I just opened a PR in turbo-rails related to this as it affects Phlex too. hotwired/turbo-rails#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 %>

@joeldrapper
Copy link

joeldrapper commented Feb 12, 2023

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

@boardfish
Copy link
Collaborator

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.

@joeldrapper
Copy link

@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.

@joeldrapper
Copy link

The PR got merged, so I assume support will ship in the next release. hotwired/turbo-rails#433

@joeldrapper
Copy link

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?

@joelhawksley
Copy link
Member

I think we should update the docs. @boardfish or @joeldrapper (or anyone else here) want to draft a PR? I'm happy to review.

@rromanchuk
Copy link

something broke for me, but still trying to figure out what exactly. All my vanilla GET link_tos 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.

@rromanchuk
Copy link

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

@joeldrapper
Copy link

@rromanchuk This doesn't seem related to ViewComponent or the renderable changes. You may want to open an issue on the turbo-rails repository.

@rromanchuk
Copy link

@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.

@AlexKovynev
Copy link

@rromanchuk it about this PR hotwired/turbo@1e78f3b

@gap777
Copy link

gap777 commented Mar 11, 2023

Would it be appropriate to consider broadcast streams scenarios for view_components as well, a la hotwired/turbo-rails#270?

@joelhawksley
Copy link
Member

Closing as stale.

@joelhawksley joelhawksley closed this as not planned Won't fix, can't repro, duplicate, stale Jan 10, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Projects
None yet
Development

No branches or pull requests