Delay when swapping content with Live View

I’m trying to implement a show/edit control using nested live views. My prototype is working, but when it swaps out the show template for the edit template, it creates a distracting effect where the content becomes shorter then longer again. Hopefully, the following video shows what’s happening (on my local dev machine):

https://www.loom.com/share/9af57c7726104de196e7ec16e25dd148

When I click the edit button, sometimes the flash is noticeable/sometimes the flash is fast. I think this variable delay is because I’m using Stimulus to replace the textarea (in the edit template) with an Emojionearea. When I disable the Simulus controller, the flash is consistently faster.

However, it would be nice if it didn’t do the “flash” at all. It looks like that part of the DOM is being cleared, then the browser updates the visual tree, then the new elements are added and the visual tree is updated again. So I’m now thinking maybe I’m doing it wrong?

The parent live view is called ShowOrEdit. The child live views are Show and Edit. Here’s what show_or_edit.html.leex looks like:

<div>
  <%= if @edit do
    live_render( @socket, ZoinksWeb.TaskLive.Edit, session: %{
      id: @block.id,
      user_id: @user_id,
      dates: @dates
    })
  else
    live_render( @socket, ZoinksWeb.TaskLive.Show, session: %{
      id: @block.id,
      user_id: @user_id
    })
  end %>
</div>

<div class="mt-5">
  <%= live_render(@socket, ZoinksWeb.SearchLive, session: %{
    blocks: [@block.id],
    user_id: @user_id
  }) %>
</div>

Depending on @edit the if statement will show one live view or the other. The 3rd live view is where the content at the bottom comes from.

Here’s part of ShowOrEdit:

  def mount( %{path_params: %{"id" => id}} = session, socket ) do
    socket = socket |> assign_new( :current_user, fn -> Zoinks.Guardian.resource_from_session(session) end)

    block = Blocks.get_block!(id)

    edit = Map.has_key?( session, :edit ) || Map.has_key?( socket.assigns, :edit )
    dates = Map.has_key?( session, :dates ) || Map.has_key?( socket.assigns, :dates )

    { :ok, assign(socket, %{
      user_id: socket.assigns.current_user.id,
      edit: edit,
      dates: dates,
      block: block
    })}
  end

  def handle_info( :show_task, socket ) do
    socket = assign( socket, %{edit: false, dates: false} )

    {:noreply, live_redirect(socket, to: Routes.task_live_path(socket, ZoinksWeb.TaskLive.ShowOrEdit, socket.assigns.block), replace: true)}
  end

  def handle_info( :edit_task, socket ) do
    socket = assign( socket, %{edit: true, dates: false} )

    {:noreply, live_redirect(socket, to: Routes.task_edit_path(socket, ZoinksWeb.TaskLive.ShowOrEdit, socket.assigns.block), replace: true)}
  end

To set @edit a nested live view can do the following:

  def handle_event("edit_task", _value, socket) do
    send( socket.parent_pid, :edit_task )

    {:noreply, socket}
  end

A couple of thoughts:

  • Is my approach to dynamically swapping nested live views valid? Is there a better way?
  • Could I do something in my show/edit templates to make them more “compatible” so that diffing the DOM is more of a “merge the details” than a “clear at the root element and replace”?

I can provide more info, I was just a bit worried that the question was already too detailed…

If you be willing to change your phoenix browser based application into an api then react angular vue can help you solve your problem.

Also you may wnat to have a look at this video on how he handles events in live view https://www.youtube.com/watch?v=qpaFivCmJOY

2 Likes

:wave:

Have you tried changing styles (setting display to hidden) instead of adding/removing elements? I think I had a project where it had something like

  <div class="<%= if @edit, do: "d-none" %>">...</div>
  <div class="<%= if not @edit, do: "d-none" %>">...</div>

Worked fine, no flashes. I believe it’s easier for the browser to hide an element but keep it in the DOM (+ no stimulus controller reactivation on element’s return?) rather than completely remove it from the DOM.

2 Likes

Thanks for the suggestion! It makes sense from a DOM/performance point of view. At this stage, however, I was trying to avoid doing that if I could. For a few reasons:

  1. To keep the DOM as clean as possible
  2. To keep logic self contained in each live view
  3. To reduce the size of the download

With regards to point 2. Originally all the logic was in ShowOrEdit - which made the parent view monolithic (it had to deal with show/edit and a special case for editing the date only). Now that I’ve moved the logic to the relevant parent/child view - the code is much more elegant/relevant. So it’d be a bummer to take a step backwards.

With regards to point 3. This is a prototype for a single item. But I would like to apply the principle to a list of items. I’m okay with doubling the size for single item - but I’d prefer to avoid doubling the size of a list. Logs in SqrNut (which is the data structure/list I have in mind) can be quite lengthy.

I’m not very experienced with the DOM, but can’t you replace an element instead of clearing, then adding?

I don’t think a live_render for N items is the right call here then for either show or edit. Every live view is a process and while processes are cheap I don’t think doing 100 per user is a good idea.

If the goal is just logic separation, keep in mind that you can always break all the edit logic into its own module and then call that module from your live view for the relevant messages.

One of the things I decided to do on this project is explore Live View. I think once that’s over, I’ll look into other possibilities like react or vue

I hadn’t thought of that! Though now that you mention it, it makes sense. So I guess I’ll go back to the drawing board on that one.

Though even if I rendered the list in one process and I re-jig the code logic, I’m assuming I’ll still have the same problem? I’d prefer to replace the template for an item, rather than download twice the data.

Nice tutorial!

Sure but if you’re managing it from one process it should now be really fast and not flash.

<div>
  <%= if @block.editing? do
    render( @socket, "_edit_row.html", %{
      id: @block.id,
      user_id: @user_id,
      dates: @dates
    })
  else
    render( @socket, "_show_row.html", %{
      id: @block.id,
      user_id: @user_id
    })
  end %>
</div>

I think the slowness from before was that when live_render is called there is some initial overhead which caused the flash.

2 Likes

Okay, cool. Looks like I should move onto version 3 and see how it goes! :smile_cat:

pls, do keep us posted…

No problem. Will do!

You’re right @benwilson512 - after the nested Live Views are swapped, the DOM is updated immediately - before the mount completes for the new Live View (this is when the DOM is “cleared”). After a short delay, mount completes and the new contents is shown.

Applying your suggestion (to use render instead of live_render), here’s the result:

As you suggested, rendering is immediate and the “flashing” has gone. I had assumed that the client would not receive any diffs until the initial mount has completed. However, obviously I was wrong

Here’s the revised template:

<div>
  <%= if @edit do
    ZoinksWeb.TaskView.render("edit.html", assigns)
  else
    ZoinksWeb.TaskView.render("show.html", assigns)
  end %>
</div>

<div class="mt-5">
  <%= live_render(@socket, ZoinksWeb.SearchLive, session: %{
    blocks: [@block.id],
    user_id: @user_id
  }) %>
</div>

And here’s the code for ShowOrEdit (before I break it up):

