Skip to content
New issue

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

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

Already on GitHub? Sign in to your account

Describe criteria for link and form morphing #178

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 16 additions & 4 deletions _source/handbook/03_page_refreshes.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,18 @@ A typical scenario for page refreshes is submitting a form and getting redirecte

${toc}

## 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>
Comment on lines +19 to +23
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@seanpdoyle same-location redirects are already replace by default as per this:

https://github.com/hotwired/turbo/blob/c471974dfaf546c071924935ec74c6c419dbd6ec/src/core/drive/navigator.js#L161-L164

So the attribute is not necessary.

Copy link
Contributor Author

@seanpdoyle seanpdoyle Mar 3, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

```

## Morphing

You can configure how Turbo handles page refresh with a `<meta name="turbo-refresh-method">` in the page's head.
Expand All @@ -22,13 +34,13 @@ You can configure how Turbo handles page refresh with a `<meta name="turbo-refre
</head>
```

The possible values are `morph` or `replace` (the default). When it is `morph,` when a page refresh happens, instead of replacing the page's `<body>,` Turbo will only update the DOM elements that have changed, keeping the rest untouched. This approach delivers better sensations because it keeps the screen state.
The possible values are `morph` or `replace` (the default). When the `<meta>` element is omitted or its `content` attribute is `replace`, Turbo will [replace the page's `<body>` element](/handbook/drive#page-navigation-basics). When the `content` attribute is `morph`, Turbo will handle [page refreshes](#page-refreshes) by updating *only* the DOM elements that have changed. This approach delivers better sensations because it keeps the screen state like element focus.

Under the hood, Turbo uses the fantastic [idiomorph library](https://github.com/bigskysoftware/idiomorph).

## Scroll preservation

You can configure how Turbo handles scrolling with a `<meta name="turbo-refresh-scroll">` in the page's head.
You can configure how Turbo handles scrolling when handling with a `<meta name="turbo-refresh-scroll">` in the page's head.

```html
<head>
Expand All @@ -37,7 +49,7 @@ You can configure how Turbo handles scrolling with a `<meta name="turbo-refresh-
</head>
```

The possible values are `preserve` or `reset` (the default). When it is `preserve`, when a page refresh happens, Turbo will keep the page's vertical and horizontal scroll.
The possible values are `preserve` or `reset` (the default). When the `<meta>` element is omitted or its `content` attribute is `reset`, Turbo will [reset the page's scroll position](/handbook/drive#application-visits). When the `content` attribute is `preserve`, Turbo will handle [page refreshes](#page-refreshes) by maintaining the page's vertical and horizontal scroll.

## Exclude sections from morphing

Expand Down Expand Up @@ -67,7 +79,7 @@ There is a new [turbo stream action](/handbook/streams.html) called `refresh` th
<turbo-stream action="refresh"></turbo-stream>
```

Server-side frameworks can leverage these streams to offer a simple but powerful broadcasting model: the server broadcasts a single general signal, and pages smoothly refresh with morphing.
Server-side frameworks can leverage these streams to offer a simple but powerful broadcasting model: the server broadcasts a single general signal, and pages smoothly refresh with morphing.

You can see how the [`turbo-rails`](https://github.com/hotwired/turbo-rails) gem does it for Rails:

Expand Down