ruby-on-railsbroadcastturbo

Rails turbo stream broadcasting, does `respond_to` cancel broadcast?


In Rails 7/turbo-rails 2, my Comment model is set up to broadcast creates/updates/deletes like this:

  broadcasts_to(->(comment) { [comment.target, :comments] },
                inserts_by: :prepend, partial: "comments/comment",
                locals: { controls: true }, target: "comments")

This has never worked properly.

For most users, the comment form UI is a modal. But our app currently has backup rendering for no-js browsers: in this case, the comment form renders on a separate page; after commit it redirects back to show the object that has the comments.

So the comments controller responds to CRUD actions with the following:

  def refresh_comments_or_redirect_to_show
    # Comment broadcasts are sent from the model
    respond_to do |format|
      # format.turbo_stream expects templates for create/edit/destroy
      format.html do
        redirect_with_query(controller: @target.show_controller,
                            action: @target.show_action, id: @target.id)
      end
    end
  end

My understanding is that broadcasting from the model should mean the comments controller CRUD actions should not render a template, but here the CRUD actions redraw the whole page, because they run the format.html block. If i add a format.turbo_stream block, it complains "No template".

I would like to have the default broadcasting work without a page redraw, and keep that no-js backup redirect. Does the respond_to block cancel the broadcast? Is there a workaround?

If i delete the respond_to block, nothing happens; the broadcast does not work, and the controller complains it has no template to render.

If i instead

      format.turbo_stream do
        if action_name == "destroy"
          render(turbo_stream: turbo_stream.remove(@comment))
        else
          render(turbo_stream: turbo_stream.prepend("comments", @comment))
        end
      end

...the comment is created/updated/destroyed in the UI for the user doing the CRUD, but seemingly not broadcast. The other user's browser window shows it is subscribed, but the UI updates do not happen.


Solution

  • Currently, class level broadasts and broadcasts_to has a bug when used with a :locals option. On the first broadcast locals hash is updated with a {model: model} and retained for future broadcasts, which means you're broadcasting the same instance over and over again. This might look like nothing is happening on the front end, which is why you should check "Network tab" > "cable" > "Messages tab" to see what turbo streams you're receiving in the browser.

    This is fixed in https://github.com/hotwired/turbo-rails/pull/710. Until it is released you can do the following:

    Fix #1

    Omit :locals option if possible.

    Fix #2

    Don't use class level broadacsts. You can break it down to individual callbacks:

    after_create_commit  -> { broadcast_prepend_later_to([comment.target, :comments], target: :comments, partial: "comments/comment", locals: {controls: true}) }
    after_update_commit  -> { broadcast_replace_later_to([comment.target, :comments], target: self, partial: "comments/comment", locals: {controls: true}) }
    after_destroy_commit -> { broadcast_remove_to([comment.target, :comments], target: self) }
    

    Fix #3

    Patch it:

    # config/initializers/turbo_broadcasts_patch.rb
    
    Turbo::Broadcastable.class_eval do
      private
    
      def broadcast_rendering_with_defaults(options)
        options.tap do |o|
          # Add the current instance into the locals with the element name (which is the un-namespaced name)
          # as the key. This parallels how the ActionView::ObjectRenderer would create a local variable.
          #- o[:locals] = (o[:locals] || {}).reverse_merge!(model_name.element.to_sym => self).compact
          o[:locals] = (o[:locals] || {}).reverse_merge(model_name.element.to_sym => self).compact
        
          if o[:html] || o[:partial]
            return o
          elsif o[:template] || o[:renderable]
            o[:layout] = false
          elsif o[:render] == false
            return o
          else
            # if none of these options are passed in, it will set a partial from #to_partial_path
            o[:partial] ||= to_partial_path
          end
        end
      end
    end