defmodule ZoinksWeb.TaskLive.ShowOrEdit do
  use Phoenix.LiveView

  alias ZoinksWeb.Router.Helpers, as: Routes
  alias Zoinks.Blocks

  def title, do: "Task - SqrNut"
  def active_menu, do: :task

  def mount( %{path_params: %{"id" => id}} = session, socket ) do
    socket = socket |> assign_new( :current_user, fn -> Zoinks.Guardian.resource_from_session(session) end)

    block = Blocks.get_block!(id)

    edit = Map.has_key?( session, :edit ) || Map.has_key?( socket.assigns, :edit )
    dates = Map.has_key?( session, :dates ) || Map.has_key?( socket.assigns, :dates )

    socket = assign( socket, %{
      user_id: socket.assigns.current_user.id,
      edit: edit,
      dates: dates
    })

    if edit do
      { :ok, edit(block, dates, socket) }
    else
      { :ok, show(block, socket) }
    end
  end

  defp show( block, socket ) do
    opened = block.ends == nil
    formatted_start = if block.starts == nil do nil else block.starts |> Timex.format!("{D} {Mfull}, {YYYY}") end
    formatted_ends = if block.ends == nil do nil else block.ends |> Timex.format!("{D} {Mfull}, {YYYY}") end
    display = if opened do formatted_start else "#{formatted_start} - #{formatted_ends}" end

    assign( socket, %{
      block: block,
      edit: false,
      dates: false,
      formatted_start: formatted_start,
      formatted_ends: formatted_ends,
      display: display,
      closed: !opened
    })
  end

  defp edit( block, dates, socket ) do
    changeset = Blocks.change_block(block)

    opened = block.ends == nil
    formatted_start = if block.starts == nil do nil else block.starts |> Timex.format!("{YYYY}-{0M}-{0D}") end
    formatted_ends = if block.ends == nil do nil else block.ends |> Timex.format!("{YYYY}-{0M}-{0D}") end
    display = if opened do formatted_start else "#{formatted_start} - #{formatted_ends}" end

    assign(socket, %{
      block: block,
      edit: true,
      dates: dates,
      changeset: changeset,
      display: display,
      formatted_start: formatted_start,
      formatted_ends: formatted_ends,
      closed: !opened
    })
  end

  def render(assigns), do: Phoenix.View.render( ZoinksWeb.TaskView, "show_or_edit.html", assigns )

  def handle_event("validate", %{"block" => block_params}, socket) do
    changeset = socket.assigns.block
    |> Blocks.update_block(block_params)
    |> Map.put( :action, :update )

    {:noreply, assign(socket, changeset: changeset)}
  end

  def handle_event("show_task", _session, socket) do
    socket = show( socket.assigns.block, socket )

    {:noreply, live_redirect(socket, to: Routes.task_live_path(socket, ZoinksWeb.TaskLive.ShowOrEdit, socket.assigns.block), replace: true)}
  end

  def handle_event("edit_task", _value, socket) do
    socket = edit( socket.assigns.block, false, socket )

    {:noreply, live_redirect(socket, to: Routes.task_edit_path(socket, ZoinksWeb.TaskLive.ShowOrEdit, socket.assigns.block), replace: true)}
  end

  def handle_event("edit_dates", _value, socket) do
    socket = edit( socket.assigns.block, true, socket )

    {:noreply, live_redirect(socket, to: Routes.task_dates_path(socket, ZoinksWeb.TaskLive.ShowOrEdit, socket.assigns.block), replace: true)}
  end

  def handle_params( %{"id" => id} = params, _uri, socket ) do
    {:noreply, socket}
  end

  def handle_event("close_task", _value, socket), do: save( socket.assigns.block, %{ends: Timex.now |> Timex.format!("{YYYY}-{0M}-{0D}")}, socket )
  def handle_event("reopen_task", _value, socket), do: save( socket.assigns.block, %{ends: nil}, socket )
  def handle_event("save", %{"block" => block_params}, socket), do: save( socket.assigns.block, block_params, socket )

  defp save( block, block_params, socket ) do
    case Blocks.Bulk.update_block_then_logs( block, block_params, for: socket.assigns.current_user ) do
      {:ok, results } ->
        handle_event( "show_task", nil, assign(socket, %{block: results.block}) )

      {:error, :block, changeset, _notsure} ->
        { :noreply, assign(socket, changeset: changeset) }
    end
  end

  def handle_event("delete_task", _, socket) do
    case Blocks.Bulk.delete_block_then_update_logs( socket.assigns.block, for: socket.assigns.current_user ) do
      {:ok, _ } ->
        {:stop, socket
        |> put_flash(:info, "Task deleted")
        |> redirect(to: Routes.task_live_path(socket, ZoinksWeb.TaskLive.Index))}

      {:error, changeset} ->
        {:noreply, assign(socket, changeset: changeset)}
    end
  end
end
1 Like