Describe criteria for link and form morphing#178
Describe criteria for link and form morphing#178seanpdoyle wants to merge 1 commit intohotwired:mainfrom
Conversation
Expand upon the Page Refresh sections explaining how to morph and preserve scrolling. Page Refreshes --- A "page refresh" is a [application visit](/handbook/drive#application-visits) with a `"replace"` action to a URL with a whose [pathname](https://developer.mozilla.org/en-US/docs/Web/API/URL/pathname) matches the current URL [path](https://developer.mozilla.org/en-US/docs/Learn/Common_questions/Web_mechanics/What_is_a_URL#path_to_resource). Page refreshes can be initiated by driving the page with a link, or by [redirecting after a form submission](/handbook/drive#redirecting-after-a-form-submission). In either case, the elements must have a `[data-turbo-action="replace"]` attribute: ```html <a href="/" data-turbo-action="replace">Page refresh link</a> <form action="/redirect_back" method="post" data-turbo-action="replace"> <button>Page refresh form</button> </form> ```
|
This level of detail feels necessary, since there has been some surprise around links and forms not morphing. Below is a self-contained Turbo Rails application that morphs with form submissions and link clicks. The system test covering scroll preservation passes. If the require "bundler/inline"
gemfile(true) do
source "https://rubygems.org"
git_source(:github) { |repo| "https://github.com/#{repo}.git" }
gem "rails"
gem "propshaft"
gem "sqlite3"
gem "turbo-rails"
gem "capybara"
gem "cuprite", "~> 0.9", require: "capybara/cuprite"
end
require "active_record/railtie"
# require "active_storage/engine"
require "action_controller/railtie"
require "action_view/railtie"
# require "action_mailer/railtie"
# require "active_job/railtie"
# require "action_cable/engine"
# require "action_mailbox/engine"
# require "action_text/engine"
require "rails/test_unit/railtie"
class App < Rails::Application
config.load_defaults Rails::VERSION::STRING.to_f
config.root = __dir__
config.hosts << "example.org"
config.eager_load = false
config.session_store :cookie_store, key: "cookie_store_key"
config.secret_key_base = "secret_key_base"
config.consider_all_requests_local = true
config.turbo.draw_routes = false
Rails.logger = config.logger = Logger.new($stdout)
routes.append do
post "/" => "application#create"
root to: "application#index"
end
end
$template = DATA.read
class ApplicationController < ActionController::Base
include Rails.application.routes.url_helpers
def index
render inline: $template, formats: :html
end
def create
redirect_to root_url(count: params[:count].to_i + 1)
end
end
class ApplicationSystemTestCase < ActionDispatch::SystemTestCase
driven_by :cuprite, using: :chrome, screen_size: [1400, 1400], options: { js_errors: true }
end
Capybara.configure do |config|
config.server = :webrick
config.default_normalize_ws = true
end
ENV["DATABASE_URL"] = "sqlite3::memory:"
ENV["RAILS_ENV"] ||= "test"
Rails.application.initialize!
require "rails/test_help"
class TurboSystemTest < ApplicationSystemTestCase
test "reproduces bug" do
visit root_path
scroll_to find_button("Morph")
assert_scroll_preserved do
click_button "Morph"
assert_text "Count 1"
end
assert_scroll_preserved do
click_button "Morph"
assert_text "Count 2"
end
assert_scroll_preserved do
click_button "Morph"
assert_text "Count 3"
end
assert_scroll_preserved do
click_link "Morph"
assert_text "Count 4"
end
assert_scroll_preserved do
click_link "Morph"
assert_text "Count 5"
end
end
def assert_scroll_preserved(&block)
assert_no_changes -> { evaluate_script("window.scrollY") }, &block
end
end
__END__
<html>
<head>
<script type="importmap">
{
"imports": {
"@hotwired/turbo-rails": "<%= asset_path("turbo.js") %>"
}
}
</script>
<script type="module">
import "@hotwired/turbo-rails"
</script>
<%= turbo_refreshes_with method: :morph, scroll: :preserve %>
<%= yield :head %>
</head>
<body>
<p style="margin-bottom: 100vh;">Count <%= params.fetch(:count, 0) %><p>
<%= button_to "Morph", root_path, method: "post",
data: {turbo_action: "replace"},
params: {count: params[:count].to_i} %>
<%= link_to "Morph", root_path(count: params[:count].to_i + 1),
data: {turbo_action: "replace"} %>
</body>
</html> |
| <a href="/" data-turbo-action="replace">Page refresh link</a> | ||
|
|
||
| <form action="/redirect_back" method="post" data-turbo-action="replace"> | ||
| <button>Page refresh form</button> | ||
| </form> |
There was a problem hiding this comment.
@jorgemanrubia is this guidance correct? Are the [data-turbo-action] attributes necessary, or should the resulting Visit get classified as a "page refresh" through a different mechanism? This guidance is based on boolean criteria in PageView.isPageRefresh.
There was a problem hiding this comment.
@seanpdoyle same-location redirects are already replace by default as per this:
So the attribute is not necessary.
There was a problem hiding this comment.
@jorgemanrubia this documentation was mostly in response to confusion stemming from <a>- and <form>-driven Page Refreshes.
I've verified that redirects in the way you've described *do* work as expected:
require "bundler/inline"
gemfile(true) do
source "https://rubygems.org"
git_source(:github) { |repo| "https://github.com/#{repo}.git" }
gem "rails"
gem "propshaft"
gem "sqlite3"
gem "turbo-rails"
gem "capybara"
gem "cuprite", "~> 0.9", require: "capybara/cuprite"
end
require "active_record/railtie"
# require "active_storage/engine"
require "action_controller/railtie"
require "action_view/railtie"
# require "action_mailer/railtie"
# require "active_job/railtie"
# require "action_cable/engine"
# require "action_mailbox/engine"
# require "action_text/engine"
require "rails/test_unit/railtie"
class App < Rails::Application
config.load_defaults Rails::VERSION::STRING.to_f
config.root = __dir__
config.hosts << "example.org"
config.eager_load = false
config.session_store :cookie_store, key: "cookie_store_key"
config.secret_key_base = "secret_key_base"
config.consider_all_requests_local = true
config.turbo.draw_routes = false
Rails.logger = config.logger = Logger.new($stdout)
routes.append do
post "/" => "application#create"
root to: "application#index"
end
end
class ApplicationController < ActionController::Base
include Rails.application.routes.url_helpers
class_attribute :template, default: DATA.read
def index
render inline: template, formats: :html
end
def create
redirect_to root_url
end
end
class ApplicationSystemTestCase < ActionDispatch::SystemTestCase
driven_by :cuprite, using: :chrome, screen_size: [1400, 1400], options: { js_errors: true}
end
Capybara.configure do |config|
config.server = :webrick
config.default_normalize_ws = true
end
ENV["DATABASE_URL"] = "sqlite3::memory:"
ENV["RAILS_ENV"] ||= "test"
Rails.application.initialize!
require "rails/test_help"
class TurboSystemTest < ApplicationSystemTestCase
test "reproduces bug" do
visit root_path
assert_css "h1", text: "Loaded without morphing"
click_button "Morph"
assert_css "h1", text: "Loaded with morph"
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"
addEventListener("turbo:morph", () => document.querySelector("h1").textContent = "Loaded with morph")
</script>
<meta name="turbo-refresh-method" content="morph">
</head>
<body>
<h1>Loaded without morphing</h1>
<%= button_to "Morph", root_path %>
</body>
</html>There was a problem hiding this comment.
On further inspection, I realized the difference between #178 (comment) and #178 (comment).
The Navigator.getDefaultAction you've shared:
#getDefaultAction(fetchResponse) {
const sameLocationRedirect = fetchResponse.redirected && fetchResponse.location.href === this.location?.href
return sameLocationRedirect ? "replace" : "advance"
}differs from the Visit.isPageRefresh comparison:
isPageRefresh(visit) {
return !visit || (this.lastRenderedLocation.pathname === visit.location.pathname && visit.action === "replace")
}The Navigator.getDefaultAction compares URL.href values, whereas the Visit.isPageRefresh compares URL.pathname.
Put another way, redirects back require exact matches of the full URLs (including query parameters), while refreshing when the action is forced only compares the URL paths (excluding query parameters).
This script passes with the `data: {turbo_action: "replace"}` option, then fails without the option:
require "bundler/inline"
gemfile(true) do
source "https://rubygems.org"
git_source(:github) { |repo| "https://github.com/#{repo}.git" }
gem "rails"
gem "propshaft"
gem "sqlite3"
gem "turbo-rails"
gem "capybara"
gem "cuprite", "~> 0.9", require: "capybara/cuprite"
end
require "active_record/railtie"
# require "active_storage/engine"
require "action_controller/railtie"
require "action_view/railtie"
# require "action_mailer/railtie"
# require "active_job/railtie"
# require "action_cable/engine"
# require "action_mailbox/engine"
# require "action_text/engine"
require "rails/test_unit/railtie"
class App < Rails::Application
config.load_defaults Rails::VERSION::STRING.to_f
config.root = __dir__
config.hosts << "example.org"
config.eager_load = false
config.session_store :cookie_store, key: "cookie_store_key"
config.secret_key_base = "secret_key_base"
config.consider_all_requests_local = true
config.turbo.draw_routes = false
Rails.logger = config.logger = Logger.new($stdout)
routes.append do
post "/" => "application#create"
root to: "application#index"
end
end
class ApplicationController < ActionController::Base
include Rails.application.routes.url_helpers
class_attribute :template, default: DATA.read
def index
render inline: template, formats: :html
end
def create
redirect_to root_url(count: params[:count].to_i + 1)
end
end
class ApplicationSystemTestCase < ActionDispatch::SystemTestCase
driven_by :cuprite, using: :chrome, screen_size: [1400, 1400], options: { js_errors: true }
end
Capybara.configure do |config|
config.server = :webrick
config.default_normalize_ws = true
end
ENV["DATABASE_URL"] = "sqlite3::memory:"
ENV["RAILS_ENV"] ||= "test"
Rails.application.initialize!
require "rails/test_help"
class TurboSystemTest < ApplicationSystemTestCase
test "reproduces bug" do
visit root_path
scroll_to find_button("Morph")
assert_scroll_preserved do
click_button "Morph"
assert_text "Count 1"
end
end
def assert_scroll_preserved(&block)
assert_no_changes -> { evaluate_script("window.scrollY") }, &block
end
end
__END__
<html>
<head>
<script type="importmap">
{
"imports": {
"@hotwired/turbo-rails": "<%= asset_path("turbo.js") %>"
}
}
</script>
<script type="module">
import "@hotwired/turbo-rails"
</script>
<meta name="turbo-refresh-method" content="morph">
<meta name="turbo-refresh-scroll" content="preserve">
</head>
<body>
<p style="margin-bottom: 100vh;">Count <%= params.fetch(:count, 0) %><p>
<%= button_to "Morph", root_path,
data: {turbo_action: "replace"},
params: {count: params[:count].to_i} %>
</body>
</html>Does that inconsistency need to be resolved? Should replace actions become more flexible by comparing URL.pathname instead of URL.href? Should page refreshes become more strict by comparing URL.href instead of URL.pathname?
There was a problem hiding this comment.
@seanpdoyle The motivation behind using pathname is here, although this also generates slight inconsistencies in mobile adapters that still only check href. What is your opinion on this?
There was a problem hiding this comment.
What is your opinion on this?
I think behavior should be consistent. I'm not sure I have enough practical experience to judge which side should change to match the other.
If it's determined that inconsistency is the best option, then I think a documentation change (like the one proposed in this PR) will be necessary to clarify what is and is not going to result in a Page Refresh.
There was a problem hiding this comment.
@jorgemanrubia should determining Visit Action based on the URL (as described in #178 (comment)) be consistent in both Navigator.getDefaultAction and Visit.isPageRefresh?
If it should, do you have an opinion on which implementation should change to be consistent with the other?
|
Hi! I opened this issue today and @seanpdoyle pointed that it was related to the discussion in the comment above. I wanted to check on the motivations behind adding the condition I found that if I want to preserve the current location and push a new one onto the browser's history with the default Could it be possible to also support |
|
|
||
| ## Page Refreshes | ||
|
|
||
| A "page refresh" is a [application visit](/handbook/drive#application-visits) with a `"replace"` action to a URL with a whose [pathname](https://developer.mozilla.org/en-US/docs/Web/API/URL/pathname) matches the current URL [path](https://developer.mozilla.org/en-US/docs/Learn/Common_questions/Web_mechanics/What_is_a_URL#path_to_resource). Page refreshes can be initiated by driving the page with a link, or by [redirecting after a form submission](/handbook/drive#redirecting-after-a-form-submission). In either case, the elements must have a `[data-turbo-action="replace"]` attribute: |
There was a problem hiding this comment.
@seanpdoyle here I'd clarify that that [data-turbo-action="replace"] bit is only needed when using links, since, for forms, that's the default. Forms that redirect back will trigger a page refresh, without having to specify the refresh action.
Yes, but this has implications regarding how the internal cache of snapshots work. Turbo currently does a performance optimization where it caches the page before performing the navigation, relying on the fact that the body will be replaced. With morphing, this approach doesn't work. We would need to change how the caching sequence happens. I think Turbo should let you use morphing in other navigation scenarios. My concern is not really the caching issue since that is easy-ish to fix. But I'd like to get the API right. It's true that for some apps, in some scenarios, it makes sense to morph pages despite having different paths because they are very similar. |
| <button>Page refresh form</button> | ||
| </form> | ||
| ``` | ||
|
|
There was a problem hiding this comment.
I'm adding this suggestion because I got stuck on it for a while. I had a form with a GET action and I didn't understand why Turbo was using morphing to merge the response into the existing page data.
| Morphing is only triggered by `replace` actions. By default, link clicks and forms with `method="get"` trigger `advance` application visits and therefore the new page content will not be morphed. | |
|
|
||
| ## Page Refreshes | ||
|
|
||
| A "page refresh" is a [application visit](/handbook/drive#application-visits) with a `"replace"` action to a URL with a whose [pathname](https://developer.mozilla.org/en-US/docs/Web/API/URL/pathname) matches the current URL [path](https://developer.mozilla.org/en-US/docs/Learn/Common_questions/Web_mechanics/What_is_a_URL#path_to_resource). Page refreshes can be initiated by driving the page with a link, or by [redirecting after a form submission](/handbook/drive#redirecting-after-a-form-submission). In either case, the elements must have a `[data-turbo-action="replace"]` attribute: |
There was a problem hiding this comment.
Could we clarify that URL parameters are not considered in this pathname check? It should be a given, but if I don't read the linked MDN article I might forget.
| @@ -11,6 +11,18 @@ A typical scenario for page refreshes is submitting a form and getting redirecte | |||
|
|
|||
There was a problem hiding this comment.
An important callout until hotwired/turbo#1316 and hotwired/turbo#1319 land.
I was stuck on this for hours!
| At this time morphing is only supported document-wide. You cannot add morphing to a `<turbo-frame>`. | |
Expand upon the Page Refresh sections explaining how to morph and preserve scrolling.
Page Refreshes
A "page refresh" is a application visit with a
"replace"action to a URL with a whose pathname matches the current URL path. Page refreshes can be initiated by driving the page with a link, or by redirecting after a form submission. In either case, the elements must have a[data-turbo-action="replace"]attribute: