stimulusreflex / stimulus_reflex

Build reactive applications with the Rails tooling you already know and love.
https://docs.stimulusreflex.com
MIT License
2.28k stars 172 forks source link

out-of-band Reflex updates #64

Closed leastbad closed 4 years ago

leastbad commented 5 years ago

I want to demonstrate triggering the delivery of a Reflex payload from outside of the request loop. That could be in a few milliseconds or an arbitrary amount of time later. The key detail is that it's generated in response to an external event such as a webhook or notification.

I figured that a good teaching vehicle would be an ActionJob. I can send in the same parameters that are used to call channel.receive() + the stream_name.

Except, wait... you hit logical flaw number one: when you create an instance of StimulusReflex::Channel, you need to pass it a Connection so that the render_page method has a valid ActionDispatch::Request object.

Huh! Okay, what are my other options? Well, I could make use of the new ApplicationController.renderer.render(partial: 'messages/message', locals: { message: message }) thing and then call ActionCable.server.broadcast directly.

The problem there is that I'd have to call both the reflex action AND the controller action in order to properly replicate the instance variables for the template. eff that

Which brings me full circle to questioning my original premise for wait_for_it. However, maybe the fact that it could serve literally the wrong page (if the user has navigated away) is a sign that this is Not The Right Way To Do This.

Where I landed was a different idea altogether. We're already talking about adding the capacity to do a Turbolinks/classic browser redirect operation. What if we used [the same or a similar mechanism] to send a message to the client that says: hey, if you're still on URL X, there is a potentially newer version of the page which you could refresh.

The advantages of this approach are that it gives implementation flexibility to the developer in terms of handling how/when/if they want to handle out-of-band updates, but also that a request coming from the client over the existing connection is going to have a valid connection + stream_name and all of the other important details otherwise absent.

The only other idea I had was that perhaps there is a unique ID attached to each connection object, and that this ID could be passed into the ActionJob and later used to obtain a reference to the connection for purposes of instantiating a new StimulusReflex::Channel.

Alright, I'm spent. Who has opinions?! :smiling_imp:

hopsoft commented 5 years ago

I like the idea of supporting something like this in CableReady:

cable_ready[stream_name].refresh

That is smart enough to perform any of the following based on detected support.

leastbad commented 5 years ago

Wow, that would be cool.

leastbad commented 4 years ago

Circling back on this, it seems like the pattern that works best is to use CableReady to broadcast a message to the client. This channel handler then initiates a SR stimulate() operation, refreshing the content. I've found that a good strategy is to create a Stimulus controller that is also an ActionCable channel consumer. Indeed, I don't actually have a channels folder as all of my channels are managed by Stimulus controllers, which can easily call StimulusReflex.

By way of example, here is a complete polymorphic comment system implemented via SR and CableReady:

class CommentChannel < ApplicationCable::Channel
  def subscribed
    stream_for params[:commentable_type].constantize.find(params[:commentable_id])
  end
end
import { Controller } from 'stimulus'
import StimulusReflex from 'stimulus_reflex'
import consumer from '../lib/consumer'

export default class extends Controller {
  static targets = ['comment']

  connect () {
    this.element[this.identifier] = this
    StimulusReflex.register(this)
    if (this.element.dataset.commentableId) {
      const controller = this
      consumer.subscriptions.create(
        {
          channel: 'CommentChannel',
          commentable_type: this.element.dataset.commentableType,
          commentable_id: this.element.dataset.commentableId
        },
        {
          received () {
            controller.stimulate('ApplicationReflex#refresh')
          }
        }
      )
    }
  }

  comment (e) {
    e.preventDefault()
    if (this.commentTarget.value.length)
      this.stimulate('CommentsReflex#comment', {
        class: this.element.dataset.commentableType,
        id: this.element.dataset.commentableId,
        body: this.commentTarget.value
      })
    this.commentTarget.value = ''
  }
}
class Comment < ApplicationRecord
  belongs_to :commentable, polymorphic: true, optional: true
  belongs_to :user

  after_save do
    CommentChannel.broadcast_to(commentable, {})
  end
end
class CommentsReflex < StimulusReflex::Reflex
  delegate :current_user, to: :connection

  def comment(payload)
    current_user.comments.create({
      body: payload["body"],
      commentable_type: payload["class"],
      commentable_id: payload["id"],
    })
  end
end

The after_save callback in the Events model could just as easily be an ActiveJob perform method.

Also, the ApplicationReflex#refresh is an empty method that simply forces a content refresh.