hotwired / turbo

The speed of a single-page web application without having to write any JavaScript
https://turbo.hotwired.dev
MIT License
6.71k stars 427 forks source link

Replace two different turbo-frame from one response #56

Closed davidjr82 closed 3 years ago

davidjr82 commented 3 years ago

Situation:

Question:

Great package. Thanks!

robzolkos commented 3 years ago

See this commit to see how to do this https://github.com/hotwired/turbo-rails/commit/5c49e57f3431956818e9e40a7e87f7bcdaf3a0e5

davidjr82 commented 3 years ago

Thanks @robzolkos

I am not using ruby, but this looks to me like it is related to turbo-streams, returning different responses depending on the existence of a template, and not related to turbo-frames, but maybe I am wrong and not understanding the PR. What I want is to return 2 turbo-frames in the same response, not to return one or the other.

Is that what the PR does and I am misunderstanding it?

davidjr82 commented 3 years ago

By the way, here is a discussion on the hotwire discuss, of someone trying to achieve the same thing:

https://discuss.hotwire.dev/t/loading-two-frames-from-a-single-application-visit-event/1556

Intrepidd commented 3 years ago

As far as I understand, frames are for scoped navigation. If you want to replace 2 parts of your page at once after a form submit (or from websocket) you need to use turbo streams to replace both those elements.

If the only thing you do with those blocks is bulk replace them and they don't have their own navigation context, you don't even need to use frames, turbo streams just replace from DOM id.

dhh commented 3 years ago

If you want to replace multiple frames, you can also just target _top and replace the whole thing via drive. But otherwise there won't be a path to custom replace two frames. When you need that, you gotta go to turbo streams.

davidjr82 commented 3 years ago

Yes, finally I am targeting _top to do it. The downside of this is that, as a redirect is needed to use _top, the whole page can make heavy operations (not my current case), and targeting _top would be unnecessarily slow because that parts of the page wouldn't change.

Using streams could avoid that, but it feels a little bit overcomplicated to me just to replace two frames. That is why I thought that maybe there would be an easy way where two frames can be used in a form response.

Thanks and good work!

tothda commented 3 years ago

Hi! How about this solution?

  1. A form submitted from a turbo-frame
  2. The server applies the change and sends a redirect back to the browser
  3. Browser (turbo) calls the redirect's location
  4. In the result of this GET request I simply render two turbo stream fragments. The first one replaces the turbo-frame which was the source of the form submission. The second replaces any other element on the page. Note: the response content type has to be Content-Type: text/html; turbo-stream
<turbo-stream action="replace" target="order_form_5">
    <template>
        <turbo-frame id="order_form_5">
            <div class="py-8 border-b border-black">
                Content of order form
            </div>
        </turbo-frame>
    </template>
</turbo-stream>
<turbo-stream action="replace" target="welcome">
    <template>
        <h1 id="welcome">i am changed</h1>
    </template>
</turbo-stream>
davidjr82 commented 3 years ago

@tothda fantastic! it works!

In fact, it's even simpler. There is no need to redirect to another location that loads the streams. Just return a response from the form submitted with turbo-frame, with:

  1. Content-Type: text/html; turbo-stream
  2. Status code 200
  3. All the streams you want to replace, with the formatting that you have written in your message

And all contents will be replaced without any kind of async calls.

@dhh I think this is a common case (example: the user name changes in the navbar dropdown when the user updates his profile name). Maybe it makes sense to add a little note in the turbo-frame/turbo-stream doc? Do you think it's a good idea?

Thanks to all!

vivid commented 1 year ago

Thanks @tothda and @davidjr82!

Small update: with Rails 7 I used content type "text/vnd.turbo-stream.html" and it worked perfectly!

adnen-chouibi commented 1 year ago

For new searchers, how To target multiple elements with a single action, use the targets attribute with a CSS query selector https://turbo.hotwired.dev/reference/streams#targeting-multiple-elements

nickjj commented 1 year ago

Here's another use case where this would be handy for GET requests using only frames.

Imagine having:

When navigating to different videos using the table of contents, both the video frame and links frame would get updated.

Using _top here to break out and use drive is painful because rendering the TOC is heavy.

seanabrahams commented 1 year ago

There can be an infinite number of scenarios where having a single turbo-frame generated request update multiple frames in its response is desirable.

Using data-turbo-frame="_top" results in a terrible experience when the page update is complicated and results in content shifting around during the repaint and potentially losing scroll position.

The scenario that brought me here is that we have a system where we allow access to edit the view templates but not the backend logic. There are workarounds to do things with turbo-streams but it would be much simpler to allow a view author to just list the elements to be replaced: data-turbo-frame="_self nav #toc" to replace the <nav> and <div id="toc"> elements in addition to the <turbo-frame> element that initiated the request. Or, if <nav> and <div id="toc"> need to be <turbo-frame> elements, then data-turbo-frame="_self nav toc" and <turbo-frame id="nav"> and <turbo-frame id="toc">.

