Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Ability to override frame-target from server response #257

Open
acetinick opened this issue Apr 23, 2021 · 59 comments · May be fixed by hotwired/turbo-rails#367
Open

Ability to override frame-target from server response #257

acetinick opened this issue Apr 23, 2021 · 59 comments · May be fixed by hotwired/turbo-rails#367

Comments

@acetinick
Copy link

acetinick commented Apr 23, 2021

Currently if the response from turbo requests returns a redirect response whilst inside a frame, there is no way for the server side to conditionally control what turbo to do with the response, eg. force a full page refresh with target="_top"

There are use cases especailly for modal windows where upon succesful save we would want to perform a redirect back to the index page, where as 422 response would do the normal turbo frame replacement within current frame.

It would be nice to support something like this, which will allow us to control targets from serverside to frame targets by setting frame target via the http response headers.

 if @record.save
  response.headers["Turbo-Frame"] = "_top"
  redirect_to settings_locations_url
else
  render partial: 'form', status: :unprocessable_entity
end

What everyones thoughts are on this? or is there something I am missing to make this easier.

@sukei
Copy link

sukei commented Apr 23, 2021

I got some use cases where controlling that behavior from the server would be useful. Session expiration is one of them.

@WriterZephos
Copy link

I like this idea and think it is inline with the programming model turbo is built for, which is letting the server do the work.

@saltysealion
Copy link

How is this problem currently being solved? I'm at this exact situation and not sure what I can do to work around it.

@dhh
Copy link
Member

dhh commented Sep 4, 2021

I'd be happy to see something like this.

seanpdoyle added a commit to seanpdoyle/turbo that referenced this issue Sep 7, 2021
seanpdoyle added a commit to seanpdoyle/turbo-rails that referenced this issue Sep 10, 2021
The bandwidth benefits of optimizing `Turbo-Frame:` header responses to
omit the layout do not offset the HTTP-layer cache busting trade-offs.

Similarly, there's an opportunity to implement behavior changes related
to:

* [@hotwired/turbohotwired#361][]
* [@hotwired/turbohotwired#257][]

if both `<turbo-frame>` elements' `FrameController` instances and
`Session` instances were able to share `Visit` instances built from
fully formed `<html>` pages. By trimming the layout and outer portions
of the document, `turbo-rails` forces frames to deal with incomplete
fragments.

[@hotwired/turbohotwired#257]: hotwired/turbo#257
[@hotwired/turbohotwired#361]: hotwired/turbo#361
seanpdoyle added a commit to seanpdoyle/turbo-rails that referenced this issue Sep 15, 2021
The bandwidth benefits of optimizing `Turbo-Frame:` header responses to
omit the layout do not offset the HTTP-layer cache busting trade-offs.

Similarly, there's an opportunity to implement behavior changes related
to:

* [@hotwired/turbohotwired#361][]
* [@hotwired/turbohotwired#257][]

if both `<turbo-frame>` elements' `FrameController` instances and
`Session` instances were able to share `Visit` instances built from
fully formed `<html>` pages. By trimming the layout and outer portions
of the document, `turbo-rails` forces frames to deal with incomplete
fragments.

[@hotwired/turbohotwired#257]: hotwired/turbo#257
[@hotwired/turbohotwired#361]: hotwired/turbo#361
@seanpdoyle
Copy link
Contributor

Unfortunately, sending a response with Turbo-Frame: _top is incompatible with the browser built-in fetch API.

A fetch Response resulting in a redirect deliberately prevents access to the intermediate redirect response with a status in the 300...399 range.

According to the 2.2.6. Responses section of the specification:

Except for the last URL, if any, a response’s URL list is not exposed to script as that would violate atomic HTTP redirect handling.

The Atomic HTTP redirect handling section of the specification states:

Redirects (a response whose status or internal response’s (if any) status is a redirect status) are not exposed to APIs. Exposing redirects might leak information not otherwise available through a cross-site scripting attack.

I'm no CORS expert, but the specification mentions some CORS-related leeway with regard to headers.

Unless I'm missing a crucial concept, I don't think there is a way for Turbo to excise the server's Turbo-Frame: _top header from the chain of responses. Without access to that value, the client-side is unable to react to the server's override.

There are some related discussions on whatwg/fetch:

Alternatives

If those limitations hold, we'll need to investigate alternatives to sending back a header.

  1. Decide on a special case, reserved query parameter (for the sake of argument: ?turbo_frame_override=_top). During the form submission response code, we can tease out that value (and maybe even delete it from the URL) and push a new Visit onto the history. Responses to URLs without that query parameter would preserve the current behavior and continue to drive the frame element to the new URL.
  2. Replace Fetch with XMLHttpRequest, and use that to access the intermediate response and its headers. This assumes that's even possible (I haven't experimented)
  3. Add a unique identifier to the headers of each Turbo Frame-initiated Fetch Request. Since the value is shared between the client and server, we could send frame target overrides via cookies
  4. Change the Turbo Frame semantics for HTTP Response codes. For example, 201 Created responses are sent back with a Location: header. We could use 201 to signify that a frame response should navigate to the URL in the header, and treat 303 See Other responses as _top level redirects.

I've ranked them from least to most regrettable. I'm hoping I'm missing something obvious here!

@seanpdoyle
Copy link
Contributor

seanpdoyle commented Sep 16, 2021

I've had success with a work-around that writes to the Rails session and sends down a Turbo-Frame: _top header on subsequent GET requests to the same frame. The session (and the backing cookie) is tied to the user.

# app/controllers/concerns/turbo_frame_redirectable.rb
module TurboFrameRedirectable
  extend ActiveSupport::Concern

  included do
    before_action -> { session[:turbo_frames] ||= {} }, if: -> { turbo_frame_request? }
    after_action  :write_turbo_frame_header,            if: -> { turbo_frame_request? && request.get? }
  end

  # Override Rails' default redirect_to so that it accepts a `turbo_frame:` option
  #
  #   redirect_to post_url(@post), status: :see_other, turbo_frame: "_top"
  #
  def redirect_to(*arguments, **options, &block)
    if (turbo_frame = request.headers["Turbo-Frame"]) && options.key?(:turbo_frame)
      session[:turbo_frames].reverse_merge! turbo_frame => options[:turbo_frame]
    end

    super
  end

  private

  def write_turbo_frame_header
    response.headers["Turbo-Frame"] = session[:turbo_frames].delete request.headers["Turbo-Frame"]
  end
end

Then, read that value on the client-side and intervene when _top.

import { clearCache, visit } from "@hotwired/turbo"

addEventListener("turbo:submit-start", ({ detail: { formSubmission: requestFormSubmission } }) => {
  const { formElement: form, fetchRequest: request } = requestFormSubmission

  if (request.headers["Turbo-Frame"]) {
    const listenForTurboFrameOverride = ({ detail: { fetchResponse: response, formSubmission: responseFormSubmission } }) => {
      if (responseFormSubmission === requestFormSubmission
        && response.redirected
        && response.header("Turbo-Frame") == "_top") {
        clearCache()
        visit(response.location)
      }
    }

    form.addEventListener("turbo:submit-end", listenForTurboFrameOverride, { once: true })
  }
})

This combination of workarounds is functional, but has its drawnbacks:

  • multiple browser tabs navigating multiple frames
  • other timing issues that would cause a GET response to become out of sync with its corresponding POST/PUT/DELETE etc
  • relies on Rails' session management, which breaks with Turbo's current server-agnostic paradigm.

While it's still unclear how servers will ensure that a Turbo-Frame: _top header persists across 303 See Other redirects, the client-side logic is much more straightforward:

Whenever a response with a Turbo-Frame: _top header is handled by a FrameController, propose a full-page Visit.

I've opened #397 to experiment with that.

seanpdoyle added a commit to seanpdoyle/turbo that referenced this issue Sep 17, 2021
hotwired#257

---

While it's still unclear how servers will persist a `Turbo-Frame:`
header override across destructive actions and the `GET` requests that
result from their `303 See Other` responses, the client side concern is
more straightforward:

  Whenever a response with a `Turbo-Frame: _top` header is handled by a
  `FrameController`, propose a full-page Visit.

This commit adds test coverage for both `<a>` element initiated `GET`
requests, as well as `<form>` element initiated `POST` requests.
seanpdoyle added a commit to seanpdoyle/turbo that referenced this issue Sep 17, 2021
hotwired#257

---

While it's still unclear how servers will persist a `Turbo-Frame:`
header override across destructive actions and the `GET` requests that
result from their `303 See Other` responses, the client side concern is
more straightforward:

  Whenever a response with a `Turbo-Frame: _top` header is handled by a
  `FrameController`, propose a full-page Visit.

This commit adds test coverage for both `<a>` element initiated `GET`
requests, as well as `<form>` element initiated `POST` requests.
seanpdoyle added a commit to seanpdoyle/turbo-rails that referenced this issue Sep 17, 2021
The bandwidth benefits of optimizing `Turbo-Frame:` header responses to
omit the layout do not offset the HTTP-layer cache busting trade-offs.

Similarly, there's an opportunity to implement behavior changes related
to:

* [@hotwired/turbohotwired#361][]
* [@hotwired/turbohotwired#257][]

if both `<turbo-frame>` elements' `FrameController` instances and
`Session` instances were able to share `Visit` instances built from
fully formed `<html>` pages. By trimming the layout and outer portions
of the document, `turbo-rails` forces frames to deal with incomplete
fragments.

[@hotwired/turbohotwired#257]: hotwired/turbo#257
[@hotwired/turbohotwired#361]: hotwired/turbo#361
seanpdoyle added a commit to seanpdoyle/turbo that referenced this issue Nov 2, 2021
hotwired#257

---

While it's still unclear how servers will persist a `Turbo-Frame:`
header override across destructive actions and the `GET` requests that
result from their `303 See Other` responses, the client side concern is
more straightforward:

  Whenever a response with a `Turbo-Frame: _top` header is handled by a
  `FrameController`, propose a full-page Visit.

This commit adds test coverage for both `<a>` element initiated `GET`
requests, as well as `<form>` element initiated `POST` requests.
seanpdoyle added a commit to seanpdoyle/turbo that referenced this issue Nov 2, 2021
hotwired#257

---

While it's still unclear how servers will persist a `Turbo-Frame:`
header override across destructive actions and the `GET` requests that
result from their `303 See Other` responses, the client side concern is
more straightforward:

  Whenever a response with a `Turbo-Frame: _top` header is handled by a
  `FrameController`, propose a full-page Visit.

This commit adds test coverage for both `<a>` element initiated `GET`
requests, as well as `<form>` element initiated `POST` requests.
@tleish
Copy link
Contributor

tleish commented Nov 5, 2021

Why do we need to return "_top" at all?

What if when a response from the server does not include header['turbo-frame'], couldn't Turbo just assume _top? and treat the response as a standard turbo request (updates the page URL, swaps the entire body, etc)?

I know at the moment if the response to a turbo-frame request does not include a matching turbo-frame ID, then nothing happens on the page and an error is logged into the console. Is there any reason we couldn't make this the default behavior? Is there a scenario I'm not thinking of?

This solve multiple scenarios that we've run into, including a common scenario where a turbo-frame request responds with a session timeout redirect to the login page.

@acetinick
Copy link
Author

I think it could be the case for _top and wanting a full page redirect. However being able to return and change the frame in the server response has other benefits in being able to conditionally replace frames server side too.

@seanpdoyle
Copy link
Contributor

seanpdoyle commented Nov 6, 2021

@tleish that's an interesting suggestion!

What if when a response from the server does not include header['turbo-frame'], couldn't Turbo just assume _top?

I know at the moment if the response to a turbo-frame request does not include a matching turbo-frame ID, then nothing happens on the page and an error is logged into the console.

The only determining factor is the presence or absence of a <turbo-frame> element with an [id] that matches the Turbo-Frame header in the request. The Turbo-Frame header isn't currently sent in the response at all.

Requiring that Turbo detect a <turbo-frame> element with an [id] that matches a newly present Turbo-Frame header value would be a breaking change. Since this feature is being proposed with a two-sided Rails + Turbo.js integration, that breaking change could be covered in @hotwired/turbo-rails. It'd be a breaking change for any other server-side framework (turbo-laravel, for instance).

If this feature is deemed worthwhile, it might be worth the breaking changes.

Am I understanding your proposal correctly?

@tleish
Copy link
Contributor

tleish commented Nov 6, 2021

@seanpdoyle - yes, this was my original thinking, but I misunderstood the logic for how turbo-frame determined to log an error on a missing turbo-frame response.

Does turbo-frame look for the actual ID in the response DOM? Where ever the existing logic is to replace the current turbo-frame or log an error, instead of just logging the error of missing turbo-frame ID I'm suggesting it also load the page/URL. The logic would be located in the same location, it would not be breaking (for Rails or any other server-side frameworks).

In what situation would the software receive an invalid turbo-frame response that you would want to do nothing? Situations I can think of missing turbo-frame ID response:

  • Expired session and redirect to login
  • Stale CSRF Token with error response
  • If/else response where else return non-turbo-frame
  • others?

@seanpdoyle
Copy link
Contributor

@tleish I do think that handling a response without a matching <turbo-frame> in that way is worth exploring.

Having said that, the code mentioned in #257 (comment) was developer with slightly different situations in mind. For example, when there is a matching <turbo-frame> element in both the source and response document. In that circumstance, I think applications might want a mechanism to ignore the presence of the matching frames and "break out" of the frame to navigate the entire page.

@tleish
Copy link
Contributor

tleish commented Nov 11, 2021

@seanpdoyle - I personally prefer keeping the breakout login in the HTML response vs a header.

Most Common Scenarios

The most common reasons for breaking out of a frame involve no edge case changes to code:

  • 3xx (e.g. form redirect or expired session redirect to login)
  • 4xx (e.g. code error or stale CSRF Token with error response)
  • 5xx (e.g. server error)

I feel like developer should not need to add additional code to break out of turbo-frames in the above common scenarios (e.g. add _top if 400).

Client Side Determines Turbo-Frame Break

With Turbo today, if a developer wants the server response to be rendered inside the turbo-frame, they must customize the HTML response to include the turbo-frame id (make sense). Today, they can also define breaking out of turbo-frame in the HTML (even if response has matching turbo-frame#id)

<turbo-frame id="messages" target="_top">
  <a href="/messages/1">
    Following link will replace the whole page, even if response has matching turbo-frame#id.
  </a>

  <form action="/messages">
    Submitting form will replace the whole page, even if response has matching turbo-frame#id.
  </form>
</turbo-frame>

or

<turbo-frame id="messages">
  <a href="/messages/1" data-turbo-frame="_top">
    Following link will replace the whole page, even if response has matching turbo-frame#id.
  </a>

  <form action="/messages" data-turbo-frame="_top">
    Submitting form will replace the whole page, even if response has matching turbo-frame#id.
  </form>
</turbo-frame>

Server Side Determines Turbo-Frame Break

If a developer is creating custom code to include a matching turbo-frame, but they do not want the client to use the matching turbo-frame, then it make sense to me that they also add custom code to handle this case. I could see this as one of two options:

  1. Add headers["Turbo-Frame"]="_top" to the response
  2. Assuming that the code breaks out of a turbo-frame if the response does not include a matching turbo-frame#id, then alter the turbo-frame#id in the HTML response.

In option 1

  • It seems a little odd for me to have HTML define the turbo-frame replacement logic (i.e. the turbo-frame#id), but then a header which overrides this logic. Personally I prefer to keep this type of logic as close together as possible. If I'm a developer troubleshooting a turbo-frame response I'm not familiar with I would be looking in the HTML template/view and also inspecting the HTML response from the server. If I see that the response has a matching turbo-frame#id, but it's not replacing as I would expect... it might take hours of troubleshooting until I finally discover that there's a new header in the response I wasn't looking for.
  • It seems non-Rails frameworks will need to add helper methods to handle this logic.

In option 2

  • The logic is in a single place. Easier to troubleshoot and just as easyto alter the turbo-frame#id
  • No additional tooling need to other backend frameworks to support this

Client

<turbo-frame id="messages">
  <a href="/messages/1">Message 1</a>
</turbo-frame>

Server Response

<turbo-frame id="<%= 'message-1' unless turbo_frame_target_top? %>">
  <a href="/messages/1">Message 1</a>
</turbo-frame>

The above says I don't want this HTML to be included in an existing turbo-frame if a certain condition is met. #turbo_frame_target_top? might be my own customer helper method. You could just as easily define the target id in the controller which can break out of the frame:

<turbo-frame id="<%= @turbo_frame_id %>">
  <a href="/messages/1">Message 1</a>
</turbo-frame>

For me, I prefer option 2.

@seanpdoyle
Copy link
Contributor

seanpdoyle commented Nov 13, 2021

I think there are two scenarios worth highlighting and distinguishing from one
another.

To help guide the discussion around them, let's focus on a concrete use case:

A multi-step form within a <turbo-frame> nested in a modal <dialog> element

Let's outline a hypothetical page:

<html>
  <head>
    <script type="module">
      import { Application, Controller } from "https://unpkg.com/@hotwired/stimulus/dist/stimulus.js"
      window.Stimulus = Application.start()

      Stimulus.register("dialog", class extends Controller {
        showModal() {
          if (this.element.open) return
          else this.element.showModal()
        }
      })
    </script>
  </head>

  <body>
    <main>
      <form action="/articles/new" data-turbo-frame="dialog_frame">
        <button aria-expanded="false" aria-controls="dialog">New article</button>
      </form>
    </main>

    <dialog id="dialog" data-controller="dialog" data-action="turbo:frame-load->dialog#showModal">
      <turbo-frame id="dialog_frame"></turbo-frame>
    </dialog>
  </body>
</html>

There are three areas to highlight:

  1. The <form> element targets the <turbo-frame id="dialog_frame"> element,
    and will drive it with GET /articles/new requests whenever it's submitted
  2. The <turbo-frame id="dialog_frame"> is nested within a <dialog> element
  3. The <dialog> element is controlled by a dialog Stimulus controller that
    calls HTMLDialogElement.showModal() whenever the descendant frame completes
    a navigation

Handling a response without a matching <turbo-frame>

Like @tleish mentioned, there is a desire treat responses with status codes in
the range of 301..303 and 401..403 (and possible more) differently.

For example, if the user's session expired and they click the "New article"
button, the server might respond with a 401 and a response containing
something like:

<html>
  <body>
    <h1>Access denied</h1>
    <p>Your session has ended. Please <a href="/sessions/new">Log in</a></p>
  </body>
</html>

Since that response doesn't contain a matching <turbo-frame id="dialog_frame">, replacing the entire page's contents with the response
feels appropriate.

Similarly, if the response were a 302 redirect to /sessions/new with the
following body:

<html>
  <body>
    <h1>Log in</h1>
    <form method="post" action="/sessions">
      <label for="session_email">Email address</label>
      <input type="email" id="session_email" name="session[email"]>
      <!-- ... -->
    </form>
  </body>
</html>

The absence of a <turbo-frame id="dialog_frame"> might be considered an
indication that the full page should be replaced with the response's contents.

This behavior would require some changes to the Turbo's internals, but are
reasonable. If we wanted this behavior, the path forward is fairly clear.

Handling a response with a matching <turbo-frame>

The situations that 210 and 397 attempt to address are different in that
they involve responses with a <turbo-frame> that matches the request.

For example, imagine that the first step in the Article creation process
served by /articles/new presents the User with a brief set of instructions:

<body>
  <turbo-frame id="dialog_frame">
    <h1>Writing an Article</h1>

    <a href="/help" data-turbo-frame="_top">Learn more</a>
    <a href="/articles/new?step=title">Get started</a>
  </turbo-frame>
</body>

Clicking the "Learn more" link targets the _top "frame" and navigates the
whole page, "breaking out" of the frame. Even if the GET /help response
contains a <turbo-frame id="dialog_frame"> element, the entire page is
navigated. This is the only mechanism that Turbo provides to "break out" of a
frame.

Clicking the "Get started" link makes a GET /articles/new?step=title and
targets the <turbo-frame id="dialog_frame"> ancestor. Imagine that the
response contains a <form> to start the Article creation process:

<body>
  <turbo-frame id="dialog_frame">
    <h1>What's the title?</h1>

    <form method="post" action="/articles">
      <label for="article_title">Title</label>
      <input id="article_title" name="article[title]">

      <button>Next</button>
    </form>
  </turbo-frame>
</body>

Submitting the <form> makes a POST /articles request. Imagine that the
corresponding server-side handler for the request validates the presence of the
article[title] input.

In the case of a missing value, the response sent back is a 422 Unprocessable entity. We'd want the <turbo-frame id="dialog_frame"> to render that response
(within the <dialog id="dialog">).

In the case of a valid value, (like "My Title"), the response sent back is a
302 Found to the next step in the process served by
/articles/new?step=body&title=My+Title:

<body>
  <turbo-frame id="dialog_frame">
    <h1>My Title</h1>

    <form method="post" action="/articles">
      <input type="hidden" name="article[title"] value="My Title">

      <label for="article_body">Body</label>
      <textarea id="article_body" name="article[body]"></textarea>

      <button>Submit</button>
    </form>
  </turbo-frame>
</body>

This presents a similar situation as before. In the case of an invalid
article[body] value, the server would respond with a 422 Unprocessable entity, which we'd want to render within the <turbo-frame id="dialog_frame">
within the <dialog id="dialog"> page.

However, in the case of a valid value and success (for example, "<p>My article</p>"), the server would create the Article record and redirect to
/articles/1:

<body>
  <main>
    <h1>My Title</h1>

    <p>My article</p>

    <form action="/articles/1/edit" data-turbo-frame="dialog_frame">
      <button aria-expanded="false" aria-controls="dialog">Edit "My Title"</button>
    </form>
  </main>

  <dialog id="dialog">
    <turbo-frame id="dialog_frame"></turbo-frame>
  </dialog>
</body>

This is where things become unclear. We want the successful submission to "break
out" of the frame and fully navigate the page to /articles/1. However, since
there is a <turbo-frame id="dialog_frame"></turbo-frame> in both the
requesting page and response, we can't rely on the presence or absence to make
that decision.

Declaring each page's <turbo-frame id="dialog_frame"> with the
[target="_top"] attribute would handle the "create, then redirect the page"
use case, but would break the multi-step experience, and would also break
intermediate-step validations. Support for both [target="_top"] and 422
status responses is what 210 aims to implement.

Conversely, omitting the [target] attribute and controlling whether or not to
stay "contained" within the frame is what 397 (paired with a server-side
component like what's mentioned in ) aims to support.

I would love to cover all of these behaviors without requiring that the server
track frame state in its session, or respond with a Turbo-Frame header.

Are there other solutions that I'm not considering that support the experience
described above?

@tleish
Copy link
Contributor

tleish commented Nov 13, 2021

@seanpdoyle - that's an interesting scenario.

1. A dynamic turbo-frame ID

One approach is to create a unique id for the dialog frame and pass the frame as an ID

<body>
- <turbo-frame id="dialog_frame">
+ <%= dialog_frame_id = params[:dialog_frame_id] || "dialog_frame_#{Time.now.to_i}" %>
+ <turbo-frame id="<%= dialog_frame_id %>">
    <h1>My Title</h1>

    <form method="post" action="/articles">
+     <input hidden="dialog_frame_id" value="<%= dialog_frame_id %>">
      <input type="hidden" name="article[title"] value="My Title">

      <label for="article_body">Body</label>
      <textarea id="article_body" name="article[body]"></textarea>

      <button>Submit</button>
    </form>
  </turbo-frame>
</body>

The downside of this approach is the complexity that I now have to update all targets which reference the frame on the current page and responding page. It also feels kludgy, but may be worth considering depending on how "corner case" you consider the scenario .

2. Respond with a header

Have the server respond with a Turbo-Frame header. The downside is this is far from the turbo-frame, so troubleshooting why the behavior is altered could prove difficult.

BTW, does Turbo include other features a developer can change by responding with specific header?

3. Respond with a meta tag

Respond with some type of meta tag in the header. Something like:

<meta name="turbo-frame-control" content="extract(default)|ignore">

This follows other similar turbo conventions (<meta name="turbo-visit-control">, <meta name="turbo-cache-control">) and is more in storing the state in HTML.

The downside is similar to the header solution which is far from the turbo-frame (although slightly closer in that it's in the HTML), so troubleshooting why the behavior is altered could prove difficult.

4. Respond with a turbo-frame attribute

Respond with an attribute which controls if the turbo-frame should be extracted:

Add a new attribute

<turbo-frame id="dialog_frame" data-turbo-extract="true(default)|false">

The advantage, it's close to the iframe and could make it easier to troubleshoot behavior.

The disadvantage is, in a page with multiple turbo-frames you'd have to include this parameter on more than one (maybe?), where the header or meta solution applies to the entire page. Not sure if this is solution considering only one turbo-frame request is sent at a time.

@tleish
Copy link
Contributor

tleish commented Nov 13, 2021

Something else to consider with any of these scenarios, what happens if the response indicates to break out of the frame but it does not have a <head> element:

In other words:

turbo-rails/app/controllers/turbo/frames/frame_request.rb

layout -> { false if turbo_frame_request? }

@seanpdoyle
Copy link
Contributor

what happens if the response indicates to break out of the frame but it does not have a element

I agree! This is part of the reasoning behind hotwired/turbo-rails#232. I haven't pushed as hard for that because I'm not sure if we've exhausted other options. Having said that, I believe that communicating in fully-formed HTML documents has other benefits.

@seanpdoyle
Copy link
Contributor

seanpdoyle commented Nov 13, 2021

Implementing with Turbo Streams

It's worth mentioning that something like this is currently possible with a
combination of HTTP, <turbo-frame> elements, and <turbo-stream> elements in
response to Form submissions.

Consider the example HTML from before:

<html>
  <head><!-- ... --></head>
  <body>
    <main>
      <form action="/articles/new" data-turbo-frame="dialog_frame">
        <button aria-expanded="false" aria-controls="dialog">New article</button>
      </form>
    </main>

    <dialog id="dialog" data-controller="dialog" data-action="turbo:frame-load->dialog#showModal">
      <turbo-frame id="dialog_frame"></turbo-frame>
    </dialog>
  </body>
</html>

Instead of trying to navigate the page in compliance with the frame, let's add
[target="_top"] and "navigate" with Turbo Streams:

<html>
  <head><!-- ... --></head>
  <body>
    <main>
      <form action="/articles/new" data-turbo-frame="dialog_frame">
        <button aria-expanded="false" aria-controls="dialog">New article</button>
      </form>
    </main>

    <dialog id="dialog" data-controller="dialog" data-action="turbo:frame-load->dialog#showModal">
-      <turbo-frame id="dialog_frame"></turbo-frame>
+      <turbo-frame id="dialog_frame" target="_top"></turbo-frame>
    </dialog>
  </body>
</html>

Let's consider a hypothetical Rails controller's implementation. We'll render
streams for each intermediate step, then redirect at the end.

⚠️ The example from before with the multi-step form to create an Article is a contrived example. ⚠️

As a side-effect of handling multiple steps in the same controller, the code is
a bit awkward.

🚨Pseudo-code ahead 🚨:

class Article < ApplicationRecord
  validates :title, presence: true, on: :title

  with_options presence: true do
    validates :title
    validates :body
  end
end

class ArticlesController < ApplicationController
  def new
    @article = Article.new title: params[:title]
  end

  def create
    @article = Article.new article_params

    case params[:step]
    when "title"
      if @article.validate context: :title
        params[:step] = "body"

        render :new
      else
        render :new, status: :unprocessable_entity
      end
    when "body"
      if @article.save
        redirect_to article_url(@article)
      else
        render :new, status: :unprocessable_entity
      end
    else
      render :new, status: :unprocessable_entity
    end
  end

  def show
    @article = Article.find params[:id]
  end

  private def article_params
    params.require(:article).permit(:title, :body)
  end
end

In tandem with a controller like that, consider an articles/step partial
rendered in both articles/new.html.erb and articles/new.turbo_stream.erb templates:

<%# app/views/articles/_step.html.erb %>

<% case local_assigns[:step] when "title" %>
  <h1>What's the title?</h1>

  <form method="post" action="/articles">
    <label for="article_title">Title</label>
    <input id="article_title" name="article[title]" value="<%= @article.title %>">

    <button>Next</button>
  </form>
<% when "body" %>
  <h1><%= @article.title %></h1>

  <form method="post" action="/articles">
    <input type="hidden" name="article[title"] value="<%= @article.title %>">

    <label for="article_body">Body</label>
    <textarea id="article_body" name="article[body]"><%= @article.body %></textarea>

    <button>Submit</button>
  </form>
<% else %>
  <h1>Writing an Article</h1>

  <a href="/help">Learn more</a>
  <a href="/articles/new?step=title" data-turbo-frame="dialog_frame">Get started</a>
<% end %>
<%# app/views/articles/new.html.erb %>

<turbo-frame id="dialog_frame">
  <%= render partial: "articles/step", locals: { article: @article, step: params[:step] } %>
</turbo-frame>
<%# app/views/articles/new.turbo_stream.erb %>

<turbo-stream target="dialog_frame" action="update">
  <template>
    <%= render partial: "articles/step", locals: { article: @article, step: params[:step] } %>
  </template>
</turbo-stream>

In this case, the controller is capable of responding to typical HTML requests,
and can also "upgrade" the response to be a Turbo Stream when the request header
is present.

Making it work with Turbo Frames and redirects

Personally, I have a strong distaste for responding to <form> submissions with
<turbo-stream> elements, and would much prefer to communicate in
tried-and-true HTML documents and HTTP redirects instead.

I'm still very interested in supporting a multi-step form
experience with <turbo-frame> requests and page redirects.

Thank you, @tleish. I appreciate these suggestions. Some of them are new to me
(I like the idea of a <meta> or attribute on the frame!). Unfortunately,
there's another aspect of this I forgot to mention in the previous response.

Whichever server-side detail we'd check for while "breaking out" during a
redirect response would need to be sturdy enough to survive the subsequent
sequence of responses. When the server sends back 302 Found or 303 See other
response, the browser follows-up with a subsequent GET to the URL encoded into
the Location: header. Unfortunately, it seems that the fetch API makes this
intermediate 3xx response completely inaccessible to client-side code.

That means that a Turbo-Frame: _top header sent in the 3xx response would
not
be included in the subsequent GET without some server-side shenanigans
(which is why my example code includes both client- and server-side
logic). This would be a drawback for the <meta> and <turbo-frame data-turbo-extract="..."> approaches as well. There'd need to be a way for the
server's response logic to persist whatever internal flag is necessary to render
the Turbo-Frame: header, <meta> value, or [data-turbo-extract] value in a
request handled at a later point in time.

A query parameter might work, but "breaking out" based on
?turbo_frame_target=_top (or something else arcane enough to not conflict with
any application-level domain term) would need to be cleaned-up so that
subsequent interactions can start with a clean slate.

I hope I'm misunderstanding the constraints of the fetch implementation.
Accessing the intermediate 3xx response's headers would open the door to
Option 2.

seanpdoyle added a commit to seanpdoyle/turbo-rails that referenced this issue Nov 18, 2022
Closes hotwired/turbo#257
Closes hotwired/turbo#397

Follow-up to:

* hotwired/turbo#257 (comment)
* hotwired/turbo#257 (comment)

Depends on hotwired/turbo#660

Introduces the `Turbo::Stream::Redirect` concern to override the
[redirect_to][] routing helper.

When called with a `turbo_frame:` option, the `redirect_to` helper with
check whether the request was made with the Turbo Stream `Accept:`
header. When it's absent, the response will redirect with a typical HTTP
status code and location. When present, the controller will respond with
a `<turbo-stream>` element that invokes [Turbo.visit($URL, { frame:
$TURBO_FRAME })][hotwired/turbo#649] where `$URL` is set to the
redirect's path or URL, and `$TURBO_FRAME` is set to the `turbo_frame:`
argument.

This enables server-side actions to navigate the entire page with a
`turbo_frame: "_top"` option. Incidentally, it also enables a frame
request to navigate _a different_ frame.

Typically, an HTTP that would result in a redirect nets two requests:
the first submission, then the subsequent GET request to follow the
redirect.

In the case of a "break out", the same number of requests are made: the
first submission, then the subsequent GET made by the `Turbo.visit`
call.

Once the `Turbo.visit` call is made, the script removes its ancestor
`<script>` by calling [document.currentScript.remove()][].

[redirect_to]: https://edgeapi.rubyonrails.org/classes/ActionController/Redirecting.html#method-i-redirect_to
[hotwired/turbo#649]: hotwired/turbo#649
[document.currentScript.remove()]: https://developer.mozilla.org/en-US/docs/Web/API/Document/currentScript
seanpdoyle added a commit to seanpdoyle/turbo-rails that referenced this issue Nov 18, 2022
Closes hotwired/turbo#257
Closes hotwired/turbo#397

Follow-up to:

* hotwired/turbo#257 (comment)
* hotwired/turbo#257 (comment)

Depends on hotwired/turbo#660

Introduces the `Turbo::Stream::Redirect` concern to override the
[redirect_to][] routing helper.

When called with a `turbo_frame:` option, the `redirect_to` helper with
check whether the request was made with the Turbo Stream `Accept:`
header. When it's absent, the response will redirect with a typical HTTP
status code and location. When present, the controller will respond with
a `<turbo-stream>` element that invokes [Turbo.visit($URL, { frame:
$TURBO_FRAME })][hotwired/turbo#649] where `$URL` is set to the
redirect's path or URL, and `$TURBO_FRAME` is set to the `turbo_frame:`
argument.

This enables server-side actions to navigate the entire page with a
`turbo_frame: "_top"` option. Incidentally, it also enables a frame
request to navigate _a different_ frame.

Typically, an HTTP that would result in a redirect nets two requests:
the first submission, then the subsequent GET request to follow the
redirect.

In the case of a "break out", the same number of requests are made: the
first submission, then the subsequent GET made by the `Turbo.visit`
call.

Once the `Turbo.visit` call is made, the script removes its ancestor
`<script>` by calling [document.currentScript.remove()][].

[redirect_to]: https://edgeapi.rubyonrails.org/classes/ActionController/Redirecting.html#method-i-redirect_to
[hotwired/turbo#649]: hotwired/turbo#649
[document.currentScript.remove()]: https://developer.mozilla.org/en-US/docs/Web/API/Document/currentScript
seanpdoyle added a commit to seanpdoyle/turbo-rails that referenced this issue Nov 19, 2022
Closes hotwired/turbo#257
Closes hotwired/turbo#397

Follow-up to:

* hotwired/turbo#257 (comment)
* hotwired/turbo#257 (comment)

Depends on hotwired/turbo#660

Introduces the `Turbo::Stream::Redirect` concern to override the
[redirect_to][] routing helper.

When called with a `turbo_frame:` option, the `redirect_to` helper with
check whether the request was made with the Turbo Stream `Accept:`
header. When it's absent, the response will redirect with a typical HTTP
status code and location. When present, the controller will respond with
a `<turbo-stream>` element that invokes [Turbo.visit($URL, { frame:
$TURBO_FRAME })][hotwired/turbo#649] where `$URL` is set to the
redirect's path or URL, and `$TURBO_FRAME` is set to the `turbo_frame:`
argument.

This enables server-side actions to navigate the entire page with a
`turbo_frame: "_top"` option. Incidentally, it also enables a frame
request to navigate _a different_ frame.

Typically, an HTTP that would result in a redirect nets two requests:
the first submission, then the subsequent GET request to follow the
redirect.

In the case of a "break out", the same number of requests are made: the
first submission, then the subsequent GET made by the `Turbo.visit`
call.

Once the `Turbo.visit` call is made, the script removes its ancestor
`<script>` by calling [document.currentScript.remove()][].

[redirect_to]: https://edgeapi.rubyonrails.org/classes/ActionController/Redirecting.html#method-i-redirect_to
[hotwired/turbo#649]: hotwired/turbo#649
[document.currentScript.remove()]: https://developer.mozilla.org/en-US/docs/Web/API/Document/currentScript
seanpdoyle added a commit to seanpdoyle/turbo-rails that referenced this issue Nov 19, 2022
Closes hotwired/turbo#257
Closes hotwired/turbo#397

Follow-up to:

* hotwired/turbo#257 (comment)
* hotwired/turbo#257 (comment)

Depends on hotwired/turbo#660

Introduces the `Turbo::Stream::Redirect` concern to override the
[redirect_to][] routing helper.

When called with a `turbo_frame:` option, the `redirect_to` helper with
check whether the request was made with the Turbo Stream `Accept:`
header. When it's absent, the response will redirect with a typical HTTP
status code and location. When present, the controller will respond with
a `<turbo-stream>` element that invokes [Turbo.visit($URL, { frame:
$TURBO_FRAME })][hotwired/turbo#649] where `$URL` is set to the
redirect's path or URL, and `$TURBO_FRAME` is set to the `turbo_frame:`
argument.

This enables server-side actions to navigate the entire page with a
`turbo_frame: "_top"` option. Incidentally, it also enables a frame
request to navigate _a different_ frame.

Typically, an HTTP that would result in a redirect nets two requests:
the first submission, then the subsequent GET request to follow the
redirect.

In the case of a "break out", the same number of requests are made: the
first submission, then the subsequent GET made by the `Turbo.visit`
call.

Once the `Turbo.visit` call is made, the script removes its ancestor
`<script>` by calling [document.currentScript.remove()][].

[redirect_to]: https://edgeapi.rubyonrails.org/classes/ActionController/Redirecting.html#method-i-redirect_to
[hotwired/turbo#649]: hotwired/turbo#649
[document.currentScript.remove()]: https://developer.mozilla.org/en-US/docs/Web/API/Document/currentScript
seanpdoyle added a commit to seanpdoyle/turbo-rails that referenced this issue Nov 19, 2022
Closes hotwired/turbo#257
Closes hotwired/turbo#397

Follow-up to:

* hotwired/turbo#257 (comment)
* hotwired/turbo#257 (comment)

Depends on hotwired/turbo#660

Introduces the `Turbo::Stream::Redirect` concern to override the
[redirect_to][] routing helper.

When called with a `turbo_frame:` option, the `redirect_to` helper with
check whether the request was made with the Turbo Stream `Accept:`
header. When it's absent, the response will redirect with a typical HTTP
status code and location. When present, the controller will respond with
a `<turbo-stream>` element that invokes [Turbo.visit($URL, { frame:
$TURBO_FRAME })][hotwired/turbo#649] where `$URL` is set to the
redirect's path or URL, and `$TURBO_FRAME` is set to the `turbo_frame:`
argument.

This enables server-side actions to navigate the entire page with a
`turbo_frame: "_top"` option. Incidentally, it also enables a frame
request to navigate _a different_ frame.

Typically, an HTTP that would result in a redirect nets two requests:
the first submission, then the subsequent GET request to follow the
redirect.

In the case of a "break out", the same number of requests are made: the
first submission, then the subsequent GET made by the `Turbo.visit`
call.

Once the `Turbo.visit` call is made, the script removes its ancestor
`<script>` by calling [document.currentScript.remove()][].

[redirect_to]: https://edgeapi.rubyonrails.org/classes/ActionController/Redirecting.html#method-i-redirect_to
[hotwired/turbo#649]: hotwired/turbo#649
[document.currentScript.remove()]: https://developer.mozilla.org/en-US/docs/Web/API/Document/currentScript
seanpdoyle added a commit to seanpdoyle/turbo-rails that referenced this issue Nov 19, 2022
Closes hotwired/turbo#257
Closes hotwired/turbo#397

Follow-up to:

* hotwired/turbo#257 (comment)
* hotwired/turbo#257 (comment)

Depends on hotwired/turbo#660

Introduces the `Turbo::Stream::Redirect` concern to override the
[redirect_to][] routing helper.

When called with a `turbo_frame:` option, the `redirect_to` helper with
check whether the request was made with the Turbo Stream `Accept:`
header. When it's absent, the response will redirect with a typical HTTP
status code and location. When present, the controller will respond with
a `<turbo-stream>` element that invokes [Turbo.visit($URL, { frame:
$TURBO_FRAME })][hotwired/turbo#649] where `$URL` is set to the
redirect's path or URL, and `$TURBO_FRAME` is set to the `turbo_frame:`
argument.

This enables server-side actions to navigate the entire page with a
`turbo_frame: "_top"` option. Incidentally, it also enables a frame
request to navigate _a different_ frame.

Typically, an HTTP that would result in a redirect nets two requests:
the first submission, then the subsequent GET request to follow the
redirect.

In the case of a "break out", the same number of requests are made: the
first submission, then the subsequent GET made by the `Turbo.visit`
call.

Once the `Turbo.visit` call is made, the script removes its ancestor
`<script>` by calling [document.currentScript.remove()][].

[redirect_to]: https://edgeapi.rubyonrails.org/classes/ActionController/Redirecting.html#method-i-redirect_to
[hotwired/turbo#649]: hotwired/turbo#649
[document.currentScript.remove()]: https://developer.mozilla.org/en-US/docs/Web/API/Document/currentScript
seanpdoyle added a commit to seanpdoyle/turbo-rails that referenced this issue Nov 19, 2022
Closes hotwired/turbo#257
Closes hotwired/turbo#397

Follow-up to:

* hotwired/turbo#257 (comment)
* hotwired/turbo#257 (comment)

Depends on hotwired/turbo#660

Introduces the `Turbo::Stream::Redirect` concern to override the
[redirect_to][] routing helper.

When called with a `turbo_frame:` option, the `redirect_to` helper with
check whether the request was made with the Turbo Stream `Accept:`
header. When it's absent, the response will redirect with a typical HTTP
status code and location. When present, the controller will respond with
a `<turbo-stream>` element that invokes [Turbo.visit($URL, { frame:
$TURBO_FRAME })][hotwired/turbo#649] where `$URL` is set to the
redirect's path or URL, and `$TURBO_FRAME` is set to the `turbo_frame:`
argument.

This enables server-side actions to navigate the entire page with a
`turbo_frame: "_top"` option. Incidentally, it also enables a frame
request to navigate _a different_ frame.

Typically, an HTTP that would result in a redirect nets two requests:
the first submission, then the subsequent GET request to follow the
redirect.

In the case of a "break out", the same number of requests are made: the
first submission, then the subsequent GET made by the `Turbo.visit`
call.

Once the `Turbo.visit` call is made, the script removes its ancestor
`<script>` by calling [document.currentScript.remove()][].

[redirect_to]: https://edgeapi.rubyonrails.org/classes/ActionController/Redirecting.html#method-i-redirect_to
[hotwired/turbo#649]: hotwired/turbo#649
[document.currentScript.remove()]: https://developer.mozilla.org/en-US/docs/Web/API/Document/currentScript
@chmich
Copy link

chmich commented Jan 14, 2023

Thanks for this issue and i hope you can find a way.

Its a show stopper for many cases.

i currently built a turbo app. Turbo is so cool but when you really want to bring it to shine the most point is always the responding of the create action of the controller. At this point its not a nice-to have, its a must that you have to decide inside the controller whether responding as turbo-stream or, in case of success: by a simple redirect_to.

I now made a workaround by stimulus. It works but its not a clean soulution and a little bit cumbersome, or too cumbersome for apps where you want to save time.

@saltysealion
Copy link

saltysealion commented Jan 15, 2023 via email

@acetinick
Copy link
Author

I think we can close this since latest versions of Turbo now allow creating custom actions.

So can easily create a redirect_to action which will do a Turbo.visit.

I think redirect_to should be standard action, however this is how I now solve all these conditional streams use cases.

@chmich
Copy link

chmich commented Jan 16, 2023

@acetinick
do you mean this? dev.to / stimulusreflex

i could find nothing in the official docs

@saltysealion
What i built is:
i called it functional-tags, its nothing else than a empty div tag in the layout, with an id, and by turbo-stream i put a element there. This is picked up by stimulus which does an action.

@j-manu
Copy link

j-manu commented Jan 19, 2023

@chmich You can use https://github.com/marcoroth/turbo_power-rails which provides a bunch of custom actions including redirect

seanpdoyle added a commit to seanpdoyle/turbo-rails that referenced this issue Jan 24, 2023
Closes hotwired/turbo#257
Closes hotwired/turbo#397

Follow-up to:

* hotwired/turbo#257 (comment)
* hotwired/turbo#257 (comment)

Depends on hotwired/turbo#660

Introduces the `Turbo::Stream::Redirect` concern to override the
[redirect_to][] routing helper.

When called with a `turbo_frame:` option, the `redirect_to` helper with
check whether the request was made with the Turbo Stream `Accept:`
header. When it's absent, the response will redirect with a typical HTTP
status code and location. When present, the controller will respond with
a `<turbo-stream>` element that invokes [Turbo.visit($URL, { frame:
$TURBO_FRAME })][hotwired/turbo#649] where `$URL` is set to the
redirect's path or URL, and `$TURBO_FRAME` is set to the `turbo_frame:`
argument.

This enables server-side actions to navigate the entire page with a
`turbo_frame: "_top"` option. Incidentally, it also enables a frame
request to navigate _a different_ frame.

Typically, an HTTP that would result in a redirect nets two requests:
the first submission, then the subsequent GET request to follow the
redirect.

In the case of a "break out", the same number of requests are made: the
first submission, then the subsequent GET made by the `Turbo.visit`
call.

Once the `Turbo.visit` call is made, the script removes its ancestor
`<script>` by calling [document.currentScript.remove()][], and marking
it with [data-turbo-cache="false"][]

[redirect_to]: https://edgeapi.rubyonrails.org/classes/ActionController/Redirecting.html#method-i-redirect_to
[hotwired/turbo#649]: hotwired/turbo#649
[document.currentScript.remove()]: https://developer.mozilla.org/en-US/docs/Web/API/Document/currentScript
[data-turbo-cache="false"]: https://turbo.hotwired.dev/reference/attributes#data-attributes
seanpdoyle added a commit to seanpdoyle/turbo-rails that referenced this issue Jan 28, 2023
Closes hotwired/turbo#257
Closes hotwired/turbo#397

Follow-up to:

* hotwired/turbo#257 (comment)
* hotwired/turbo#257 (comment)

Depends on hotwired/turbo#660

Introduces the `Turbo::Stream::Redirect` concern to override the
[redirect_to][] routing helper.

When called with a `turbo_frame:` option, the `redirect_to` helper with
check whether the request was made with the Turbo Stream `Accept:`
header. When it's absent, the response will redirect with a typical HTTP
status code and location. When present, the controller will respond with
a `<turbo-stream>` element that invokes [Turbo.visit($URL, { frame:
$TURBO_FRAME })][hotwired/turbo#649] where `$URL` is set to the
redirect's path or URL, and `$TURBO_FRAME` is set to the `turbo_frame:`
argument.

This enables server-side actions to navigate the entire page with a
`turbo_frame: "_top"` option. Incidentally, it also enables a frame
request to navigate _a different_ frame.

Typically, an HTTP that would result in a redirect nets two requests:
the first submission, then the subsequent GET request to follow the
redirect.

In the case of a "break out", the same number of requests are made: the
first submission, then the subsequent GET made by the `Turbo.visit`
call.

Once the `Turbo.visit` call is made, the script removes its ancestor
`<script>` by calling [document.currentScript.remove()][], and marking
it with [data-turbo-cache="false"][]

[redirect_to]: https://edgeapi.rubyonrails.org/classes/ActionController/Redirecting.html#method-i-redirect_to
[hotwired/turbo#649]: hotwired/turbo#649
[document.currentScript.remove()]: https://developer.mozilla.org/en-US/docs/Web/API/Document/currentScript
[data-turbo-cache="false"]: https://turbo.hotwired.dev/reference/attributes#data-attributes
@elik-ru
Copy link

elik-ru commented Mar 15, 2023

I had similar problem and have found a way to fix it easily.

My idea: When i need to show a [bootstrap] modal with some form or wizard, i want to load it from the server, and if after submit it does not pass validation, updated version should be reloaded, not affecting the whole page.

So how i approached it initially:

I have added an empty <turbo-frame id="modal"> to the end of the page.
I have added a button_to with data-turbo-frame="modal".
So when a button is clicked it sends a request to the server, which returns modal html wrapped with <turbo-frame id="modal">.
So, loaded content is put inside that modal placeholder (and is made visible with the help of small stimulus controller)

The form inside modal also has data-turbo-frame="modal". So each time form is submitted and not passed validation (or you navigate between steps in wizard in my case), new version form is shown inside same turbo-frame. Works perfectly until you successfully submit the form. In this case i want to make a redirect and reload main page content.

What happens: Because my whole page template includes placeholder for modal, it updates only that placeholder.

How i solved it: I wrapped actual modal with additional turbo-frame. So now, form submissions update only that additional frame (which looks absolutely the same for the end user), by when i make a full redirect, that inner turbo-frame is not present in the response, and this causes turbo to render full page.

seanpdoyle added a commit to seanpdoyle/turbo-rails that referenced this issue May 14, 2023
Closes hotwired/turbo#257
Closes hotwired/turbo#397

Follow-up to:

* hotwired/turbo#257 (comment)
* hotwired/turbo#257 (comment)

Depends on hotwired/turbo#660

Introduces the `Turbo::Stream::Redirect` concern to override the
[redirect_to][] routing helper.

When called with a `turbo_frame:` option, the `redirect_to` helper with
check whether the request was made with the Turbo Stream `Accept:`
header. When it's absent, the response will redirect with a typical HTTP
status code and location. When present, the controller will respond with
a `<turbo-stream>` element that invokes [Turbo.visit($URL, { frame:
$TURBO_FRAME })][hotwired/turbo#649] where `$URL` is set to the
redirect's path or URL, and `$TURBO_FRAME` is set to the `turbo_frame:`
argument.

This enables server-side actions to navigate the entire page with a
`turbo_frame: "_top"` option. Incidentally, it also enables a frame
request to navigate _a different_ frame.

Typically, an HTTP that would result in a redirect nets two requests:
the first submission, then the subsequent GET request to follow the
redirect.

In the case of a "break out", the same number of requests are made: the
first submission, then the subsequent GET made by the `Turbo.visit`
call.

Once the `Turbo.visit` call is made, the script removes its ancestor
`<script>` by calling [document.currentScript.remove()][], and marking
it with [data-turbo-cache="false"][]

[redirect_to]: https://edgeapi.rubyonrails.org/classes/ActionController/Redirecting.html#method-i-redirect_to
[hotwired/turbo#649]: hotwired/turbo#649
[document.currentScript.remove()]: https://developer.mozilla.org/en-US/docs/Web/API/Document/currentScript
[data-turbo-cache="false"]: https://turbo.hotwired.dev/reference/attributes#data-attributes
@lukepass
Copy link

I had similar problem and have found a way to fix it easily.

My idea: When i need to show a [bootstrap] modal with some form or wizard, i want to load it from the server, and if after submit it does not pass validation, updated version should be reloaded, not affecting the whole page.

So how i approached it initially:

I have added an empty <turbo-frame id="modal"> to the end of the page. I have added a button_to with data-turbo-frame="modal". So when a button is clicked it sends a request to the server, which returns modal html wrapped with <turbo-frame id="modal">. So, loaded content is put inside that modal placeholder (and is made visible with the help of small stimulus controller)

The form inside modal also has data-turbo-frame="modal". So each time form is submitted and not passed validation (or you navigate between steps in wizard in my case), new version form is shown inside same turbo-frame. Works perfectly until you successfully submit the form. In this case i want to make a redirect and reload main page content.

What happens: Because my whole page template includes placeholder for modal, it updates only that placeholder.

How i solved it: I wrapped actual modal with additional turbo-frame. So now, form submissions update only that additional frame (which looks absolutely the same for the end user), by when i make a full redirect, that inner turbo-frame is not present in the response, and this causes turbo to render full page.

Hello @elik-ru, I have exactly your same scenario and I'm stuck at Because my whole page template includes placeholder for modal, it updates only that placeholder.. When the form is submitted successfully I get an empty modal.

Unfortunately I can't understand how you solved the problem by adding a nother turbo frame. Could you please elaborate? What do you mean with when i make a full redirect, that inner turbo-frame is not present in the response.

Thanks a lot!

@elik-ru
Copy link

elik-ru commented Jun 14, 2023

@lukepass That how it worked for me:

  1. In template you put a placeholder <turbo-frame id="modal"></div>
  2. Initial modal load renders <turbo-frame id="modal"> <turbo-frame id="modal_inner"><form data-turbo-frame="modal_inner"></form></div></div>
  3. When on form submit you need to render the form again, you repeat step (2) and only "modal_inner" is updated
  4. When you want to break out you just do a redirect, target frame "modal_inner" is not found in the response and Turbo is doing full page update.

Bad news is that it's not working anymore. That behavior was changed here: #863 (and included in version 7.3.0). Now instead of doing full page update it shows "Content missing" inside the frame (modal_inner in my case)

@elik-ru
Copy link

elik-ru commented Jun 14, 2023

@lukepass But it looks like you can restore this functionality by adding a custom event handler to application.js:

document.addEventListener("turbo:frame-missing", function (event) {
    event.preventDefault()
    event.detail.visit(event.detail.response)
})

event.target contains "turbo-frame#modal_inner", so you can check it if needed.

@chmich
Copy link

chmich commented Jun 14, 2023

In the meantime, I wrote the render_turbo_stream gem. There are some helpers that make it easier to control the turbo actions in general.

@lukepass
Copy link

Understood @elik-ru thanks!
@chmich unfortunately I'm using Turbo along with Symfont UX but I can work on elik-ru's solution.

@rbclark
Copy link

rbclark commented Jun 26, 2023

I have a very similar use-case as @elik-ru, and #863 made this an issue that that is once again very relevant. The inability to specify from the server whether a response should be a full page load causes a lot of problems when dealing with handling failure cases for a form submission when inside a modal, since only the server knows whether the page should redirect or render an error, not the client. It seems there are 2 options at the moment, neither of which is ideal as an end user:

  1. Send the whole page so every time and don't use frames at all. (which defeats the purpose of turbo in this use case)
  2. Use the solution described in Don't break out of frames when frame missing #863 (comment) (which goes against the turbo motto of "The speed of a single-page web application without having to write any JavaScript.")

@zmitic
Copy link

zmitic commented Jun 30, 2023

If someone is interested, 204 (no content) status can be used given how rare it is. To jump out of frame, I use this (Symfony):

class TurboRedirectResponse extends Response
{
    public function __construct(string $url)
    {
        parent::__construct(status: 204, headers: ['location' => $url]);
    }
}

The important bit, event listener. All these checks for nullability are because I don't want to risk failures and I am terrible with JS:

document.addEventListener('turbo:submit-end', (event) => {
    let response = event.detail?.fetchResponse?.response;
    let status = response?.status;
    let url = response?.headers?.get('Location') ?? null;

    if (status === 204 && url) {
        Turbo.visit(url, {action: 'advance' }) // this should also have `frame` value, not tested yet
        event.preventDefault();

        return false;
    }
})

And finally controller:

public function doSomething(): Response
{
	if ($shouldControllerRedirect) {
		return new TurboRedirectResponse($url); // should have target frame as second param here
	}

	return new Response('<turbo-frame id="test">This is where form is rendered as usual</turbo-frame>');
}

The idea is far from perfect and right now, issues a full visit but that is only because of missing frame (commented above). But I expect it to work, someone could improve on this idea. I tried to catch 303 in event listener, but it didn't work at all.

@elik-ru
Copy link

elik-ru commented Dec 5, 2023

I took me some time to understand how to make the workaround work perfectly.

First you must understand one thing about layouts. It you don't specify layout in your controller - turbo-rails does it for you with this piece of code:

    layout -> { "turbo_rails/frame" if turbo_frame_request? }

So when doing frame requests it renders special tiny layout, which saves render time and transferred bytes, which is what we want.

But if for some reason you need custom layout and specify it as

layout "admin"
  • you break this feature. Now you always have a full layout event if you don't' need it. How to do it correctly? Pretty simple, just follow same logic:
  layout -> { turbo_frame_request? ? "turbo_rails/frame" : "admin" }

Now we have custom full layout by default, and tiny layout for turbo-frame requests. Nice.

Next we come to the break-out problem. That proposed solution kinda worked for me, but not ideally:

document.addEventListener("turbo:frame-missing", function(event) {
    if (event.detail.response.redirected) {
        event.preventDefault()
        event.detail.visit(event.detail.response);
    }
})

It is detected correctly, but Turbo is making full page reload, doing 2 requests in a row. I was digging around, tried to do Turbo.visit(event.detail.response.url);, which was working better (without full page reload), but still doing double requests.
I was looking at the code in Turbo and it seems that it was supposed to work, but it was not. Until I realised that thing with layouts. Response was rendered with short layout! So Turbo detects that page head content is different and triggers a full-page reload! That means we must detect somehow this situation and render full layout after a redirect.

So here is the final solution:

class ApplicationController < ActionController::Base
  add_flash_types :turbo_breakout
  layout -> {
    turbo_frame_request? && ! turbo_frame_breakout? ? "turbo_rails/frame" : "application"
  }
  def turbo_frame_breakout?
    flash[:turbo_breakout].present?.tap { flash.delete(:turbo_breakout) }
  end

  ...
  def some_action
     redirect_to target_path, success: "Congratulations!", turbo_breakout: true
  end
end

And same js snippet:

document.addEventListener("turbo:frame-missing", function(event) {
    if (event.detail.response.redirected) {
        event.preventDefault()
        event.detail.visit(event.detail.response);
    }
})

So, what we are doing here?

  1. add_flash_types registers new flash type, so redirect_to can recognise it
  2. we set proper layout. Tiny one for turbo-frame requests, but not when we are trying to break out.
  3. turbo_frame_breakout? checks the value in flash and removes it (otherwise it could be shown with other messages to the user).

Finally, redirect_to target_path, success: "Congratulations!", turbo_breakout: true is doing a redirect setting 2 flash messages. One for the user and the other for choosing correct layout.

Last question: what happens if some redirect occurs without our flash message? Well, it still will be working, with that double load and full-page visit, but still working, so I think it's a good fallback for unexpected cases.

@krschacht
Copy link
Contributor

I was able to override the frame-target using the meta tag. It's explained here in the docs:
https://turbo.hotwired.dev/handbook/frames#%E2%80%9Cbreaking-out%E2%80%9D-from-a-frame

In certain, specific cases, you might want the response to a request to be treated as a new, full-page navigation instead, effectively “breaking out” of the frame. The classic example of this is when a lost or expired session causes an application to redirect to a login page. In this case, it’s better for Turbo to display that login page rather than treat it as an error. The simplest way to achieve this is to specify that the login page requires a full-page reload, by including the turbo-visit-control meta tag: ...

@elik-ru
Copy link

elik-ru commented Jan 29, 2024

@krschacht It will make a full-page reload. Meaning 2 requests for the page, and reloading/reevaluating of JS/CSS.

@radanskoric
Copy link
Contributor

One relatively common subcase of this problem is when you want to break out of the frame and redirect the full page back to itself to refresh it. Since the introduction of morphing we also have the refresh stream action which will always apply itself to the full page.

So, that case can be solved relatively simply by having the controller emit the refresh action when the submission is successful:

render turbo_stream: turbo_stream.action(:refresh, "")

(Not yet a built in action, pending hotwired/turbo-rails#595).

I agree with the sentiments some people expressed here that it doesn't feel right to use stream actions as response to a form submission but at the moment that is probably the simplest, most maintainable, solution.

seanpdoyle added a commit to seanpdoyle/turbo-rails that referenced this issue Nov 23, 2024
Closes hotwired/turbo#257
Closes hotwired/turbo#397

Follow-up to:

* hotwired/turbo#257 (comment)
* hotwired/turbo#257 (comment)

Depends on hotwired/turbo#660

Introduces the `Turbo::Stream::Redirect` concern to override the
[redirect_to][] routing helper.

When called with a `turbo_frame:` option, the `redirect_to` helper with
check whether the request was made with the Turbo Stream `Accept:`
header. When it's absent, the response will redirect with a typical HTTP
status code and location. When present, the controller will respond with
a `<turbo-stream>` element that invokes [Turbo.visit($URL, { frame:
$TURBO_FRAME })][hotwired/turbo#649] where `$URL` is set to the
redirect's path or URL, and `$TURBO_FRAME` is set to the `turbo_frame:`
argument.

This enables server-side actions to navigate the entire page with a
`turbo_frame: "_top"` option. Incidentally, it also enables a frame
request to navigate _a different_ frame.

Typically, an HTTP that would result in a redirect nets two requests:
the first submission, then the subsequent GET request to follow the
redirect.

In the case of a "break out", the same number of requests are made: the
first submission, then the subsequent GET made by the `Turbo.visit`
call.

Once the `Turbo.visit` call is made, the script removes its ancestor
`<script>` by calling [document.currentScript.remove()][], and marking
it with [data-turbo-cache="false"][]

[redirect_to]: https://edgeapi.rubyonrails.org/classes/ActionController/Redirecting.html#method-i-redirect_to
[hotwired/turbo#649]: hotwired/turbo#649
[document.currentScript.remove()]: https://developer.mozilla.org/en-US/docs/Web/API/Document/currentScript
[data-turbo-cache="false"]: https://turbo.hotwired.dev/reference/attributes#data-attributes
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Development

Successfully merging a pull request may close this issue.