Hotwire Discussion

Triggering Turbo Frame with JS

Could you post a sample of your stream fragment? I’m curious to see what the fragment looks like, because to my knowledge the classic AJAX request should work. Unless I’m totally missing something.

With my family right now, but once the holiday winds down I can take a look at this myself and try to spin up my own example.

EDIT: I was totally missing something. Check the below responses discussing how turbo-stream intercepts form submissions.

Yeah, the response type is correct. I added two forms on a page, both within Frames, that post to the same route. One sends the AJAX request on key up, and the other that submits using a submit button. That way I always have a confirmed working example on the page as I try different things out in the Stimulus controller. I have also updated the headers in the XHR to match the ones Turbo Frames adds to the framed form request.

As @danjac-2020 said, calling visit works fine, but it adds the URL to the location bar. Ive been looking through the inner workings of Turbo to get an idea for what normally happens. I think it intercepts normal link clicks and form submissions, creates its own requests and listens for their response (but I can totally be wrong, my fiancé doesn’t want me on the computer today so progress is slow :joy:). Much to explore!

Hey - so I am working on a project where I wanted to trigger a turbo-frame refresh programmatically as well. I did some serious source code diving and hopefully I can help!

With regards to the programmatic form submission:

Turbo intercepts form submission events, but weirdly, the JS formElement.submit() method does not trigger the submit event. Confusing for sure - but the MDN docs lay it out:

The event is not sent to the form when calling the form.submit() method directly.

You can see that it’s looking for this event in the FormInterceptor class, (in the turbo source).

Programmatically triggering a click on a button in the form is a good workaround to ensure the submit event is fired, however, you can simply use dispatchEvent to achieve the same thing:

this.formTarget.dispatchEvent(new CustomEvent('submit', { bubbles: true }))

I can confirm that this does work and does not require any hidden buttons :slight_smile: (might want to confirm browser compatibility though - my project only targets the latest browsers).


With regards to the autocomplete, wouldn’t it work to use a little bit of stimulus & a turbo-frame like this?

<div data-controller="autocomplete">
  <form method="get" action="search" data-turbo-frame="search-results" data-autocomplete-target="form">
    <input name="q" data-action="keyup->autocomplete#search">
  </form>
  <turbo-frame id="search-results">
    <div class="result">...</div>
    <div class="result">...</div>
  </turbo-frame>
</div>
// autocomplete_controller.js
export default extends Controller {
  static targets= ['form']

  search() {
    this.formTarget.dispatchEvent(new CustomEvent('submit', {bubbles: true}))
  }
}

I think you could also add in debouncing to reduce server strain. While it’s marvelous how simple this is from a client-side implementation perspective - I think autocompletes are fraught with edge-cases, what if one of the search queries arrives out of order and such… Not that those problems aren’t solvable, but they’re certainly ignored here.


I’m also trying to wrap my head around when it makes the most sense to use a turbo frame and turbo streams. You could clearly do this with a turbo stream as well. Would be curious to see if there’s clear advantages to that approach over this one!

11 Likes

This is a good solution, but I’m wondering what the advantage is here vs doing a straightforward fetch() to return an HTML fragment, given you have to use a Stimulus controller anyway?

I’m still exploring the uses of frames/streams, but I think they work best when you can perform the action with a simple form/link (e.g. wrap a toggle button in a form that returns a frame with updated content, or using streams to handle form validation) or when using WebSockets to return partial updates. If you need anything more complicated then use Stimulus. Which is totally fine - I think we just need more documentation and examples around the typical use cases.

1 Like

There it is. That little tidbit threw off my entire approach to solving this. Thanks for clearing that up! I should’ve double checked the doc myself but it seemed so obvious that I didn’t consider it.

1 Like

Hey! So it looks like @mgodwin beat me to a punch on a couple of things! First things first, I uploaded a test project with this functionality to my GitHub account. You can find it here. Even if this isn’t useful now, I wanted to drop a link to it for anyone who may stumble across this Discourse thread in the future.

That said, @mgodwin already nailed the tricky part. It turns out that the turbo-stream mechanism listens for form submission events, and for some reason the submit() function does not emit a form submission event. That means that it’ll bring back a normal HTML response. That said, it looks like there’s another method, requestSubmit() which does issue a submit event. Weird stuff from JavaScript land. Consequently, you can write your controller and html in the following fashion:

<!-- app/views/books/index.html.erb -->
<%= form_tag('/search', method: :get, data: { controller: "search" }) do %>
  <%= text_field_tag :search, params[:search], data: { action: "input->search#findResults" } %>
