Search code examples
javascriptruby-on-railsruby-on-rails-7slimselecttom-select

Rails 7 JavaScript event listeners stop working after browser back?


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:

  1. Open cities index and click on the search bar.
  2. 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.
  3. I add any amount of search tokens, 1-N.
  4. Click "Search" -> Seeing the results page.
  5. 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?


Solution

  • // 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

    https://tom-select.js.org/docs/api/#destroy