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

Ability to override frame-target from server response #257

Open acetinick opened 3 years ago

acetinick commented 3 years ago

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 commented 3 years ago

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

WriterZephos commented 3 years ago

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 commented 3 years ago

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 commented 3 years ago

I'd be happy to see something like this.

seanpdoyle commented 3 years ago

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 commented 3 years ago

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:

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 https://github.com/hotwired/turbo/pull/397 to experiment with that.

tleish commented 2 years ago

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 commented 2 years ago

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 commented 2 years ago

@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 commented 2 years ago

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

seanpdoyle commented 2 years ago

@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 https://github.com/hotwired/turbo/issues/257#issuecomment-921273533 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 commented 2 years ago

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

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

In option 2

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 commented 2 years ago

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 commented 2 years ago

@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 commented 2 years ago

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 commented 2 years ago

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 https://github.com/hotwired/turbo-rails/pull/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 commented 2 years ago

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.

tobyzerner commented 2 years ago

@seanpdoyle Thanks for laying this out very clearly – it's the exact problem I'm facing in my app at the moment. Hope we can get to the bottom of it soon. I too would rather avoid sending streams back from forms for this kind of flow.

I think you're correct about the constraints of fetch, unfortunately.

I do like @tleish's idea of an attribute on the turbo-frame. What about something like data-turbo-ignore? The effect it would have is: if Turbo makes a request for a frame, and the resulting frame in the response has this attribute, then pretend it's not there. Then Turbo can proceed as in https://github.com/hotwired/turbo/issues/257#issuecomment-968096384 "Handling a response without a matching <turbo-frame>" – ie. do a full page render.

In practice, data-turbo-ignore could be placed on any global frame that is a "placeholder", like so:

<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" data-turbo-ignore></turbo-frame>
    </dialog>
  </body>
</html>

Now, when you navigate to /articles/new and subsequent steps (or validation errors), these responses would all take place inside of #dialog_frame. Note that the turbo-frame element in these responses does not have the data-turbo-ignore attribute.

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

But when the article is finally created, the server responds with a redirect to /articles/1, which is a full page:

<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" data-turbo-ignore></turbo-frame>
  </dialog>
</body>

But because of the presence of data-turbo-ignore, Turbo would act as if there is no matching #dialog_frame frame, and perform a full page replacement instead.

seanpdoyle commented 2 years ago

@tobyzerner presuming the change is made to redirect the page when a matching <turbo-frame> is missing, an attribute like [data-turbo-ignore] already exists: [disabled]:

disabled is a boolean attribute that prevents any navigation when present

Unfortunately, given the issues with the inaccessible nature of fetch and the intermediate redirect response, I'm not sure of the best way to pass the state from the POST request to the subsequent render.

If it were possible, the developer ergonomics of a Turbo-Frame: header are clear:

def create
  @article = Article.new article_params

  if @article.save
    response.headers["Turbo-Frame"] = "_top"
    redirect_to articles_url(@article)
  else
    render :new, status: :unprocessable_entity
  end
end

Given that that approach might not be possible, what would be the criteria for setting or omitting the [disabled] attribute?.

What should be the conditional for the page-wide <turbo-frame id="dialog_frame">? It can't be [disabled], otherwise it cannot be targeted. At the same time, the response that "breaks out" would need to set [disabled] to "break out".

<dialog id="dialog" data-controller="dialog" data-action="turbo:frame-load->dialog#showModal">
  <turbo-frame <%= "disabled" if ... %> id="dialog_frame"></turbo-frame>
</dialog>

The articles/new template is the intermediate step that wants to stay "contained" by the frame. Would it ever set [disabled]?

<turbo-frame <%= "disabled" if ... %> 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]" value="<%= @article.title %>">

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

The articles/show template is the end-state that ultimately "breaks out" of the frame. The template would need to render the <turbo-frame> without [disabled] when directly requested, but with [disabled] when being redirected to from after a successful submission. What are the conditions for that:

<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 <%= "disabled" if ... %> id="dialog_frame"></turbo-frame>
</dialog>
seanpdoyle commented 2 years ago

I believe that following a 302 Found, 303 See other, or 307 Temporary Redirect response, the subsequent request is made with the same headers as the original.

This means that the secondary request is made with Turbo-Frame: dialog_frame. Leaning on that same mechanism might afford the server with more context to decide whether or not to render [disabled].

