Using streams with recursive and/or deeply nested schemas

I don’t know if Chris will respond to this as well, but let me try to clarify some things.

First of all, LiveView provides a way to render HTML templates on the server and keep a client in sync with those. It does this by splitting the template into static and dynamic parts to only send those parts for subsequent updates that are affected by a change. LiveView’s change tracking works best for small assigns, where you can easily say that they either changed or not. As soon as lists or nested data structures are involved, things get tricky. LiveView has some optimizations for change-tracking maps, but it does not change-track lists. This means that if you do:

<ul>
  <li :for={item <- @items}>
    {item.name}
  </li>
</ul>

using regular assigns, LiveView will re-send all the list items when the list is updated. Even if you actually only append or prepend a single item to the list. There are two ways to address this:

  1. use streams
  2. use a LiveComponent for each item

1. Streams

Streams are meant mainly as an optimization for collections of items - say your typical table that contains many rows. They provide a way to keep those items rendered on the client without the server needing to keep all of them in memory. Because the server does not know which items are actually rendered on the client, changes sometimes do require more coordination, for example to specify at which position an item should be inserted or updated.

Streams work by designating a DOM node as a stream container (marked with phx-update="stream"). On the server, the only special handling that streams receive is that their content is pruned (-> changed back into an empty list) after each render. Let’s look at an example:

<ul id="my-stream" phx-update="stream">
  <li :for={{id, item} <- @streams.items}>
    {item.name}
  </li>
</ul>

When you initially do

stream(socket, :items, [%{id: 1, name: "First"}, %{id: 2, name: "Second"}, %{id: 3, name: "Third"}])

the client will receive the following HTML (we won’t look at the diff structure in detail, but on the client LiveView always reconstructs the full HTML after applying a diff and feeds that HTML to morphdom):

<ul id="my-stream" phx-update="stream">
  <li id="items-1">First</li>
  <li id="items-2">Second</li>
  <li id="items-3">Third</li>
</ul>

The diff also contains metadata for each streamed item (which stream it belongs to, its id, its position in the stream and the stream limit). When you now do something on the server that does not touch the stream, subsequent patches to the DOM basically render an empty container:

<ul id="my-stream" phx-update="stream"></ul>

and the client handles those containers in a special way to preserve all stream items until they are deleted. When you now stream_insert something, the client will receive new HTML including the new item, and together with the metadata, it will know how to properly insert this item as a child in the stream container.

The important part is that stream children are always rendered as direct child of a stream container. So trying to transform a nested structure into a single stream will not work if you cannot also render your structure as a flat list of DOM nodes.

2. LiveComponents

LiveComponents are meant to provide a way to encapsulate components that handle their own events. LiveComponents are not meant to optimize server memory usage. The server will always hold all LiveComponent assigns in memory (ignoring temporary_assigns and streams inside LiveComponents, that can be used for optimizations). They are special in how they affect DOM patching, so that’s why they also play an important role when talking about rendering a list of items. The important part is that each LiveComponent performs its own change tracking. So when having something like this:

<ul>
  <.live_component :for={item <- @items} id={item.id} module={...} item={item} />
</ul>

and you change the @items assign, each LiveComponent will be updated, it calculates its changes and send the necessary diffs to the client. If nothing changed, only the updated order of LiveComponents is sent to the client, as a list of integer IDs. Importantly, LiveComponents in diffs are basically treated as a flat map of %{id => diff}.

LiveComponents are updated in the following cases:

  1. a render is triggered that renders its <.live_component /> tag
  2. send_update is used to directly update a component with new assigns

Questions

Now, before looking at a possible solution for arbitrarily nested data structures, I want to try to directly answer your questions:

LiveView uses morphdom to make sure that the rendered DOM looks like the HTML it generates from the diffs it receives from the server. So LiveView does not “replace” the old DOM node with the new one, but (if possible) updates it in place. This patching applies to all children of a patched element as well. Special handling is in place for nodes with phx-update (streams, ignore, append/prepend).

If the reconstructed HTML from the diff does not contain the nested old content, it is discarded, unless it is rendered inside an element with phx-update="ignore" or inside a nested phx-update="stream".

I will go into this in detail in the next section.

You’d need to provide more details on when you’ve seen “dangling” content. I can think of one instance where this is expected: when you render a non-stream item in a phx-update="stream" container. In this case, such items are rendered and updated in the DOM, but they are never removed unless the whole container is removed. (This behavior is documented.)

A way to provide surgical updates for nested data structures

By leveraging both Streams and LiveComponents, you should be able to achieve what you want: patching only parts of the DOM that are affected by a change. The idea is that you transform your data structure in such a way that lists are rendered as streams of live components, that can themselves contain more streams of live components. If you provide those live components a deterministic ID, that you can also reference when you need to perform a nested update, you can surgically patch the DOM with minimal diff over the wire by using send_update, targeting the individual nested component. Let’s look at an example:

