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

Having this as a short term solution is fine. But doesn’t seems like a perfect approach.

On a side note, is there any specific reason for not dispatching an event ?

1 Like

The specific reason is to encourage you to design your application not to care about where new HTML comes from or how it gets into the document :blush:

2 Likes

I thought of having this as a companion to the turbo:load event, but for streams. All the use cases that turbo:load solves for a regular visit, this non-existing event will solve for streams.

Maybe I am missing something obvious here. But this is my primary motivation

2 Likes

The turbo:load event dates all the way back to 2012 when it was called page:load. We used it as a way to initialize jQuery plugins and other JavaScript behavior when the page changed.

The world has moved on since then, and we now have MutationObserver to detect changes to the DOM, and libraries like Stimulus that present a nice API on top. That’s what we’re designing for.

3 Likes

This is interesting, I too was looking for the event listeners approach, but I’m curious on how I can achieve this with stimulus. Is there a way with stimulus to react to a child node being removed?

1 Like

@perezperret, you could make the child that may disappear a target and then check for the presence of the target in an action.

If you want to constantly be monitoring for changes to children in the DOM tree, you likely need a custom mutation observer with childList set to true.

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

https://stimulus.hotwire.dev/reference/lifecycle-callbacks

2 Likes

Is there any documentation on how to use that API? I can’t seem to find any docs on how to use them (@stimulus/mutation-observers).

1 Like

To be clear, the API I’m referring to is the set of Lifecycle Callbacks available to Stimulus controllers. The @stimulus/mutation-observers package is how that’s implemented.

All, this seems fine it you are using StimulusJS as your framework. But that’s not the case for us. We are in the process of implementing Hotwire for asynchronous UI updates in an existing application.

Our existing Javascript code has a hook that needs to be ran whenever new content is inserted into the page. It looks for matching DOM entries, and enables the JS needed for the things like date pickers, fancier select controls etc.

All we need to hook this up is an event that is fired after a stream is rendered. Ideally with the DOM element on the newly inserted content, but even that isn’t strictly necessary.

From what’s been said it sounds like the proposed solution would mean that we would have to rewrite all our existing JS code to use stimulus controllers, and that’s probably not tenable.

Is there any chance you could reconsider this event?

If not I’m not sure what other options are available to us (maintaining a fork maybe?)

3 Likes

What is the solution if we don’t want to use Stimulus?

@samstickland Why wouldn’t it be tenable to rewrite to Stimulus? It sounds like your JS is structured in such a way - DOM manipulation of nodes created after DOMContentLoaded - that Stimulus would be a match made in heaven. Sure, every migration to new tech takes time and testing, but you need to fix your javascript anyway. It’s also very easy to implement - you can probably keep most of your code and wrap it in the connect() { JS CODE HERE } method on a per-controller basis. Just add a data-controller="dropdown" to the DOM node in question, and put your code in a dropdown_controller.js and you won’t need an event at all.

If you really don’t want to add Stimulus to your project for this, it’s not like Stimulus is doing anything truly magic here. You can observe the relevant part of the DOM with your own Mutation Observer code and run the code on newly created elements when they appear in the DOM. Here’s a nice vanilla JS example I found through twenty seconds of googling. This is similar to what Stimulus is doing under the hood.

(Also, nitpick: you say you’re implementing Hotwire, but Hotwire includes Stimulus. You probably mean Turbo.)

@n-studio One solution is using Mutation Observers directly, see above.

1 Like

I quite have the same issue except it has a little quirk.

Basically I have a turbo-frame updated through a turbo Stream with a Stimulus controller attached to the first DOM element of this Turbo-frame. (mostly the debate of this thread but as I have Stimulus I thought I was covered)

Everything is fine, the Stimulus controller fires each time the frame is replaced with a broadcast.

Now I have a little problem because the Stimulus logic that lies in the connect method is about manipulationg the DOM just appended. And it seems that connect() fires before the full frame is loaded. (It is quite a long bit of HTML, with images etc…). Then basically I end up with flaky JS: sometimes the HTML is rendered properly (frame fully loaded before Stimulus connect starts ??), sometimes not.

I have found a very hacky and unreliable way: sleep 50 milliseconds in connect() before firing the function that modifies the content. Though it may work for certain visitors but those with a slow bandwith may still have the problem.

Otherwise I end up in the same situation as above: no hook seems to work inside my connect() method… Would someone have an idea? I have mostly been playing with the hooks , but I am thinking having my stimulus controller outside the turbo-frame and add a mutation observer… Though I am not sure at which point the mutation observer will fire as it is quite an expensive JS bit and can’t afford to have it triggered multiple times before the HTML is fully loaded …

EDIT: by no hook work, I mean no hook that satisfies detecting the Turbo stream is rendered / loaded…

1 Like

Without code this is hard to answer, but isn’t the connect lifecycle callback meant to run when there’s a change to the DOM? So if the DOM manipulation method you’re running is idempotent, it shouldn’t really matter if it fires twice?

There is also frame specific events you might be able to use to trigger the controller method instead of the generic connect callback. There’s a turbo:frame-load event, for instance. Might not work in your case since it’s broadcast from a stream, not sure.

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?

2 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:

4 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.