Skip to content

Commit

Permalink
Add blog post on breaking out of a frame on successful form submit
Browse files Browse the repository at this point in the history
The rails app inside the spec folder is an app added just to be
able to add tests for Rails features mentioned in articles.
  • Loading branch information
radanskoric committed Jun 10, 2024
1 parent df10e48 commit 951781a
Show file tree
Hide file tree
Showing 79 changed files with 1,417 additions and 1 deletion.
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -22,3 +22,8 @@ package-lock.json
# Misc
assets/js/dist
.DS_Store

# The test rails app that's inside the spec folder
spec/rails_app/tmp/
spec/rails_app/log/*.log
spec/rails_app/storage
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
---
layout: post
title: "How to reuse the same page in different Turbo Frame flows"
date: 2024-05-24
date: 2024-05-28
categories: articles
tags: rails hotwire turbo turbo-frames
---
Expand Down
152 changes: 152 additions & 0 deletions _posts/2024-06-11-update-full-page-on-form-in-frame-submit.markdown
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
---
layout: post
title: "How to refresh the full page when submitting a form inside a Turbo Frame?"
date: 2024-06-11
categories: articles
tags: rails hotwire turbo turbo-frames forms
---

Let's consider some UI examples and check if we can get the Turbo magic by just slapping a Turbo Frame in the right place. By that I mean: can we make the implementation really **as simple as plain HTML** with the, as the slogan says *"the speed of a single-page web application"*:
- Navigating full pages of content. ✅
- Links that change a fixed different part of UI, like tabs. ✅
- Navigating a self contained small part of the UI like an image gallery. ✅
- Inline editing of elements in a list. ✅
- Submitting a form shows errors or modifies the full page, like adding an item to a list. ❌
- ...

Huh, that last one is relatively common, why is it not straightforward?

All we want there is to have a list and a form that can both show errors and add to the list when there are no errors. It's by no means **hard**, but it's not trivial and it sounds like it should be.

The general problem description would be: **having a Turbo Frame that might update itself or might update the full page**. The problematic part is that **the logic** for which of those two actions are needed is dynamically determined **on the server**. Turbo has a number of mechanisms to **statically** define the target of a link click or form submission in the page itself, but it's not as easy when we need to control it from the server. It's confusing enough that there's [a long standing open issue on Turbo Github repo, with many comments]`(https://github.com/hotwired/turbo/issues/257){:target="_blank"}`.

I've faced this problem myself and used different solutions but I wanted to find the best one so I went through that whole thread. The answer: **it depends**. You don't have to go through the thread, here are all the techniques with their tradeoffs.

## Techniques

I am assuming you're using Rails but solutions should be easily transferable to a different backend framework.

### Just add target="_top" to the Turbo Frame

It's as simple as creating the frame with "_top" as its target:
```ruby
turbo_frame_tag :target_top, target: "_top"
```
And voila, the form submission will navigate the full page rather than just the frame.

The problem with that is that it will **always** navigate the full page. Even if there are errors that you want to render inside the frame, Turbo will attempt to navigate the full page, breaking the process. So this is not viable if you need to also show errors. But, if you know every form submission is successful, this is by far the simplest approach.

### Emit a refresh action on a successful submit

The idea is that on a successful submit you emit a [refresh stream action]`(https://turbo.hotwired.dev/reference/streams#refresh){:target="_blank"}` instead of a redirect like you might for a plain HTML page. This action was added when [morphing](/articles/turbo-morphing-deep-dive) functionality was introduced and it causes Turbo to "refresh" the current page. Depending on your other configuration this will mean fetching the full page again and then either *replacing* the content of the `body` tag or *morphing* it. Either way, the result will be that the full page will be updated in an efficient manner by Turbo.

The neat part is that the error part doesn't need to change at all, it can stay identical to how it would be for the plain HTML approach. This is how it might look for an endpoint where we create a record that has validations:
```ruby
def create
@record = Record.create(record_params)
if @record.valid?
respond_to do |format|
format.turbo_stream { render turbo_stream: turbo_stream.action(:refresh, "") }
format.html { redirect_to :index }
end
else
render :new # Rely on the form rendering showing errors
end
end
```
An important assumption of this approach is that you want to modify the current page. Next approach covers the case when that's not true.

### Use custom full page redirect stream action

If instead of modifying the current page you want to either show errors or move to a different page, you'd normally use a redirect. But this will not work with Turbo because it would redirect only the Turbo Frame. If you want to redirect the full page you'll need to create a [custom stream action]`(https://turbo.hotwired.dev/handbook/streams#custom-actions){:target="_blank"}`. Custom stream actions allow us to expand the default list of stream actions that Turbo provides.

First define a new stream action for the full page redirect:
```javascript
Turbo.StreamActions.full_page_redirect = function() {
document.location = this.getAttribute("target")
}
```
And then, similar to above example with the refresh action, respond with it when handling a turbo request:
```ruby
respond_to do |format|
format.turbo_stream do
render turbo_stream: turbo_stream.action(:full_page_redirect, redirect_path)
end
format.html { redirect_to redirect_path }
end

```

### Rely on turbo-visit-control meta tag

A page can advertise to Turbo that it always needs to be loaded as a full page load. It does this with a special [turbo-visit-control]`(https://turbo.hotwired.dev/reference/attributes#meta-tags){:target="_blank"}` meta tag. It's as simple as calling a helper inside the view template:
```erb
<% turbo_page_requires_reload %>
```
When you respond with a redirect to a page with this meta tag, Turbo will:
1. Fetch the page.
2. Before attempting to update the Frame, check for this meta tag.
3. If it finds the meta tag it will abandon Frame update and instead issue a full page reload to the same url.

This is very simple if the destination page always requires to be a full page load. The downside is that it will cause the page to be loaded **twice**. However, this is a good tradeoff if the scenario happens rarely.

A common example is the login page. If you have authentication that might expire, it means that any visit might redirect to the login page. In that case it makes perfect sense to place this meta tag on the page.

### Do not use a Turbo Frame

If you've been paying attention you'll notice that the above solutions all make the sad path, an error showing on the form, very simple, and the small added complexity is on the happy path. This often makes sense but if for you it's really important that the happy path is the simple one you have another option.

You can not use a Turbo Frame at all. The happy path is then literally identical to the plain HTML approach, since it really is a plain HTML form submission.

But you still want to show errors inline. The trick is to wrap the form in a plain element with an id and then use stream actions to replace it, effectively simulating a frame update in the case of an error.

First, in the view you use a plain `div` instead of a Turbo Frame:
```erb
<div id="<%= dom_id(record, "form") %>">
<%= form_for record do |f| %>
...
<% end %>
</div>
```
and then in the controller use a regular redirect for the happy path and a stream action to [replace]`(https://rubydoc.info/github/hotwired/turbo-rails/main/Turbo/Streams/TagBuilder#replace-instance_method){:target="_blank"}` the form content in case of an error:
```ruby
if @record.valid?
redirect_to :index
else
respond_to do |format|
format.turbo_stream do
render turbo_stream: turbo_stream.replace(
dom_id(@record, "form"),
partial: "records/form",
locals: {record: @record}
)
end
format.html { render :new }
end
end
```

As you can see, the reason that we opted before to keep the error path simple is that it *usually* results in overall simpler code. However, some cases might be different and then this can be a good choice.

## Why is it like this and when will it be improved?

It's fair to ask, why doesn't Turbo just work here? Is it really so hard? There are a number of heuristics we could use but they all have a problem of being correct in many cases but also being wrong in other, perfectly reasonable, cases.

We can't make this logic fuzzy, Turbo needs to be predictable. And since a reasonable logic has not been found yet, we have to be explicit about it. And this is the direction that most promising solutions suggested in that [Github Issue]`(https://github.com/hotwired/turbo/issues/257){:target="_blank"}` explore: how to allow the developer to be explicit in the simplest way possible. However, a satisfactory solution that actually works has not been found yet. Most promising ones are limited by what can be done in the browser. So we might have to wait for browser evolution until being able to get a truly satisfactory solution. At this point, I'm leaning towards this being something that will always require us to add a little extra complexity.

### Break out of the frame when missing matching frame?

However there is one area where Turbo might offer a bit more out of the box. What to do when the response (or the page to which we redirected) **doesn't** contain the target turbo frame? Currently it results in an error so it's not possible that anyone is relying on it as a feature. Turbo could instead treat it as a signal to update the full page. This could simplify some of the cases.

This might be a real future *partial solution*. It even has [DHH endorsing it]`(https://github.com/hotwired/turbo/issues/257#issuecomment-1188397132){:target="_blank"}`.

The good news is that, if you want to get this behaviour today, all you need to do is add this global listener to the [frame-missing event]`(https://turbo.hotwired.dev/reference/events#turbo%3Aframe-missing){:target="_blank"}`:

```javascript
document.addEventListener("turbo:frame-missing", function (event) {
event.preventDefault()
event.detail.visit(event.detail.response)
})
```

Do you have an alternative solution that I missed or a different take on it? Please share it in the comments below!
1 change: 1 addition & 0 deletions spec/rails_app/.ruby-version
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
ruby-3.3.1
64 changes: 64 additions & 0 deletions spec/rails_app/Gemfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
source "https://rubygems.org"

ruby "3.3.1"

# Bundle edge Rails instead: gem "rails", github: "rails/rails", branch: "main"
gem "rails", "~> 7.1.3", ">= 7.1.3.3"

# The modern asset pipeline for Rails [https://github.com/rails/propshaft]
gem "propshaft"

# Use sqlite3 as the database for Active Record
gem "sqlite3", "~> 1.4"

# Use the Puma web server [https://github.com/puma/puma]
gem "puma", ">= 5.0"

# Use JavaScript with ESM import maps [https://github.com/rails/importmap-rails]
gem "importmap-rails"

# Hotwire's SPA-like page accelerator [https://turbo.hotwired.dev]
gem "turbo-rails"

# Hotwire's modest JavaScript framework [https://stimulus.hotwired.dev]
gem "stimulus-rails"

# Use Redis adapter to run Action Cable in production
# gem "redis", ">= 4.0.1"

# Use Kredis to get higher-level data types in Redis [https://github.com/rails/kredis]
# gem "kredis"

# Use Active Model has_secure_password [https://guides.rubyonrails.org/active_model_basics.html#securepassword]
# gem "bcrypt", "~> 3.1.7"

# Windows does not include zoneinfo files, so bundle the tzinfo-data gem
gem "tzinfo-data", platforms: %i[ windows jruby ]

# Reduces boot times through caching; required in config/boot.rb
gem "bootsnap", require: false

# Use Active Storage variants [https://guides.rubyonrails.org/active_storage_overview.html#transforming-images]
# gem "image_processing", "~> 1.2"

group :development, :test do
# See https://guides.rubyonrails.org/debugging_rails_applications.html#debugging-with-the-debug-gem
gem "debug", platforms: %i[ mri windows ]
end

group :development do
# Use console on exceptions pages [https://github.com/rails/web-console]
gem "web-console"

# Add speed badges [https://github.com/MiniProfiler/rack-mini-profiler]
# gem "rack-mini-profiler"

# Speed up commands on slow machines / big apps [https://github.com/rails/spring]
# gem "spring"
end

group :test do
# Use system testing [https://guides.rubyonrails.org/testing.html#system-testing]
gem "capybara"
gem "selenium-webdriver"
end
24 changes: 24 additions & 0 deletions spec/rails_app/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# README

This README would normally document whatever steps are necessary to get the
application up and running.

Things you may want to cover:

* Ruby version

* System dependencies

* Configuration

* Database creation

* Database initialization

* How to run the test suite

* Services (job queues, cache servers, search engines, etc.)

* Deployment instructions

* ...
6 changes: 6 additions & 0 deletions spec/rails_app/Rakefile
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
# Add your own tasks in files placed in lib/tasks ending in .rake,
# for example lib/tasks/capistrano.rake, and they will automatically be available to Rake.

require_relative "config/application"

Rails.application.load_tasks
Empty file.
1 change: 1 addition & 0 deletions spec/rails_app/app/assets/stylesheets/application.css
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
/* Application styles */
4 changes: 4 additions & 0 deletions spec/rails_app/app/channels/application_cable/channel.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
module ApplicationCable
class Channel < ActionCable::Channel::Base
end
end
4 changes: 4 additions & 0 deletions spec/rails_app/app/channels/application_cable/connection.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
module ApplicationCable
class Connection < ActionCable::Connection::Base
end
end
2 changes: 2 additions & 0 deletions spec/rails_app/app/controllers/application_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
class ApplicationController < ActionController::Base
end
Empty file.
41 changes: 41 additions & 0 deletions spec/rails_app/app/controllers/form_in_frame_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
class FormInFrameController < ApplicationController
before_action :save_message, except: %i[index]