What are the compelling reasons to not support this use case?

onEXHovia commented 1 year ago

Another solution to the problem could be to custom renderer. Use only one frame per page and exclude blocks you don't want to update.

<html>
    <head>
        <title>Example</title>
    </head>
    <body>
        <turbo-frame id="detail-page" data-turbo-action="advance" data-turbo-marphdom="true">
            <div class="row">
                <div class="col-12 col-lg-4 col-xl-3" data-morphdom-ignore>
                    Not update aside col
                </div>
                <div class="col-12 col-lg-8 col-xl-9">
                    <a href="/link" data-turbo="true">Link 1</a>
                    <a href="/link2" data-turbo="true">Link 2</a>
                </div>
            </div>
        </turbo-frame>
    </body>
</html>
import morphdom from 'morphdom'

document.addEventListener('turbo:before-frame-render', (event) => {
  const { target } = event;
  if (!target.hasAttribute('data-turbo-morphdom')) {
    return;
  }

  event.detail.render = (fromNode, toNode) => {
    morphdom(fromNode, toNode, {
      onBeforeElUpdated: (fromEl, toEl) => {
        if (fromEl.isEqualNode(toEl)) {
          return false
        }

        return !fromEl.hasAttribute('data-morphdom-ignore');
      },
    });
  };
});

Sure, you can set up nested frames on the page in the same way.

cryptogopher commented 9 months ago

This may or may not solve the problem for you, but there is one more option that is not obvious nor seem to be documented. You can nest turbo streams inside turbo frame like this:

<turbo-frame id="some_frame">
  <turbo-stream action="update" target="element1">
    ...
  </turbo-stream>

  <turbo-stream action="update" target="element2">
    ...
  </turbo-stream>
</turbo-frame>
krschacht commented 8 months ago

I'm struggling with this same issue of wanting to update two frames in response to a single click. My situation is much like @nickjj described with his video player example.

Turbo 8 creates a possible solution to this problem. You can target _top and if rails determines this is a Page Refresh then it will use page morphing and smartly update only the parts of the page that changed, but there can be many separate parts sprinkled throughout the page within different frames. This new behavior is explained pretty well in this article: https://jonsully.net/blog/turbo-8-page-refreshes-morphing-explained-at-length

But rails only does a Page Refresh morph when you have a route such as /videos and clicking a button POSTs to a route such as /videos/123 then redirects back to /videos. But if you instead have a route such as /videos which has a link that does a GET that targets _top, rails decides to do a full page Turbo Drive refresh instead.

I "solved" this issue by turning my link into a POST which then does a redirect back to my original URL, but this does not feel like the right solution. It feels dirty and hacky and caused me to introduce a new route. I'm still investigating if the new Page Refresh action can be explicitly invoked rather than implicitly being triggered by comparing the URLs. It feels like we should just be able to put data-turbo-action="morph" on an element to explicitly trigger it, but it does not appear to work this way.

nickjj commented 8 months ago

Thanks @krschacht. I'm not sure if Turbo 8 will have a solution in the end because the page morphing happens client side right?

If that's the case then the expensive queries and view rendering will happen server side to generate the full HTML payload to send back to the client where it will be diff'd. This includes expensive table of contents rendering, questions and answers and other content that could exist on the page in addition to the video frame.

Using individual frames and streams allows you to bypass having to render those other expensive areas of the page because on the server it will only render what needs to be rendered in the frame or stream (in this case the video player).

Unless I'm drastically misunderstanding how Turbo 8 works? It's one of those things where I really want to use it to simplify everything but I'm not sure I can due to the efficiency loss.

krschacht commented 8 months ago

@nickjj It does still render server side and morph on the client, but when you follow the full Rails Turbo playbook, I think this isn't an issue in practice.

At the end of the day, full page refreshes always end up fast through two techniques: caching and decomposition through frames. (1) There is such a heavy incentive to make the full page render fast and rails caching provides multiple ways to address this. So if the concern over morphing is that the full page render is expensive then your issue is likely elsewhere—we generally need to make the full page render fast, regardless. (2) In the cases where there is still a challenge on full page load, you throw the offending section into a turbo frame. Not only does this facilitate caching, but you can then load the frame in parallel/async so that the rest of the page doesn't block on it. You can also put a data-turbo-permanent and/or refresh=morph on your frame. These two additions to your frame are golden. First, you trigger a full page reload and the permanent tag ensures this frame won't be re-requested. This sounds like the solution to your expensive table-of-contents situation. Second, in the rare case when you do want the frame to be reloaded without reloading the full page, you trigger that explicitly with javascript and having refresh=morph will even do morphing of the newly updated frame.

I'm still piecing all this together, but overall Turbo 8 does bring the right new tools for replacing multiple turbo-frames in one response. I'm still just trying to figure out how to trigger full page morphs without having to redirect back to the original page.