For example, we could encode a Turbo-Drive: visit header when a frame request is made via a <a> click or <form method="get"> submission, and a Turbo-Drive: form-submission header when the request is made from a <form method="post"> submission.

That way, the articles/show page could use that information to omit the [disabled] attribute on a direct access (i.e. when Turbo-Drive: visit), and include the [disabled] attribute following a submission (i.e. when Turbo-Drive: form-submission).

Unless I'm missing something, this is all predicated on sending full HTML bodies at all times (like proposed in https://github.com/hotwired/turbo-rails/pull/232).

tleish commented 2 years ago

For this single scenario, another solution a developer could create today is with a javascript to redirect. The final response might look something like (untested):

<turbo-frame id="dialog_frame" target="_top">
  <script>Turbo.visit('/articles')</script>
  Success!
</turbo-frame>

or using a custom stimulus controller

<script>
export default class extends Stimulus.Controller {
  static values = {url: String}

  visit() {
    Turbo.visit(this.urlValue);
  }
}
</script>
...
<turbo-frame id="dialog_frame" target="_top" data-controller="location" data-location-url-value="/articles" data-action="turbo:frame-render->location#visit">
  Success!
</turbo-frame>

The backend process is almost exactly the same as a backend redirect (2 controller actions involved), but you loose some in latency of sending the 2nd request.

seanpdoyle commented 2 years ago

@tleish what are the conditions for including or excluding that script?

A similar mechanism could involve a <a href="..." data-turbo-target="_top" data-controller="autoclick" hidden> where the autoclick controller programmatically clicks the link when it's mounted.

The issue that I'm still not sure about is when to render the element, given the context lost during redirect. Maybe a query param?

tobyzerner commented 2 years ago

@seanpdoyle I think what I am proposing with [data-turbo-ignore] is slightly different to [disabled]. Whereas [disabled] disables all navigation within the frame, [data-turbo-ignore] (or just [ignore], actually) would simply tell Turbo "don't consider this frame as a source – only a target".

As such, there would be no condition needed, and the attribute could be present all the time.

So the <turbo-frame> in the layout template would include it all the time:

<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" ignore></turbo-frame>
    </dialog>
  </body>
</html>

Now when you click "New article", Turbo navigates to /articles/new within #dialog_frame. /articles/new returns:

<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= details">Get started</a>
  </turbo-frame>
</body>

Turbo extracts the #dialog_frame contents from this response and shoves it into the layout #dialog_frame, and then the Stimulus controller runs showModal. So far so good.

Now we click "Get started". This navigates to /articles/new?step=details within #dialog_frame. The server responds:

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

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

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

Turbo extracts the #dialog_frame contents from this response and shoves it into the layout #dialog_frame. Nothing new here.

Now we click "Submit". Turbo submits the form. The server creates the article and responds with a 303 redirect to /articles/1. The response HTML is:

<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" ignore></turbo-frame>
  </dialog>
</body>

Since this submission has taken place inside of the #dialog_frame frame, Turbo looks for a matching frame in this response. It finds the empty #dialog_frame in the layout. But, because [ignore] is present, Turbo pretends that it's not there.

Now, presuming the change is made to redirect the page when a matching <turbo-frame> is missing, Turbo will break out of #dialog_frame and replace the whole page with the whole response.

There is no conditional necessary – ignore is present on the layout's #dialog_frame the whole time. And it's never present on the "create article" page's #dialog_frame.

Instead of [ignore], perhaps [placeholder] would be more descriptive.

Does that make sense? Perhaps I'm missing something...

seanpdoyle commented 2 years ago

@tobyzerner thats really clever! I like that much better than the rest of the server-side management.

I'm not sold on "ignore". I liked the idea of a "source" frame versus a "destination" frame, but I'm not sure if those terms are right either.

Is it an "outlet"? A "slot"?

Whichever ends up being the name, I think there's enough here to start digging at implementing an entirely HTML based solution for this.

tleish commented 2 years ago

@tleish what are the conditions for including or excluding that script?

@seanpdoyle - I'm suggesting javascript do the redirect, not the server. Modifying a previous example, something like

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
+     params[:step] = "finished"

-     redirect_to article_url(@article)
+     render :new
    else
      render :new, status: :unprocessable_entity
    end
  else
    render :new, status: :unprocessable_entity
  end