<% end %>
<div id="books"></div>
// app/assets/javascripts/controllers/search_controller.js

import { Controller } from "stimulus"

export default class extends Controller {
  findResults(event) {
    var searchValue = event.target.value;

    this.element.requestSubmit();
  }
}
# app/controllers/search_controller.rb
class SearchController < ApplicationController
  def search
    @books = Book.search_title(params[:search])

    respond_to do |format|
      format.turbo_stream
      format.html         { redirect_to root_url(search: params[:search]) }
    end
  end
end
<!-- app/views/search/search.turbo_stream.erb -->
<turbo-stream action="update" target="books">
  <template>
    <ul>
      <% @books.each do |book| %>
        <%= content_tag :li, book.title %>
      <% end %>
    </ul>
  </template>
</turbo-stream>

Like @mgodwin mentioned, you could add a debounce function like is mentioned in this thread. That said, I discovered a couple of interesting things while working on this toy app that I wanted to document here.

First, controllers being moved into the assets directory makes it harder to add packages like lodash to help with utility functions like debounce. Yarn package management seems to still be tied to Webpack, which is kind of frustrating. I wonder if there’s a particular reason that the controllers have been moved back into the asset folder in the stimulus-rails integration? If the asset pipeline had access to JavaScript package management, this would probably be a welcomed change by many.

Next, I originally tried to erroneously trigger a turbo-stream response using fetch() to send a get request to the server. As we’ve already learned, Turbo listens purely for the submit event, meaning it requires a form to be submitted. That said, when I was using this approach it surprisingly was still returning turbo-stream fragments to the server, it just wasn’t injecting them. I imagine the behavior of Turbo just listening for submit events is intentional in order to retain the spirit of semantic HTML and progressive enhancement. That said, I’m curious as to what causes a fetch request to return a turbo-stream response.

Finally to reply to @danjac-2020 , I think the big thing with turbo-streams is to provide a standard approach to sending HTML fragments over the wire to be injected into the DOM. I’ve done some toying around with HTML fragments over the wire just using fetch requests and Stimulus prior to the release of Turbo, and I ran into two big problems.

  1. Inserting HTML outside of a Stimulus controller gets kind of hacky. You can’t assign something outside of a controller as a target, which means you need to use a typical getElementById() call. This totally works, but it feels out of place in a Stimulus controller which has the nicely organized target system.

  2. Adding multiple fragments of HTML to the page in response to one request is really hard. Or at least in my experiments it was a struggle. Let’s say that you have the user submitting a form, and on a validation error you want to inject inline errors and a toast message into the DOM. With a normal fetch request, the server’s response comes back as a single HTML file that you have to break up into parts and inject into the page. With turbo-streams, you can inject multiple fragments into different parts of the page in a single request, with no added overhead on your part. All you have to do is ping the server with a form submission and have a turbo-stream response with multiple fragments.

Turbo seems to be the logical conclusion of the original Turbolinks. The original idea is still there as turbo-drive, but now there is also context-specific navigation using turbo-frames and a standard way to inject HTML into an existing DOM using turbo-drive. Additionally, it avoids dependence on websockets unlike a solution like StimulusReflex. I really enjoyed working with it today, and if there’s any way that we could contribute to the docs, I’d love to help flesh them out.

3 Likes

Re form validation: that’s one place turbo-streams works well.

Re debounce: I use stimulus-use (has lots of other nice bits of functionality e.g. for communicating between controllers) which has debounce and throttle functions. Of course it’s another package to install, but worth it - https://github.com/stimulus-use/stimulus-use.

Interesting re requestSubmit() and @mgodwin’s suggestion: I have a test project with autocomplete here https://github.com/danjac/movieapp/ and might try this out, thanks both.

2 Likes

Wow, did not know about requestSubmit()! I’d say that’s potentially superior to the custom event bubbling that I mentioned in my post.

RE - package management in the stimulus-rails repo, DHH calls out using browser based ESM as very intentional:

Stimulus for Rails makes it easy to use this modest framework with the asset pipeline and ES6/ESM in the browser. It uses the 7kb es-module-shim to provide importmap support for all ES6-compatible browsers. This means you can develop and deploy without using any bundling or transpiling at all! Far less complexity, no waiting for compiling.

I for one am really excited about this approach - one of the biggest upsides being that you don’t have to use webpack at all :confetti_ball: . Also called out in the docs is that you can import other modules (if you need) using skypack.dev. It won’t work for every project, but you have an escape hatch back to webpack/er if you need. For many projects, I suspect that this will be more than sufficient though.

