Ensure turbo_frame_tag requests src after turbo_stream_from is connected

One workaround (hack) might be to load the turbo-frame with a blank src, and then use a stimulus controller and update the URL once “all” the streams are connected.

That’s an interesting idea indeed. I’ll see if I can wire something like that up.

I might suggest posting this issue to the hotwire/turbo github issues. It seems you might benefit from a way to detect when a specific turbo-stream has successfully connected.

I’ll re-post there as well - I was reading that it might be better to post here first :smiley:


Also I started working on some potential server side code to detect if a specific channel has a connection. I was thinking maybe I can delay the broadcast for a set period of time until the connect has been made, then potentially ignore if after a period passes.

Here’s what I came up with on the server side that could go into the worker…

     streamables = [@campaign.user, "campaign_list_items"]
     signed_stream_name = Turbo::StreamsChannel.signed_stream_name(streamables)
     verified_stream_name = Turbo::StreamsChannel.verified_stream_name(signed_stream_name)
     redis = ActionCable.server.pubsub.redis_connection_for_subscriptions

     while within_time_window do # haven't spent to much time on the loop here (this is pseudo code)
       # redis.pubsub("channels", verified_stream_name).present? is a valid check that returns true once the connection is made on the client. 
       if redis.pubsub("channels", verified_stream_name).present?
         campaign.user.broadcast_update_to(
           campaign.user, 
           :campaign_list_items, 
           target: "campaign_#{campaign.id}", 
           partial: "some_partial")
       else 
         redo
       end
     end

Update
I’ve created an issue Make turbo_frame_tag requests after all turbo_stream_froms have been connected · Issue #777 · hotwired/turbo · GitHub

I’ve been messing with this a bit and it looks like the “hack” you suggested may not work.

What I’m getting is a dom element and the return value from isConnected doesn’t seem to be related to TurboStream at all.

A boolean value that is true if the node is connected to its relevant context object, and false if not.

let test = document.createElement('p');
console.log(test.isConnected); // Returns false
document.body.appendChild(test);
console.log(test.isConnected); // Returns true

Looks like it just tells me that the element has been added to the connected to the Dom not that the turbo_stream web socket connection was made.

Back to the drawing board :pensive:

Another possible idea:

# index page (slim)
= turbo_stream_from current_user, :campaign_list_items, id: 'turbo_stream-current_user`
document.getElementById('turbo_stream-current_user').channel.state === 'connected'

Just an update here…

element.channel.state

This new suggestion seems to just return undefined.

element.channel 

This returns the following

Which makes me think that state is not an attribute.

At this point the only solution that’s seems to not have broken is the following

import { Controller } from "@hotwired/stimulus";

export default class extends Controller {
  static targets = ["delayedTurboFrameTag"];

  connect() {
    setTimeout(() => {
      this.delayedTurboFrameTagTargets.forEach((target) => {
        target.src = target.dataset.delayedSrc;
      });
    }, 250);
  }
}
<div data-controller="delayed-stream">
  <%= turbo_stream_from current_user, :campaign_list_items %>

  <% @campaigns.each do |campaign| %>
    <%= turbo_frame_tag dom_id(campaign),
      data: {
        delayed_stream_target: 'delayedTurboFrameTag',
        delayed_src: campaign_list_items_path(campaign.id)
      } do %>
        <div> - </div>
    <% end %>
  <% end %>
</div>

But I can’t imagine this actually being consistant

I wish there was a way to create a consumer/subscription manually with Hotwire and use the connected event there.

I also saw this connectedCallback. But I’m unsure how to hook into this.

:thinking:

Sorry, I’m using a slightly different turbo stream library which includes state. For the native Turbo, you could try.

What about something like:

import ApplicationController from 'controllers/application_controller';

export default class extends ApplicationController {
  connect() {
    this.dispatchConnectedTimeout();
  }

  disconnect() {
    this.dispatch('disconnected');
    this.dispatch(`disconnected:${this.element.id}`);
  }

