ruby-on-railsruby-on-rails-7hotwire-railsturbo-frames

In rails, Turbo just appends the response from the server at the end of current page


The problem: Turbo just appends the response from server to the end of current page. Not replacing the page like expected.

Recently upgraded from Rails 6 to 7. Also I switched from importmap to esbuild.

I have a form that is making a post to a create method. The controller responds with

  if @stamp.save       
        redirect_to stamps_path, notice: "Input saved"
      else

The strange thing that happens is that the response is being appended to the bottom of the source code from the top. So at the end of the original source code a new head-section from the response is appended like

<...> The whole initial html <...>
</body>
<title>Page title</title> <-- The server response gets appended after </body>
<meta .... the whole page gets repeated again

Not really sure what to show you here, been digging through all settings etc. I have never seen Turbo behave like this with a respons.

source "https://rubygems.org"
git_source(:github) { |repo| "https://github.com/#{repo}.git" }

ruby "3.1.2"
gem "rails", "~> 7.0.2", ">= 7.0.2.4"
gem "sprockets-rails"
gem "puma", "~> 5.6"
gem "haml-rails", "~> 2.0"
gem "sqlite3", "~> 1.4"
gem 'devise', '~> 4.8'
gem 'devise-i18n', '~> 1.10'
gem "turbo-rails"
gem "jsbundling-rails", "~> 1.0"
gem 'sass-rails'
gem "stimulus-rails"
gem 'rubyXL', '~> 3.3'
gem "jbuilder"
gem "tzinfo-data", platforms: %i[ mingw mswin x64_mingw jruby ]
gem 'kaminari', '~> 1' # pagination gem
gem "bootsnap", require: false

group :development, :test do
  gem "debug", platforms: %i[ mri mingw x64_mingw ]
end

group :development do
  gem "web-console"
end

group :test do
  gem "capybara"
  gem "selenium-webdriver"
  gem "webdrivers"
end

{
  "private": true,
  "license": "ISC",
  "name": "name",
  "version": "0.1.0",
  "scripts": {
    "release": "npm run build && npm run publish",
    "test": "echo \"Error: no test specified\" && exit 1",
    "build": "esbuild app/javascript/*.* --bundle --sourcemap --outdir=app/assets/builds --public-path=assets"
  },
  "dependencies": {
    "@fortawesome/fontawesome-svg-core": "^6.1.2",
    "@fortawesome/free-brands-svg-icons": "^6.2.0",
    "@fortawesome/free-solid-svg-icons": "^6.1.2",
    "@hotwired/stimulus": "^3.1.0",
    "@hotwired/turbo-rails": "^7.2.5",
    "@popperjs/core": "^2.11.5",
    "@rails/activestorage": "^6.0.0",
    "bootstrap": "^5.2.0",
    "esbuild": "^0.17.7"
  }
}

I've been trying to replicate another rails app I have using Turbo. Been looking into all settings etc. Other pages can take a redirect_to and replacing the content as expected. I'm using haml so the HTML-structure should be fine.


Solution

  • I can only think of a one way this could happen: you have show.erb template. Just rename it to show.html.erb (or .haml, .slim, it doesn't matter).


    By default, the forms are submitted as TURBO_STREAM, it's what rails shows in the logs, however, this is not the whole picture. The form submit request sets Accept header:

    Accept: text/vnd.turbo-stream.html, text/html, application/xhtml+xml
    #       ^
    # here is where `TURBO_STREAM` comes from
    

    These are the response Content-Types that turbo expects to receive. When you use respond_to block in controller actions, Accept header is what determines which format block to call:

    respond_to do |format|
      if @model.save(model_params)
        # if you have turbo_stream, it will run first
        # format.turbo_stream { render turbo_stream: turbo_stream.replace(model) }
        # if you don't have turbo_stream, next format in `Accept` header is html
        format.html { redirect_to model_url(@model) }
        format.json { render :show, status: :ok, location: @model }
        # if nothing matches, you get `ActionController::UnknownFormat` error.
        # the one you get when trying to render `format.js`, but Turbo 
        # doesn't handle js format, so it doesn't send `Accept: text/javascript`
      else
        format.html { render :edit, status: :unprocessable_entity }
        format.json { render json: @model.errors, status: :unprocessable_entity }
      end
    end
    

    When you use respond_to block, rails will automatically set some render options, one of which is :content_type which in turn sets Content-Type response header. So if format.html block runs it sets Content-Type: text/html which turbo knows how to handle by redirecting or replacing the page content.

    The important part is that we went from turbo_stream format to html. Needless to say that html needs to render html looking response and turbo_stream has to be a <turbo-stream> tag.

    What happens when you don't use respond_to block. Rails will try to render a template that matches the format. For example, turbo_stream request will look for show.turbo_stream.erb then show.html.erb then show.erb, like this:

    render :show, formats: [:turbo_stream, :html]
    

    Let's say it finds show.html.erb, because there is an html extension present, rails will set response Content-Type to text/html and everything works as expected.

    However, if it finds show.erb, there is nothing to tell rails that it is in html format, we didn't use respond_to block so response Content-Type is not set explicitly, the only option left is to fallback to the first Accept type, which is turbo_stream. Now you're rendering an html template but response type is turbo_stream, and there is no <turbo-stream> tag.

    When turbo sees a turbo_stream response it appends it to the document, which is what you're seeing. Since there are no <turbo-stream> instructions, it just stays untouched.


    Long story short, something along the way needs to set the correct content type.

    Either use respond_to block which will set necessary render options. Or set render options yourself: content_type: "text/html". Or let rails know the content type by using html.erb and turbo_stream.erb extensions.

    Note, when you redirect, turbo_stream content type stays, because turbo handles the redirect on the front end. If there is no html.erb extension and no respond_to block in the show action, you get a content type mismatch.