javascriptruby-on-railsruby-on-rails-7stimulusjs

Scroll to element after turbo stream is rendered


I made this stimulus controller just so I can get more insight into what events fire and when, this is the connect portion of the controller:

connect() {
    console.log("controller connected");
    // Turbo Drive Events
    document.addEventListener('turbo:click', () => {
      console.log('turbo:click fired');
    });

    document.addEventListener('turbo:before-visit', () => {
      console.log('turbo:before-visit fired');
    });

    document.addEventListener('turbo:visit', () => {
      console.log('turbo:visit fired');
    });

    document.addEventListener('turbo:visit-start', () => {
      console.log('turbo:visit-start fired');
    });

    document.addEventListener('turbo:visit-received', () => {
      console.log('turbo:visit-received fired');
    });

    document.addEventListener('turbo:before-cache', () => {
      console.log('turbo:before-cache fired');
    });

    document.addEventListener('turbo:before-render', () => {
      console.log('turbo:before-render fired');
    });

    document.addEventListener('turbo:render', () => {
      console.log('turbo:render fired');
    });

    document.addEventListener('turbo:load', () => {
      console.log('turbo:load fired');
    });

    document.addEventListener('turbo:visit-ended', () => {
      console.log('turbo:visit-ended fired');
    });

    document.addEventListener('turbo:frame-load', () => {
      console.log('turbo:frame-load fired');
    });

    // Turbo Stream Events
    document.addEventListener('turbo:before-stream-render', () => {
      console.log('turbo:before-stream-render fired');
    });

    document.addEventListener('turbo:after-stream-render', () => {
      console.log('turbo:after-stream-render fired');
    });

    // Turbo Frame Events
    // Note: 'turbo:frame-load' is already listed under Turbo Drive Events

    // Turbo Form Events
    document.addEventListener('turbo:submit-start', () => {
      console.log('turbo:submit-start fired');
    });

    document.addEventListener('turbo:submit-end', () => {
      console.log('turbo:submit-end fired');
    });
  }

I have some form on the page (not including it because it seems irrelevant to the question but I can if I need to), with which I'm deliberately not sending the right information to the back-end so the code ends up in a certain branch condition in my rails controller, this is that bit:

respond_to do |format|
  format.turbo_stream do
    render turbo_stream: turbo_stream.update('message-space', partial: 'shared/form_errors', locals: { message: @errors })
  end
  format.html { render :edit, status: :unprocessable_entity }
end

I get these 3 in my console logged after I submit the form and I can see the errors on the page:

turbo:submit-start fired
turbo:submit-end fired
turbo:before-stream-render fired

In other words, it tells me that form submit started/ended and something new is about to be rendered. Which aligns with the description on the :

https://turbo.hotwired.dev/reference/events

I am using rails 7.1.2 and @hotwired/stimulus 3.2.2

My use case for other events is after a turbo stream updates the content of the errors on the page, I want to scroll the user up to the error content because the submit button is at the bottom of the page and the errors are shown on the top of the page.

I can probably do some unholy things with javascript events and whatnot to get the above behavior, but I am just curious how this works.

My question is why aren't the other events firing? or maybe my understanding of this is not yet up to par.