  dispatchConnectedTimeout(timeout = 250) {
    if( Turbo.session.streamObserver.streamSourceIsConnected(this.element) ) {
      this.dispatch('connected');
      this.dispatch(`connected:${this.element.id}`);
    } else {
      setTimeout(() => {
        this.dispatchConnectedTimeout(timeout + 250);
      }, timeout);
    }
  }
}

then

# index page (slim)
= turbo_stream_from current_user, :campaign_list_items, id: 'current_user_campaign_list_items_stream', data: { controller: 'turbo-stream-events' }

- @campaigns.each do |campaign|
  = turbo_frame_tag dom_id(campaign), data: {turbo: true, controller: 'turbo-frame', action: 'turbo-stream-events:connected:current_user_campaign_list_items_stream@window->turbo-frame#src', turbo_frame_src_value: campaign_list_items_path(campaign)} do
    .grid.grid-cols-7.p-3.bt
      .left.w-6.word-wrap = link_to campaign.name, campaign_path(campaign), data: {"turbo-frame": "_top"}
      .right -
      .right -
      .right -
      .right -
      .right -
      .right -```

This is only for when the HTML component connects to the DOM, not when the stream connects to the websocket.

I believe turbo saves the state of the socket to the HTMLElement.streamSource. So this might also work.

document.getElementById('turbo_stream-current_user').streamSource.readyState === WebSocket.OPEN

Noice! I’ll have a look when I’ve got time. Fighting some other fires at the moment and will report back asap.

Thanks again @tleish for all the help here :smiley:

I tried to look into your most recent suggestion

But it looks like calling streamSource on my side just results in an undefined\

console.log(this.streamTarget.streamSource);

I also tried your other suggestion here but the unfortunate part is that I’m always getting a return of true.

Back to the drawing board.

My next thing I’m going to try is to see if I can manually open a connection like I used to with ActionCable directly using createConsumer and it’s connected callback. :thinking:

I decided to give the server implementation one more chance and I think I found a possible solution that I don’t hate that should be quite easy to reuse for other methods like this.

<%= turbo_stream_from [current_user, :campaign_list_items] %>

<% @campaigns.each do |campaign| %>
  <%= turbo_frame_tag dom_id(campaign), src: campaign_list_items_path(campaign.id) do %>
    <div class="grid grid-cols-7 p-3 bt"> </div>
  <% end %>
</div>
class RetriableBroadcaster
  def initialize(broadcast_method, *streamables, **options)
    @broadcast_method = broadcast_method
    @streamables = streamables
    @options = options
  end

  def call(attempts = 0)
    return broadcast if client_connected?
    return unless attempts <= max_retries

    attempts += 1
    sleep wait_for
    call(attempts)
  end

  private

  def broadcast
    @streamables.first.public_send(
      @broadcast_method,
      *@streamables,
      **@options.except(:max_retries, :wait_for)
    )
  end

  def wait_for
    @options[:wait_for].presence || 0.1
  end

  def max_retries
    @options[:max_retries].presence || 5
  end

  def client_connected?
    redis.pubsub("channels", verified_stream_name).present?
  end

  def redis
    @redis ||= ActionCable.server.pubsub.redis_connection_for_subscriptions
  end

  def signed_stream_name
    Turbo::StreamsChannel.signed_stream_name(@streamables)
  end

  def verified_stream_name
    Turbo::StreamsChannel.verified_stream_name(signed_stream_name)
  end
end
module Campaigns
  class ListItemStatsWorker
    include Sidekiq::Worker
    sidekiq_options queue: :within_30_seconds, retry: 0

    def perform(campaign_id)
      return unless (campaign = Campaign.find(campaign_id))

      RetriableBroadcaster.new(
        :broadcast_update_to,
        campaign.user,
        :campaign_list_items,
        target: "campaign_#{campaign.id}",
        partial: "dashboard/campaigns/list_items/list_item",
        locals: {
          campaign: campaign, stats: CampaignStatsPresenter.new(campaign, nil, nil)
        },
        max_retries: 5
      ).call
    end
  end
end

Ideally we could build this into source and provide a signature more like

user.broadcast_update_to(
  campaign.user, 
  :campaign_list_items, 
  target: "target_name", 
  partial: "partial_path", 
  max_retries: 5
)

I cloned a repo that uses the default turbo channel connection library and inspected the <turbo-cable-stream-source element in chrome. Doing so reveals all sorts of properties you can use to check state.

document.getElementById('ID').subscription.consumer.connection.isOpen()

document.getElementById('ID').subscription.consumer.connection.isActive()

document.getElementById('ID').subscription.consumer.connection.getState() == 'open'

document.getElementById('ID').subscription.consumer.connection.disconnected === false

document.getElementById('ID').subscription.consumer.connection.webSocket.readyState === WebSocket.OPEN

Super cool! I’ll take a look at those when I have a moment. Thanks for the continued digging!

My revised stimulus controller

import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
  connect() {
    this.dispatchState();
    this.webSocket.addEventListener('open', this.dispatchState.bind(this));
    this.webSocket.addEventListener('close', this.dispatchState.bind(this));
  }

  disconnect() {
    this.webSocket.removeEventListener('open', this.dispatchState);
    this.webSocket.removeEventListener('close', this.dispatchState);
  }

  // events: connecting|open|closing|closed
  // see: https://developer.mozilla.org/en-US/docs/Web/API/WebSocket/readyState
  dispatchState() {
    const state = this.connection.getState();
    console.debug('dispatch', state);
    console.debug('dispatch', `${state}:${this.element.id}`);
    this.dispatch(state);
    this.dispatch(`${state}:${this.element.id}`);
  }

  get connection() {
    return this.element.subscription.consumer.connection;
  }

  get webSocket() {
    return this.connection.webSocket;
  }
}
# index page (slim)
= turbo_stream_from current_user, :campaign_list_items, id: 'current_user_campaign_list_items_stream', data: { controller: 'actioncable-events' }

- @campaigns.each do |campaign|
  = turbo_frame_tag dom_id(campaign), data: {turbo: true, controller: 'turbo-frame', action: 'actioncable-events:open:current_user_campaign_list_items_stream@window->turbo-frame#src', turbo_frame_src_value: campaign_list_items_path(campaign)} do
    .grid.grid-cols-7.p-3.bt
      .left.w-6.word-wrap = link_to campaign.name, campaign_path(campaign), data: {"turbo-frame": "_top"}
      .right -
      .right -
      .right -
      .right -
      .right -
      .right -```