RE: what causes fetch to return turbo_stream responses - I’m fairly certain that there is an accept header added to the fetch request which allows rails to do content-type negotiation and target those requests specifically in the controller. Of course, that will only send down the markup, you then need to inject the markup client-side. It looks like you can use Turbo.connectStreamSource if you wanted to go totally manual mode (which is used under the hood, I believe), but I haven’t really found a reason to use it directly yet. Just using form submission & WS seems more idiomatic and sufficient.

1 Like

I use Turbo with Django, so had to do a bit more investigation in how the server side works without much insight into Rails. There is indeed an Accept header passed back, and you need to respond with the correct content type (text/html; turbo-stream;) but neither are sufficient to get fetch() to play nice - one problem I have is sending updates generated from HTML media element events, so using a form submission is not really an option. Of course I use fetch(); but then I’m handling low level HTML changes in Stimulus. It works, but it could be a lot more elegant if I could use streams.

Honestly, it feels hacky to manually trigger forms or hidden buttons to get this functionality and I can imagine it becoming a source of bugs if abused in a large code base. I think a better option would be to have a Turbo.stream() function that works like visit().

2 Likes

Regarding the hidden button approach, I initially did it as a test to better understand what was working and what wasn’t so it was more of a starting point, very hacky. I agree that using it in production would be a bad idea. For what I was trying to figure out (submitting a form with JS while still taking advantage of either Frames or Streams), requestSubmit() was exactly what I was looking for.

I am still digging through the source cause I find it very interesting. I’ll share anything that may be relevant. Please do the same, I’d be interesting in hearing what you figure out.

1 Like

I’m stuck on this same problem. And for some reason I can’t find the method requestSubmit. It doesn’t even exist in the hotwired/turbo repo. Where can I find it?

It’s a standard function that is implemented on form elements in JavaScript. In order to access the method you’ll need to call it on the form element that you’re trying to submit.

1 Like

It’s part of the HTML spec, built in to any browser: https://developer.mozilla.org/en-US/docs/Web/API/HTMLFormElement/requestSubmit

Walter

Here’s the interesting part of that documentation:

The obvious question is: Why does this method exist, when we’ve had the submit() method since the dawn of time?

The answer is simple. submit() submits the form, but that’s all it does. requestSubmit(), on the other hand, acts as if a submit button were clicked. The form’s content is validated, and the form is submitted only if validation succeeds. Once the form has been submitted, the submit event is sent back to the form object.

Walter

4 Likes

Props to @jacobdaddario for requestSubmit.

I’ve successfully replaced the following rails-ujs code with Turbo.

Before:

<%= check_box_tag dom_id(todo, "checkbox"), 1, todo.completed?, data: { remote: true, url: toggle_todo_path(todo), method: :post } %>

After:

<%= form_with model: todo, url: toggle_todo_path(todo), method: :post do |form| %>
  <%= form.check_box :completed, data: { controller: "checkbox", action: "checkbox#submit" } %>
<% end %>
// app/javascript/controllers/checkbox_controller.js
import { Controller } from "stimulus"

export default class extends Controller {
  submit() {
    this.element.closest("form").requestSubmit();
  }
}

You can see the whole commit here: https://github.com/mrhead/todos/pull/49/commits/0dc609973e7d7310e59506c05bb4101a0be8b025

1 Like

Remember that Safari on all platforms does not support this. You’ll need to add a polyfill to support your mobile users.

Walter

4 Likes

Would it be more compatible to manually fire the submit event on the form? I believe in the other thread that was a solution proposed by another commenter.

This is a bit out of topic, but I would like to mention it anyway.

Because of out of order responses, I would suggest to use Stimulus and manually issue and abort fetch requests. I.e. if we need to send a new fetch request and the previous one did not finish yet, then just abort the previous one. This fixes out of order search results.

You can see it in my Stimulus search example here: https://github.com/mrhead/stimulus-search/blob/main/app/javascript/controllers/search_controller.js

Back to the original problem. Yesterday I thought that requestSubmit() is the right answer. However, as it was pointed out by @walterdavis, it’s not supported by Safari.

So I tried to manually fire the submit event:

this.element.closest("form").dispatchEvent(new CustomEvent("submit", { bubbles: true }))

This works in Safari and Chrome, but not in Firefox which just submits the form without Turbo :man_shrugging:.

So if anyone is using the custom event approach, then I would suggest to test it in Firefox.

In the meantime, I’m going to look for a solution which work in all mainstream browsers :).

I’ve decided to use form-request-submit-polyfill which is pretty lightweight and works well for me.

1 Like