How to correctly handle latency with Liveview

Yea I would do exactly that, update the URL with a patch (either directly or from a push event) and show some kind of loading state while waiting.

handle_params would be called but if the state needed to persist across reconnections I would just update the URL via live view to make things easier to test.

If you change the URL on the client without telling the server then I’m sure you could create some interesting bugs too.

1 Like

Oh I misunderstood, I’m so sorry! I tried this out and it works nicely!

If you use a query param whose sole purpose is the state of a modal and only change that you shoooouuuuullllld be fine probably maybe, but I like your way better!

defmodule MyAppWeb.HomeLive do
  use MyAppWeb, :live_view

  @impl true
  def handle_params(%{"edit" => "edit"}, _uri, socket) do
    {:noreply, assign(socket, :edit, true)}
  end

  def handle_params(_params, _uri, socket) do
    {:noreply, assign(socket, :edit, false)}
  end

  @impl true
  def render(assigns) do
    ~H"""
    <button
      type="button"
      phx-click={JS.show(to: "#foo") |> JS.patch(~p"/?edit=edit")}
    >
      Open Modal
    </button>

    <div id="foo" class="border-4 border-black" style="display: none;">
      <div :if={!@edit}>
        Loading...
      </div>
      <div :if={@edit} phx-mounted={JS.show(to: "#foo")}>
        Loaded
      </div>
    </div>
    """
  end
end

…and of course:

> liveSocket.enableLatencySim(2000)

on the JS console.

I wrote the snippet on my phone so probably lacked some detail.

For custom dropdowns/selects that interact with the server I add a loading class to the button which gets removed when the dom is patched. It was the simplest solution, plus a loading spinner with CSS.

I have another annoying case where radio options can sometimes be overridden if you change them too fast. But I haven’t tried to work around that yet.

I didn’t test this, but I’m pretty sure that the phx-mounted will break on high latency because of the following scenario:

  1. Imagine that the div also has a close button that will call JS.hide and a patch with edit as false;
  2. You click in the show button, that will first show the div with the loading part because @edit is false;
  3. You click in the close button, that will hide the div again;
  4. The server receives the first button click and change @edit to true, that will trigger the phx-mounted command and will show the div again even though you already closed in in the client.

Basically is a similar issue to the one I showed in my select gif in the first post.

I tested this and was able to open, and close (before @edit had loaded) and it didn’t re-appear. (And it works across re-connects)

<button type="button" phx-click={JS.exec("js-show", to: "#edit") |> JS.push("EDIT")}>
  Edit
</button>

<div
  id="edit"
  style={if !@edit, do: "display: none;"}
  js-show={JS.show(transition: {"transition transition-opacity duration-200", "opacity-0", "opacity-100"})}
  js-hide={JS.hide(transition: {"transition transition-opacity duration-200", "opacity-100", "opacity-0"})}
>
  <button type="button" phx-click={JS.exec("js-hide", to: "#edit") |> JS.push("CLOSE")}>
    Close
  </button>
  <div :if={!@edit}>
    Loading...
  </div>
  <div :if={@edit}>
    Editing
  </div>
</div>

I tested it here, if you put a latency simulation of 2~4 seconds and click in the edit button then the close button and the edit button again really fast, the div will not show up the second time.

In case anyone is interested, I opened an issue in LiveView’s github page talking about the second example I gave in the first post.

Have looked into the thread.

It seems, that you have a race condition. You edit one form field, and it triggers a phx-change event. User starts editing another field. Server responds and resets the user inputs.

If latency is an issue, then possibly it is better not to emit phx-change events at all? Just allow user to edit form and send the whole data with phx-submit. You can disable the fields with phx-disable-with during server works on phx-submit event. Of course, you will lose out-of-the-box validation mechanism with this approach, but you will get consistent UX for all users.
This kind of race-conditions are not LiveView-specific. You can get the same results with other frameworks too.

You can look into other options, if you want to have a real-time validation:

  1. Validate purely client-side. This one will work even without network connection.
  2. Assign nothing in your phx-change function. If you assign something - it will trigger a re-render and erase newer edits. You can push_event with validation results instead and process it in the client-side hooks (show appropriate error messages). By the way, you can use this trick to edit contents of any part of your page.
  3. Use different assigns for form data and form errors. Change assigns only for form errors. This way only errors will be re-rendered. User edits will remain.
  4. Possibly something else :slight_smile:

By the way, here are some posts about restoring state for Phoenix LiveView forms:

Also I like the idea of using URL params to store the state of the form (if it is tiny), or filters. It works nice with browser API, and enables “back” and “refresh” buttons work right.

2 Likes

I push an event to the front end to update the url params without triggering handle_params. Pretty sure I took the JavaScript from liveview internals. No claims that this is a good idea but I needed to do this in a couple of spots and it hasn’t bit me yet.

// update the URL with the given params
// see update_and_assign_url_query_params/2
window.addEventListener("phx:set-search-params", ({ detail }) => {
  if (!canPushState()) {
    return
  }

  let searchParams = new URLSearchParams(window.location.search)

  Object.entries(detail).forEach(([key, value]) => {
    searchParams.set(key, value)
  })

  let currentState = history.state || {}

  const origin = window.location.origin
  const pathname = window.location.pathname
  searchParams = searchParams.toString()
  const url = `${origin}${pathname}?${searchParams}`

  history.replaceState(currentState, "", url)
})

And here is what I use in a liveview.

  defp update_and_push_url_query_params(socket, new_params) do
    new_params = encode_params(new_params)

    socket
    |> push_event("set-search-params", new_params)
    |> update(:params, &Map.merge(&1, new_params))
  end

  defp encode_params(%{} = params) do
    for {key, value} <- params, reduce: %{} do
      acc ->
        Map.put(acc, key, encode_param(value))
    end
  end

  defp encode_param(param) when is_list(param) do
    Enum.map(param, &encode_param/1)
  end

  defp encode_param(%Date{} = date) do
    Date.to_iso8601(date)
  end

  defp encode_param(%DateTime{} = datetime) do
    Ensure.utc_string(datetime)
  end

  defp encode_param(value)
       when is_boolean(value)
       when is_integer(value)
       when is_binary(value)
       when is_number(value) do
    value
  end

  defp encode_param(param) when is_map(param) and not is_struct(param) do
    encode_params(param)
  end
2 Likes

Here is a quick trick that I found to leverage .phx-submit-loading to change the internal element of a button (ex. change the button icon to a loading spinner):

<.button with_icon type="submit" color="success" class="group/button">
  <Loading.spinner class="hidden group-[.phx-submit-loading]/button:block w-5 h-5" />
  <Icons.create class="block group-[.phx-submit-loading]/button:hidden w-5 h-5" /> Create
</.button>

This probably is obvious for frontend users, but since I’m normally working more in backend stuff, I was not aware of the tailwind group feature that allows you to set classes in child elements based on a class from a parent element.

2 Likes

This doesn’t precisely answer your question, but I found this blog post from fly.io which sheds some light on techniques to handle this latency :slight_smile: