Odd behaviour when using JS.push() and JS.dispatch() in the same function chain

I’m experiencing some undesirable behaviour in Liveview when I try to use a JS.dispatch command in a chain that includes a JS.push.

I have 3 main elements on a liveview. A block of filters, a button that shows or hides the filters and a list of items returned as per the filters.

# the filter element
      <.form
        for={@available_filters}
        id="filters-form"
        data-hide-filter-block={
          JS.hide(to: "#filters-form")
          |> JS.dispatch("myapp:change-text", to: "#show-filters-button")
        }
      >
         #stuff
    <./form>

# the button 
      <button
        class="btn"
        id="show-filters-button"
        phx-click={JS.dispatch("myapp:toggle-filter-form")}
      >
        <%= IO.inspect(Time.utc_now()) %>
      </button>

# the clickable element
          <div
            id={"item_#{item.id}"}
            phx-click={
              JS.push("select-item", value: %{item: item.id})
              |> JS.exec("data-hide-filter-block", to: "#filters-form")
            }
          >
            #stuff
         </div

# the event listener to change the text
window.addEventListener("myapp:change-text", (event) => {
  event.target.textContent= "Show Filters";
});

The button has a JS.dispatch() event triggering an event listener that hides/shows the filters as appropriate and changes the text of the button to “ShowHide Filters”. This works fine.

When an Item is clicked I send an event to the server to retrieve the item and I want to hide the filters then change the text of the button. When I try to use the JS.dispatch command in the same chain as the JS.push the odd behaviour happens.

The event listener targeted by JS.dispatch is triggered successfully and executes its code, hiding the filters and changing the button text. The button then immediately reverts to its “default” state and the filters re-appear. It all happens just quickly enough to see the visual transition.

I’ve tried a few different variations of issuing the JS.dispatch command, always producing the same result. The code above is the current code state.

I know that the filter block is not being re-rendered in the liveview sense because the timestamp on the button text stays the same and it does not appear in the diff on the websocket message, nor is the filter function component being re-rendered due to an assigns change.

For the life of me I can’t figure out what is going on. What I can see is:

  • The item gets clicked
  • The server event is handled successfully
  • The event listener is executed
  • The filters are hidden and the button text changes very briefly
  • The filters are visible and button text has reverted to its initial state.

I can’t see any “special” rules on the JS.dispatch() documentation and other JS commands like hide/show/add_class aren’t producing similar behaviour. Likewise, if I remove the JS.push() then the JS.dispatch() works as expected.

Have I missed something obvious?

EDIT:
Since posting I’ve also noticed that the above is only true for the first click of an individual item. Subsequent clicks produce the expected behaviour.

The discription your given, feels a bit similar to the problem I’ve described in this bug: updated hook triggered for multiple elements instead of only the updated element. · Issue #2805 · phoenixframework/phoenix_live_view · GitHub

It’s not exactly the same, but the fact that you’re changing the element in an event listener and things change without a rerender feels like it might be similar to what is described in the bug.

I think the bug has something todo with changing the element on the client side, but I’ve not gotten any confirmation about that yet.

Hmm, it sounds like the server’s response to JS.push results in a re-render that overwrites the state set in the client side. Could you share the handle_event("select-item", ...) callback handler?

The handle_event is very simple!

  def handle_event(
        "select-item",
        %{"item" => item_id},
        socket
      ) do
    item = ItemContext.get_item_by_id(item) |> struct_to_map()

    {:noreply,
     assign(socket, %{
       selected_item: item
     })}
  end

It’s weird, it does act like a re-render. I have “solved” this for now by putting a phx-update="ignore" on the button element but nothing about the button is available in the websocket diff and the component that the button lives in isn’t being re-rendered.

The button also doesn’t have any references to the assigns content in the component so by my (potentially flawed) understanding of liveview change tracking it should not be affected by the change to the selected_item socket property.