end

<%# 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>
+ <% when "finished" %>
+ <a href="<%= article_url(@article) =>" data-turbo-target="_top" data-controller="autoclick" hidden>
<% 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 %>
``
tobyzerner commented 2 years ago

@seanpdoyle Awesome! Look forward to seeing the results. You're right, "ignore" is not optimal. I think I like "outlet" most.

tleish commented 2 years ago

I like the simplicity of @tobyzerner suggestion I originally presented out the word "ignore", not expecting it to be the right word... just a concept. If using @tobyzerner suggestion, other terms might be:

seanpdoyle commented 2 years ago

I've opened https://github.com/hotwired/turbo/pull/445 to introduce the turbo:frame-missing event.

The current response to a missing frame is clearing the requesting <turbo-frame> element's contents and logging an error.

The idea behind the turbo:frame-missing is to preserve the existing behavior but provide a seam for applications to act. The event fires with the { detail: { fetchResponse } }, which can be used to create Visit options to advance the history in a way that doesn't fire a subsequent request.

This way, applications can intervene in a way that doesn't have backwards compatibility concerns.

That PR also treats a [disabled] element in the response as missing.

I'm hoping that the combination of the event and that change open the door to what @tobyzerner outlined in https://github.com/hotwired/turbo/issues/257#issuecomment-968179718.

The idea being that the [ignored] attribute in the examples above could be replaced with [disabled], so long as the client application removed the [disabled] attribute when the frame is connected:

Stimulus.register("auto-enable", class extends Controller {
  connect() { this.element.removeAttribute("disabled") }
})

addEventListener("turbo:frame-missing", async ({ target, detail: { fetchResponse } }) => {
  const { location, redirected, statusCode, responseHTML } = fetchResponse
  const response = { redirected, statusCode, responseHTML: await responseHTML }

  Turbo.visit(location, { response })
})

With that, an element rendered on the server as [disabled] (equivalent to [ignore]) that's mounted during the initial request can remove the [disabled] attribute:

<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" ignore></turbo-frame>
+    <turbo-frame id="dialog_frame" data-controller="auto-enable" disabled></turbo-frame>
  </dialog>
  </body>
</html>

That means that subsequent responses carry on like the rest, but when it's time to "beak out" of the frame, the page with the <turbo-frame id="dialog_frame" disabled></turbo-frame> is treated as missing, and the turbo:frame-missing fires and results in a call to Turbo.visit.

If that ends up resolving the underlying issue, it might be better to ship fewer changes and try to get by with less. If the pattern is useful and sticks, we could try baking-in support directly with a new <turbo-frame> attribute.

Would that work?

tobyzerner commented 2 years ago

@seanpdoyle Sounds fantastic! I'll give it a test drive (pun intended) on my project and report back soon. Thanks for your work on this.

didroe commented 2 years ago

I'm interested in a solution to this issue, for the use case of implementing modals containing forms. Where sometimes you want to re-render the modal with new/error content, and sometimes redirect the whole page. But you don't know until you're on the backend which path you want to take.

Adding support for the server to control the target frame feels like the right solution - it's way more flexible than the client interpreting certain status codes (ie. https://github.com/hotwired/turbo/pull/210), and is less convoluted than disabling the frame and implementing a frame-missing handler. Being able to specify the target frame feels like a simple mechanism that can address a large number of use cases, and it's ultimately the server that knows how a rendered view should be interpreted. Other features could then be built on top at the server end, to give a good out-of-the-box experience. eg. 303/500 defaulting to targetting _top.

A header seems the cleanest and most simple implementation to me. Turbo itself need only support the header, it can leave it up to the server to pass the target frame through the redirect by whatever means it likes. Personally, I think a query param could be a good solution for passing the target through. I explored a possible implementation of this on this branch. As the redirect is opaque to the client, no handling of query params would be necessary in Turbo itself (as it would never see the redirected URL).

In a future world, we might hope that whatwg adds a way to expose particular headers from the initial/redirected response. And then the query param workaround could be dropped in favour of that mechanism.

akaspick commented 2 years ago

I see a lot of solutions using Turbo.visit after the redirect, but redirecting automatically sends a GET request to the destination url. In my scenario I want to have access to body of the GET (as it includes flash messages) and not simply ignoring the initial GET request and submitting another request via Turbo.visit.