Solution

  • There is no turbo:after-stream-render event (and turbo:visit-start, turbo:visit-received, turbo:visit-ended). The 3 events that you see is all that happens.

    Solution is to do your scrolling with a turbo stream:

    respond_to do |format|
      format.turbo_stream do
        render turbo_stream: [
          turbo_stream.update("message-space", partial: "shared/form_errors", locals: {message: @errors}),
          turbo_stream.after(
            "message-space",
            helpers.javascript_tag(%(
              var div = document.querySelector("#message-space");
              div.scrollIntoView({behavior: "smooth"});
            ))
          )
        ]
      end
    end
    

    You can also just add javascript inside shared/_form_errors partial, that way you won't have to change anything in your controllers.


    A custom turbo stream action is also a solution to every turbo stream problem:

    // app/javascript/application.js
    
    Turbo.StreamActions.scroll_to = function () {
      const target = this.targetElements[0];
      target.scrollIntoView({behavior: "smooth"});
    };
    
    respond_to do |format|
      format.turbo_stream do
        render turbo_stream: [
          turbo_stream.update("message-space", partial: "shared/form_errors", locals: {message: @errors}),
          turbo_stream.action(:scroll_to, "message-space")
        ]
      end
    end
    

    Optionally, you can make it a method turbo_stream.scroll_to("message-space"):

    # config/initializers/turbo_actions.rb
    
    module CustomTurboStreamActions
      def scroll_to(...)
        action(:scroll_to, ...)
      end
    
      ::Turbo::Streams::TagBuilder.include(self)
    end
    

    https://turbo.hotwired.dev/handbook/streams#custom-actions


    https://turbo.hotwired.dev/reference/events

    // this is my events.js file i use when trying to figure things out
    
    // NOTE: PREVIEW
    // Turbo Drive adds a data-turbo-preview attribute to the <html> element when
    // it displays a preview from cache. You can check for the presence of this
    // attribute to selectively enable or disable behavior when a preview is
    // visible.
    // https://turbo.hotwired.dev/handbook/building#detecting-when-a-preview-is-visible
    const preview = () =>
      document.documentElement.hasAttribute("data-turbo-preview") || null;
    
    // NOTE: EVENTS
    // TODO: morph events for Turbo v8
    // https://turbo.hotwired.dev/reference/events
    
    // TURBO:CLICK fires when you click a Turbo-enabled link. The clicked element
    // is the event target. Access the requested location with event.detail.url.
    // Cancel this event to let the click fall through to the browser as normal
    // navigation.
    addEventListener("turbo:click", (event) => {
      console.log(preview(), "turbo:click", event);
    });
    
    // TURBO:BEFORE-VISIT fires before visiting a location, except when navigating
    // by history. Access the requested location with event.detail.url. Cancel this
    // event to prevent navigation.
    addEventListener("turbo:before-visit", (event) => {
      console.log(preview(), "turbo:before-visit", event);
    });
    
    // TURBO:BEFORE-FETCH-REQUEST fires before Turbo issues a network request to
    // fetch the page. Access the requested location with event.detail.url and the
    // fetch options object with event.detail.fetchOptions. This event fires on the
    // respective element (turbo-frame or form element) which triggers it and can
    // be accessed with event.target property. Request can be canceled and
    // continued with event.detail.resume (see Pausing Requests).
    addEventListener("turbo:before-fetch-request", (event) => {
      console.log(preview(), "turbo:before-fetch-request", event);
    });
    
    // TURBO:VISIT fires immediately after a visit starts. Access the requested
    // location with event.detail.url and action with event.detail.action.
    addEventListener("turbo:visit", (event) => {
      console.log(preview(), "turbo:visit", event.detail, event);
    });
    
    // TURBO:SUBMIT-START fires during a form submission. Access the FormSubmission
    // object with event.detail.formSubmission. Abort form submission (e.g. after
    // validation failure) with event.detail.formSubmission.stop(). (use
    // event.originalEvent.detail.formSubmission.stop() if you’re using jQuery).
    addEventListener("turbo:submit-start", (event) => {
      console.log(preview(), "turbo:submit-start", event);
    });
    
    // TURBO:BEFORE-FETCH-RESPONSE fires after the network request completes.
    // Access the fetch options object with event.detail. This event fires on the
    // respective element (turbo-frame or form element) which triggers it and can
    // be accessed with event.target property.
    addEventListener("turbo:before-fetch-response", (event) => {
      console.log(preview(), "turbo:before-fetch-response", event);
    });
    
    // TURBO:SUBMIT-END fires after the form submission-initiated network request
    // completes. Access the FormSubmission object with event.detail.formSubmission
    // along with FormSubmissionResult properties included within event.detail.
    addEventListener("turbo:submit-end", (event) => {
      console.log(preview(), "turbo:submit-end", event);
    });
    
    // TURBO:BEFORE-STREAM-RENDER fires before rendering a Turbo Stream page
    // update. Access the new <turbo-stream> element with event.detail.newStream.
    // Customize the element’s behavior by overriding the event.detail.render
    // function (see Custom Actions).
    addEventListener("turbo:before-stream-render", (event) => {
      console.log(preview(), "turbo:before-stream-render", event);
    });
    
    // TURBO:BEFORE-CACHE fires before Turbo saves the current page to cache.
    addEventListener("turbo:before-cache", (event) => {
      console.log(preview(), "turbo:before-cache", event);
    });
    
    // TURBO:BEFORE-RENDER fires before rendering the page. Access the new <body>
    // element with event.detail.newBody. Rendering can be canceled and continued
    // with event.detail.resume (see Pausing Rendering). Customize how Turbo Drive
    // renders the response by overriding the event.detail.render function (see
    // Custom Rendering).
    addEventListener("turbo:before-render", (event) => {
      console.log(preview(), "turbo:before-render", event);
    });
    
    // TURBO:RENDER fires after Turbo renders the page. This event fires twice
    // during an application visit to a cached location: once after rendering the
    // cached version, and again after rendering the fresh version.
    addEventListener("turbo:render", (event) => {
      console.log(preview(), "turbo:render", event);
    });
    
    // TURBO:BEFORE-FRAME-RENDER fires before rendering the <turbo-frame> element.
    // Access the new <turbo-frame> element with event.detail.newFrame. Rendering
    // can be canceled and continued with event.detail.resume (see Pausing
    // Rendering). Customize how Turbo Drive renders the response by overriding the
    // event.detail.render function (see Custom Rendering).
    addEventListener("turbo:before-frame-render", (event) => {
      console.log(preview(), "turbo:before-frame-render", event);
    });
    
    // TURBO:FRAME-RENDER fires right after a <turbo-frame> element renders its
    // view. The specific <turbo-frame> element is the event target. Access the
    // FetchResponse object with event.detail.fetchResponse property.
    addEventListener("turbo:frame-render", (event) => {
      console.log(preview(), "turbo:frame-render", event);
    });
    
    // TURBO:FRAME-LOAD fires when a <turbo-frame> element is navigated and
    // finishes loading (fires after turbo:frame-render). The specific
    // <turbo-frame> element is the event target.
    addEventListener("turbo:frame-load", (event) => {
      console.log(preview(), "turbo:frame-load", event);
    });
    
    // TURBO:FRAME-MISSING fires when the response to a <turbo-frame> element
    // request does not contain a matching <turbo-frame> element. By default, Turbo
    // writes an informational message into the frame and throws an exception.
    // Cancel this event to override this handling. You can access the Response
    // instance with event.detail.response, and perform a visit by calling
    // event.detail.visit(...).
    addEventListener("turbo:frame-missing", (event) => {
      console.log(preview(), "turbo:frame-missing", event);
    });
    
    // TURBO:FETCH-REQUEST-ERROR fires when a form or frame fetch request fails due
    // to network errors. This event fires on the respective element (turbo-frame
    // or form element) which triggers it and can be accessed with event.target
    // property. This event can be canceled.
    addEventListener("turbo:fetch-request-error", (event) => {
      console.log(preview(), "turbo:fetch-request.error", event);
    });
    
    // TURBO:LOAD fires once after the initial page load, and again after every
    // Turbo visit. Access visit timing metrics with the event.detail.timing
    // object.
    addEventListener("turbo:load", (event) => {
      console.log(preview(), "turbo:load", event);
    });