How do you control LiveView elements visibility from BOTH server-side and frontend

Hi all

I am still learning Elixir, Phoenix and LiveView. I love the way I can describe the state in my LiveView and it is automagically rendered in frontend. However, right now I am playing with the features where latency happens to be quite important in a couple of places.

I am implementing a tiny sound recorder and I’d really like to hide the “start recording” microphone button ASAP, before the LiveView on server side reacts - so that user wouldn’t click it second time, also fast UI responsiveness looks nice.

Well, that’s where JS module comes into play and I can easily and quickly hide the button with this:

<button
      id="microphone-button"
      class={[
        "fixed bottom-3 ...",
        microphone_button_should_be_visible?(@recording_state) || "hidden"
        ]}
      phx-click={microphone_clicked_js()}
    >
 
 def microphone_button_should_be_visible?(recording_state) do
    recording_state == nil || recording_state == :ready
  end

 def microphone_clicked_js(js \\ %JS{}) do
    js
    |> JS.push("microphone_clicked")
    |> JS.add_class("hidden", to: "#microphone-button")
...
end

As you see I have microphone_button_should_be_visible? function for server side to compute when button should be visible, but since I am pretty sure it is to be hidden on click, I speculatively add hidden class for it not waiting for server side to react.

Unhiding problem
Problem happens when the recording is done and I want to show the microphone button again, so user could initiate the next recording session. My live view correctly sets recording_state assign (I verified it) and microphone_button_should_be_visible? should work correctly, but button never appears unless I use JS to remove “hidden” class.

Could somebody, please, help me understand what’s happening here?

  • Is LiveView-via-heex-based adding-removing “hidden” class not working because JS.add_class somehow added a “distinctively different” attribute that is not controlled by LiveView state anymore?

  • Or does LiveView somehow fail to compute DOM diff when some parts of it are manipulated by JS and I need to somehow explicitly ask it to rerender a particular control?

  • Or am I missing something even more basic? How would you approach the problem?

1 Like

Can you inspect the html after you click? I suspect that you’ll see TWO instances of the hidden class on that element while it’s hidden. If only one of them gets removed, that would explain what’s happening.

IF that’s the case, then I suggest adding a conditional to your JS event handler to check if the element already has the hidden class before adding it.

One instance of “hidden” only. I start suspecting that possibly JS.remove class kind’a removes the DOM leaf that LiveView was supposed to update, so nothing is updated from live view then :confused:

I hope I am not the first one who wants to manage UI mostly from LiveView, but with some speculative speedups via JS library-based manipulations, so hopefully somebody has solved this kind of issue already.

I have a feeling that the class list is not tracked correctly when liveview wants to patch.

Try and move the entire list for class microphone-button to assigns, in this way when you will change that assign it will patch the entire list.

… JS commands are DOM-patch aware, so operations applied by the JS APIs will stick to elements across patches from the server.
source: Phoenix.LiveView.JS

Yup, it’s a feature that can seem like a bug in certain use cases since the typical scenario would have the hide/show and corresponding show/hide be from client driven interactions.

What you could do is push an event via push_event/2 from the server to tell the client to show the record button again with a client side hook using vanilla JS or JS.exec/liveSocket.execJS + JS.show in a data attribute.

# HEEX
<button ... phx-hook="MicButton" data-show={JS.show()}>
  Record
</button>

# LiveView/LiveComponent
push_event(socket, "show-mic-button", %{})

# app.js
let Hooks = {}
Hooks.MicButton = {
  mounted(){
    this.handleEvent("show-mic-button", () =>
      this.liveSocket.execJS(this.el, this.el.getAttribute("data-show"))
    )
  }
}

let liveSocket = new LiveSocket("/live", Socket, {hooks: Hooks, ...})
3 Likes

Oh, so class added or removed by JS is supposed to stay forever regardless of the server side assigns changes… not quite expected by me to be honest - I thought that the whole main point of JS module is to speculatively update UI while waiting for the “true” state coming over websocket :slight_smile:

If I get it correctly if I ever changed element visibility via JS adding/removing “hidden” class, I have to stick with JS controling their visibility forever. Well, if it is this way, then it is this way. In my particular case, I’ll probably skip using “speculative JS” - too much hassle.

  • Just to double check: is there possibly a workaround?
  • Does anybody change element visibility speculatively on JS while waiting for the true server state over websocket? Is there a way to do it with some simple(ish) code?

I think this is working as expected, to visualize this in a more clear way, let’s go back to how we would do this in the old days:

class= "fixed bottom-3 <%= microphone_button_should_be_visible?(@recording_state) || "hidden" %>",

It is clear that the only thing the assign knows is how to generate some text in a place, it has no actual concept of dom interaction with class tag like JS. The liveview also can’t track any modifications that are made by JS, so you create an invariant that the server has no idea about.

As I proposed above, try to keep the entire class list in the assign, so it actually rewrites the broken state created by JS with a valid server-side state. I cannot confirm this works 100%, because the patching mechanism is internal and might have changed. It would take a lot of time for me to recreate your setup, so it would be nice if you tried that and came back with a response.

1 Like

if I understand correctly what you want, you don’t need the JS.add_class/1. you can rely on the added phx-click-loading class. remove the |> JS.add_class(...) line, and adjust your button class to "fixed bottom-3 ... phx-click-loading:hidden".

from Elixir documentation on Bindings:

All phx- event bindings apply their own css classes when pushed

actually, you don’t even need the microphone_clicked_js/1 function. you can just do phx-click="microphone_clicked", which will immediately add the "phx-click-loading" class in the clicked element, and remove it when your event handler finishes its job (and we can use the "phx-click-loading:hidden" syntax thanks to the custom Phoenix Tailwind variants).

JS.push/1 is necessary only if you want to enhance the push event (adding the phx-click-loading class to a different element, for example).

1 Like