Ensure turbo_frame_tag requests src after turbo_stream_from is connected

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
    })
  }
}