I've tried dynamically setting the target attribute to _top, but it appears that once the response is available, it's too late to set the target. Is there any way to manually update the _top "frame" with the response contents?

I really can't wait to have a baked in solution to this overall issue as I thought a modal dialog would be the perfect fit for Turbo, but it's apparently not an easy thing to deal with at all.

I like the idea of automatically redirecting if the turbo-frame isn't available, but in my use case this is not what I want to occur as I want access to the body of the redirected url and display that instead. Controlling the target frame on the server side would solve my issue though. I think if the redirected url is different than browser url, then an automatic redirect would be valid, but otherwise rendering the content from the redirect in the _top frame would be the way to go.

acetinick commented 2 years ago

Everyone, this became critical for us and a solution for redirecting to login screen for some turbo actions was critical. I ended up solving this with using CableReady and this handy gem. https://github.com/marcoroth/cable-streams, which basically extends the operations of turbo streams by getting cable ready to handle them.

I was already using CableReady in some places, and the support for extra operations is great and ability to even customize your own.

So now I have this as a before_action method

image

janklan commented 2 years ago

Yeah, I'd love to be able to set a target frame from the server - whichever way it works, incl. a well-known query string parameter.

When I'm redirecting the request, I always know whether or not there is a particular <turbo-frame> in the target destination. If I know there is no <turbo-frame id="modal-content">, it makes sense to instruct Turbo not to look for it.

didroe commented 2 years ago

Inspired by https://github.com/hotwired/turbo/issues/257#issuecomment-1107893858, I'm wondering if a new type of stream action could be a good approach.

Example 1 - redirect the whole page, adding a new history entry

<turbo-stream action="navigate" target="_top" drive-action="advance" href="https://example.com/next"/>

Example 2 - redirect the whole page, replacing current history entry

<turbo-stream action="navigate" target="_top" drive-action="replace" href="https://example.com/next"/>

Example 3 - reload multiple frames without changing history

<turbo-stream action="navigate" target="results" href="https://example.com/results"/>
<turbo-stream action="navigate" target="stats" href="https://example.com/stats"/>

The last example highlights other use cases that this approach can also be used for.

Experiment

I've experimented with this approach by creating a stimulus controller to do the navigation, and appending a hidden element that uses that controller in the stream response. This seems to work quite well but it would be nice to have something built-in.

Example Stimulus controller:

export default class extends Controller {
  static values = {
    url: String,
    target: String,
    action: String,
  }

  connect() {
    const link = document.createElement("a")
    link.href = this.urlValue
    link.dataset.turboAction = this.actionValue
    link.dataset.turboFrame = this.targetValue
    this.container.appendChild(link)
    link.click()
  }

  get container() {
    if (this.targetValue === "_top") {
      return document.body
    }

    return document.getElementById(this.targetValue)
  }
}

Backend helper:

  def turbo_stream_navigate(url, target: "_top", action: :advance)
    span = tag.span(
      nil,
      class: "hidden",
      data: {
        controller: "turbo-navigate",
        turbo_navigate_url_value: url,
        turbo_navigate_target_value: target,
        turbo_navigate_action_value: action
      }
    )

    if target == "_top"
      turbo_stream.append_all("body") { span }
    else
      turbo_stream.append(target) { span }
    end
  end

Example backend controller usage for main use-case in this issue:

  def create
    # ...
    if @item.valid?
      render turbo_stream: helpers.turbo_stream_navigate(next_path)
    else
      render :new, status: :unprocessable_entity
    end
  end
seanpdoyle commented 2 years ago

@didroe would it be possible to implement that behavior without expanding the <turbo-stream> supported attributes and actions?

Maybe something that relied on an <a> that clicks itself when it's connected to the document.

<script>
import { Application, Controller } from "@hotwired/stimulus"

const application = Application.start()
application.register("navigate", class extends Controller {
  connect() {
    this.element.click()
    this.element.remove()
  }
})
</script>

<!-- redirect the entire page -->
<turbo-stream action="append_all" target="body">
  <template>
    <a href="/another/page" data-controller="turbo-navigate"></a>
  </template>
</turbo-stream>

