Phoenix.LiveView.JS does not respect transition time option and calls `JS.push/1` immediately

Hi, here is an example script:

Application.put_env(:sample, Example.Endpoint,
  http: [ip: {127, 0, 0, 1}, port: 5001],
  server: true,
  live_view: [signing_salt: "aaaaaaaa"],
  secret_key_base: String.duplicate("a", 64)
)

Mix.install(
  jason: "~> 1.0",
  phoenix: "~> 1.7.0",
  phoenix_live_view: "~> 0.20.0",
  plug_cowboy: "~> 2.5"
)

defmodule Example.ErrorView do
  def render(template, _), do: Phoenix.Controller.status_message_from_template(template)
end

defmodule Example.HomeLive do
  use Phoenix.LiveView, layout: {__MODULE__, :live}

  alias Phoenix.LiveView.JS

  def mount(_params, _session, socket) do
    {:ok, assign(socket, :count, 0)}
  end

  defp phx_vsn, do: Application.spec(:phoenix, :vsn)
  defp lv_vsn, do: Application.spec(:phoenix_live_view, :vsn)

  def render("live.html", assigns) do
    ~H"""
    <script src={"https://cdn.jsdelivr.net/npm/phoenix@#{phx_vsn()}/priv/static/phoenix.min.js"}></script>
    <script src={"https://cdn.jsdelivr.net/npm/phoenix_live_view@#{lv_vsn()}/priv/static/phoenix_live_view.min.js"}></script>
    <script>
      let liveSocket = new window.LiveView.LiveSocket("/live", window.Phoenix.Socket)
      liveSocket.connect()
    </script>
    <style>
    </style>
    <%= @inner_content %>
    """
  end

  def render(assigns) do
    ~H"""
    <button class="fadeIn" phx-click={
      JS.toggle_class("fadeIn fadeOut", time: 5000)
      |> JS.push("example")
    }>Example</button>
    """
  end

  def handle_event("example", _params, socket) do
    IO.inspect(Time.utc_now())
    {:noreply, socket}
  end
end

defmodule Example.Router do
  use Phoenix.Router
  import Phoenix.LiveView.Router

  pipeline :browser do
    plug(:accepts, ["html"])
  end

  scope "/", Example do
    pipe_through(:browser)

    live("/", HomeLive, :index)
  end
end

defmodule Example.Endpoint do
  use Phoenix.Endpoint, otp_app: :sample
  socket("/live", Phoenix.LiveView.Socket)
  plug(Example.Router)
end

{:ok, _} = Supervisor.start_link([Example.Endpoint], strategy: :one_for_one)
Process.sleep(:infinity)

The problem is that JS.push/1 is called immediately without giving a chance for animation. Using this example you can reproduce the issue (see inspected time in logs).

I have no idea if it’s a bug or intended behaviour. All I know is that at the start of every handle_event/3 function body For now instead of inspecting time I’m adding: Process.sleep/1 call which looks like a workaround, but after adding it animation works as expected. I wonder if other people experienced the same and if so what did you do with that. Is Process.sleep/1 call good here or maybe you can recommend some alternative (within JS + handle_event/3).

This is as designed. You want the interaction to happen as soon as possible, and the UI feedback from the animation can mask the latency. What the :time does is locks the UI from patching over the animation for that duration, so you don’t end up stomping on your animations. You definitely don’t want to sleep in your handlers. The animation time should almost always be 200-300ms and not much more. If you are trying to lock the UI for long periods because you are doing some heavily async work, consider using <.async_result> and handling loading states, but it’s not clear what your real app goals are so hard to say.

3 Likes

As said it’s about animations that are skipped by such behaviour. 5s is for debugging only and in normal case I do not “estimate” how long a job would run. I wanted to return back to said 200-300ms after I find a good solution.

If I understand you correctly I should still avoid Process.sleep/1 anyway and write something like the code below, right?

  def handle_event(msg, params, socket) do
    # wait for animation to end and continue work in handle_info/2
    Process.send_after(self(), {msg, params}, @animation_duration)
    {:noreply, socket}
  end

  def handle_info({msg, params}, socket) do
    # …
  end

Ah, one more thing. The reason I have problem with animation is most probably because push_patch/2 call in my code which is not part of this example. Since it’s intended I have to (somehow) wait for animation end before doing any live navigation. That’s why I mentioned Process.sleep/1 as a workaround.

For that kind of feature I would make the button transitionable after the first render, and its visibility would depend on UI state.

Change the state to reflect the fact work has started (and this hides the button), and when you hear back from the background work, change the state to reflect that work is done.

If you see the UI as the result of a pure function over a state, a bit like Elm does it, it’s way easier than juggling with timings. Of course we also have JS to do small bits of this client-side for optimistic updates, but this shouldn’t cover “how long does work run for”.

Process.sleep will freeze this specific liveview process for the connected user for the duration of the sleep and is to be avoided.

1 Like

No I’m not waiting for anything except animation (current 5s, in final version ~200ms). First of all I want to toggle a class, so JS part is required as I can’t do that within handle_event (for sure I can create a Hook for that and let live process wait for message from JavaScript, but this is only more complicated).

Yeah and that’s why I found this intended behaviour weird.

Yeah, I know - that was just a quick workaround to see if timing is a problem. Process.send_after/3 makes more sense for me as it allows to receive other messages.


I simply expected that I can instruct JS to do one thing and after time passed do another one. Mentioned push_patch/2 I use in real code is called too fast not letting the animation to even start and because of that behaviour I have to wait “by hand” this or other way.

What I’m doing is to navigate to other live route (handled by same module and process) and want that to happen after some element fade out (I want url change). As above I have posted just a minimal script to show issue with timing which I simply got wrong. I was surprised to see it’s expected as therefore we cannot do any animations before live navigation without using either Process.sleep/1, Process.send_after/3. or any other better or worse solution.