Skip to content
Advertisement

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?

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

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

User contributions licensed under: CC BY-SA
3 People found this is helpful
Advertisement