<!-- redirect any number of `<turbo-frame>` elements -->
<turbo-stream action="append_all" target="body">
  <template>
    <a href="/another/page" data-controller="turbo-navigate" data-turbo-frame="frame-a"></a>
    <a href="/another/page" data-controller="turbo-navigate" data-turbo-frame="frame-b"></a>
  </template>
</turbo-stream>

Plus, since they're <a> elements, you can decide between "advance" or "replace" semantics with [data-turbo-action], like you typically would with any other <a> element, instead of passing it through a bespoke [data-turbo-navigation-action-value] attribute. The same is true for [href] instead of [data-turbo-navigation-url-value], and [data-turbo-frame] instead of [data-turbo-navigation-target-value].

The idea is the same, but by relying on built-in platform features and their Tubro-powered extensions, we can cut down on the amount of machinery involved.

Could something like that suit your use case?

didroe commented 2 years ago

@seanpdoyle Thanks for your thoughts, I've simplified my approach using your suggestion.

This feels like quite a common use case to me, so I wonder if there's still value in providing an out-of-the-box capability for this. I agree with the goal of minimizing the amount of machinery however.

Something for you to consider in the future:

A helper for this could be provided by turbo-rails, but without adding any core features. This could be done in an elegant way if:

francesco-loreti commented 2 years ago

Any news?

dhh commented 2 years ago

Reviewing all this, it seems like we have a wide array of interesting edge cases, but also a base case where I think our current default is just bad. If a request originates from within a frame, and the response to that request doesn't include a matching frame, then I think we should just break out of the frame. Either replacing the entire page or following the redirect. I think that's a better default, it doesn't break something that isn't broken already, and it'll solve the majority of the issues here.

Happy to take a PR to that effect.

francesco-loreti commented 2 years ago

Probably, I have a different turbo-frame problem that requires overriding. In a Rails application, I use the Devise and RackCAS gems. I need the first to manage authentication, while the second delegates authentication to an external CAS server. When the visitor logs into the app, they are redirected to a portal outside the app for authentication. If authenticated, the CAS returns to the calling page with a token or cas_ticket which is written to a "sessions" table in the db. All these steps take place in HTML format and work on first login, if the TurboFrame is set to "_top" and if the session is active. The problem occurs if, during navigation, the application session expires, the application returns the error "401 unauthorized" and the returned page is not loaded because it cannot be rendered within the turbo-frame indicated in the calling link. For this, I need to override the TurboFrame call by setting it, for example, to "_top" or load the new page in the browser.

How do you think I can solve it?

To try to solve it, I thought of a javascript script that forces the page to load at the end of the user's session. The solution is not beautiful and not very practical either. But at the moment I have no solutions. I have seen various attempts in other posts but I would hope for a solution not to reinvent already tested things.

seanpdoyle commented 2 years ago

I've opened https://github.com/hotwired/turbo-rails/pull/367 against turbo-rails.

@didroe I've borrowed from your suggestion, and did my best to make it work without too many changes to the framework.

dhagher commented 1 year ago

@seanpdoyle

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.

Unless I'm mistaken, can't this https://developer.mozilla.org/en-US/docs/Web/API/Response/redirected#disallowing_redirects be used to access headers from redirect request? (ie change redirect option on fetch https://developer.mozilla.org/en-US/docs/Web/API/Request/Request)

seanpdoyle commented 1 year ago

@dhagher unfortunately, passing redirect: "error" as an option to the fetch yields an exception, which fails the promise.

When I chain a .catch or wrap the await in a try...catch block, the exception instance's .message property is "TypeError: Load failed".

chmich commented 1 year ago

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 commented 1 year ago

Hi, can you elaborate on what the work around was?

On Sat, Jan 14, 2023 at 11:00 PM Christian @.***> wrote:

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.

— Reply to this email directly, view it on GitHub https://github.com/hotwired/turbo/issues/257#issuecomment-1382722565, or unsubscribe https://github.com/notifications/unsubscribe-auth/AABXR7XFEVTIXGLV2EJKSRDWSKIOHANCNFSM43N5SCPQ . You are receiving this because you commented.Message ID: @.***>

acetinick commented 1 year ago

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 commented 1 year ago

@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 commented 1 year ago

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

elik-ru commented 1 year ago

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.

lukepass commented 1 year ago

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 commented 1 year ago

@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: https://github.com/hotwired/turbo/pull/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)