1 Like

What I’ve been doing for expensive loading pages is try the lazy loading turbo frame, and having an animation that makes it appear like work is happening. This might make more sense, especially if your background job is in the 1-3 seconds consistently. It looks like that’s what you ended up down, but I may be misreading the code.

I’ve got a sample here (Adding Loading Screen with Turbo • Blogging On Rails) but this makes the dashboard load fast, and you arent fighting a race condition. It’s also simpler from a technical perspective, because you don’t need to add any more front end javascript code.

The other technique I’ve tried is going straight to action cable, and having the page send the request to start the background job. This would work better if you’re not sure how long the background processing is going to take. (Stimulus + ActionCable + ActiveJob: Loading Asynchronous API Data • Blogging On Rails)

Jumping in here because I have a jQuery loader in a production app that I’ve been longing to replace.

In the lazy frame scenario, are there any events I could hook into that would replace jQuery’s after success callback? I need to run some processing on the returned remote content, but it only makes sense to do that if that remote content (HTML – sometimes many megabytes) is fully loaded.

Thanks in advance,

Walter

Ye Olde Code:

<script>
  $('#preview').load('<%= @title.html.file.url %>', function(){
    var anchor = window.location.hash.toString().split('#')[1];
    if(!!anchor){
      setTimeout(function(){
        window.scrollTo({left: $('#' + anchor).offset()['left'], top: $('#' + anchor).offset()['top'] - 20});
      },8); // this timing is not waiting for the success function to load the html, but for the DOM to settle
    }
  });
