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 >
<%= 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?
CodePudding user response:
// 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"
>
<!-- ^
NOTE: this class
-->
</select>
<div >
<!-- ... -->
</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