Possible payload size improvement to HEEX list comprehensions

I would not tackle reordering now. I think if you have all of these requirements and reordering, maybe it is fine to tell you to use LiveComponents, which solves the problems mentioned here as well.

Something we also discussed is to have a a phx-order attribute for containers inside streams and the browser will sort according to it. Either way, I’d say it is a separate problem.

I think discussing here is fine. I am not yet sure if something like stream_diff will be added to LV but some of the underlying improvements (such as :previous for stream_insert and phx-order) should.

2 Likes

I do believe that improving list comprehensions is worth exploring, but I think one solution for optimizing payload sizes wasn’t really discussed here (apart from being briefly mentioned in José last message): LiveComponents

If the only goal is to optimize payload size, ignoring memory usage, LiveComponents can be part of the solution. Re-visiting the original demo with LiveComponents:

Mix.install([{:phoenix_playground, "~> 0.1.3"}])

defmodule ListItem do
  use Phoenix.LiveComponent

  def render(assigns) do
    ~H"""
    <li><%= @item.name %></li>
    """
  end
end

defmodule DemoLive do
  use Phoenix.LiveView

  def render(assigns) do
    ~H"""
    <button phx-click="add">add</button>

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

  def mount(_params, _session, socket) do
    {:ok, assign(socket, :items, [])}
  end

  def handle_event("add", _params, socket) do
    items = socket.assigns.items
    id = length(items)
    new_item = %{id: id + 1, name: "New#{id + 1}"}
    {:noreply, assign(socket, :items, [new_item | items])}
  end
end

PhoenixPlayground.start(live: DemoLive)

The payload size still increases with each item, but only the component id is sent.

This can be further improved with streams:

Mix.install([{:phoenix_playground, "~> 0.1.3"}])

defmodule ListItem do
  use Phoenix.LiveComponent

  def render(assigns) do
    ~H"""
    <li id={@id}><%= @item.name %></li>
    """
  end
end

defmodule DemoLive do
  use Phoenix.LiveView

  def render(assigns) do
    ~H"""
    <button phx-click="add">add</button>

    <ul id="items" phx-update="stream">
      <.live_component :for={{id, item} <- @streams.items} id={id} item={item} module={ListItem} />
    </ul>
    """
  end

  def mount(_params, _session, socket) do
    {:ok, socket |> assign(:count, 0) |> stream(:items, [])}
  end

  def handle_event("add", _params, socket) do
    id = socket.assigns.count
    new_item = %{id: id, name: "New#{id + 1}"}
    {:noreply, socket |> stream_insert(:items, new_item) |> update(:count, &(&1 + 1))}
  end
end

PhoenixPlayground.start(live: DemoLive)

to have a basically constant diff size.

Of course the UX is not optimal, because one has to define a new module, but maybe this is also something that could be improved, e.g., by having “inline live components”?

5 Likes

We could trivially add inline live components by having a function component that calls the LiveComponent passing an inner block as argument and rendering it inside the component? We only need an ID for the component. Perhaps that’s the simplest solution to the problem at hand?

Here is an iteration on top of yours @steffend:

Mix.install([{:phoenix_playground, "~> 0.1.3"}])

defmodule CachedComponent do
  use Phoenix.LiveComponent

  def cached_component(assigns) do
    ~H"""
    <.live_component module={CachedComponent} id={assigns.id} inner_block={assigns.inner_block} />
    """
  end

  def render(assigns) do
    rendered = ~H"""
    <%= render_slot(@inner_block, []) %>
    """

    # This is a hack
   %{rendered | root: true}
  end
end

defmodule DemoLive do
  use Phoenix.LiveView
  import CachedComponent, only: [cached_component: 1]

  def render(assigns) do
    ~H"""
    <button phx-click="add">add</button>

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

  def mount(_params, _session, socket) do
    {:ok, socket |> assign(:count, 0) |> stream(:items, [])}
  end

  def handle_event("add", _params, socket) do
    id = socket.assigns.count
    new_item = %{id: id, name: "New#{id + 1}"}
    {:noreply, socket |> stream_insert(:items, new_item) |> update(:count, &(&1 + 1))}
  end
end

PhoenixPlayground.start(live: DemoLive)

There is a hack in there, which we can ignore for now. It also has one limitation in that, although we now diff the list, we don’t diff inside the list elements. However, now that I look at the problem, I don’t think any of the solutions discussed so far would be able to address this (except proper LiveComponents).

