Open acetinick opened 3 years ago
I got some use cases where controlling that behavior from the server would be useful. Session expiration is one of them.
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.
How is this problem currently being solved? I'm at this exact situation and not sure what I can do to work around it.
I'd be happy to see something like this.
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
:
If those limitations hold, we'll need to investigate alternatives to sending back a header.
?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.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!
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:
GET
response to become out of sync with its corresponding POST
/PUT
/DELETE
etcWhile 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.
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.
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.
@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?
@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:
@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.
@seanpdoyle - I personally prefer keeping the breakout login in the HTML response vs a header.
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).
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>
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:
headers["Turbo-Frame"]="_top"
to the responseIn 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.
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:
<turbo-frame>
nested in a modal <dialog>
elementLet'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:
<form>
element targets the <turbo-frame id="dialog_frame">
element,
and will drive it with GET /articles/new
requests whenever it's submitted<turbo-frame id="dialog_frame">
is nested within a <dialog>
element<dialog>
element is controlled by a dialog
Stimulus controller that
calls HTMLDialogElement.showModal()
whenever the descendant frame completes
a navigation<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.
<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?
@seanpdoyle - that's an interesting scenario.
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 .
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?
meta
tagRespond 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.
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.
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? }
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.
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.
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 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.
@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>
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).
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.
@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?
@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...
@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 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 %>
``
@seanpdoyle Awesome! Look forward to seeing the results. You're right, "ignore" is not optimal. I think I like "outlet" most.
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:
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?
@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.
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.
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.
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
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.
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.
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
@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?
@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:
Turbo.visit
, avoiding the need to insert a linkAny news?
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.
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.
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.
@seanpdoyle
I hope I'm misunderstanding the constraints of the
fetch
implementation. Accessing the intermediate3xx
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)
@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"
.
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.
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: @.***>
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.
@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.
@chmich You can use https://github.com/marcoroth/turbo_power-rails which provides a bunch of custom actions including redirect
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.
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 abutton_to
withdata-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!
@lukepass That how it worked for me:
<turbo-frame id="modal"></div>
<turbo-frame id="modal"> <turbo-frame id="modal_inner"><form data-turbo-frame="modal_inner"></form></div></div>
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)
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.
What everyones thoughts are on this? or is there something I am missing to make this easier.