ViewComponent / view_component

A framework for building reusable, testable & encapsulated view components in Ruby on Rails.
https://viewcomponent.org
MIT License
3.24k stars 416 forks source link

Turbo-Streaming ViewComponents #1106

Open cpjmcquillan opened 2 years ago

cpjmcquillan commented 2 years ago

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.

Context

Turbo broadcasting discussion Hotwire by default in Rails 7

cpjmcquillan commented 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.

boardfish commented 2 years ago

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 commented 2 years ago

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 commented 2 years ago

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 commented 2 years ago

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 commented 2 years ago

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 commented 2 years ago

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 commented 2 years ago

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 commented 2 years ago

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

boardfish commented 2 years ago

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 commented 2 years ago

@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 commented 2 years ago

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 commented 2 years ago

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 commented 2 years ago

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 commented 2 years ago

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 commented 2 years ago

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 commented 2 years ago

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

boardfish commented 2 years ago

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

jrochkind commented 2 years ago

(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 commented 2 years ago

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

        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 commented 2 years ago

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 commented 1 year ago

👋 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 %>
joeldrapper commented 1 year ago

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 commented 1 year ago

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 commented 1 year ago

@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 commented 1 year ago

The PR got merged, so I assume support will ship in the next release. https://github.com/hotwired/turbo-rails/pull/433

joeldrapper commented 1 year ago

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 commented 1 year ago

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 commented 1 year ago

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 commented 1 year ago

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 commented 1 year ago

@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 commented 1 year ago

@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 commented 1 year ago

@rromanchuk it about this PR https://github.com/hotwired/turbo/commit/1e78f3b1ef4e5263c4d6fd2003ae8298e27b3b3c

gap777 commented 1 year ago

Would it be appropriate to consider broadcast streams scenarios for view_components as well, a la https://github.com/hotwired/turbo-rails/issues/270?