We could add the API above to Phoenix itself, here are some options:

  1. Introduce “anonymous LiveComponents”, which is a LiveComponent where its rendering block is given and there is no module, so we could do:

      <.live_component :for={{id, item} <- @streams.items} id={id}>
        <li id={id}><%= item.name %></li>
      </.live_component>
    

    Making this work requires changes to the engine, so we validate the root (as we do for LiveComponents).

  2. Introduce some sort cache_tag, which mirrors dynamic_tag, so we could do:

      <.cache_tag :for={{id, item} <- @streams.items} tag_name="li" id={id}>
        <%= item.name %>
      </.cache_tag>
    

    I don’t like the name cache_tag, since this is really only about comprehensions, but that’s the overall idea. Other names could be introduced.

4 Likes

Wow anonymous LiveComponents is very cool idea :bulb: and I think the anonymous LiveComponents can accepts cache_tag as list params

Personally I don’t like the idea to push people towards LiveComponents just to have better performance. I’ve been there, done that, but it didn’t felt “right”.

LiveComponents have their own lifecycle and state. They can handle events, update state and re-render. This usage basically stripes them down to… being a marker in HEEX?

If I’m correct it’s working because it persists all the used live component IDs and when we render more it can diff these ids to know which should be added / updated, at the expense of some memory for keeping these IDs. Just, there’s more chatting (I’ve noticed client sends updates about no-longer used IDs so they can be cleaned up?) and you need to use them with streams otherwise there is no performance or memory gain (correct?)

I think I came up with a good example showing what I’d love to have.

  • It’s a 10-element list comprehension
  • on click, these 10 elements are generated again out of 11 possible elements, so there’s one insertion, one removal and 9 updates
  • there’s one shared assign used in all these elements
Mix.install([{:phoenix_playground, "~> 0.1.3"}])

defmodule DemoLive do
  use Phoenix.LiveView

  def render(assigns) do
    ~H"""
    <button phx-click="randomize">randomize</button>

    <ul>
      <li :for={item <- @items}>
        Count: <span><%= @count %></span>, 
        item: <%= item.name %>
      </li>
    </ul>
    """
  end

  def mount(_params, _session, socket) do
    socket = socket |> assign(:count, 0) |> assign(:items, random_items())
    {:ok, assign(socket, :count, 0)}
  end

  def handle_event("randomize", _params, socket) do
    {:noreply, socket |> assign(:items, random_items()) |> update(:count, &(&1 + 1))}
  end

  def random_items() do
    1..11
    |> Enum.take_random(10)
    |> Enum.map(&%{id: &1, name: "New#{&1 + 1}"})
  end
end

PhoenixPlayground.start(live: DemoLive)

I believe the smallest diff would contain:

  • reorder by sending order of IDs
  • 9 updates to the shared assign @count
  • 1 full insert

At the same time, it would be best to make it working with small changes to the non-optimized code. I’ve tested it with .cached_component and it didn’t help. Maybe it would be better with stream_diff but probably it would break that shared assign part, since we wouldn’t update it for non-touched elements. A bit tricky, mostly because in the previous part of the discussion we ignored assigns coming outside of the current item :thinking:

What do you think?

2 Likes

Yeah, I’m not sold on LiveComponents, I’ve completely avoided them in my app, I know that processes are cheap, but I don’t think thats a good argument alone, especially if we can avoid building chatty apps.

1 Like

LiveComponents don’t use additional processes. They run within their parents LV process.

3 Likes

I agree and disagree. Nothing is perfect. If LiveComponents are the best solution to the problem right now, then that’s what we should push people to. However, we are discussing ways to improve the current status quo, so let’s consider that indeed they can be improved.

Now we should split the discussion in two. Do LiveComponents provide technically (not API wise) the benefits we want? And I think the answer is yes. If you use a list (it does not have to be streams) in your example above and you put each item inside a LiveComponent, adding, removing, updating, and re-ordering should send minimal diffs. If this is indeed true, then it is great news! It means we have already solved the problem, we now only need to improve the API surface. Can you verify if this is true?

Once we verify this, then it is a question of how to better encapsulate LiveComponents so they are less bureaucratic (for example, they don’t require a whole module).

For example, we could say we could cache any function component as long as it has an :id (or :cache or whatever) directive. So your template could be written as:

  def render(assigns) do
    ~H"""
    <button phx-click="randomize">randomize</button>

    <ul>
      <%= for item <- @items do %>
        <.list_item :id={item.id} count={@count} item={@item} />
      <% end %>
    </ul>
    """
  end

  defp list_item(assigns) do
    ~H"""
    <li id={@id}>
      Count: <span><%= @count %></span>, 
      item: <%= @item.name %>
    </li>
    """
  end