</script>

Just wanted to followup here…

After about 3 days after deploying the solution that I mentioned here we’re still dealing with the same issue.

This lead me to work on implementing a solution like @tleish suggested.

Here’s essentially how I boiled it down

import { Controller } from "@hotwired/stimulus";

export default class extends Controller {
  static targets = ["stream", "delayedFrame"];

  connect() {
    this.checkConnection();
  }

  checkConnection = () => {
    if (this.webSocket.readyState === 1) {
      this.attachSrc();
    } else {
      setTimeout(this.checkConnection, 100);
    }
  };

  attachSrc = () => {
    this.delayedFrameTargets.forEach((frame) => {
      frame.src = frame.dataset.src;
    });
  };

  get webSocket() {
    return this.streamTarget.subscription.consumer.connection.webSocket;
  }
}
#worker 
module CampaignReports
  class RecaculateWorker
    include Sidekiq::Worker

    def perform(campaign_report_id)
      campaign_report = CampaignReport.find(campaign_report_id)
      return unless campaign_report.calculatable?

      campaign_report.calculate.save!

      campaign_report.user.broadcast_update_to(
        campaign_report.user,
        :campaign_list_items,
        target: "campaign_report_#{campaign_report.id}",
        partial: "dashboard/campaign_reports/campaign_report",
        locals: {campaign_report: campaign_report}
      )
    end
  end
end
# index.html.erb
<div data-controller="delayed-turbo-frame">
  <%= turbo_stream_from current_user, :campaign_list_items, data: {delayed_turbo_frame_target: "stream"} %>

  <% @campaigns.each do |campaign| %>
    <%= render "dashboard/campaign_reports/campaign_report", campaign_report: campaign.campaign_report %>
  <% end %>
</div>
# partial 
<%= turbo_frame_tag dom_id(campaign_report),
  data: {
    delayed_turbo_frame_target: campaign_report.calculatable? ? "delayedFrame" : "",
    src: campaign_report_path(campaign_report)
  } do %>
  <div></div>
<% end %>

I believe this allows this to be quite reusable for one or many different turbo_frames. It’s been in production for a couple days now and we no longer have any issues.

Thanks again for everyone that’s provided help one this one :muscle:

Interesting solution!
I think you can use the disabled attribute and let the default src do his job. Also, I checked every turbo-cable-stream-source connections, but maybe it’s unnecessary.

import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
  static targets = ["delayedFrame"];

  connect() {
    this.checkConnection();
  }

  checkConnection = () => {
    if (this.connections().every(connection => connection?.isOpen())) {
      // console.log('done');
      this.enableFrames();
    } else {
      // console.log('working...');
      setTimeout(this.checkConnection, 100);
    }
  };

  connections() {
    let connections = []
    const streams = document.querySelectorAll('turbo-cable-stream-source')
    streams.forEach(stream => {
      connections.unshift(
        stream.subscription?.consumer.connection
      )
    });

    return connections
  }

  enableFrames = () => {
    this.delayedFrameTargets.forEach((frame) => {
      frame.disabled = false;
    });
  };
}

FYI, new native options soon available.

see: How to detect when Turbo Stream broadcast is established? · Issue #434 · hotwired/turbo-rails · GitHub

Thank you all!

Based on the recommendations above, this seems to be reliable.

import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
  static targets = ["stream", "frame"]

  connect() {
    this.checkConnection()
  }

  checkConnection = () => {
    const observer = new MutationObserver(() => {
      this.attachSrc()
      observer.disconnect()
    })

    observer.observe(this.streamTarget, { attributeFilter: ["connected"] })
  }

  attachSrc = () => {
    this.frameTargets.forEach((frame) => {
      frame.src = frame.dataset.src
    })
  }
}