-
Notifications
You must be signed in to change notification settings - Fork 330
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
"Break out" of a frame from the server #367
base: main
Are you sure you want to change the base?
Conversation
888b0a6
to
3f66023
Compare
3f66023
to
d8e85d6
Compare
The suite passes locally when executing |
6d69e39
to
5b3ed2d
Compare
I am not responsible for Devise, but wanted to give this a big thumbs up, as imho this PR would help Devise support Turbo a bit better. There is a pattern that I feel a lot of apps use which is to have some sort of If that link is currently inside a WIth this PR (and maybe an updated PR in responders/devise), forcing the turbo_frame: '_top' makes the user behavior closely match our pre-turbo world. |
6ab111c
to
29e1101
Compare
1d98a5d
to
d0596ce
Compare
@seanpdoyle this is very helpful, and much needed. Thank you! |
@tomasc thank you for raising that concern! I've opened hotwired/turbo#694 to make the Turbo Action available to the server as a request header. If that lands, this implementation could incorporate that value into the Stream's |
Thanks @seanpdoyle ! Hope it gets merged soon, I could use this on a project right now ;-). |
cbe2e96
to
8d8b3f2
Compare
This is code pulled from PR hotwired/turbo-rails#367
Wondering what the status is on this one? I tried it in a test project and it worked wonderfully and I haven't seen anything else as elegant as adding |
looks great, looking forward to it being merged! My current solution: // app/javascript/application.js
Turbo.StreamActions.redirect = function () {
Turbo.visit(this.target);
}; # my_controller.rb
render turbo_stream: turbo_stream.action(:redirect, posts_path) |
@yshmarov's solution looks super clean! |
@yshmarov Have you thought of a way to combine your solution to also have an alert/notice? |
@yshmarov That's pretty nice. |
@yshmarov If you want to keep scroll, add Turbo.visit(url, { action: "replace" }) |
@kevinmcconnell in response to #367 (comment), I've tried my best to distill a common use case I've encountered into a self-contained application. Boiled down, the scenario entails the following:
I've created a single-file application to make this scenario more concrete. You can copy-paste the following into a It utilizes the It "works", in that it meets the acceptance criteria outlined above without introducing new abstractions or Turbo mechanisms. However, the resulting There are three System Tests covering the behavior. If you'd like to experiment with it locally, change the
I've explored this, and haven't found a way to make a server-set HTTP header available to Turbo when the resulting redirect occurs. I believe Turbo's use of
Is there an architectural change to be made to Turbo to improve support for this style of scenario? If there isn't a way to support this directly, are there any examples of concrete changes to the example application below that you'd make to flesh out Turbo Stream-powered solutions? require "bundler/inline"
gemfile(true) do
source "https://rubygems.org"
git_source(:github) { |repo| "https://github.com/#{repo}.git" }
gem "rails"
gem "propshaft"
gem "puma"
gem "sqlite3"
gem "turbo-rails"
gem "capybara"
gem "cuprite", "~> 0.9", require: "capybara/cuprite"
end
ENV["DATABASE_URL"] = "sqlite3::memory:"
ENV["RAILS_ENV"] = "test"
require "active_record/railtie"
require "action_controller/railtie"
require "action_view/railtie"
require "rails/test_unit/railtie"
class App < Rails::Application
config.load_defaults Rails::VERSION::STRING.to_f
config.root = __dir__
config.eager_load = false
config.secret_key_base = "secret_key_base"
config.consider_all_requests_local = true
config.turbo.draw_routes = false
routes.append do
resources :todos, only: [:new, :create]
root to: "todos#index"
end
end
Rails.application.initialize!
ActiveRecord::Schema.define do
create_table :todos, force: true do |t|
t.text :body, null: false
end
end
class Todo < ActiveRecord::Base
validates :body, presence: true
end
class TodosController < ActionController::Base
include Rails.application.routes.url_helpers
class_attribute :template, default: DATA.read
def index
@todos = Todo.all
render inline: template, formats: :html
end
def new
@todo = Todo.new
render_new_template
end
def create
@todo = Todo.new(params.require(:todo).permit(:body))
if @todo.save
flash.notice = "Todo created"
redirect_to root_url(turbo_visit_control: "reload")
else
render_new_template status: :unprocessable_entity
end
end
helper_method def breaking_out_of_turbo_frame?
turbo_frame_request? && params[:turbo_visit_control] == "reload"
end
before_action -> { flash.keep }, if: :breaking_out_of_turbo_frame?
private
def render_new_template(**)
render formats: :html, inline: <<~HTML, **
<turbo-frame id="new_todo">
<%= "Failed to create Todo" if @todo.errors.any? %>
<%= form_with model: @todo do |form| %>
<%= form.label :body %>
<%= form.text_field :body %>
<%= form.button %>
<% end %>
<%= link_to "Cancel", root_path %>
</turbo-frame>
HTML
end
end
class ApplicationSystemTestCase < ActionDispatch::SystemTestCase
driven_by :cuprite, using: :chrome, screen_size: [1400, 1400], options: {js_errors: true, headless: true}
end
require "rails/test_help"
class TurboSystemTest < ApplicationSystemTestCase
test "navigates frame to form and back" do
visit root_path
fill_in "Search", with: "a search term"
click_link "New Todo"
assert_field "Search", with: "a search term"
assert_field "Body"
click_link "Cancel"
assert_field "Search", with: "a search term"
assert_no_field "Body"
assert_no_text "Todo created"
end
test "re-renders the form with validation messages within the frame" do
visit root_path
fill_in "Search", with: "a search term"
click_link "New Todo"
fill_in "Body", with: " "
click_button "Create Todo"
assert_field "Body", with: " "
assert_text "Failed to create Todo"
assert_no_text "Todo created"
assert_no_css "p"
end
test "breaks out of frame on successful creation" do
visit root_path
fill_in "Search", with: "a search term"
click_link "New Todo"
fill_in "Body", with: "Hello, world"
click_button "Create Todo"
assert_css "p", text: "Hello, world"
assert_text "Todo created"
assert_field "Search", with: ""
assert_no_field "Body"
end
end
__END__
<!DOCTYPE html>
<html>
<head>
<%= csrf_meta_tags %>
<script type="importmap">
{
"imports": {
"@hotwired/turbo-rails": "<%= asset_path("turbo.js") %>"
}
}
</script>
<script type="module">
import "@hotwired/turbo-rails"
</script>
<%= turbo_page_requires_reload_tag if breaking_out_of_turbo_frame? %>
</head>
<body>
<%= flash.notice if flash.notice.present? %>
<label>Search to demonstrate page state being preserved <input></label>
<turbo-frame id="new_todo">
<%= link_to "New Todo", new_todo_path %>
</turbo-frame>
<% @todos.each do |todo| %>
<p><%= todo.body %></p>
<% end %>
</body>
</html> |
Incorporating @yshmarov suggestion from (#367 (comment)) into the requires the following changes shared below. diff --git a/app.rb b/app.rb
index aad947f..6dbadfb 100644
--- a/app.rb
+++ b/app.rb
@@ -71,17 +71,17 @@ class TodosController < ActionController::Base
@todo = Todo.new(params.require(:todo).permit(:body))
if @todo.save
flash.notice = "Todo created"
- redirect_to root_url(turbo_visit_control: "reload")
+ respond_to do |format|
+ format.html { redirect_to root_url }
+ format.turbo_stream { render turbo_stream: turbo_stream.action(:visit, root_url) }
+ end
else
render_new_template status: :unprocessable_entity
end
end
- helper_method def breaking_out_of_turbo_frame?
- turbo_frame_request? && params[:turbo_visit_control] == "reload"
- end
- before_action -> { flash.keep }, if: :breaking_out_of_turbo_frame?
-
private
def render_new_template(**)
@@ -166,10 +166,11 @@ __END__
</script>
<script type="module">
- import "@hotwired/turbo-rails"
+ import { Turbo } from "@hotwired/turbo-rails"
+ Turbo.StreamActions.visit = function () {
+ Turbo.visit(this.target)
+ }
</script>
-
- <%= turbo_page_requires_reload_tag if breaking_out_of_turbo_frame? %>
</head>
<body> While it's a suitable workaround given the constraints, and behaves the way it needs to, I dislike that it mixes HTML and Turbo Stream content types. Through that lens, I'm similarly dissatisfied with the original approach proposed by this PR's changeset. What I've come to appreciate about the Copy-paste the following into a app.rb file, then execute it with ruby app.rb.require "bundler/inline"
gemfile(true) do
source "https://rubygems.org"
git_source(:github) { |repo| "https://github.com/#{repo}.git" }
gem "rails"
gem "propshaft"
gem "puma"
gem "sqlite3"
gem "turbo-rails"
gem "capybara"
gem "cuprite", "~> 0.9", require: "capybara/cuprite"
end
ENV["DATABASE_URL"] = "sqlite3::memory:"
ENV["RAILS_ENV"] = "test"
require "active_record/railtie"
require "action_controller/railtie"
require "action_view/railtie"
require "rails/test_unit/railtie"
class App < Rails::Application
config.load_defaults Rails::VERSION::STRING.to_f
config.root = __dir__
config.eager_load = false
config.secret_key_base = "secret_key_base"
config.consider_all_requests_local = true
config.turbo.draw_routes = false
routes.append do
resources :todos, only: [:new, :create]
root to: "todos#index"
end
end
Rails.application.initialize!
ActiveRecord::Schema.define do
create_table :todos, force: true do |t|
t.text :body, null: false
end
end
class Todo < ActiveRecord::Base
validates :body, presence: true
end
class TodosController < ActionController::Base
include Rails.application.routes.url_helpers
class_attribute :template, default: DATA.read
def index
@todos = Todo.all
render inline: template, formats: :html
end
def new
@todo = Todo.new
render_new_template
end
def create
@todo = Todo.new(params.require(:todo).permit(:body))
if @todo.save
flash.notice = "Todo created"
respond_to do |format|
format.html { redirect_to root_url }
format.turbo_stream { render turbo_stream: turbo_stream.action(:visit, root_url) }
end
else
render_new_template status: :unprocessable_entity
end
end
private
def render_new_template(**)
render formats: :html, inline: <<~HTML, **
<turbo-frame id="new_todo">
<%= "Failed to create Todo" if @todo.errors.any? %>
<%= form_with model: @todo do |form| %>
<%= form.label :body %>
<%= form.text_field :body %>
<%= form.button %>
<% end %>
<%= link_to "Cancel", root_path %>
</turbo-frame>
HTML
end
end
class ApplicationSystemTestCase < ActionDispatch::SystemTestCase
driven_by :cuprite, using: :chrome, screen_size: [1400, 1400], options: {js_errors: true, headless: true}
end
require "rails/test_help"
class TurboSystemTest < ApplicationSystemTestCase
test "navigates frame to form and back" do
visit root_path
fill_in "Search", with: "a search term"
click_link "New Todo"
assert_field "Search", with: "a search term"
assert_field "Body"
click_link "Cancel"
assert_field "Search", with: "a search term"
assert_no_field "Body"
assert_no_text "Todo created"
end
test "re-renders the form with validation messages within the frame" do
visit root_path
fill_in "Search", with: "a search term"
click_link "New Todo"
fill_in "Body", with: " "
click_button "Create Todo"
assert_field "Body", with: " "
assert_text "Failed to create Todo"
assert_no_text "Todo created"
assert_no_css "p"
end
test "breaks out of frame on successful creation" do
visit root_path
fill_in "Search", with: "a search term"
click_link "New Todo"
fill_in "Body", with: "Hello, world"
click_button "Create Todo"
assert_css "p", text: "Hello, world"
assert_text "Todo created"
assert_field "Search", with: ""
assert_no_field "Body"
end
end
__END__
<!DOCTYPE html>
<html>
<head>
<%= csrf_meta_tags %>
<script type="importmap">
{
"imports": {
"@hotwired/turbo-rails": "<%= asset_path("turbo.js") %>"
}
}
</script>
<script type="module">
import { Turbo } from "@hotwired/turbo-rails"
Turbo.StreamActions.visit = function () {
Turbo.visit(this.target)
}
</script>
</head>
<body>
<%= flash.notice if flash.notice.present? %>
<label>Search to demonstrate page state being preserved <input></label>
<turbo-frame id="new_todo">
<%= link_to "New Todo", new_todo_path %>
</turbo-frame>
<% @todos.each do |todo| %>
<p><%= todo.body %></p>
<% end %>
</body>
</html> |
There are two semantically meaningful HTTP status codes that might be worth considering as special-case escape hatches to "break out" of Turbo Frame requests from the server. There is 201 Created:
This means that a server like Rails could control a frame with a status code through something like head: def create
@todo = Todo.new(todo_params)
if @todo.save
if turbo_frame_request?
head :created, location: @todo
else
redirect_to @todo
end
else
render :new, status: :unprocessable_entity
end
end Then the Turbo Frame Controller could special case responses with There is also 205 Reset Content:
This could communicate to Turbo that it should "reset" the view, similar to a Page Refresh. The downside here is that it doesn't have the |
@yshmarov Perfect, thanks. |
Now UX is to click a "New post" button that will render the post form dynamically in a target turbo frame. The Stimulus refresh controller is renamed as it now needs to handle a special redirect use case; on successful submission of the POST from the "examples_post_form" we want to render an updated list in the "examples_posts" turbo frame. The framework does not yet provide an intuitive mechanism for breaking out of a frame on redirect, so we use a Stimulus Turbo.visit to the "examples_post" form here. This does result in a "double GET" request, the first results in re-rendering the requesting "examples_post_form", but the second is needed to re-render the "examples_posts" frame. There are other approaches, described in the resources below, which also lay out the problem in more detail: - https://www.ducktypelabs.com/turbo-break-out-and-redirect/ - hotwired/turbo-rails#367 (comment) - https://discuss.hotwired.dev/t/break-out-of-a-frame-during-form-redirect/1562/26
I'd love to see this get merged. I've used it so thoroughly and comprehensively in one project that I was thrown for a loop when I discovered in a new Rails 8 project that this didn't work. It was only after I reviewed every instance of "turbo_frame" in my codebase did I remember that I had this file in my codebase:
|
Really hoping we can get this merged. Been wanting this functionality and resorting to client side forced refreshes. |
Closes hotwired/turbo#257 Closes hotwired/turbo#397 Follow-up to: * hotwired/turbo#257 (comment) * hotwired/turbo#257 (comment) Depends on hotwired/turbo#660 Introduces the `Turbo::Stream::Redirect` concern to override the [redirect_to][] routing helper. When called with a `turbo_frame:` option, the `redirect_to` helper with check whether the request was made with the Turbo Stream `Accept:` header. When it's absent, the response will redirect with a typical HTTP status code and location. When present, the controller will respond with a `<turbo-stream>` element that invokes [Turbo.visit($URL, { frame: $TURBO_FRAME })][hotwired/turbo#649] where `$URL` is set to the redirect's path or URL, and `$TURBO_FRAME` is set to the `turbo_frame:` argument. This enables server-side actions to navigate the entire page with a `turbo_frame: "_top"` option. Incidentally, it also enables a frame request to navigate _a different_ frame. Typically, an HTTP that would result in a redirect nets two requests: the first submission, then the subsequent GET request to follow the redirect. In the case of a "break out", the same number of requests are made: the first submission, then the subsequent GET made by the `Turbo.visit` call. Once the `Turbo.visit` call is made, the script removes its ancestor `<script>` by calling [document.currentScript.remove()][], and marking it with [data-turbo-cache="false"][] [redirect_to]: https://edgeapi.rubyonrails.org/classes/ActionController/Redirecting.html#method-i-redirect_to [hotwired/turbo#649]: hotwired/turbo#649 [document.currentScript.remove()]: https://developer.mozilla.org/en-US/docs/Web/API/Document/currentScript [data-turbo-cache="false"]: https://turbo.hotwired.dev/reference/attributes#data-attributes
0cf26aa
to
05f5928
Compare
I've re-purposed this PR to pitch a different approach. I've updated the PR description to elaborate on the details. |
05f5928
to
dea6972
Compare
Looks great! |
71b5dd1
to
aa39599
Compare
…_and_redirect_to` Introduces the `Turbo::Stream::Redirect` concern to introduce the `#break_out_of_turbo_frame_and_redirect_to` and `#turbo_stream_redirect_to` methods. The `#break_out_of_turbo_frame_and_redirect_to` draws inspiration from the methods provided by the [Turbo::Native::Navigation][] concern. When handling requests made from outside a `<turbo-frame>` elements (without the `Turbo-Frame` HTTP header), respond with a typical HTML redirect response. When handling request made from inside a `<turbo-frame>` element (with the `Turbo-Frame` HTTP header), render a `<turbo-stream action="visit">` element with the redirect's pathname or URL encoded into the `[location]` attribute. When Turbo Drive receives the response, it will call `Turbo.visit()` with the value read from the `[location]` attribute. ```ruby class ArticlesController < ApplicationController def show @Article = Article.find(params[:id]) end def create @Article = Article.new(article_params) if @article.save break_out_of_turbo_frame_and_redirect_to @Article else render :new, status: :unprocessable_entity end end end ``` Response options (like `:notice`, `:alert`, `:status`, etc.) are forwarded to the underlying redirection mechanism (`#redirect_to` for `Mime[:html]` requests and `#turbo_stream_redirect_to` for `Mime[:turbo_stream]` requests). This enables server-side actions to navigate the entire page with a, regardless of the provenance of the request. Typically, an HTTP that would result in a redirect nets two requests: the first submission, then the subsequent GET request to follow the redirect. In the case of a "break out", the same number of requests are made: the first submission, then the subsequent GET made by the `Turbo.visit` call. To support this behavior, this commit introduces the first `@hotwire/turbo-rails`-specific Turbo `StreamAction`: the `turbo-stream[action="visit"]`. [Turbo::Native::Navigation]: https://github.com/hotwired/turbo-rails/blob/v2.0.11/app/controllers/turbo/native/navigation.rb
aa39599
to
0f2e889
Compare
I'd like to share some concerns about the API changes, since they feel like a step backward. The previous API was simple and intuitive: redirect_to articles_url, turbo_frame: "_top" Now we have I know this change came from @kevinmcconnell's comment about using Turbo Streams rather than making Turbo Frames more flexible with page updates. The reasoning being that Streams already provides ways to target arbitrary areas of the page, and making Frames more "server-directed" could create unnecessary overlap. However, I see two key reasons to reconsider this:
Could we reconsider the previous API? I think it'd maintain a consistent pattern while keeping Frames and Streams focused on their core purposes. |
I really like that idea, it sounds very simple and is easy to implement on non rails backends |
I have explored that possibility in the past, but could not find a way to make it work with |
Closes hotwired/turbo#257
Closes hotwired/turbo#397
Follow-up to:
Introduces the
Turbo::Stream::Redirect
concern to introduce the#break_out_of_turbo_frame_and_redirect_to
and#turbo_stream_redirect_to
methods. The#break_out_of_turbo_frame_and_redirect_to
draws inspiration from themethods provided by the Turbo::Native::Navigation concern.
When handling requests made from outside a
<turbo-frame>
elements(without the
Turbo-Frame
HTTP header), respond with a typical HTMLredirect response.
When handling request made from inside a
<turbo-frame>
element (withthe
Turbo-Frame
HTTP header), render a<turbo-stream action="visit">
element with the redirect's pathname or URL encoded into the
[location]
attribute.When Turbo Drive receives the response, it will call
Turbo.visit()
withthe value read from the
[location]
attribute.Response options (like
:notice
,:alert
,:status
, etc.) areforwarded to the underlying redirection mechanism (
#redirect_to
forMime[:html]
requests and#turbo_stream_redirect_to
forMime[:turbo_stream]
requests).This enables server-side actions to navigate the entire page with a,
regardless of the provenance of the request.
Typically, an HTTP that would result in a redirect nets two requests:
the first submission, then the subsequent GET request to follow the
redirect.
In the case of a "break out", the same number of requests are made: the
first submission, then the subsequent GET made by the
Turbo.visit
call.
To support this behavior, this commit introduces the first
@hotwire/turbo-rails
-specific TurboStreamAction
: theturbo-stream[action="visit"]
.