turbo: Unable to manage history state programmatically (stream response)

My use case is fairly simple. I have a list of products and a filter POST form. Whenever a user triggers form submit I receive a stream response that updates the product list and current page URL to something shareable. Filter form itself is contained in a <turbo-frame> if that matters.

My goal is to update the page URL every time user makes changes to a filter form while letting them go back in history and “unapply” filters by simply pressing the Back button in a browser.

Here is my custom stream action:

Turbo.StreamActions.my_push_state = function () {
  const url = this.getAttribute("url");
  history.pushState(history.state, "", url);
  Turbo.navigator.history.push(url);
  console.log("my_push_state", url);
};

And here is the stream response from the server after the filter form is submitted:

<turbo-stream action="replace"><template>...list of products</template></turbo-stream>

<turbo-stream
  action="my_push_state"
  url="http://localhost:3000/?foo=bar"
></turbo-stream>

I cannot make it work. When the Back button is pressed the following error appears in a console:

Uncaught DOMException: Failed to execute 'removeChild' on 'Node': The node to be removed is not a child of this node.
    at ProgressBar.uninstallProgressElement (webpack-internal:///./node_modules/@hotwired/turbo/dist/turbo.es2017-esm.js:1481:38)
    at eval (webpack-internal:///./node_modules/@hotwired/turbo/dist/turbo.es2017-esm.js:1455:22)
uninstallProgressElement @ turbo.es2017-esm.js?7ebb:1457
eval @ turbo.es2017-esm.js?7ebb:1431
setTimeout (async)
fadeProgressElement @ turbo.es2017-esm.js?7ebb:1453
hide @ turbo.es2017-esm.js?7ebb:1430
hideVisitProgressBar @ turbo.es2017-esm.js?7ebb:2070
visitRequestFinished @ turbo.es2017-esm.js?7ebb:2050
finishRequest @ turbo.es2017-esm.js?7ebb:1795
requestFinished @ turbo.es2017-esm.js?7ebb:1920
perform @ turbo.es2017-esm.js?7ebb:529
await in perform (async)
issueRequest @ turbo.es2017-esm.js?7ebb:1767
visitStarted @ turbo.es2017-esm.js?7ebb:2018
start @ turbo.es2017-esm.js?7ebb:1722
startVisit @ turbo.es2017-esm.js?7ebb:2283
historyPoppedToLocationWithRestorationIdentifier @ turbo.es2017-esm.js?7ebb:2931
History.onPopState @ turbo.es2017-esm.js?7ebb:2200

About this issue

  • Original URL
  • State: open
  • Created 2 years ago
  • Comments: 23 (1 by maintainers)

Most upvoted comments

Hey everyone, after I confirmed that data-turbo-action="advance" was not working properly in turbo-frames, I had opened an issue just for this. But now I’ve confirmed in the latest version of turbo rails this has been fixed! It may not address everyone’s needs from this thread. It’s still not yet programmatic management of history state. But it’s an important tool in the toolbox which now works. 😃

Details over on this issue: https://github.com/hotwired/turbo/issues/1156

OK. Doing this directly like this window.Turbo.navigator.history.replace({ href: url }); works fine.

@trsteel88 Potential solution: render turbo_streams from within a turbo-frame.

Example scenario: I have a clothes #index page and the user can apply various filters via select-inputs to find what they want (e.g. color, size, etc), but perhaps I also have a sidebar with various categories (e.g. shirts, pants, etc) and clicking on a category should retain any filters I already applied (i.e. If I was searching for medium size blue shirts, clicking on “Pants” in the sidebar should start me off looking at medium, blue, pants). Problem: my main filters and results are in a turbo frame together, whereas my sidebar is in a completely separate div, and will not be updated when the turbo frame updates on form submission.

If we set up our turbo-frame with data-turbo-action="advance" and also render a turbo stream within that frame, we can get the desired behavior:

<%= turbo_frame_tag :products, data: {turbo_action: :advance} do %>
  <%= turbo_stream.replace :sidebar do %>
    <% @categories.each do |category| %>
      <%= link_to category.name, products_path(filters: params[:filters]) %>
    <% end %>
  <% end %>

  <!-- pseudo-code -->
  <%= form_for :filters, method: :get, url: products_path do %>
    <select-input-that-sends params[filters][color]>
    <select-input-that-sends params[filters][size]>

    <%= submit_tag 'Filter' %>
  <% end %>

  <div>
    <!-- products HTML here -->
  </div>
<% end %>

This will:

  1. Replace the :products turbo-frame with the response of the form submission (i.e. our newly filtered products)
  2. Update the URL to contain the :filters parameters from our form submission
  3. Update the sidebar via a turbo stream so that now all our sidebar links will contain our updated filter params

End result is that all our state is captured in the URL, the user does not lose their scroll position, and browser back/forward arrows worked as expected (including working with Turbo’s caching, so that you get instantaneous updates when navigating forward/back).

This works because turbo_streams run as soon as they are rendered on the page, so whenever our frame updates, boom, our stream runs as well.

@pySilver The request would look something like:

Started GET "/products?filters%5Bcategory_id%5D=1&filters%5Bcolor=blue&size=medium&commit=Filter
Processing by ProductsController#index as HTML

And the response would be our app/views/products/index.html.erb template, containing our :products turbo-frame (and the turbo-frame, in turn, contains the turbo_stream.replace call).

Let me know if that makes sense.

@pySilver Whoops, I made a mistake in my example. The sidebar links should include an appropriate data-turbo-frame attr look like this:

<!-- sidebar links should target our :products turbo-frame -->
<%= link_to category.name, products_path(filters: params[:filters]), data: {turbo_frame: :products} %>

The above link just hits our ProductsController#index as HTML (although, you could do a more complex controller/view set up where the response includes only the HTML needed for the frame, as opposed to the entire index view, which is what is happening in my example)

@trsteel88 in that case you would need to make the “state” part of the actual URL string. You can utilize URLSearchParams for this:

const url = window.location
const params = new URLSearchParams(url.search)

params.set("turbo_stream_history", true)
url.search = params

window.history.pushState({}, "", url);