We’re trying to render a treeview representing files and folders. The files are stored in a SQL database where each entry has a type file or folder and each entry has a parent_id that points to the parent folder. When we get this data from the database, we get a nested structure:

%FileEntry{
  id: 1,
  type: :folder,
  name: "root",
  # preloaded with the parent_id
  children: [
    %FileEntry{
      id: 2,
      type: :file,
      name: "foo.txt",
      children: nil
    },
    %FileEntry{
      id: 3,
      type: :folder,
      name: "subfolder",
      children: [
        %FileEntry{
          id: 4,
          type: :file,
          name: "bar.txt",
          children: nil
        }
      ]
    }
  ]
}

This could be nested arbitrarily deep. We’re not required to fully render the tree. Our list_files function could only return a few levels, with further children being not preloaded. Now, let’s look at the code to render this:

defmodule MyAppWeb.TreeView do
  def mount(_params, _session, socket) do
    # this would be the result of a query to the database, so probably more like
    # tree = Files.list_files(parent: nil, depth: 2)
    tree = [
      %FileEntry{
        id: 1,
        type: :folder,
        name: "root",
        # preloaded with the parent_id
        children: [
          %FileEntry{
            id: 2,
            type: :file,
            name: "foo.txt",
            children: nil
          },
          %FileEntry{
            id: 3,
            type: :folder,
            name: "subfolder",
            children: [
              %FileEntry{
                id: 4,
                type: :file,
                name: "bar.txt",
                children: nil
              },
              %FileEntry{
                id: 5,
                type: :folder,
                name: "subfolder 2",
                children: :not_loaded
              }
            ]
          }
        ]
      }
    ]

    {:ok, stream(socket, :tree, tree)}
  end

  def render(assigns) do
    ~H"""
    <ul id="tree" phx-update="stream">
      <.live_component
        :for={{dom_id, entry} <- @streams.tree}
        module={Example.TreeComponent}
        id={entry.id}
        dom_id={dom_id}
        entry={entry}
      />
    </ul>

    <button phx-click="rename_bar">Rename bar.txt</button>
    """
  end

  # assuming updates to individual entries are received via PubSub
  def handle_info({:update, entry}, socket) do
    send_update(Example.TreeComponent, id: entry.id, entry: entry)

    {:noreply, socket}
  end

  def handle_event("rename_bar", _params, socket) do
    # simulate a file event that could also be sent via PubSub
    send(self(), {:update, %FileEntry{id: 4, type: :file, name: "bar - #{DateTime.utc_now()}.txt", children: nil}})
    {:noreply, socket}
  end
end

and then the TreeComponent:

defmodule Example.TreeComponent do
  use Phoenix.LiveComponent

  # subsequent updates don't affect the children by default;
  # those are updated on their own
  def update(assigns, %{assigns: %{entry: _}} = socket) do
    {:ok, assign(socket, assigns)}
  end

  def update(assigns, socket) do
    entry = assigns.entry

    # this is a memory optimization to not keep all children in memory
    socket = case entry do
      %{type: :folder, children: [_ | _ ] = children} ->
        socket
        |> stream(:children, children)
        |> assign(entry: Map.put(entry, :children, []))

      _ ->
        socket
        |> stream(:children, [])
        |> assign(entry: entry)
    end

    {:ok, socket}
  end

  def render(assigns) do
    ~H"""
    <li>
      {@entry.name}
      <ul :if={@entry.type == :folder} id={"folder-#{@entry.id}"} phx-update="stream">
        <.live_component
          :for={{dom_id, entry} <- @streams.children}
          module={Example.TreeComponent}
          id={entry.id}
          dom_id={dom_id}
          entry={entry}
        />
      </ul>
      <button :if={@entry.children == :not_loaded} phx-click="load_children" phx-target={@myself}>Load children</button>
    </li>
    """
  end

  def handle_event("load_children", _params, socket) do
    # this would probably look more like
    # children = File.list_files(parent: socket.assigns.entry.id, depth: 2)
    children = [
      %FileEntry{
        id: 100 + floor(:rand.uniform(1000)),
        type: :file,
        name: "more.txt",
        children: nil
      }
    ]

    socket
    |> assign(:entry, Map.put(socket.assigns.entry, :children, []))
    |> stream(:children, children)
    |> then(&{:noreply, &1})
  end
end

Here’s a single file sample with the full code: LiveView File Tree optimized with Streams + LiveComponents · GitHub

I hope this clarifies things a bit. Let me know if you have further questions!

12 Likes