I have rails 7 app where I’m trying to build a search bar using either tom-select or slim-select. My issue reproduces no matter which library I’m using therefore it must be the issue on my rails side.
app/views/cities/index.html.erb
<%= form_for :city, url: cities_path, method: 'GET' do |f| %> <div class="mt-4 border bg-light px-4 py-3 rounded-3"> <%= f.select :search_city, [], {}, placeholder: 'Type to search', data: { controller: 'ts--search', ts__search_url_value: autocomplete_cities_path } %> <%= f.submit 'Search', class: 'btn mx-auto' %> </div> <% end %>
and this is my js controller (in this case I’m using tom-select) app/javascript/controllers/ts/search_controller.js
import { Controller } from "@hotwired/stimulus"; import { get } from "@rails/request.js"; import TomSelect from "tom-select"; export default class extends Controller { static values = { url: String }; connect() { var config = { plugins: ["input_autogrow", "remove_button", "no_active_items"], render: { option: this.render_option, item: this.render_option, }, valueField: "value", loadThrottle: 400, load: (q, callback) => this.search(q, callback), closeAfterSelect: true, persist: false, create: false, delimiter: ", ", maxItems: 10, }; new TomSelect(this.element, config); } async search(q, callback) { const response = await get(this.urlValue, { query: { query: q }, responseKind: "json", }); if (response.ok) { callback(await response.json); } else { console.log("Error in search_ctrl: "); callback(); } } render_option(data, escape) { return `<div>${escape(data.text)}</div>`; } }
app/controllers/cities_controller.rb
class CitiesController < ApplicationController def index end def autocomplete list = City.order(:name) .where("name ilike :q", q: "%#{params[:q]}%") render json: list.map { |u| { text: u.name, value: u.id, sub: u.state } } end end
Problem Repro:
- Open cities index and click on the search bar.
- Dropdown opens up, I can type, and select a suggestion. Once selected, suggestion appears in the search bar with an ‘x’ clicking which will remove the it from the search bar.
- I add any amount of search tokens, 1-N.
- Click “Search” -> Seeing the results page.
- Click the back button in the browser (or swipe back on a phone)
Expected behavior: The search bar is exactly as it was before the search. clicking on ‘x’ removes the token. clicking on the Search bar allows entering the search query and adding more tokens.
Actual behavior: I can see the tokens, but clicking anything but the ‘Search’ button, does nothing. I can see the same behavior across multiple demos like this one and this one.
How can i make the JS work after coming back?
Advertisement
Answer
// TLDR // app/javascript/controllers/ts/search_controller.js disconnect() { this.element.tomselect.destroy(); }
When browser “back button” is used Turbo Drive does a restoration visit and displays a cached copy of the page. This copy is saved just before visiting another page. Any attached javascript behavior is lost, we only get html.
When Stimulus connects to [data-controller=ts–search] the select element is modified by TomSelect from this:
<select placeholder="Type to search" data-controller="ts--search" data-ts--search-url-value="/cities/autocomplete" name="city[search_city]" id="city_search_city"> </select>
to this:
<select placeholder="Type to search" data-controller="ts--search" data-ts--search-url-value="/cities/autocomplete" name="city[search_city]" id="city_search_city" multiple="multiple" tabindex="-1" class="tomselected ts-hidden-accessible"> <!-- ^ NOTE: this class --> </select> <div class="ts-wrapper multi plugin-input_autogrow plugin-remove_button plugin-no_active_items has-options"> <!-- ... --> </div>
When clicking another link, this modified html is saved to cache and later is restored when using browser back navigation. Then Stimulus connects again, however, TomSelect skips .tomselected
elements to avoid appending .ts-wrapper again. It looks the same because html and styles are loaded, but no javascript behavior is attached.
We can get a bit more context by turning on Stimulus debug logging:
// app/javascript/controllers/application.js application.debug = true // <= set this to `true` // app/javascript/controllers/ts/search_controller.js // inside connect() console.log(this.element.getAttribute("class")); new TomSelect(this.element, config); console.log(this.element.getAttribute("class"));
If the page with the search form is cached and we navigate to it by clicking a link:
// a cached page is displayed while // waiting for response from the server ts--search #initialize // found ts--search on the page tomselected ts-hidden-accessible // cached <select> // new TomSelect() has no effect tomselected ts-hidden-accessible // at least it looks the same ts--search #connect // finished connecting // a fresh response from the server arrived ts--search #disconnect // <= SOLUTION ts--search #initialize // run the lifecycle again on a new page null // untouched <select> from the server // new TomSelect() now works tomselected ts-hidden-accessible // new fancy select is on the page ts--search #connect // done
When using browser back navigation:
// a cached page is displayed ts--search #initialize // found ts--search on the page tomselected ts-hidden-accessible // cached <select> tomselected ts-hidden-accessible // new TomSelect() does nothing ts--search #connect // fail
One more thing happens when navigating away from our form (by clicking away, browser back or browser forward):
before-cache ts--search #disconnect
Before the page is cached by Turbo, Stimulus calls disconnect()
in our search controller. We can restore the original select here, before turbo caches the page. This way TomSelect can be reapplied on the cached page.
// app/javascript/controllers/ts/search_controller.js disconnect() { this.element.tomselect.destroy(); }
https://turbo.hotwired.dev/handbook/drive#restoration-visits
https://turbo.hotwired.dev/handbook/building#understanding-caching
https://stimulus.hotwired.dev/reference/lifecycle-callbacks#disconnection