The reason why we need at least a function component is so we can track its own assigns with its own changed entries. And asking people to write a function component to rely on this optimization should be more straight-forward than whole LiveComponents (or even streams).

2 Likes

TIL, that’s awesome, I was of the believe that was the difference with function components vs live components. I’ll take another look at this.

I guess where there is a difference is that you need to send_updates over the channel, vs just assigning and render. So a message will still need to be send with Phoenix.LiveView — Phoenix LiveView v1.0.0-rc.6 this does make your app more chatty especially if you broadcast to live_views and have to use send_update within them!

The update is sending a message to the current process, which is very cheap. It doesn’t go over any channel.

2 Likes

Yes Jose, it seems to work. Minimal example:

Mix.install([{:phoenix_playground, "~> 0.1.3"}])

defmodule DemoComponent do
  use Phoenix.LiveComponent

  def render(assigns) do
    ~H"""
    <li>
      Count: <span><%= @count %></span>, 
      item: <%= @item.name %>
    </li>
    """
  end
end


defmodule DemoLive do
  use Phoenix.LiveView

  def render(assigns) do
    ~H"""
    <button phx-click="randomize">randomize</button>

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

  def mount(_params, _session, socket) do
    socket = socket |> assign(:count, 0) |> assign(:items, random_items())
    {:ok, assign(socket, :count, 0)}
  end

  def handle_event("randomize", _params, socket) do
    {:noreply, socket |> assign(:items, random_items()) |> update(:count, &(&1 + 1))}
  end

  def random_items() do
    1..11
    |> Enum.take_random(10)
    |> Enum.map(&%{id: &1, name: "New#{&1 + 1}"})
  end
end

PhoenixPlayground.start(live: DemoLive)

and this is a payload from the console

it updates only dynamic parts that changed, and it seem to reorder correctly as well. So I’d say it’s what we want when it comes to the payload size! :rocket:

Just, I’d like to understand the tradeoffs. If I’m correct:

  • it keeps the assigns separately for each live_component, possibly doubling memory consumption
  • it keeps a list of assigned IDs.

It seems these are not kept in the socket but somewhere else in the process state. I’ll look how exactly they’re handled currently soon.

So the questions:

  • how we could make the API more approachable? I like your suggestion of adding an attribute that will make any function component live_component. Probably it shouldn’t be id, though, since it might be used simply to have it in DOM. Maybe something like phx-component-id?
  • I know it might be a big change, but would it be possible to avoid storing assigns separately for each live_component created that way and instead rely on __changed__ to detect what should be updated? :thinking: Because that’s the only thing we need old assigns for, right?

Woot!

It doesn’t double memory consumption per se because they would all point to the same items. The best way to think about it is that you will have a list of items, [item], and LiveComponents will keep one additional map of %{id => item}, but the item is the same. The extra cost here is on keeping the map (there are other costs related to LiveComponents but we may be able to optimize them for this case).

Yes, that may be possible too. It just depends on how much work we want to put into the Diff engine in order to make this work. The first step would probably be to extract the LiveComponent logic out and allow it to be replaced by this new logic for these new “components”.

2 Likes

Does this hold true when doing send update? IIRC send flattens the term, even when sending to self(). When receiving the value you’ve got two values that while equivalent are not literally pointers to the same heap spot.

That is a very good question, given live component are part of the same process and we have function component, then I don’t see why they couldn’t be married and we would avoid all the hidden costs, although they might be cheap, they are still there.
For people that build complex apps that rival native apps, then performance matters!

We now have :key for :for in LiveView 1.1! :slight_smile:

8 Likes

This is a really important change, thanks for your work!

I saw this is based on LiveComponents internally, so I would assume the :key has to be a global id. But I don’t see that mentioned in the docs anywhere. Is that the case?

We automatically prefix the key with the current module, line and file, so it doesn’t have to be globally unique.

3 Likes

Very cool! But wait, what happens if you do this?

def foo(assigns) do
  ~H"""
  <div>
    <div :for={id <- [1, 2, 3]} :key={id}>{"Div #{id}"}</div>
  <div>
  """
end

def bar(assigns) do
  ~H"""
  <.foo />
  <.foo />
  <div>
    <%= for _i <- 1..10 do %>
      <.foo />
    <% end %>
  </div>
  """
end

Won’t they all have the same module/line? You would need the whole call stack or something, right?

Well yeah, it’s best effort - so you can construct cases like this where it won’t work. You’ll get an exception in that case, but I’m not sure how common this would be. If you always render 1 2 and 3, the best diff would be to just unroll that loop manually, then everything is static :smiley:

1 Like