def index
end

def target_top
redirect_to action: :index
end

def refresh_action
respond_to do |format|
format.turbo_stream { render turbo_stream: turbo_stream.action(:refresh, "") }
format.html { redirect_to :index }
end
end

def visit_control
redirect_to action: :index, params: { force_reload: true}
end

def custom_action
redirect_path = form_in_frame_path
respond_to do |format|
format.turbo_stream { render turbo_stream: turbo_stream.action(:full_page_redirect, redirect_path) }
format.html { redirect_to redirect_path }
end
end

private

def save_message
if params[:message].blank?
render partial: "error", status: :unprocessable_entity
return
end

session[:form_in_frame] = params[:message]
end

end
2 changes: 2 additions & 0 deletions spec/rails_app/app/helpers/application_helper.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
module ApplicationHelper
end
2 changes: 2 additions & 0 deletions spec/rails_app/app/helpers/form_in_frame_helper.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
module FormInFrameHelper
end
12 changes: 12 additions & 0 deletions spec/rails_app/app/javascript/application.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
// Configure your import map in config/importmap.rb. Read more: https://github.com/rails/importmap-rails
import "@hotwired/turbo-rails"
import "controllers"

addEventListener("turbo:render", (event) => {
console.log("Turbo Rendered with: ", event.detail.renderMethod, (event.detail.isPreview ? "(preview)" : ""));
})

