Event to know a `turbo-stream` has been rendered

Thanks @evenreven, actually Stimulus connect is working well. When a broadcast stream is appended to the DOM, the Stimulus connect() method does fire. (Mostly what has been advised higher in the thread) Though it seems that it fires before the whole stream is downloaded/appended… Then the JS in the connect method() doesn’t do its job properly as some DOM nodes are missing.

events like DOMcontentloaded or tubo:load obviously don’t work. turbo:frame-load does work but only on first page paint, not with the streams.

Then having a hook for a completed stream would be really helpful… Although today I will move the controller outside the stream and play with MutationObserver, there is a chance I can still do what I need.

EDIT: The more I think about, the more I realize it doesn’t probably have to do with Turbo or Stimulus. My problem is probably rather about the paint of the page…

@sam Can you recommend a way to differentiate if a controller was added/connected on page load or later via turbo stream?

1 Like

I just set up Bearer | Infinite scrolling pagination with Rails, Hotwire, and Turbo – however some of my posts require external JS libs like Anime.js (https://codepen.io/favoriteusername/pen/BaPwzRM).

How do I initialize Anime on my posts after they’ve been rendered by the Turbo stream? I tried this but to no avail:

// app/javascripts/application.js
addEventListener("turbo:after-stream-render", (event) => {
  const fallbackToDefaultActions = event.detail.render;

  event.detail.render = function (streamElement) {
    var wave = anime.timeline().add({
      targets: ".wave path",
      strokeDashoffset: [anime.setDashoffset, 0],
      delay: function (el, i) {
        return i * 250;
      },
      easing: "easeInOutSine",
      duration: 600
    });
  };
});
// app/javascript/controllers/infinite_scroll_controller.js:
import ApplicationController from "./application_controller";
import { useIntersection } from "stimulus-use";

import anime from "animejs";

export default class extends ApplicationController {
  static targets = ["button"];

  connect() {
    super.connect();
    useIntersection(this, { element: this.buttonTarget });

    var wave = anime
      .timeline()
      .add({
        targets: ".wave path",
        strokeDashoffset: [anime.setDashoffset, 0],
        delay: function (el, i) {
          return i * 250;
        },
        easing: "easeInOutSine",
        duration: 600
      });
    }
}

Thanks!

I don’t understand where the resistance to adding this event is coming from.

Aside from offering nice parity with turbo:load and turbo:frame-load (and good symmetry with the before-stream-render event), it’s a convenient solution to a real problem: how to init 3rd party libraries that need to hook into your markup.

There are libraries which already listen to turbo:load and turbo:frame-load in order to do whatever init they need, but without a similar event for when a stream has completed render, these libraries are incomplete.

The world has moved on since then, and we now have MutationObserver to detect changes to the DOM

Fine, then why not remove all events? No need for turbo:load or turbo:frame-load or probably a bunch of the other ones.

Is it really so much trouble to emit these events, to build a library that is easy to work with in various ways? Does it add a ton of weight to turbo or create some sort of maintenance nightmare? Help me understand!

You can put a controller on the element and implement the disconnect() method to do something when it’s removed from the page.

I suppose that works for replace and remove, but streams support 7 actions, and the one I’ve used the most is append. Adding a controller for every message just to init 3rd party libraries seems … onerous … plus presents a problem on normal page load—no need to re-init on each connect() during initial page load. But maybe I’m missing something obvious here?

3 Likes

For anyone else who stumbles on this and wishes for this event, you can actually create it yourself! This is what I’ve added to my application.js:

const afterRenderEvent = new Event("turbo:after-stream-render");
addEventListener("turbo:before-stream-render", (event) => {
  const originalRender = event.detail.render

  event.detail.render = function (streamElement) {
    originalRender(streamElement)
    document.dispatchEvent(afterRenderEvent);
  }
})

This introduces a turbo:after-stream-render event so you can re-init your 3rd party libraries after Turbo Stream renders. There’s probably some way to make it even better that I haven’t thought of / haven’t needed yet (eg, by firing it on a more appropriate target or maybe by attaching more data to the event). But for now this serves my needs :smile:

Hope this helps someone else :beers:

10 Likes

I have a specific case where it’s really not possible to add a controller to elements that I need to check after stream is rendered, unless I will force in my team to spend tremendous time to rebuild area where it’s needed.

Yes, world moved forward, but for web apps it’s often not simple to keep up with the pace and in such cases it would help to have supporting solution. For me refusing to add this even seems to counter the idea of progressive enhancement, since I just need to decide whether to use turbo for my project if I can’t adapt it to what I already have.

For what I needed @sdhull’s solution is perfect.

1 Like

I was content with the “Use a Stimulus controller on the turbo stream response” answer until I hit a need for a generic solution.

In my case, I’m implementing a confirm dialog (using SweetAlert) on turbo stream links (I couldn’t get button_to’s to work). I’m using the Turbo.setConfirmMethod method to handle this. So, I want to be able to close the SweetAlert modal once the turbostream request completes. I’ve currently solved this by adding a Stimulus controller that does this into the turbostream response. I only have a few links currently so its not bad, but definitely is repetitive, puts a burden on other developers to remember this, and (imo) couples concerns.

+1 for adding the event.

I’ve successfully updated half of the huge legacy app to use Turbo, with frames and streams, with zero dependency on Stimulus whatsoever. And I’m incredibly happy with that!

I don’t find Stimulus easy nor comfortable to use. I don’t find the necessity to introduce Yet Another Dependency to be justified either, only because some stubborn Gatekeeper thinks they know better than we, mere peasants.

Adding the lifecycle events to stream render is only logical, since other components also have it. And so far zero logical reasoning was provided against it.

This whole lot is rather disappointing.

This is the generic solution I am using.

I have a reusable controller that triggers a CustomEvent.
The value of the CustomEvent is configurable via data-value attribute:

// app/javascript/controllers/i_am_here_controller.js
import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
  static values = {
    eventName: String
  }

  connect() {
    const trigger = new CustomEvent(this.eventNameValue);
    window.dispatchEvent(trigger);
  }
}

In the element that is rendered with the TurboStream I connect this controller:

<div
  data-controller="i-am-here"
  data-i-am-here-event-name-value="my-element-rendered"
>
  /* edited */
</div>

Now I just have to listen to this event in another controller
which is responsible to react to when the content is loaded:

// app/javascript/controllers/reaction_controller.js
import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
  connect() {
    window.addEventListener("my-element-rendered", this.react.bind(this));
  }

  react() {
    console.log("Here I can react");
  }
}

I’ve built something similar to @fguillen’s solution and I’m looking to replace it, too many gotchas.

  1. You call it “generic”, but you need to customize each and every turbo stream that you expect to be able to trigger an event.
  2. The event is triggered on connect, so all turbo stream actions below the one that renders this have not yet been processed. Therefore, at least for a drop-in replacement to turbo:after-stream-render, it must have this element on the bottom or the event will trigger prematurely.
  3. If you need a deterministic trigger (e.g. to unlock a form’s submit button after you’ve disabled it), you have to trigger the event manually in case your request doesn’t actually render the turbo stream (e.g. for my Rails app: in case of a halted callback chain, incorrect response format or a non-200 status such as an error or a 302).

In short, everyone who uses it must be very aware of what happens behind the scenes in order not to introduce bugs, which feels very counterintuitive for a framework that’s supposed to make things easier.

Would a MutationObserver to check for the absence of turbo-stream tags in the DOM be an approach worth checking out?