Turbo.StreamActions.full_page_redirect = function() {
document.location = this.getAttribute("target")
}

9 changes: 9 additions & 0 deletions spec/rails_app/app/javascript/controllers/application.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { Application } from "@hotwired/stimulus"

const application = Application.start()

// Configure Stimulus development experience
application.debug = false
window.Stimulus = application

export { application }
7 changes: 7 additions & 0 deletions spec/rails_app/app/javascript/controllers/hello_controller.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
connect() {
this.element.textContent = "Hello World!"
}
}
11 changes: 11 additions & 0 deletions spec/rails_app/app/javascript/controllers/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
// Import and register all your controllers from the importmap under controllers/*

import { application } from "controllers/application"

// Eager load all controllers defined in the import map under controllers/**/*_controller
import { eagerLoadControllersFrom } from "@hotwired/stimulus-loading"
eagerLoadControllersFrom("controllers", application)

// Lazy load controllers as they appear in the DOM (remember not to preload controllers in import map!)
// import { lazyLoadControllersFrom } from "@hotwired/stimulus-loading"
// lazyLoadControllersFrom("controllers", application)
7 changes: 7 additions & 0 deletions spec/rails_app/app/jobs/application_job.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
class ApplicationJob < ActiveJob::Base
# Automatically retry jobs that encountered a deadlock
# retry_on ActiveRecord::Deadlocked

# Most jobs are safe to ignore if the underlying records are no longer available
# discard_on ActiveJob::DeserializationError
end
4 changes: 4 additions & 0 deletions spec/rails_app/app/mailers/application_mailer.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
class ApplicationMailer < ActionMailer::Base
default from: "[email protected]"
layout "mailer"
end
3 changes: 3 additions & 0 deletions spec/rails_app/app/models/application_record.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
class ApplicationRecord < ActiveRecord::Base
primary_abstract_class
end
Empty file.
3 changes: 3 additions & 0 deletions spec/rails_app/app/views/form_in_frame/_error.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
<%= turbo_frame_tag turbo_frame_request_id do %>
An error has occurred: the message can't be empty
<% end %>
Loading

0 comments on commit 951781a

Please sign in to comment.